@atlisp/mcp 1.1.3 → 1.2.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/README.md +1 -0
- package/package.json +1 -1
- package/src/atlisp-mcp.js +181 -191
- package/src/cad-worker.js +3 -2
- package/src/cad.js +73 -42
- package/src/handlers/cad-handlers.js +12 -0
- package/src/handlers/function-handlers.js +32 -46
- package/src/handlers/package-handlers.js +42 -2
- package/src/session-manager.js +89 -0
- package/src/session-transport.js +89 -0
- package/src/subscription-manager.js +27 -12
package/README.md
CHANGED
|
@@ -254,6 +254,7 @@ curl http://localhost:8110/health
|
|
|
254
254
|
| `install_atlisp` | 安装 @lisp | 无 |
|
|
255
255
|
| `get_function_usage` | 获取函数用法 | `name`: 函数名 |
|
|
256
256
|
| `list_functions` | 列出所有函数 | 无 |
|
|
257
|
+
| `load_atlisp_functionlib` | 从网络加载函数库(AI 提示词/编程) | `format`: json/list |
|
|
257
258
|
|
|
258
259
|
### MCP 方法
|
|
259
260
|
|
package/package.json
CHANGED
package/src/atlisp-mcp.js
CHANGED
|
@@ -55,11 +55,11 @@ import rawBody from 'raw-body';
|
|
|
55
55
|
import iconv from 'iconv-lite';
|
|
56
56
|
import rateLimit from 'express-rate-limit';
|
|
57
57
|
import { randomUUID } from 'crypto';
|
|
58
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
58
59
|
const { decode: charsetDecode, encodingExists } = iconv;
|
|
59
60
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
60
61
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
61
|
-
import {
|
|
62
|
-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
62
|
+
import { SessionTransport } from './session-transport.js';
|
|
63
63
|
import {
|
|
64
64
|
ListToolsRequestSchema,
|
|
65
65
|
CallToolRequestSchema,
|
|
@@ -79,9 +79,14 @@ import * as functionHandlers from './handlers/function-handlers.js';
|
|
|
79
79
|
import { listResources, readResource } from './handlers/resource-handlers.js';
|
|
80
80
|
import { listPrompts, getPrompt } from './handlers/prompt-handlers.js';
|
|
81
81
|
import * as subMgr from './subscription-manager.js';
|
|
82
|
-
import {
|
|
82
|
+
import { sessionManager } from './subscription-manager.js';
|
|
83
|
+
import { SseSessionManager } from './sse-session-manager.js';
|
|
84
|
+
import { SseSession } from './sse-session.js';
|
|
83
85
|
import config from './config.js';
|
|
84
86
|
|
|
87
|
+
const mcpContext = new AsyncLocalStorage();
|
|
88
|
+
const sseSessionManager = new SseSessionManager();
|
|
89
|
+
|
|
85
90
|
const SERVER_CAPABILITIES = {
|
|
86
91
|
tools: {},
|
|
87
92
|
resources: { subscribe: true, listChanged: true },
|
|
@@ -108,6 +113,30 @@ const mcpLimiter = rateLimit({
|
|
|
108
113
|
|
|
109
114
|
export { CAD_PLATFORMS, FILE_EXTENSIONS, MOCK_PACKAGES } from './constants.js';
|
|
110
115
|
|
|
116
|
+
const FUNCTION_LIB_URL = process.env.FUNCTION_LIB_URL || 'http://s3.atlisp.cn/json/functions.json';
|
|
117
|
+
let cachedFunctionLib = null;
|
|
118
|
+
let cachedAt = null;
|
|
119
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
120
|
+
|
|
121
|
+
export async function loadAtlibFunctionLib() {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
if (cachedFunctionLib && cachedAt && (now - cachedAt) < CACHE_TTL) {
|
|
124
|
+
return cachedFunctionLib;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(FUNCTION_LIB_URL, { headers: { 'User-Agent': 'atlisp-mcp' } });
|
|
128
|
+
if (response.ok) {
|
|
129
|
+
const data = await response.json();
|
|
130
|
+
cachedFunctionLib = data;
|
|
131
|
+
cachedAt = now;
|
|
132
|
+
return data;
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log(`loadAtlibFunctionLib error: ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
const TOOL_HANDLERS = {
|
|
112
141
|
connect_cad: () => cadHandlers.connectCad(),
|
|
113
142
|
eval_lisp: (a) => cadHandlers.evalLisp(a.code),
|
|
@@ -124,19 +153,48 @@ const TOOL_HANDLERS = {
|
|
|
124
153
|
init_atlisp: () => cadHandlers.initAtlisp(),
|
|
125
154
|
get_function_usage: (a) => functionHandlers.getFunctionUsage(a.name, a.package),
|
|
126
155
|
list_functions: (a) => functionHandlers.listFunctions(a.package),
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
130
|
-
|
|
156
|
+
load_atlisp_functionlib: async (a) => {
|
|
157
|
+
const data = await loadAtlibFunctionLib();
|
|
158
|
+
if (!data) return { content: [{ type: 'text', text: '加载函数库失败' }], isError: true };
|
|
159
|
+
if (a.format === 'list') {
|
|
160
|
+
const items = data.functions?.map(f => `${f.name}: ${f.description || ''}`) || [];
|
|
161
|
+
return { content: [{ type: 'text', text: items.join('\n') }] };
|
|
162
|
+
}
|
|
163
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
131
164
|
},
|
|
132
|
-
get_install_code: () => ({
|
|
133
|
-
content: [{
|
|
134
|
-
type: 'text',
|
|
135
|
-
text: `复制以下代码到 CAD 命令行加载:\n(load "atlisp" "atlisp")\n(vlax-create-object "atlisp.atlisp.1")`
|
|
136
|
-
}]
|
|
137
|
-
}),
|
|
138
165
|
};
|
|
139
166
|
|
|
167
|
+
function getOrCreateSessionServer(sessionId) {
|
|
168
|
+
let entry = sessionServers.get(sessionId);
|
|
169
|
+
if (!entry) {
|
|
170
|
+
const server = createMcpServer();
|
|
171
|
+
const transport = new SessionTransport(sessionId);
|
|
172
|
+
server.connect(transport);
|
|
173
|
+
entry = { server, transport };
|
|
174
|
+
sessionServers.set(sessionId, entry);
|
|
175
|
+
sessionTranports.set(sessionId, transport);
|
|
176
|
+
}
|
|
177
|
+
return entry;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function removeSessionServer(sessionId) {
|
|
181
|
+
const entry = sessionServers.get(sessionId);
|
|
182
|
+
if (entry) {
|
|
183
|
+
entry.transport.close().catch(() => {});
|
|
184
|
+
entry.server.close().catch(() => {});
|
|
185
|
+
sessionServers.delete(sessionId);
|
|
186
|
+
sessionTranports.delete(sessionId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function broadcastToAllSessions(uri) {
|
|
191
|
+
for (const [sid, entry] of sessionServers) {
|
|
192
|
+
entry.server.sendResourceUpdated({ uri }).catch(err => {
|
|
193
|
+
log(`Failed to send resource update to session ${sid}: ${err.message}`);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
140
198
|
export const tools = [
|
|
141
199
|
{
|
|
142
200
|
name: 'connect_cad',
|
|
@@ -228,7 +286,7 @@ export const tools = [
|
|
|
228
286
|
},
|
|
229
287
|
{
|
|
230
288
|
name: 'get_function_usage',
|
|
231
|
-
description: '获取 @lisp
|
|
289
|
+
description: '获取 @lisp 函数用法',
|
|
232
290
|
inputSchema: {
|
|
233
291
|
type: 'object',
|
|
234
292
|
properties: {
|
|
@@ -249,20 +307,14 @@ export const tools = [
|
|
|
249
307
|
}
|
|
250
308
|
},
|
|
251
309
|
{
|
|
252
|
-
name: '
|
|
253
|
-
description: '
|
|
310
|
+
name: 'load_atlisp_functionlib',
|
|
311
|
+
description: '从 http://s3.atlisp.cn/json/functions.json 加载 @lisp 函数库(用于 AI Agent 的提示词和编程调用)',
|
|
254
312
|
inputSchema: {
|
|
255
313
|
type: 'object',
|
|
256
314
|
properties: {
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
required: ['platform']
|
|
315
|
+
format: { type: 'string', enum: ['json', 'list'], description: '返回格式:json(完整JSON)或 list(函数名:描述 列表),默认 json' }
|
|
316
|
+
}
|
|
260
317
|
}
|
|
261
|
-
},
|
|
262
|
-
{
|
|
263
|
-
name: 'get_install_code',
|
|
264
|
-
description: '获取 @lisp 安装代码',
|
|
265
|
-
inputSchema: { type: 'object', properties: {} }
|
|
266
318
|
}
|
|
267
319
|
];
|
|
268
320
|
|
|
@@ -311,10 +363,11 @@ async function initCadConnection() {
|
|
|
311
363
|
}
|
|
312
364
|
}
|
|
313
365
|
|
|
314
|
-
|
|
366
|
+
const sessionServers = new Map();
|
|
367
|
+
const sessionTranports = new Map();
|
|
315
368
|
|
|
316
369
|
export function createMcpServer() {
|
|
317
|
-
server = new Server(
|
|
370
|
+
const server = new Server(
|
|
318
371
|
{ name: SERVER_NAME, version: SERVER_VERSION },
|
|
319
372
|
{ capabilities: SERVER_CAPABILITIES }
|
|
320
373
|
);
|
|
@@ -326,9 +379,14 @@ export function createMcpServer() {
|
|
|
326
379
|
return await handleToolCall(name, args || {});
|
|
327
380
|
});
|
|
328
381
|
|
|
329
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () =>
|
|
330
|
-
|
|
331
|
-
|
|
382
|
+
server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
|
|
383
|
+
const ctx = mcpContext.getStore();
|
|
384
|
+
const sessionId = ctx?.sessionId;
|
|
385
|
+
const subscribedUris = sessionId ? subMgr.list(sessionId) : subMgr.list();
|
|
386
|
+
return {
|
|
387
|
+
resources: await listResources(subscribedUris)
|
|
388
|
+
};
|
|
389
|
+
});
|
|
332
390
|
|
|
333
391
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
334
392
|
const { uri } = request.params;
|
|
@@ -352,13 +410,15 @@ export function createMcpServer() {
|
|
|
352
410
|
|
|
353
411
|
server.setRequestHandler(SubscribeRequestSchema, async (request) => {
|
|
354
412
|
const { uri } = request.params;
|
|
355
|
-
|
|
413
|
+
const ctx = mcpContext.getStore();
|
|
414
|
+
subMgr.subscribe(uri, ctx?.sessionId);
|
|
356
415
|
return { success: true };
|
|
357
416
|
});
|
|
358
417
|
|
|
359
418
|
server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
|
|
360
419
|
const { uri } = request.params;
|
|
361
|
-
|
|
420
|
+
const ctx = mcpContext.getStore();
|
|
421
|
+
subMgr.unsubscribe(uri, ctx?.sessionId);
|
|
362
422
|
return { success: true };
|
|
363
423
|
});
|
|
364
424
|
|
|
@@ -374,74 +434,18 @@ export function createMcpServer() {
|
|
|
374
434
|
return server;
|
|
375
435
|
}
|
|
376
436
|
|
|
377
|
-
async function dispatchMcpMethod(responder, method, params, id) {
|
|
378
|
-
try {
|
|
379
|
-
if (method === 'initialize') {
|
|
380
|
-
responder({ jsonrpc: '2.0', id, result: SERVER_INFO });
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const methodHandlers = {
|
|
385
|
-
'tools/list': async () => ({ tools }),
|
|
386
|
-
'resources/list': async () => ({ resources: await listResources(subMgr.list()) }),
|
|
387
|
-
'resources/read': async () => {
|
|
388
|
-
const uri = params?.uri;
|
|
389
|
-
if (!uri) throw createError('INVALID_PARAMS', { detail: '缺少 uri 参数' });
|
|
390
|
-
const data = await readResource(uri);
|
|
391
|
-
return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(data) }] };
|
|
392
|
-
},
|
|
393
|
-
'resources/subscribe': async () => {
|
|
394
|
-
const uri = params?.uri;
|
|
395
|
-
if (!uri) throw createError('INVALID_PARAMS', { detail: '缺少 uri 参数' });
|
|
396
|
-
subMgr.subscribe(uri);
|
|
397
|
-
return { success: true };
|
|
398
|
-
},
|
|
399
|
-
'resources/unsubscribe': async () => {
|
|
400
|
-
const uri = params?.uri;
|
|
401
|
-
if (!uri) throw createError('INVALID_PARAMS', { detail: '缺少 uri 参数' });
|
|
402
|
-
subMgr.unsubscribe(uri);
|
|
403
|
-
return { success: true };
|
|
404
|
-
},
|
|
405
|
-
'tools/call': async () => {
|
|
406
|
-
const toolName = params?.name;
|
|
407
|
-
if (!toolName) throw createError('INVALID_PARAMS', { detail: 'missing tool name' });
|
|
408
|
-
return await handleToolCall(toolName, params?.arguments || {});
|
|
409
|
-
},
|
|
410
|
-
'prompts/list': async () => ({ prompts: await listPrompts() }),
|
|
411
|
-
'prompts/get': async () => {
|
|
412
|
-
const promptName = params?.name;
|
|
413
|
-
if (!promptName) throw createError('INVALID_PARAMS', { detail: '缺少 prompt name 参数' });
|
|
414
|
-
return await getPrompt(promptName, params?.arguments || {});
|
|
415
|
-
}
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
const handler = methodHandlers[method];
|
|
419
|
-
if (handler) {
|
|
420
|
-
const result = await handler();
|
|
421
|
-
responder({ jsonrpc: '2.0', id, result });
|
|
422
|
-
} else {
|
|
423
|
-
responder({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } });
|
|
424
|
-
}
|
|
425
|
-
} catch (e) {
|
|
426
|
-
const mcpError = toMcpError(e);
|
|
427
|
-
log(`MCP error [${method}]: ${mcpError.message}`);
|
|
428
|
-
responder({ jsonrpc: '2.0', id, error: { code: -32603, message: mcpError.message } });
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
437
|
|
|
432
|
-
async function handleMcpRequest(session, method, params, id) {
|
|
433
|
-
await dispatchMcpMethod(
|
|
434
|
-
(response) => session.sendResponse(response),
|
|
435
|
-
method, params, id
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
438
|
|
|
439
439
|
export async function startServer() {
|
|
440
440
|
if (config.transport === 'stdio') {
|
|
441
441
|
await initCadConnection();
|
|
442
442
|
const mcpServer = createMcpServer();
|
|
443
443
|
const transport = new StdioServerTransport();
|
|
444
|
-
|
|
444
|
+
const sessionId = 'stdio-session';
|
|
445
|
+
sessionManager.createSession(sessionId);
|
|
446
|
+
await mcpContext.run({ sessionId }, async () => {
|
|
447
|
+
await mcpServer.connect(transport);
|
|
448
|
+
});
|
|
445
449
|
console.error(`${SERVER_NAME} 运行在 stdio 模式`);
|
|
446
450
|
|
|
447
451
|
subMgr.setNotify((uri, data) => {
|
|
@@ -502,129 +506,115 @@ export async function startServer() {
|
|
|
502
506
|
});
|
|
503
507
|
}
|
|
504
508
|
|
|
505
|
-
const mcpServer = createMcpServer();
|
|
506
|
-
|
|
507
|
-
const transport = new StreamableHTTPServerTransport({
|
|
508
|
-
sessionIdGenerator: () => randomUUID(),
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
await mcpServer.connect(transport);
|
|
512
|
-
log('MCP Server connected to StreamableHTTPServerTransport');
|
|
513
|
-
|
|
514
509
|
subMgr.setNotify((uri, data) => {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
510
|
+
broadcastToAllSessions(uri);
|
|
511
|
+
for (const session of sseSessionManager.listActive()) {
|
|
512
|
+
sseSessionManager.sendToClient(session.clientId, 'resource_updated', { uri });
|
|
513
|
+
}
|
|
518
514
|
});
|
|
519
515
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
527
|
-
}
|
|
516
|
+
async function handleMcpPost(req, res) {
|
|
517
|
+
const sessionId = req.headers['mcp-session-id'] || req.query.sessionId || randomUUID();
|
|
518
|
+
sessionManager.createSession(sessionId);
|
|
519
|
+
const body = req.body;
|
|
520
|
+
if (!body || !body.method) {
|
|
521
|
+
return res.status(400).json({ error: 'Invalid request' });
|
|
528
522
|
}
|
|
529
|
-
});
|
|
530
523
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
524
|
+
const { server, transport } = getOrCreateSessionServer(sessionId);
|
|
525
|
+
transport.resetCleanupTimer();
|
|
526
|
+
|
|
527
|
+
await mcpContext.run({ sessionId }, async () => {
|
|
528
|
+
try {
|
|
529
|
+
const response = await transport.handleRequest(body);
|
|
530
|
+
if (response) {
|
|
531
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
532
|
+
if (transport.sessionId) {
|
|
533
|
+
headers['mcp-session-id'] = transport.sessionId;
|
|
534
|
+
}
|
|
535
|
+
res.writeHead(200, headers);
|
|
536
|
+
res.end(JSON.stringify(response));
|
|
537
|
+
} else {
|
|
538
|
+
if (!res.headersSent) {
|
|
539
|
+
res.status(202).end();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {
|
|
543
|
+
log(`Transport error [${sessionId}]: ${error.message}`);
|
|
544
|
+
if (!res.headersSent) {
|
|
545
|
+
res.status(500).json({ error: 'Internal server error', message: error.message });
|
|
546
|
+
}
|
|
538
547
|
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
app.all('/mcp', mcpLimiter, handleMcpPost);
|
|
552
|
+
app.all('/mcp/:path', mcpLimiter, handleMcpPost);
|
|
553
|
+
app.get('/mcp/stream', mcpLimiter, handleMcpPost);
|
|
541
554
|
|
|
542
555
|
const sseEndpoint = '/sse';
|
|
543
556
|
app.get(sseEndpoint, async (req, res) => {
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
await sseTransport.handleRequest(req, res);
|
|
548
|
-
} catch (error) {
|
|
549
|
-
log(`SSE transport error: ${error.message}`);
|
|
550
|
-
if (!res.headersSent) {
|
|
551
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
});
|
|
555
|
-
app.post(sseEndpoint, mcpLimiter, async (req, res) => {
|
|
556
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
557
|
-
const sseTransport = sessionId ? sseEndpoint + '?sessionId=' + sessionId : sseEndpoint;
|
|
558
|
-
try {
|
|
559
|
-
const transport = new SSEServerTransport(sseEndpoint, res);
|
|
560
|
-
await mcpServer.connect(transport);
|
|
561
|
-
await transport.handleRequest(req, res);
|
|
562
|
-
} catch (error) {
|
|
563
|
-
log(`SSE POST error: ${error.message}`);
|
|
564
|
-
if (!res.headersSent) {
|
|
565
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
});
|
|
557
|
+
const sessionId = randomUUID();
|
|
558
|
+
sessionManager.createSession(sessionId);
|
|
559
|
+
getOrCreateSessionServer(sessionId);
|
|
569
560
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
await mcpServer.connect(sseTransport);
|
|
574
|
-
await sseTransport.handleRequest(req, res);
|
|
575
|
-
} catch (error) {
|
|
576
|
-
log(`/mcp/sse transport error: ${error.message}`);
|
|
577
|
-
if (!res.headersSent) {
|
|
578
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
});
|
|
582
|
-
app.post('/mcp/sse', mcpLimiter, async (req, res) => {
|
|
583
|
-
const sseTransport = new SSEServerTransport('/mcp/sse', res);
|
|
584
|
-
try {
|
|
585
|
-
await mcpServer.connect(sseTransport);
|
|
586
|
-
await sseTransport.handleRequest(req, res);
|
|
587
|
-
} catch (error) {
|
|
588
|
-
log(`/mcp/sse POST error: ${error.message}`);
|
|
589
|
-
if (!res.headersSent) {
|
|
590
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
});
|
|
561
|
+
const sseSession = new SseSession(res, { clientId: sessionId });
|
|
562
|
+
sseSessionManager.add(sessionId, sseSession);
|
|
594
563
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
602
|
-
}
|
|
603
|
-
}
|
|
564
|
+
sseSession.sendEndpoint(`/message?sessionId=${sessionId}`);
|
|
565
|
+
|
|
566
|
+
req.on('close', () => {
|
|
567
|
+
sseSessionManager.remove(sessionId);
|
|
568
|
+
removeSessionServer(sessionId);
|
|
569
|
+
});
|
|
604
570
|
});
|
|
605
571
|
|
|
606
572
|
app.post('/message', mcpLimiter, async (req, res) => {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
573
|
+
const sessionId = req.query.sessionId;
|
|
574
|
+
if (!sessionId) {
|
|
575
|
+
return res.status(400).json({ error: 'Missing sessionId' });
|
|
576
|
+
}
|
|
577
|
+
const sseSession = sseSessionManager.get(sessionId);
|
|
578
|
+
if (!sseSession) {
|
|
579
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
580
|
+
}
|
|
581
|
+
sessionManager.createSession(sessionId);
|
|
582
|
+
const { server, transport } = getOrCreateSessionServer(sessionId);
|
|
583
|
+
transport.resetCleanupTimer();
|
|
584
|
+
|
|
585
|
+
const body = req.body;
|
|
586
|
+
if (body && body.method) {
|
|
587
|
+
try {
|
|
588
|
+
const response = await transport.handleRequest(body);
|
|
589
|
+
if (response) {
|
|
590
|
+
sseSession.sendResponse(response);
|
|
591
|
+
}
|
|
592
|
+
if (!res.headersSent) {
|
|
593
|
+
res.status(202).end();
|
|
594
|
+
}
|
|
595
|
+
} catch (error) {
|
|
596
|
+
log(`SSE message error [${sessionId}]: ${error.message}`);
|
|
597
|
+
if (!res.headersSent) {
|
|
598
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
599
|
+
}
|
|
616
600
|
}
|
|
601
|
+
} else {
|
|
602
|
+
res.status(400).json({ error: 'Invalid request' });
|
|
617
603
|
}
|
|
618
604
|
});
|
|
619
605
|
|
|
620
606
|
const httpServer = app.listen(config.port, config.host, async () => {
|
|
621
607
|
await initCadConnection();
|
|
622
|
-
console.error(`@lisp MCP Server 启动 - http://${config.host}:${config.port}
|
|
608
|
+
console.error(`@lisp MCP Server 启动 - http://${config.host}:${config.port} (多会话模式)`);
|
|
623
609
|
});
|
|
624
610
|
|
|
625
611
|
async function shutdown(signal) {
|
|
626
612
|
console.error(`\n收到 ${signal},正在优雅关闭...`);
|
|
627
|
-
|
|
613
|
+
sseSessionManager.closeAll();
|
|
614
|
+
sessionManager.clear();
|
|
615
|
+
for (const [sid] of sessionServers) {
|
|
616
|
+
removeSessionServer(sid);
|
|
617
|
+
}
|
|
628
618
|
await cad.disconnect();
|
|
629
619
|
closeLog();
|
|
630
620
|
httpServer.close(() => process.exit(0));
|
package/src/cad-worker.js
CHANGED
|
@@ -244,10 +244,11 @@ process.stdin.on('data', (data) => {
|
|
|
244
244
|
if (!line.trim()) continue;
|
|
245
245
|
try {
|
|
246
246
|
const msg = JSON.parse(line);
|
|
247
|
+
const requestId = msg.requestId;
|
|
247
248
|
handleMessage(msg).then(result => {
|
|
248
|
-
process.stdout.write(JSON.stringify(result) + '\n');
|
|
249
|
+
process.stdout.write(JSON.stringify({ ...result, requestId }) + '\n');
|
|
249
250
|
}).catch(err => {
|
|
250
|
-
process.stdout.write(JSON.stringify({ error: err.message }) + '\n');
|
|
251
|
+
process.stdout.write(JSON.stringify({ error: err.message, requestId }) + '\n');
|
|
251
252
|
});
|
|
252
253
|
} catch (e) {
|
|
253
254
|
process.stdout.write(JSON.stringify({ error: 'invalid message' }) + '\n');
|
package/src/cad.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
4
5
|
import { CAD_PLATFORMS, FILE_EXTENSIONS } from './constants.js';
|
|
5
6
|
import config from './config.js';
|
|
6
7
|
|
|
@@ -15,8 +16,19 @@ let _platform = null;
|
|
|
15
16
|
let _version = null;
|
|
16
17
|
|
|
17
18
|
let heartbeatTimer = null;
|
|
19
|
+
const pendingRequests = new Map();
|
|
20
|
+
let stdoutHandlerSetup = false;
|
|
21
|
+
|
|
22
|
+
const MAX_BUFFER_SIZE = 1024 * 1024;
|
|
18
23
|
|
|
19
24
|
export function resetWorker() {
|
|
25
|
+
for (const [id, { reject, timeout }] of pendingRequests) {
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
reject(new Error('Worker reset'));
|
|
28
|
+
}
|
|
29
|
+
pendingRequests.clear();
|
|
30
|
+
stdoutHandlerSetup = false;
|
|
31
|
+
|
|
20
32
|
if (worker) {
|
|
21
33
|
worker.kill();
|
|
22
34
|
worker = null;
|
|
@@ -27,6 +39,43 @@ export function resetWorker() {
|
|
|
27
39
|
}
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
function setupStdoutHandler(w) {
|
|
43
|
+
let buffer = '';
|
|
44
|
+
stdoutHandlerSetup = true;
|
|
45
|
+
|
|
46
|
+
w.stdout.on('data', (data) => {
|
|
47
|
+
try {
|
|
48
|
+
buffer += data.toString();
|
|
49
|
+
if (buffer.length > MAX_BUFFER_SIZE) {
|
|
50
|
+
buffer = buffer.slice(-MAX_BUFFER_SIZE);
|
|
51
|
+
}
|
|
52
|
+
const lines = buffer.split('\n');
|
|
53
|
+
buffer = lines.pop() || '';
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
if (!line.trim()) continue;
|
|
56
|
+
try {
|
|
57
|
+
const result = JSON.parse(line);
|
|
58
|
+
const rid = result.requestId;
|
|
59
|
+
if (rid && pendingRequests.has(rid)) {
|
|
60
|
+
const { resolve, reject, timeout } = pendingRequests.get(rid);
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
pendingRequests.delete(rid);
|
|
63
|
+
if (result.error) {
|
|
64
|
+
reject(new Error(result.error));
|
|
65
|
+
} else {
|
|
66
|
+
resolve(result);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (parseErr) {
|
|
70
|
+
console.error('JSON parse error in worker output:', parseErr.message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error('Unexpected error in worker stdout handler:', e.message);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
export function getWorker() {
|
|
31
80
|
if (!worker || !worker.connected) {
|
|
32
81
|
if (worker) worker.kill();
|
|
@@ -34,18 +83,27 @@ export function getWorker() {
|
|
|
34
83
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
35
84
|
});
|
|
36
85
|
worker.connected = true;
|
|
37
|
-
|
|
86
|
+
|
|
38
87
|
worker.stderr.on('data', (d) => console.error('worker stderr:', d.toString()));
|
|
39
88
|
const w = worker;
|
|
40
89
|
w.on('exit', (code) => {
|
|
41
90
|
console.error('worker exited:', code);
|
|
42
91
|
if (w !== worker) return;
|
|
43
92
|
w.connected = false;
|
|
93
|
+
|
|
94
|
+
for (const [id, { reject, timeout }] of pendingRequests) {
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
reject(new Error(`Worker exited with code ${code}`));
|
|
97
|
+
}
|
|
98
|
+
pendingRequests.clear();
|
|
99
|
+
stdoutHandlerSetup = false;
|
|
100
|
+
|
|
44
101
|
if (heartbeatTimer) {
|
|
45
102
|
clearInterval(heartbeatTimer);
|
|
46
103
|
heartbeatTimer = null;
|
|
47
104
|
}
|
|
48
105
|
});
|
|
106
|
+
setupStdoutHandler(w);
|
|
49
107
|
startHeartbeat();
|
|
50
108
|
}
|
|
51
109
|
return worker;
|
|
@@ -68,53 +126,26 @@ function startHeartbeat() {
|
|
|
68
126
|
}
|
|
69
127
|
|
|
70
128
|
export function sendMessage(msg) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let responded = false;
|
|
74
|
-
let buffer = '';
|
|
129
|
+
const w = getWorker();
|
|
130
|
+
const requestId = randomUUID();
|
|
75
131
|
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
76
133
|
const timeout = setTimeout(() => {
|
|
77
|
-
|
|
78
|
-
|
|
134
|
+
const pending = pendingRequests.get(requestId);
|
|
135
|
+
if (pending) {
|
|
136
|
+
pendingRequests.delete(requestId);
|
|
79
137
|
reject(new Error('Timeout waiting for worker'));
|
|
80
138
|
}
|
|
81
139
|
}, MESSAGE_TIMEOUT);
|
|
82
140
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const result = JSON.parse(line);
|
|
92
|
-
if (result.error) {
|
|
93
|
-
if (!responded) {
|
|
94
|
-
responded = true;
|
|
95
|
-
clearTimeout(timeout);
|
|
96
|
-
w.stdout.off('data', handler);
|
|
97
|
-
reject(new Error(result.error));
|
|
98
|
-
}
|
|
99
|
-
} else {
|
|
100
|
-
if (!responded) {
|
|
101
|
-
responded = true;
|
|
102
|
-
clearTimeout(timeout);
|
|
103
|
-
w.stdout.off('data', handler);
|
|
104
|
-
resolve(result);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
} catch (parseErr) {
|
|
108
|
-
console.error('JSON parse error in worker output:', parseErr.message, 'line:', line.slice(0, 200));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} catch (e) {
|
|
112
|
-
console.error('Unexpected error in worker stdout handler:', e.message);
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
w.stdout.on('data', handler);
|
|
117
|
-
w.stdin.write(JSON.stringify(msg) + '\n');
|
|
141
|
+
pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
142
|
+
try {
|
|
143
|
+
w.stdin.write(JSON.stringify({ ...msg, requestId }) + '\n');
|
|
144
|
+
} catch (e) {
|
|
145
|
+
clearTimeout(timeout);
|
|
146
|
+
pendingRequests.delete(requestId);
|
|
147
|
+
reject(new Error(`Failed to write to worker: ${e.message}`));
|
|
148
|
+
}
|
|
118
149
|
});
|
|
119
150
|
}
|
|
120
151
|
|
|
@@ -78,6 +78,18 @@ export async function installAtlisp() {
|
|
|
78
78
|
return { content: [{ type: 'text', text: '已发送 @lisp 安装代码到 CAD' }] };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export async function listFunctionsInCad() {
|
|
82
|
+
if (!cad.connected) { await cad.connect(); }
|
|
83
|
+
if (!cad.connected) return { content: [{ type: 'text', text: '未连接 CAD' }], isError: true };
|
|
84
|
+
try {
|
|
85
|
+
const result = await cad.sendCommandWithResult('(atoms-family 0)', null);
|
|
86
|
+
if (result) return { content: [{ type: 'text', text: result }] };
|
|
87
|
+
return { content: [{ type: 'text', text: 'CAD 函数列表为空' }] };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { content: [{ type: 'text', text: `错误: ${e.message}` }], isError: true };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
export async function initAtlisp() {
|
|
82
94
|
if (!cad.connected) { await cad.connect(); }
|
|
83
95
|
if (!cad.connected) return { content: [{ type: 'text', text: '未连接 CAD' }], isError: true };
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { log } from '../logger.js';
|
|
4
|
+
import * as cadHandlers from './cad-handlers.js';
|
|
4
5
|
|
|
5
6
|
const FUNCTIONS_DIR = process.env.FUNCTIONS_DIR || '';
|
|
6
|
-
const FUNCTIONS_URL = process.env.FUNCTIONS_URL || '
|
|
7
|
+
const FUNCTIONS_URL = process.env.FUNCTIONS_URL || 'http://s3.atlisp.cn/json/functions.json';
|
|
7
8
|
|
|
8
9
|
async function fileExists(filePath) {
|
|
9
10
|
try { await fs.access(filePath); return true; } catch { return false; }
|
|
@@ -13,40 +14,25 @@ export async function getFunctionUsage(funcName, packageName) {
|
|
|
13
14
|
try {
|
|
14
15
|
let results = [];
|
|
15
16
|
|
|
16
|
-
const url = packageName
|
|
17
|
-
? `${FUNCTIONS_URL}${packageName}.json`
|
|
18
|
-
: `${FUNCTIONS_URL}all.json`;
|
|
19
|
-
|
|
20
17
|
try {
|
|
21
|
-
const response = await fetch(
|
|
18
|
+
const response = await fetch(FUNCTIONS_URL, { headers: { 'User-Agent': 'atlisp-mcp' } });
|
|
22
19
|
if (response.ok) {
|
|
23
20
|
const data = await response.json();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
21
|
+
const funcs = data.functions || [];
|
|
22
|
+
const func = funcs.find(f => f.name === funcName || f.name === `${packageName}:${funcName}`);
|
|
23
|
+
if (func) results.push(func);
|
|
28
24
|
}
|
|
29
25
|
} catch (e) {
|
|
30
26
|
log(`function-handlers fetch error: ${e.message}`);
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
if (results.length === 0 && FUNCTIONS_DIR && await fileExists(FUNCTIONS_DIR)) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (func) results.push(func);
|
|
41
|
-
}
|
|
42
|
-
} else {
|
|
43
|
-
const files = (await fs.readdir(FUNCTIONS_DIR)).filter(f => f.endsWith('.json'));
|
|
44
|
-
for (const file of files) {
|
|
45
|
-
const raw = await fs.readFile(path.join(FUNCTIONS_DIR, file), 'utf-8');
|
|
46
|
-
const data = JSON.parse(raw);
|
|
47
|
-
const func = data.functions?.find(f => f.name === funcName);
|
|
48
|
-
if (func) results.push(func);
|
|
49
|
-
}
|
|
30
|
+
const files = (await fs.readdir(FUNCTIONS_DIR)).filter(f => f.endsWith('.json'));
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const raw = await fs.readFile(path.join(FUNCTIONS_DIR, file), 'utf-8');
|
|
33
|
+
const data = JSON.parse(raw);
|
|
34
|
+
const func = data.functions?.find(f => f.name === funcName);
|
|
35
|
+
if (func) results.push(func);
|
|
50
36
|
}
|
|
51
37
|
}
|
|
52
38
|
|
|
@@ -71,18 +57,15 @@ export async function listFunctions(packageName) {
|
|
|
71
57
|
try {
|
|
72
58
|
let allFuncs = [];
|
|
73
59
|
|
|
74
|
-
const url = packageName
|
|
75
|
-
? `${FUNCTIONS_URL}${packageName}.json`
|
|
76
|
-
: `${FUNCTIONS_URL}all.json`;
|
|
77
|
-
|
|
78
60
|
try {
|
|
79
|
-
const response = await fetch(
|
|
61
|
+
const response = await fetch(FUNCTIONS_URL, { headers: { 'User-Agent': 'atlisp-mcp' } });
|
|
80
62
|
if (response.ok) {
|
|
81
63
|
const data = await response.json();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
64
|
+
const funcs = data.functions || [];
|
|
65
|
+
if (packageName) {
|
|
66
|
+
allFuncs = funcs.filter(f => f.name.startsWith(`${packageName}:`));
|
|
67
|
+
} else {
|
|
68
|
+
allFuncs = funcs.map(f => `${f.name}: ${f.description || '-'}`);
|
|
86
69
|
}
|
|
87
70
|
}
|
|
88
71
|
} catch (e) {
|
|
@@ -92,29 +75,32 @@ export async function listFunctions(packageName) {
|
|
|
92
75
|
if (allFuncs.length === 0 && FUNCTIONS_DIR && await fileExists(FUNCTIONS_DIR)) {
|
|
93
76
|
const files = (await fs.readdir(FUNCTIONS_DIR)).filter(f => f.endsWith('.json'));
|
|
94
77
|
for (const file of files) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
78
|
+
try {
|
|
79
|
+
const raw = await fs.readFile(path.join(FUNCTIONS_DIR, file), 'utf-8');
|
|
80
|
+
const data = JSON.parse(raw);
|
|
81
|
+
const funcs = data.functions || [];
|
|
82
|
+
if (packageName) {
|
|
83
|
+
allFuncs = funcs.filter(f => f.name.startsWith(`${packageName}:`));
|
|
84
|
+
} else {
|
|
85
|
+
for (const f of funcs) {
|
|
86
|
+
allFuncs.push(`${f.name}: ${f.description || '-'}`);
|
|
87
|
+
}
|
|
104
88
|
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log(`read file ${file} error: ${err.message}`);
|
|
105
91
|
}
|
|
106
92
|
}
|
|
107
93
|
}
|
|
108
94
|
|
|
109
95
|
if (allFuncs.length === 0) {
|
|
110
|
-
return
|
|
96
|
+
return await cadHandlers.listFunctionsInCad();
|
|
111
97
|
}
|
|
112
98
|
|
|
113
99
|
if (packageName) {
|
|
114
100
|
return { content: [{ type: 'text', text: `${packageName} 包函数 (${allFuncs.length}):\n${allFuncs.map(f => f.name).join('\n')}` }] };
|
|
115
101
|
}
|
|
116
102
|
|
|
117
|
-
return { content: [{ type: 'text', text:
|
|
103
|
+
return { content: [{ type: 'text', text: allFuncs.join('\n') }] };
|
|
118
104
|
} catch (e) {
|
|
119
105
|
return { content: [{ type: 'text', text: `错误: ${e.message}` }], isError: true };
|
|
120
106
|
}
|
|
@@ -1,17 +1,57 @@
|
|
|
1
|
+
const PACKAGES_LIST_URL = process.env.PACKAGES_LIST_URL || 'http://s3.atlisp.cn/packages-list?fmt=json';
|
|
1
2
|
import { cad } from '../cad.js';
|
|
2
3
|
import { MOCK_PACKAGES } from '../constants.js';
|
|
4
|
+
let cachedPackages = null;
|
|
5
|
+
let cachedAt = null;
|
|
6
|
+
const CACHE_TTL = 10 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
async function fetchPackages() {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
if (cachedPackages && cachedAt && (now - cachedAt) < CACHE_TTL) {
|
|
11
|
+
return cachedPackages;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(PACKAGES_LIST_URL, { headers: { 'User-Agent': 'atlisp-mcp' } });
|
|
15
|
+
if (response.ok) {
|
|
16
|
+
const data = await response.json();
|
|
17
|
+
cachedPackages = data;
|
|
18
|
+
cachedAt = now;
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
} catch (e) {}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
3
24
|
|
|
4
25
|
export async function listPackages() {
|
|
26
|
+
if (!cad.connected) await cad.connect();
|
|
27
|
+
if (!cad.connected) return { content: [{ type: 'text', text: '未连接 CAD' }], isError: true };
|
|
28
|
+
const result = await cad.sendCommandWithResult('(@::package-list-in-local)');
|
|
29
|
+
if (result && result !== 'nil') return { content: [{ type: 'text', text: result }] };
|
|
5
30
|
const packages = MOCK_PACKAGES.map(p => `${p.name} v${p.version} - ${p.description}`);
|
|
6
31
|
return { content: [{ type: 'text', text: `已安装的 @lisp 包:\n${packages.join('\n')}` }] };
|
|
7
32
|
}
|
|
8
33
|
|
|
9
34
|
export async function searchPackages(query) {
|
|
10
|
-
const
|
|
35
|
+
const data = await fetchPackages();
|
|
36
|
+
if (!data) {
|
|
37
|
+
return { content: [{ type: 'text', text: '无法获取包列表' }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
const results = [];
|
|
40
|
+
const items = Array.isArray(data) ? data : (data.packages || []);
|
|
41
|
+
if (query) {
|
|
42
|
+
const q = query.toLowerCase();
|
|
43
|
+
for (const p of items) {
|
|
44
|
+
const name = (p.name || '').toLowerCase();
|
|
45
|
+
const desc = (p.description || '').toLowerCase();
|
|
46
|
+
if (name.includes(q) || desc.includes(q)) results.push(p);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
for (const p of items) results.push(p);
|
|
50
|
+
}
|
|
11
51
|
if (results.length === 0) {
|
|
12
52
|
return { content: [{ type: 'text', text: `未找到包含 "${query}" 的包` }] };
|
|
13
53
|
}
|
|
14
|
-
return { content: [{ type: 'text', text:
|
|
54
|
+
return { content: [{ type: 'text', text: `搜索结果 (${results.length}):\n${results.map(p => `${p.name} v${p.version || ''} - ${p.description || ''}`).join('\n')}` }] };
|
|
15
55
|
}
|
|
16
56
|
|
|
17
57
|
export async function installPackage(packageName) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export class Session {
|
|
2
|
+
constructor(clientId) {
|
|
3
|
+
this.clientId = clientId;
|
|
4
|
+
this.subscriptions = new Set();
|
|
5
|
+
this.createdAt = Date.now();
|
|
6
|
+
this.connected = false;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SessionManager {
|
|
11
|
+
#sessions = new Map();
|
|
12
|
+
|
|
13
|
+
createSession(clientId) {
|
|
14
|
+
let session = this.#sessions.get(clientId);
|
|
15
|
+
if (!session) {
|
|
16
|
+
session = new Session(clientId);
|
|
17
|
+
this.#sessions.set(clientId, session);
|
|
18
|
+
}
|
|
19
|
+
return session;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getSession(clientId) {
|
|
23
|
+
return this.#sessions.get(clientId) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
hasSession(clientId) {
|
|
27
|
+
return this.#sessions.has(clientId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
removeSession(clientId) {
|
|
31
|
+
return this.#sessions.delete(clientId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
subscribe(clientId, uri) {
|
|
35
|
+
const session = this.#sessions.get(clientId);
|
|
36
|
+
if (session) {
|
|
37
|
+
session.subscriptions.add(uri);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
unsubscribe(clientId, uri) {
|
|
44
|
+
const session = this.#sessions.get(clientId);
|
|
45
|
+
if (session) {
|
|
46
|
+
return session.subscriptions.delete(uri);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSubscriptions(clientId) {
|
|
52
|
+
const session = this.#sessions.get(clientId);
|
|
53
|
+
return session ? Array.from(session.subscriptions) : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
isSubscribed(clientId, uri) {
|
|
57
|
+
const session = this.#sessions.get(clientId);
|
|
58
|
+
return session ? session.subscriptions.has(uri) : false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getAllSubscribedUris() {
|
|
62
|
+
const all = new Set();
|
|
63
|
+
for (const session of this.#sessions.values()) {
|
|
64
|
+
for (const uri of session.subscriptions) {
|
|
65
|
+
all.add(uri);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return all;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
list() {
|
|
72
|
+
return Array.from(this.#sessions.entries()).map(([id, s]) => ({
|
|
73
|
+
clientId: id,
|
|
74
|
+
subscriptions: Array.from(s.subscriptions),
|
|
75
|
+
createdAt: s.createdAt,
|
|
76
|
+
connected: s.connected
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
count() {
|
|
81
|
+
return this.#sessions.size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
clear() {
|
|
85
|
+
this.#sessions.clear();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default SessionManager;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export class SessionTransport {
|
|
6
|
+
constructor(sessionId) {
|
|
7
|
+
this.sessionId = sessionId || randomUUID();
|
|
8
|
+
this.onclose = null;
|
|
9
|
+
this.onerror = null;
|
|
10
|
+
this.onmessage = null;
|
|
11
|
+
this._pendingRequest = null;
|
|
12
|
+
this._started = false;
|
|
13
|
+
this._closed = false;
|
|
14
|
+
this._cleanupTimer = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async start() {
|
|
18
|
+
if (this._started) {
|
|
19
|
+
throw new Error('Transport already started');
|
|
20
|
+
}
|
|
21
|
+
this._started = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(message) {
|
|
25
|
+
if (this._closed) return;
|
|
26
|
+
if (this._pendingRequest) {
|
|
27
|
+
const { resolve, reject, timeout } = this._pendingRequest;
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
this._pendingRequest = null;
|
|
30
|
+
resolve(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async close() {
|
|
35
|
+
if (this._closed) return;
|
|
36
|
+
this._closed = true;
|
|
37
|
+
if (this._pendingRequest) {
|
|
38
|
+
const { reject, timeout } = this._pendingRequest;
|
|
39
|
+
clearTimeout(timeout);
|
|
40
|
+
this._pendingRequest = null;
|
|
41
|
+
reject(new Error('Transport closed'));
|
|
42
|
+
}
|
|
43
|
+
this._clearCleanupTimer();
|
|
44
|
+
this.onclose?.();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async handleRequest(message, timeoutMs = 60000) {
|
|
48
|
+
if (this._closed) {
|
|
49
|
+
throw new Error('Transport closed');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!message || !message.method) {
|
|
53
|
+
throw new Error('Invalid message');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!message.id) {
|
|
57
|
+
this.onmessage?.(message);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timeout = setTimeout(() => {
|
|
63
|
+
if (this._pendingRequest?.resolve === resolve) {
|
|
64
|
+
this._pendingRequest = null;
|
|
65
|
+
reject(new Error('Request timeout'));
|
|
66
|
+
}
|
|
67
|
+
}, timeoutMs);
|
|
68
|
+
|
|
69
|
+
this._pendingRequest = { resolve, reject, timeout };
|
|
70
|
+
this.onmessage?.(message);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
resetCleanupTimer() {
|
|
75
|
+
this._clearCleanupTimer();
|
|
76
|
+
this._cleanupTimer = setTimeout(() => {
|
|
77
|
+
this.close();
|
|
78
|
+
}, SESSION_TIMEOUT);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_clearCleanupTimer() {
|
|
82
|
+
if (this._cleanupTimer) {
|
|
83
|
+
clearTimeout(this._cleanupTimer);
|
|
84
|
+
this._cleanupTimer = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default SessionTransport;
|
|
@@ -1,25 +1,38 @@
|
|
|
1
|
-
|
|
1
|
+
import { SessionManager } from './session-manager.js';
|
|
2
|
+
|
|
3
|
+
const sessionManager = new SessionManager();
|
|
2
4
|
let _notifyFn = null;
|
|
3
5
|
|
|
4
6
|
export function setNotify(fn) {
|
|
5
7
|
_notifyFn = fn;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export function subscribe(uri) {
|
|
9
|
-
|
|
10
|
+
export function subscribe(uri, sessionId) {
|
|
11
|
+
if (sessionId) {
|
|
12
|
+
return sessionManager.subscribe(sessionId, uri);
|
|
13
|
+
}
|
|
10
14
|
return true;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
|
-
export function unsubscribe(uri) {
|
|
14
|
-
|
|
17
|
+
export function unsubscribe(uri, sessionId) {
|
|
18
|
+
if (sessionId) {
|
|
19
|
+
return sessionManager.unsubscribe(sessionId, uri);
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
export function list() {
|
|
18
|
-
|
|
24
|
+
export function list(sessionId) {
|
|
25
|
+
if (sessionId) {
|
|
26
|
+
return sessionManager.getSubscriptions(sessionId);
|
|
27
|
+
}
|
|
28
|
+
return Array.from(sessionManager.getAllSubscribedUris());
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
export function isSubscribed(uri) {
|
|
22
|
-
|
|
31
|
+
export function isSubscribed(uri, sessionId) {
|
|
32
|
+
if (sessionId) {
|
|
33
|
+
return sessionManager.isSubscribed(sessionId, uri);
|
|
34
|
+
}
|
|
35
|
+
return sessionManager.getAllSubscribedUris().has(uri);
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export function notify(uri, data) {
|
|
@@ -29,9 +42,11 @@ export function notify(uri, data) {
|
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
export function clear() {
|
|
32
|
-
|
|
45
|
+
sessionManager.clear();
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
export function size() {
|
|
36
|
-
return
|
|
37
|
-
}
|
|
49
|
+
return sessionManager.count();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { sessionManager };
|