store-mcp-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/index.js +272 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "store-mcp-node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP (Model Context Protocol) adapter that auto-generates tool surfaces for nodejs-store schemas (schema-driven CRUD + sandboxed GQL query + optional ask), stdio transport",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"nodejs-store",
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"crud",
|
|
17
|
+
"gql",
|
|
18
|
+
"ai"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/coenddt/store-mcp.git"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.31.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"nodejs-store": ">=1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"nodejs-store": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.31.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* store-mcp-node — 为 nodejs-store 已注册 schema 自动生成 MCP 工具面。
|
|
5
|
+
*
|
|
6
|
+
* 语义依据:../spec/*.md(双端 parity,改动先改 spec)。
|
|
7
|
+
* 设计哲学:MCP 只是 GQL 的又一层 AI 客户端皮肤 —— 适配层零语义发明。
|
|
8
|
+
* 双出口:
|
|
9
|
+
* exportTools(store, opts) → { tools }(纯构建,零 SDK 依赖,永远可用)
|
|
10
|
+
* createStdioServer(store, opts) → stdio 阻塞运行(同一份工具清单做 tools/list,
|
|
11
|
+
* call 经 spec/02 映射表进 store 原生 API)
|
|
12
|
+
*
|
|
13
|
+
* store 端口契约见 spec/00:list/get/query/queryOne/insert/update/remove/setContext;
|
|
14
|
+
* 档位上下文 text2query(query 工具强制沙箱)取用顺序:opts.text2query → require('nodejs-store')。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ARCHIVE_SUFFIX = 'Deleted';
|
|
18
|
+
|
|
19
|
+
// ── spec/01:归档表过滤(与 store-api / store-graphql / store-grpc 逐字一致)──
|
|
20
|
+
function filterArchived(names) {
|
|
21
|
+
const set = new Set(names);
|
|
22
|
+
return names.filter((n) => !(n.endsWith(ARCHIVE_SUFFIX) && set.has(n.slice(0, -ARCHIVE_SUFFIX.length))));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── spec/01:schema 投影(fields + computes 均视为字段,与 store-graphql 对齐)──
|
|
26
|
+
function schemaProjection(defn) {
|
|
27
|
+
const fields = defn ? { ...(defn.fields || {}), ...(defn.computes || {}) } : null;
|
|
28
|
+
const keys = fields ? Object.keys(fields) : [];
|
|
29
|
+
return keys.length ? ` { ${keys.join(', ')} }` : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── spec/01:idField(缺省 _id,定位值原样透传)──
|
|
33
|
+
function idFieldOf(defn) {
|
|
34
|
+
return (defn && defn.idField) || '_id';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── 协议面错误(spec/03 判定链的皮肤侧子集)──
|
|
38
|
+
class SkinError extends Error {
|
|
39
|
+
constructor(code, message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'SkinError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertObject(value, field) {
|
|
47
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
48
|
+
throw new SkinError('invalidBody', `${field} 必须为 JSON 对象(数组/标量拒绝,spec/02)`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── spec/03:错误判定链(name 判定跨包安全,不依赖宿主 instanceof)──
|
|
53
|
+
function errorOf(e) {
|
|
54
|
+
if (e && e.name === 'SkinError') return { code: e.code, message: e.message };
|
|
55
|
+
if (e && e.name === 'AskExhausted') return { code: 'askExhausted', message: String(e.message) };
|
|
56
|
+
if (e && e.name === 'PermissionError') return { code: 'permissionDenied', message: String(e.message) };
|
|
57
|
+
if (e && e.name === 'ProfileViolation') return { code: 'profileBlocked', message: String(e.message) };
|
|
58
|
+
return { code: 'planError', message: String((e && e.message) || e) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── spec/01:工具清单构建(工具对象 + 入参 JSON Schema 直出,无需 zod)──
|
|
62
|
+
function obj(properties, required) {
|
|
63
|
+
return { type: 'object', properties, required };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function modelTools(name, defn, description) {
|
|
67
|
+
const desc = description || `${name}(MCP 工具面,由 schema 自动生成)`;
|
|
68
|
+
const d = (s) => `${desc} — ${s}`;
|
|
69
|
+
return [
|
|
70
|
+
{ name: `get_${name}`, description: d('按 id 查单条'), inputSchema: obj({ id: { type: 'string', description: '文档 id(原样透传)' } }, ['id']) },
|
|
71
|
+
{ name: `list_${name}`, description: d('列表查询:q 为自模型名始的 GQL 余部(与 REST ?q= 同源),空则按 schema 投影'), inputSchema: obj({ q: { type: 'string', description: 'GQL 余部,如 "($condition: @c0) -> { name }"' }, params: { type: 'object', description: 'GQL params(键 = 去掉 @ 的引用名)' } }, []) },
|
|
72
|
+
{ name: `create_${name}`, description: d('插入文档'), inputSchema: obj({ body: { type: 'object', description: '文档对象' } }, ['body']) },
|
|
73
|
+
{ name: `update_${name}`, description: d('按 id 定位后部分更新'), inputSchema: obj({ id: { type: 'string' }, set: { type: 'object', description: '待更新字段集' } }, ['id', 'set']) },
|
|
74
|
+
{ name: `delete_${name}`, description: d('按 id 定位删除'), inputSchema: obj({ id: { type: 'string' } }, ['id']) },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const QUERY_TOOL = {
|
|
79
|
+
name: 'query',
|
|
80
|
+
description: '全局 GQL 直查(只读沙箱:强制 text2query 档,core 硬限 1000 行 / 深度 3,禁 route_override)。gql 为自模型名始的完整 GQL 查询串,条件值一律参数化(@key 引用,值放 params)',
|
|
81
|
+
inputSchema: obj({ gql: { type: 'string' }, params: { type: 'object' } }, ['gql']),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const ASK_TOOL = {
|
|
85
|
+
name: 'ask',
|
|
86
|
+
description: 'AI 问数(只读):自然语言 → 宿主 ask() 编排(LLM 翻译 → text2query 档校验执行 → 失败结构化回喂重试)。成功返回 {data, attempts, events};失败显式报错(不降级、不返回空结果)',
|
|
87
|
+
inputSchema: obj({ question: { type: 'string', description: '自然语言问题' } }, ['question']),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 收集工具面(归档过滤 + x-mcp 注记 + ask 启用守卫)。
|
|
92
|
+
* @returns {{tools: object[], handlers: Map<string, Function>}}
|
|
93
|
+
*/
|
|
94
|
+
function buildSurface(store, opts = {}) {
|
|
95
|
+
const names = opts.resources || filterArchived(store.list());
|
|
96
|
+
const tools = [];
|
|
97
|
+
const handlers = new Map();
|
|
98
|
+
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const defn = typeof store.get === 'function' ? store.get(name) : null;
|
|
101
|
+
const xm = (defn && defn['x-mcp']) || {};
|
|
102
|
+
if (xm.hidden) continue; // spec/01 注记:模型级 hidden
|
|
103
|
+
const proj = schemaProjection(defn);
|
|
104
|
+
const idField = idFieldOf(defn);
|
|
105
|
+
const description = defn && defn.description;
|
|
106
|
+
for (const t of modelTools(name, defn, description)) {
|
|
107
|
+
if (tools.some((x) => x.name === t.name)) {
|
|
108
|
+
throw new Error(`ERR_NAME_CONFLICT:工具名 "${t.name}" 冲突(spec/01)`);
|
|
109
|
+
}
|
|
110
|
+
tools.push(t);
|
|
111
|
+
handlers.set(t.name, modelHandler(store, t.name, name, proj, idField));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// spec/02:query 工具强制 text2query 档 —— 档位上下文取用顺序见文件头
|
|
116
|
+
tools.push(QUERY_TOOL);
|
|
117
|
+
handlers.set('query', queryHandler(store, opts));
|
|
118
|
+
|
|
119
|
+
// spec/01:ask 仅 opts.llm 提供时注册;配 llm 缺 ctx 装配期抛错(fail-secure)
|
|
120
|
+
if (opts.llm !== undefined && opts.llm !== null) {
|
|
121
|
+
if (opts.ctx === undefined || opts.ctx === null) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'store-mcp: opts.llm 已提供但缺 opts.ctx —— ask 工具需要服务端用户上下文(如 {userId, roles});'
|
|
124
|
+
+ 'ctx 属受信参数,不进任何工具入参(D5)');
|
|
125
|
+
}
|
|
126
|
+
tools.push(ASK_TOOL);
|
|
127
|
+
handlers.set('ask', askHandler(opts));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { tools, handlers };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── spec/02:映射表 handler ──
|
|
134
|
+
|
|
135
|
+
function modelHandler(store, toolName, name, proj, idField) {
|
|
136
|
+
const verb = toolName.slice(0, toolName.indexOf('_'));
|
|
137
|
+
return async (args) => {
|
|
138
|
+
switch (verb) {
|
|
139
|
+
case 'get': {
|
|
140
|
+
const row = await store.queryOne(`${name}($condition: @c0)${proj}`, { c0: { [idField]: args.id } }, null);
|
|
141
|
+
if (row === null || row === undefined) {
|
|
142
|
+
throw new SkinError('notFound', `${name} ${args.id} not found`);
|
|
143
|
+
}
|
|
144
|
+
return row;
|
|
145
|
+
}
|
|
146
|
+
case 'list':
|
|
147
|
+
return store.query(args.q ? `${name}${args.q}` : `${name}${proj}`, args.params, null);
|
|
148
|
+
case 'create':
|
|
149
|
+
assertObject(args.body, 'body');
|
|
150
|
+
return store.insert(name, args.body);
|
|
151
|
+
case 'update':
|
|
152
|
+
assertObject(args.set, 'set');
|
|
153
|
+
return store.update(name, { [idField]: args.id }, args.set);
|
|
154
|
+
case 'delete':
|
|
155
|
+
return store.remove(name, { [idField]: args.id });
|
|
156
|
+
default:
|
|
157
|
+
throw new SkinError('planError', `未知工具动词: ${verb}`);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function queryHandler(store, opts) {
|
|
163
|
+
return async (args) => {
|
|
164
|
+
if (typeof args.gql !== 'string' || !args.gql.trim()) {
|
|
165
|
+
throw new SkinError('invalidParam', 'gql 必须为非空字符串(自模型名始,spec/02)');
|
|
166
|
+
}
|
|
167
|
+
const t2q = opts.text2query; // createStdioServer 装配期已解析;exportTools 不执行无此键
|
|
168
|
+
const run = () => store.query(args.gql, args.params, null); // routeOverride 恒 null(D5/CWE-639)
|
|
169
|
+
if (typeof t2q === 'function') return t2q(run);
|
|
170
|
+
return run(); // 测试注入路径之外不可达:装配期已守卫
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function askHandler(opts) {
|
|
175
|
+
return async (args) => {
|
|
176
|
+
if (typeof args.question !== 'string' || !args.question.trim()) {
|
|
177
|
+
throw new SkinError('invalidParam', 'question 必须为非空自然语言字符串');
|
|
178
|
+
}
|
|
179
|
+
// 护栏零新增:档位/只读/回喂/硬限全在宿主 ask();AskResult(data, attempts, events) 全量返回
|
|
180
|
+
const r = await opts.hostAsk(args.question);
|
|
181
|
+
return { data: r.data, attempts: r.attempts, events: r.events };
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 双出口 ──
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 纯构建工具清单(零 SDK 依赖,永远可用;对齐 store-grpc buildProto 先例)。
|
|
189
|
+
*/
|
|
190
|
+
function exportTools(store, opts = {}) {
|
|
191
|
+
return { tools: buildSurface(store, opts).tools };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 装配 MCP Server(全部守卫在此,不绑传输——测试经 InMemoryTransport、v1 HTTP 经此复用)。
|
|
196
|
+
*/
|
|
197
|
+
async function createServer(store, opts = {}) {
|
|
198
|
+
let sdk;
|
|
199
|
+
try {
|
|
200
|
+
sdk = {
|
|
201
|
+
Server: require('@modelcontextprotocol/sdk/server/index.js').Server,
|
|
202
|
+
Schemas: require('@modelcontextprotocol/sdk/types.js'),
|
|
203
|
+
};
|
|
204
|
+
} catch (e) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`store-mcp-node 缺承载依赖 @modelcontextprotocol/sdk(${e.message})。`
|
|
207
|
+
+ '安装:npm i @modelcontextprotocol/sdk');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// spec/02:query 工具强制 text2query 档 —— opts.text2query 优先,缺省从宿主取,两者皆无 ⇒ 拒绝装配
|
|
211
|
+
if (typeof opts.text2query !== 'function') {
|
|
212
|
+
try {
|
|
213
|
+
opts.text2query = require('nodejs-store').text2query;
|
|
214
|
+
} catch (_) { /* 宿主未装:下方守卫统一裁决 */ }
|
|
215
|
+
if (typeof opts.text2query !== 'function') {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'store-mcp: query 工具强制 text2query 档(spec/02),但未取得档位上下文。'
|
|
218
|
+
+ '安装 nodejs-store 或传 opts.text2query(签名 fn(async fn) -> Promise)');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (opts.llm != null) {
|
|
222
|
+
if (opts.ctx == null) throw new Error('store-mcp: opts.llm 已提供但缺 opts.ctx(fail-secure,spec/01)');
|
|
223
|
+
if (typeof opts.hostAsk !== 'function') {
|
|
224
|
+
try {
|
|
225
|
+
const host = require('nodejs-store');
|
|
226
|
+
opts.hostAsk = (q) => host.ask(q, { llm: opts.llm, ctx: opts.ctx });
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new Error(`store-mcp: ask 工具需要宿主 nodejs-store(${e.message})。安装:npm i nodejs-store`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { tools, handlers } = buildSurface(store, opts);
|
|
234
|
+
const server = new sdk.Server(
|
|
235
|
+
{ name: opts.name || 'store-mcp', version: opts.version || '0.1.0' },
|
|
236
|
+
{ capabilities: { tools: {} } });
|
|
237
|
+
|
|
238
|
+
server.setRequestHandler(sdk.Schemas.ListToolsRequestSchema, async () => ({ tools }));
|
|
239
|
+
|
|
240
|
+
server.setRequestHandler(sdk.Schemas.CallToolRequestSchema, async (req) => {
|
|
241
|
+
const name = req.params.name;
|
|
242
|
+
const args = req.params.arguments || {};
|
|
243
|
+
const handler = handlers.get(name);
|
|
244
|
+
try {
|
|
245
|
+
if (!handler) throw new SkinError('invalidParam', `未知工具: ${name}`);
|
|
246
|
+
if (opts.contextProvider) {
|
|
247
|
+
const ctx = await opts.contextProvider(req.params._meta);
|
|
248
|
+
await store.setContext(ctx === undefined ? null : ctx); // 显式清除语义,spec/02
|
|
249
|
+
}
|
|
250
|
+
const data = await handler(args);
|
|
251
|
+
// 成功响应不含任何 error 键(spec/03 正向断言)
|
|
252
|
+
return { content: [{ type: 'text', text: JSON.stringify(data === undefined ? null : data) }] };
|
|
253
|
+
} catch (e) {
|
|
254
|
+
const err = errorOf(e);
|
|
255
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(err) }] };
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return { server, tools };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* stdio 阻塞运行:装配 + StdioServerTransport。
|
|
264
|
+
*/
|
|
265
|
+
async function createStdioServer(store, opts = {}) {
|
|
266
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
267
|
+
const r = await createServer(store, opts);
|
|
268
|
+
await r.server.connect(new StdioServerTransport());
|
|
269
|
+
return r;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { exportTools, createServer, createStdioServer, filterArchived, schemaProjection, buildSurface, errorOf };
|