@bevel-software/platform-core-backend 0.13.3 → 0.13.6
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/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +4 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +10 -0
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +11 -0
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +95 -13
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/workspace/file-readers/extract-pdf.d.ts.map +1 -1
- package/dist/modules/workspace/file-readers/extract-pdf.js +18 -3
- package/dist/modules/workspace/file-readers/extract-pdf.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +26 -4
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/package.json +4 -4
- package/src/core/create-core-server.ts +4 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +4 -1
- package/src/modules/kb-fs/clone-config.ts +11 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +89 -0
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -34
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +8 -40
- package/src/modules/mcp/__tests__/mcp.routes.session.test.ts +226 -0
- package/src/modules/mcp/mcp.routes.ts +482 -398
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +54 -1
- package/src/modules/workspace/file-readers/extract-pdf.ts +18 -4
- package/src/modules/workspace/workspace.tools.ts +22 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Session routing on the MCP transport routes — how `/api/mcp` answers a
|
|
7
|
+
* request whose `Mcp-Session-Id` it cannot resolve.
|
|
8
|
+
*
|
|
9
|
+
* The distinction is load-bearing rather than cosmetic. Streamable HTTP makes
|
|
10
|
+
* HTTP 404 on a request bearing a session id the signal a client re-initializes
|
|
11
|
+
* on; 400 means "you sent something malformed", which has no recovery. These
|
|
12
|
+
* routes used to answer both misses — no session id, and a session id from a
|
|
13
|
+
* session that no longer exists — with one 400 on POST and one 404 on
|
|
14
|
+
* GET/DELETE, so a client whose session died with the server (a restart wipes
|
|
15
|
+
* the in-process map) was told its request was malformed and stayed stranded.
|
|
16
|
+
*
|
|
17
|
+
* Locked down here:
|
|
18
|
+
* - the two misses map to distinct, spec-correct codes on every verb;
|
|
19
|
+
* - both bodies are real JSON-RPC error objects, since this router answers
|
|
20
|
+
* before the SDK transport that would otherwise have shaped them;
|
|
21
|
+
* - an `initialize` carrying a STALE session id still opens a new session —
|
|
22
|
+
* the recovery path itself, which the old "no session id" guard blocked;
|
|
23
|
+
* - the live-session and cross-user branches are untouched.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The id the FIRST session minted in a test gets; `openSession` establishes it. */
|
|
27
|
+
const SESSION_ID = 'sess-1';
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await closeMountedRoutes();
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A createSession stub shaped like the real one: it hands back a transport
|
|
36
|
+
* whose `handleRequest` reports the session id it served, and announces the
|
|
37
|
+
* session through `onSessionInitialized` from INSIDE that first call — which
|
|
38
|
+
* is when the SDK transport really fires it, and the only ordering the route's
|
|
39
|
+
* `active.set` closure can observe without a TDZ error.
|
|
40
|
+
*
|
|
41
|
+
* Each call mints a DISTINCT id (`sess-1`, `sess-2`, …) rather than a constant.
|
|
42
|
+
* With a constant, a route that wrongly minted a second session answered with
|
|
43
|
+
* a body identical to the correct one, so "no second session" could only ever
|
|
44
|
+
* be asserted through the call count. Numbering them puts the mistake in the
|
|
45
|
+
* response itself: a reply naming `sess-2` IS the extra session.
|
|
46
|
+
*/
|
|
47
|
+
function makeMcpService() {
|
|
48
|
+
let minted = 0;
|
|
49
|
+
const createSession = vi.fn(
|
|
50
|
+
async (
|
|
51
|
+
userId: string,
|
|
52
|
+
_tokenId: string | null,
|
|
53
|
+
_bearer: string,
|
|
54
|
+
onSessionInitialized: (sessionId: string) => void,
|
|
55
|
+
) => {
|
|
56
|
+
minted += 1;
|
|
57
|
+
const sessionId = `sess-${minted}`;
|
|
58
|
+
let announced = false;
|
|
59
|
+
const transport = {
|
|
60
|
+
sessionId,
|
|
61
|
+
onclose: undefined as undefined | (() => void),
|
|
62
|
+
handleRequest: vi.fn(async (_req: ExpressRequest, res: ExpressResponse) => {
|
|
63
|
+
if (!announced) {
|
|
64
|
+
announced = true;
|
|
65
|
+
onSessionInitialized(sessionId);
|
|
66
|
+
}
|
|
67
|
+
res.status(200).json({ forwarded: true, sessionId, userId });
|
|
68
|
+
}),
|
|
69
|
+
close: vi.fn(async () => {}),
|
|
70
|
+
};
|
|
71
|
+
const server = { connect: vi.fn(async () => {}), close: vi.fn(async () => {}) };
|
|
72
|
+
return { transport, server };
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
return { onSessionEvicted: () => {}, createSession };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function mount(): Promise<{ baseUrl: string; createSession: ReturnType<typeof vi.fn> }> {
|
|
79
|
+
const mcpService = makeMcpService();
|
|
80
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
81
|
+
return { baseUrl, createSession: mcpService.createSession };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const INITIALIZE = {
|
|
85
|
+
jsonrpc: '2.0',
|
|
86
|
+
id: 0,
|
|
87
|
+
method: 'initialize',
|
|
88
|
+
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '0' } },
|
|
89
|
+
};
|
|
90
|
+
const TOOL_CALL = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
|
|
91
|
+
|
|
92
|
+
async function send(
|
|
93
|
+
baseUrl: string,
|
|
94
|
+
opts: { method?: string; sessionId?: string; body?: unknown; user?: string } = {},
|
|
95
|
+
): Promise<Response> {
|
|
96
|
+
const { method = 'POST', sessionId, body, user } = opts;
|
|
97
|
+
return fetch(`${baseUrl}/api/mcp`, {
|
|
98
|
+
method,
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
Accept: 'application/json, text/event-stream',
|
|
102
|
+
...(sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
103
|
+
...(user ? { 'x-test-user': user } : {}),
|
|
104
|
+
},
|
|
105
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Open a real session so `active` holds SESSION_ID. */
|
|
110
|
+
async function openSession(baseUrl: string): Promise<void> {
|
|
111
|
+
const res = await send(baseUrl, { body: INITIALIZE });
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('POST /mcp session routing', () => {
|
|
116
|
+
it('answers an unknown session id with 404 / -32001, the re-initialize signal', async () => {
|
|
117
|
+
const { baseUrl } = await mount();
|
|
118
|
+
const res = await send(baseUrl, { sessionId: 'sess-from-before-the-restart', body: TOOL_CALL });
|
|
119
|
+
expect(res.status).toBe(404);
|
|
120
|
+
await expect(res.json()).resolves.toEqual({
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
error: { code: -32001, message: 'Session not found' },
|
|
123
|
+
id: null,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('answers a missing session id with 400 / -32000 — a different code from the miss above', async () => {
|
|
128
|
+
const { baseUrl } = await mount();
|
|
129
|
+
const res = await send(baseUrl, { body: TOOL_CALL });
|
|
130
|
+
expect(res.status).toBe(400);
|
|
131
|
+
await expect(res.json()).resolves.toEqual({
|
|
132
|
+
jsonrpc: '2.0',
|
|
133
|
+
error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' },
|
|
134
|
+
id: null,
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('opens a new session for an initialize that still carries a STALE session id', async () => {
|
|
139
|
+
const { baseUrl, createSession } = await mount();
|
|
140
|
+
// Exactly what a client does after a server restart: it re-initializes
|
|
141
|
+
// before it has any reason to drop the id it was given last time.
|
|
142
|
+
const res = await send(baseUrl, { sessionId: 'sess-from-before-the-restart', body: INITIALIZE });
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
await expect(res.json()).resolves.toMatchObject({ forwarded: true, sessionId: SESSION_ID });
|
|
145
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('opens a new session for an initialize with no session id', async () => {
|
|
149
|
+
const { baseUrl, createSession } = await mount();
|
|
150
|
+
const res = await send(baseUrl, { body: INITIALIZE });
|
|
151
|
+
expect(res.status).toBe(200);
|
|
152
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('forwards to the live transport once a session exists', async () => {
|
|
156
|
+
const { baseUrl, createSession } = await mount();
|
|
157
|
+
await openSession(baseUrl);
|
|
158
|
+
const res = await send(baseUrl, { sessionId: SESSION_ID, body: TOOL_CALL });
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
await expect(res.json()).resolves.toMatchObject({ forwarded: true, sessionId: SESSION_ID });
|
|
161
|
+
// No second session: the live branch was taken, not the initialize branch.
|
|
162
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('routes an initialize over a LIVE session to that session, minting no second one', async () => {
|
|
166
|
+
// The guard this pins is branch ORDER, and only this change made it
|
|
167
|
+
// load-bearing: `initialize` used to be gated on `!sessionIdHeader`, so
|
|
168
|
+
// the live-session branch and the initialize branch were mutually
|
|
169
|
+
// exclusive and their order did not matter. Without that gate, an
|
|
170
|
+
// initialize check placed first would match here too — and silently mint
|
|
171
|
+
// a second session over a working one, which is the failure the route
|
|
172
|
+
// comment and the PR both promise cannot happen.
|
|
173
|
+
//
|
|
174
|
+
// What is asserted is what this ROUTER owes: the request reached the
|
|
175
|
+
// existing transport and no new session was created. Rejecting a
|
|
176
|
+
// re-initialize is the SDK transport's own job (`-32600 Server already
|
|
177
|
+
// initialized`), and asserting that here would be asserting against the
|
|
178
|
+
// stub rather than against the code under test.
|
|
179
|
+
const { baseUrl, createSession } = await mount();
|
|
180
|
+
await openSession(baseUrl); // mints sess-1
|
|
181
|
+
const res = await send(baseUrl, { sessionId: SESSION_ID, body: INITIALIZE });
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
// sess-1, not sess-2: the live transport answered, not a fresh one.
|
|
184
|
+
await expect(res.json()).resolves.toMatchObject({ forwarded: true, sessionId: SESSION_ID });
|
|
185
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('still refuses another user’s live session with 403', async () => {
|
|
189
|
+
const { baseUrl } = await mount();
|
|
190
|
+
await openSession(baseUrl); // owned by user-A
|
|
191
|
+
const res = await send(baseUrl, { sessionId: SESSION_ID, body: TOOL_CALL, user: 'user-B' });
|
|
192
|
+
expect(res.status).toBe(403);
|
|
193
|
+
// JSON-RPC like every other answer this router gives before the SDK
|
|
194
|
+
// transport — a client parsing /api/mcp bodies as JSON-RPC must not hit a
|
|
195
|
+
// bare `{ error }` on this one branch.
|
|
196
|
+
await expect(res.json()).resolves.toEqual({
|
|
197
|
+
jsonrpc: '2.0',
|
|
198
|
+
error: { code: -32003, message: 'Session does not belong to this user' },
|
|
199
|
+
id: null,
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe.each(['GET', 'DELETE'])('%s /mcp session routing', (method) => {
|
|
205
|
+
it('answers an unknown session id with 404 / -32001', async () => {
|
|
206
|
+
const { baseUrl } = await mount();
|
|
207
|
+
const res = await send(baseUrl, { method, sessionId: 'sess-from-before-the-restart' });
|
|
208
|
+
expect(res.status).toBe(404);
|
|
209
|
+
await expect(res.json()).resolves.toEqual({
|
|
210
|
+
jsonrpc: '2.0',
|
|
211
|
+
error: { code: -32001, message: 'Session not found' },
|
|
212
|
+
id: null,
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('answers a missing session id with 400 / -32000', async () => {
|
|
217
|
+
const { baseUrl } = await mount();
|
|
218
|
+
const res = await send(baseUrl, { method });
|
|
219
|
+
expect(res.status).toBe(400);
|
|
220
|
+
await expect(res.json()).resolves.toEqual({
|
|
221
|
+
jsonrpc: '2.0',
|
|
222
|
+
error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' },
|
|
223
|
+
id: null,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|