store-mcp-node 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +110 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-mcp-node",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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
5
  "main": "src/index.js",
6
6
  "files": [
package/src/index.js CHANGED
@@ -5,10 +5,11 @@
5
5
  *
6
6
  * 语义依据:../spec/*.md(双端 parity,改动先改 spec)。
7
7
  * 设计哲学:MCP 只是 GQL 的又一层 AI 客户端皮肤 —— 适配层零语义发明。
8
- * 双出口:
8
+ * 三个出口:
9
9
  * exportTools(store, opts) → { tools }(纯构建,零 SDK 依赖,永远可用)
10
10
  * createStdioServer(store, opts) → stdio 阻塞运行(同一份工具清单做 tools/list,
11
11
  * call 经 spec/02 映射表进 store 原生 API)
12
+ * createHttpServer(store, opts) → streamable HTTP 监听(同一份工具清单,无状态传输)
12
13
  *
13
14
  * store 端口契约见 spec/00:list/get/query/queryOne/insert/update/remove/setContext;
14
15
  * 档位上下文 text2query(query 工具强制沙箱)取用顺序:opts.text2query → require('nodejs-store')。
@@ -131,7 +132,8 @@ function buildSurface(store, opts = {}) {
131
132
 
132
133
  // spec/01:ask 仅 opts.llm 提供时注册;配 llm 缺 ctx 装配期抛错(fail-secure)
133
134
  if (opts.llm !== undefined && opts.llm !== null) {
134
- if (opts.ctx === undefined || opts.ctx === null) {
135
+ // HTTP 传输下 ctx 随请求到达(ctxFromRequest),装配期无静态 ctx 属正常
136
+ if ((opts.ctx === undefined || opts.ctx === null) && typeof opts.ctxFromRequest !== 'function') {
135
137
  throw new Error(
136
138
  'store-mcp: opts.llm 已提供但缺 opts.ctx —— ask 工具需要服务端用户上下文(如 {userId, roles});'
137
139
  + 'ctx 属受信参数,不进任何工具入参(D5)');
@@ -241,7 +243,9 @@ async function createServer(store, opts = {}) {
241
243
  }
242
244
  }
243
245
  if (opts.llm != null) {
244
- if (opts.ctx == null) throw new Error('store-mcp: opts.llm 已提供但缺 opts.ctx(fail-secure,spec/01)');
246
+ if (opts.ctx == null && typeof opts.ctxFromRequest !== 'function') {
247
+ throw new Error('store-mcp: opts.llm 已提供但缺 opts.ctx(fail-secure,spec/01)');
248
+ }
245
249
  if (typeof opts.hostAsk !== 'function') {
246
250
  try {
247
251
  const host = require('nodejs-store');
@@ -291,4 +295,106 @@ async function createStdioServer(store, opts = {}) {
291
295
  return r;
292
296
  }
293
297
 
294
- module.exports = { exportTools, createServer, createStdioServer, filterArchived, schemaProjection, buildSurface, errorOf };
298
+ /**
299
+ * streamable HTTP 监听:装配 + 每请求一个 StreamableHTTPServerTransport。
300
+ * opts: { port = 8635, host = '127.0.0.1', ctx = null, ctxFromRequest = null, endpoint = '/mcp' }
301
+ * - SDK 无状态模式禁止复用 transport(webStandardStreamableHttp 硬约束:复用会串消息 ID),
302
+ * 故每个请求新建 transport + MCP Server;每请求 ctx 因此天然隔离。
303
+ * - ctxFromRequest: async (req) => ctxObj —— 每请求身份解码钩子,与静态 ctx 互斥(同给抛错)。
304
+ * - GET/DELETE 交给 SDK 默认行为,不做自定义会话管理。
305
+ * - 端口占用不换端口:listen 的原生错误原样抛出(禁静默漂移)。
306
+ * @returns {Promise<{server: import('node:http').Server, transport: {create: () => object}, endpoint: string, close: () => Promise<void>}>}
307
+ */
308
+ async function createHttpServer(store, opts = {}) {
309
+ const http = require('node:http');
310
+
311
+ const port = opts.port === undefined ? 8635 : opts.port;
312
+ const host = opts.host || '127.0.0.1';
313
+ const endpoint = opts.endpoint || '/mcp';
314
+ const ctxFromRequest = opts.ctxFromRequest;
315
+
316
+ if (ctxFromRequest !== undefined && ctxFromRequest !== null && typeof ctxFromRequest !== 'function') {
317
+ throw new Error('store-mcp: opts.ctxFromRequest 必须为 async (req) => ctx 函数');
318
+ }
319
+ if (opts.ctx !== undefined && opts.ctx !== null && typeof ctxFromRequest === 'function') {
320
+ throw new Error(
321
+ 'store-mcp: opts.ctx 与 opts.ctxFromRequest 互斥 —— 静态上下文与每请求身份解码不可同时装配'
322
+ + '(HTTP 下 ctx 由 ctxFromRequest 逐请求决定)');
323
+ }
324
+
325
+ let StreamableHTTPServerTransport;
326
+ try {
327
+ ({ StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js'));
328
+ } catch (e) {
329
+ throw new Error(
330
+ `store-mcp-node 的 @modelcontextprotocol/sdk 缺 streamableHttp 传输(${e.message})。`
331
+ + '升级:npm i @modelcontextprotocol/sdk');
332
+ }
333
+
334
+ // 装配期先过一遍守卫(text2query 档位 / ask 依赖):失败立即抛,不等首个请求才 500
335
+ await createServer(store, { ...opts });
336
+
337
+ const createTransport = () => new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
338
+ const active = new Set(); // 活跃请求的 { transport, mcpServer }
339
+
340
+ const cleanup = (conn) => {
341
+ active.delete(conn);
342
+ Promise.allSettled([conn.transport.close(), conn.mcpServer.close()]).then((rs) => {
343
+ for (const r of rs) if (r.status === 'rejected') console.error('store-mcp: HTTP 连接清理失败', r.reason);
344
+ });
345
+ };
346
+
347
+ const server = http.createServer(async (req, res) => {
348
+ try {
349
+ const { pathname } = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
350
+ if (pathname !== endpoint) {
351
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
352
+ res.end(JSON.stringify({ error: 'notFound', message: `未知路径 ${pathname}(MCP endpoint 为 ${endpoint})` }));
353
+ return;
354
+ }
355
+
356
+ const wireOpts = { ...opts };
357
+ if (typeof ctxFromRequest === 'function') {
358
+ wireOpts.ctx = await ctxFromRequest(req);
359
+ }
360
+ const { server: mcpServer } = await createServer(store, wireOpts);
361
+ const transport = createTransport();
362
+ const conn = { transport, mcpServer };
363
+ active.add(conn);
364
+ res.on('close', () => cleanup(conn));
365
+
366
+ await mcpServer.connect(transport);
367
+ await transport.handleRequest(req, res); // 内部按 req.method 分流 POST/GET/DELETE
368
+ } catch (e) {
369
+ if (!res.headersSent) {
370
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
371
+ res.end(JSON.stringify({ error: 'httpError', message: String((e && e.message) || e) }));
372
+ } else {
373
+ res.destroy(e); // 响应已开始(如 SSE):断开连接以显式失败,禁静默截断
374
+ }
375
+ }
376
+ });
377
+
378
+ await new Promise((resolve, reject) => {
379
+ const onListening = () => { server.off('error', onError); resolve(); };
380
+ const onError = (e) => { server.off('listening', onListening); reject(e); };
381
+ server.once('error', onError);
382
+ server.once('listening', onListening);
383
+ server.listen(port, host);
384
+ });
385
+
386
+ const close = async () => {
387
+ const closed = new Promise((resolve, reject) => {
388
+ server.close((err) => (err ? reject(err) : resolve()));
389
+ });
390
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
391
+ await closed;
392
+ const conns = [...active];
393
+ active.clear();
394
+ await Promise.all(conns.map((conn) => Promise.allSettled([conn.transport.close(), conn.mcpServer.close()])));
395
+ };
396
+
397
+ return { server, transport: { create: createTransport }, endpoint, close };
398
+ }
399
+
400
+ module.exports = { exportTools, createServer, createStdioServer, createHttpServer, filterArchived, schemaProjection, buildSurface, errorOf };