@blueprint-chart/mcp 0.1.1 → 0.1.3
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 +27 -14
- package/dist/cli.js +15 -2
- package/dist/dsl/chartTypes.d.ts +16 -0
- package/dist/dsl/chartTypes.js +37 -0
- package/dist/dsl/chartTypes.test.d.ts +1 -0
- package/dist/dsl/chartTypes.test.js +32 -0
- package/dist/dsl/dataKey.d.ts +25 -0
- package/dist/dsl/dataKey.js +42 -0
- package/dist/dsl/dataKey.test.d.ts +1 -0
- package/dist/dsl/dataKey.test.js +35 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +47 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +20 -0
- package/dist/dsl/universalProperties.d.ts +30 -0
- package/dist/dsl/universalProperties.js +52 -0
- package/dist/dsl/universalProperties.test.d.ts +1 -0
- package/dist/dsl/universalProperties.test.js +26 -0
- package/dist/dsl/validate.d.ts +10 -0
- package/dist/dsl/validate.js +68 -0
- package/dist/dsl/validate.test.d.ts +1 -0
- package/dist/dsl/validate.test.js +73 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +1 -0
- package/dist/errors.test.js +21 -0
- package/dist/lib/zodToJsonSchema.d.ts +10 -5
- package/dist/lib/zodToJsonSchema.js +14 -6
- package/dist/links/buildUrls.d.ts +14 -0
- package/dist/links/buildUrls.js +20 -0
- package/dist/links/buildUrls.test.d.ts +1 -0
- package/dist/links/buildUrls.test.js +28 -0
- package/dist/links/editorConfig.d.ts +4 -0
- package/dist/links/editorConfig.js +15 -0
- package/dist/links/editorConfig.test.d.ts +1 -0
- package/dist/links/editorConfig.test.js +28 -0
- package/dist/links/encode.d.ts +11 -0
- package/dist/links/encode.js +19 -0
- package/dist/links/encode.test.d.ts +1 -0
- package/dist/links/encode.test.js +37 -0
- package/dist/prompts/authorChart.js +23 -18
- package/dist/prompts/authorChart.test.js +6 -0
- package/dist/render/diagnose.d.ts +19 -0
- package/dist/render/diagnose.js +100 -0
- package/dist/render/diagnose.test.d.ts +1 -0
- package/dist/render/diagnose.test.js +53 -0
- package/dist/render/frame.d.ts +10 -0
- package/dist/render/frame.js +10 -0
- package/dist/render/frame.test.d.ts +1 -0
- package/dist/render/frame.test.js +12 -0
- package/dist/render/jsdomEnv.d.ts +2 -1
- package/dist/render/jsdomEnv.js +14 -1
- package/dist/render/jsdomEnv.test.js +36 -2
- package/dist/render/renderSceneState.d.ts +5 -1
- package/dist/render/renderSceneState.js +4 -3
- package/dist/render/renderSceneState.test.js +13 -7
- package/dist/render/validatePipeline.d.ts +23 -0
- package/dist/render/validatePipeline.js +41 -0
- package/dist/render/validatePipeline.test.d.ts +1 -0
- package/dist/render/validatePipeline.test.js +34 -0
- package/dist/resources/docsReader.d.ts +4 -1
- package/dist/resources/docsReader.js +23 -6
- package/dist/resources/docsReader.test.js +27 -2
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/samples.d.ts +1 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +63 -5
- package/dist/server.test.js +101 -4
- package/dist/tools/describeChartType.d.ts +33 -0
- package/dist/tools/describeChartType.js +119 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +58 -0
- package/dist/tools/exportChart.d.ts +17 -0
- package/dist/tools/exportChart.js +31 -0
- package/dist/tools/exportChart.test.d.ts +1 -0
- package/dist/tools/exportChart.test.js +43 -0
- package/dist/tools/getExample.d.ts +20 -0
- package/dist/tools/getExample.js +55 -0
- package/dist/tools/getExample.test.d.ts +1 -0
- package/dist/tools/getExample.test.js +40 -0
- package/dist/tools/getGrammar.d.ts +17 -0
- package/dist/tools/getGrammar.js +38 -0
- package/dist/tools/getGrammar.test.d.ts +1 -0
- package/dist/tools/getGrammar.test.js +24 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +31 -7
- package/dist/tools/inspect.test.js +35 -13
- package/dist/tools/listChartTypes.d.ts +14 -0
- package/dist/tools/listChartTypes.js +42 -0
- package/dist/tools/listChartTypes.test.d.ts +1 -0
- package/dist/tools/listChartTypes.test.js +42 -0
- package/dist/tools/render.d.ts +14 -12
- package/dist/tools/render.js +96 -28
- package/dist/tools/render.test.js +137 -1
- package/dist/tools/validate.d.ts +11 -3
- package/dist/tools/validate.js +9 -1
- package/dist/tools/validate.test.js +17 -12
- package/dist/transports/http.d.ts +4 -2
- package/dist/transports/http.js +232 -23
- package/dist/transports/http.test.js +158 -22
- package/package.json +4 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +9 -0
|
@@ -2,26 +2,31 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { samples } from '@blueprint-chart/lib';
|
|
3
3
|
import { validateDsl } from './validate';
|
|
4
4
|
describe('validate_dsl', () => {
|
|
5
|
-
it('returns
|
|
5
|
+
it('returns valid: true with empty errors/warnings for every sample', () => {
|
|
6
6
|
for (const s of samples) {
|
|
7
7
|
const r = validateDsl({ source: s.dsl });
|
|
8
|
-
expect(r.ok, `sample
|
|
8
|
+
expect(r.ok, `sample ${s.id}`).toBe(true);
|
|
9
|
+
if (r.ok) {
|
|
10
|
+
expect(r.data.valid).toBe(true);
|
|
11
|
+
expect(r.data.errors).toEqual([]);
|
|
12
|
+
expect(r.data.warnings).toEqual([]);
|
|
13
|
+
}
|
|
9
14
|
}
|
|
10
15
|
});
|
|
11
|
-
it('returns
|
|
12
|
-
const r = validateDsl({ source: 'chart
|
|
13
|
-
expect(r.ok).toBe(
|
|
14
|
-
if (
|
|
15
|
-
expect(r.
|
|
16
|
-
expect(r.errors[0]
|
|
16
|
+
it('returns valid: false with E_UNKNOWN_CHART_TYPE on chart bar', () => {
|
|
17
|
+
const r = validateDsl({ source: 'chart bar { data { "E" = 1 } }' });
|
|
18
|
+
expect(r.ok).toBe(true);
|
|
19
|
+
if (r.ok) {
|
|
20
|
+
expect(r.data.valid).toBe(false);
|
|
21
|
+
expect(r.data.errors[0].code).toBe('E_UNKNOWN_CHART_TYPE');
|
|
22
|
+
expect(r.data.errors[0].suggestion).toMatch(/^bar-/);
|
|
17
23
|
}
|
|
18
24
|
});
|
|
19
|
-
it('
|
|
20
|
-
|
|
21
|
-
const r = validateDsl({ source: null });
|
|
25
|
+
it('still surfaces PEG errors as E_PARSE (isError channel)', () => {
|
|
26
|
+
const r = validateDsl({ source: '@@@ not valid' });
|
|
22
27
|
expect(r.ok).toBe(false);
|
|
23
28
|
if (!r.ok) {
|
|
24
|
-
expect(r.code).toBe('
|
|
29
|
+
expect(r.code).toBe('E_PARSE');
|
|
25
30
|
}
|
|
26
31
|
});
|
|
27
32
|
});
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
export interface StartHttpOptions {
|
|
2
2
|
port: number;
|
|
3
3
|
host?: string;
|
|
4
|
-
/** If set, require `Authorization: Bearer <token>` on every
|
|
4
|
+
/** If set, require `Authorization: Bearer <token>` on every request. */
|
|
5
5
|
authToken?: string;
|
|
6
6
|
/** Comma-resolved CORS allowlist. `'*'` allows any origin. Default: `'*'`. */
|
|
7
7
|
allowedOrigins?: string[] | '*';
|
|
8
8
|
/** Read `X-Forwarded-For` for client IP (enable when behind a reverse proxy). */
|
|
9
9
|
trustProxy?: boolean;
|
|
10
|
-
/** Cap on concurrent
|
|
10
|
+
/** Cap on concurrent tool calls. Default: 16. */
|
|
11
11
|
maxConcurrentRequests?: number;
|
|
12
12
|
/** Per-IP token-bucket rate limit. Disabled when undefined or 0. */
|
|
13
13
|
rateLimitPerMinute?: number;
|
|
14
14
|
/** Suppress JSON access logs. Default: false (logs to stderr). */
|
|
15
15
|
silent?: boolean;
|
|
16
|
+
/** If set, redirect GET / to this URL. Otherwise returns 404. */
|
|
17
|
+
rootRedirectUrl?: string;
|
|
16
18
|
}
|
|
17
19
|
export interface HttpHandle {
|
|
18
20
|
url: string;
|
package/dist/transports/http.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
1
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
2
4
|
import { createServer as createHttpServer, } from 'node:http';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
4
9
|
import { createServer } from '../server.js';
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PUBLIC_DIR = join(__dirname, '..', '..', 'public');
|
|
12
|
+
// Routes served from `public/`. `/favicon.ico` is mapped to the PNG file: modern
|
|
13
|
+
// browsers identify image format by magic bytes, and shipping a real multi-size
|
|
14
|
+
// ICO would add a build-time generator for no real benefit here.
|
|
15
|
+
const STATIC_ROUTES = {
|
|
16
|
+
'/favicon.ico': { file: 'favicon.png', contentType: 'image/png' },
|
|
17
|
+
'/favicon.png': { file: 'favicon.png', contentType: 'image/png' },
|
|
18
|
+
'/favicon.svg': { file: 'favicon.svg', contentType: 'image/svg+xml' },
|
|
19
|
+
'/apple-touch-icon.png': { file: 'apple-touch-icon.png', contentType: 'image/png' },
|
|
20
|
+
};
|
|
21
|
+
async function loadStaticAssets() {
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
for (const [route, asset] of Object.entries(STATIC_ROUTES)) {
|
|
24
|
+
try {
|
|
25
|
+
const body = await readFile(join(PUBLIC_DIR, asset.file));
|
|
26
|
+
cache.set(route, { body, contentType: asset.contentType });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Asset missing → route stays unregistered; request will 404 as before.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return cache;
|
|
33
|
+
}
|
|
5
34
|
class Semaphore {
|
|
6
35
|
available;
|
|
7
36
|
waiters = [];
|
|
@@ -80,8 +109,9 @@ function applyCors(res, origin, allowed) {
|
|
|
80
109
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
81
110
|
res.setHeader('Vary', 'Origin');
|
|
82
111
|
}
|
|
83
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
84
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Accept');
|
|
112
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
|
|
113
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Mcp-Protocol-Version, Accept');
|
|
114
|
+
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, Mcp-Protocol-Version');
|
|
85
115
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
86
116
|
}
|
|
87
117
|
function logEvent(silent, fields) {
|
|
@@ -95,12 +125,26 @@ function jsonResponse(res, status, body) {
|
|
|
95
125
|
res.setHeader('Content-Type', 'application/json');
|
|
96
126
|
res.end(JSON.stringify(body));
|
|
97
127
|
}
|
|
128
|
+
async function readRequestBody(req) {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
for await (const chunk of req) {
|
|
131
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
132
|
+
}
|
|
133
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
134
|
+
if (!raw) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(raw);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
98
144
|
export async function startHttp(opts) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const server = createServer();
|
|
103
|
-
await server.connect(transport);
|
|
145
|
+
const sseSessions = new Map();
|
|
146
|
+
const streamableSessions = new Map();
|
|
147
|
+
const staticAssets = await loadStaticAssets();
|
|
104
148
|
const allowedOrigins = opts.allowedOrigins ?? '*';
|
|
105
149
|
const trustProxy = opts.trustProxy ?? false;
|
|
106
150
|
const silent = opts.silent ?? false;
|
|
@@ -114,29 +158,69 @@ export async function startHttp(opts) {
|
|
|
114
158
|
}
|
|
115
159
|
const httpServer = createHttpServer(async (req, res) => {
|
|
116
160
|
const started = Date.now();
|
|
117
|
-
const
|
|
161
|
+
const rawUrl = req.url ?? '/';
|
|
162
|
+
const hostHeader = req.headers.host ?? 'unknown';
|
|
118
163
|
const ip = getClientIp(req, trustProxy);
|
|
119
|
-
|
|
120
|
-
//
|
|
164
|
+
const proto = trustProxy ? (req.headers['x-forwarded-proto'] ?? 'http') : 'http';
|
|
165
|
+
// Raw log for every single request
|
|
166
|
+
logEvent(silent, { event: 'raw_request', method: req.method, url: rawUrl, host: hostHeader, ip, proto });
|
|
167
|
+
applyCors(res, req.headers.origin, allowedOrigins);
|
|
168
|
+
// 1. Health check (Railway / load-balancer liveness probe)
|
|
169
|
+
if (rawUrl === '/healthz' || rawUrl === '/health') {
|
|
170
|
+
jsonResponse(res, 200, { status: 'ok' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// 2. CORS preflight (any path — claude.ai sends it before the actual POST)
|
|
121
174
|
if (req.method === 'OPTIONS') {
|
|
175
|
+
logEvent(silent, { event: 'preflight', ip, headers: req.headers });
|
|
122
176
|
res.statusCode = 204;
|
|
123
177
|
res.end();
|
|
124
178
|
return;
|
|
125
179
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
180
|
+
const url = new URL(rawUrl, `${proto}://${hostHeader}`);
|
|
181
|
+
const pathname = url.pathname;
|
|
182
|
+
const normalizedPath = pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname;
|
|
183
|
+
// 3. Static assets (favicon, etc.) — served unauthenticated so browsers can
|
|
184
|
+
// fetch them even when MCP_AUTH_TOKEN is set on the MCP route.
|
|
185
|
+
if (req.method === 'GET' && staticAssets.has(normalizedPath)) {
|
|
186
|
+
const asset = staticAssets.get(normalizedPath);
|
|
187
|
+
res.statusCode = 200;
|
|
188
|
+
res.setHeader('Content-Type', asset.contentType);
|
|
189
|
+
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
190
|
+
res.end(asset.body);
|
|
129
191
|
return;
|
|
130
192
|
}
|
|
131
|
-
|
|
193
|
+
// MCP lives at the root path. Everything else is 404.
|
|
194
|
+
if (normalizedPath !== '/') {
|
|
195
|
+
logEvent(silent, { event: 'path_not_found', path: pathname, ip, method: req.method });
|
|
196
|
+
jsonResponse(res, 404, { error: 'Not Found' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Disambiguate MCP traffic from browser visits to GET /. MCP clients always
|
|
200
|
+
// POST/DELETE, or GET with `text/event-stream` or an `Mcp-Session-Id` header.
|
|
201
|
+
// A plain browser GET / (no MCP signals) falls back to the optional redirect.
|
|
202
|
+
const acceptHeader = String(req.headers.accept ?? '');
|
|
203
|
+
const isMcpRequest = req.method === 'POST'
|
|
204
|
+
|| req.method === 'DELETE'
|
|
205
|
+
|| (req.method === 'GET' && (Boolean(req.headers['mcp-session-id']) || acceptHeader.includes('text/event-stream')));
|
|
206
|
+
if (!isMcpRequest) {
|
|
207
|
+
if (req.method === 'GET' && opts.rootRedirectUrl) {
|
|
208
|
+
logEvent(silent, { event: 'root_redirect', ip, to: opts.rootRedirectUrl });
|
|
209
|
+
res.statusCode = 302;
|
|
210
|
+
res.setHeader('Location', opts.rootRedirectUrl);
|
|
211
|
+
res.end();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
logEvent(silent, { event: 'root_non_mcp', ip, method: req.method, accept: acceptHeader });
|
|
132
215
|
jsonResponse(res, 404, { error: 'Not Found' });
|
|
133
216
|
return;
|
|
134
217
|
}
|
|
218
|
+
logEvent(silent, { event: 'incoming_request', method: req.method, path: pathname, ip, headers: req.headers });
|
|
135
219
|
// Bearer token auth (off if MCP_AUTH_TOKEN not set)
|
|
136
220
|
if (opts.authToken) {
|
|
137
221
|
const auth = req.headers.authorization;
|
|
138
222
|
if (auth !== `Bearer ${opts.authToken}`) {
|
|
139
|
-
logEvent(silent, { event: 'auth_rejected', ip, method: req.method });
|
|
223
|
+
logEvent(silent, { event: 'auth_rejected', ip, method: req.method, headers: req.headers });
|
|
140
224
|
jsonResponse(res, 401, { error: 'Unauthorized' });
|
|
141
225
|
return;
|
|
142
226
|
}
|
|
@@ -148,23 +232,141 @@ export async function startHttp(opts) {
|
|
|
148
232
|
jsonResponse(res, 429, { error: 'Too Many Requests' });
|
|
149
233
|
return;
|
|
150
234
|
}
|
|
151
|
-
// Concurrency cap on POST (tool calls); GET streams unrestricted
|
|
152
|
-
const cap = req.method === 'POST';
|
|
153
|
-
const release = cap ? await semaphore.acquire() : undefined;
|
|
154
235
|
try {
|
|
155
|
-
|
|
236
|
+
const streamableSessionHeader = req.headers['mcp-session-id'];
|
|
237
|
+
const streamableSessionId = Array.isArray(streamableSessionHeader)
|
|
238
|
+
? streamableSessionHeader[0]
|
|
239
|
+
: streamableSessionHeader;
|
|
240
|
+
if (req.method === 'GET') {
|
|
241
|
+
// Streamable HTTP GETs always include the Mcp-Session-Id header — they're
|
|
242
|
+
// a server→client SSE notification stream for an established session.
|
|
243
|
+
// Legacy SSE GETs are the bootstrap and have no session header.
|
|
244
|
+
if (streamableSessionId) {
|
|
245
|
+
const transport = streamableSessions.get(streamableSessionId);
|
|
246
|
+
if (!transport) {
|
|
247
|
+
logEvent(silent, { event: 'streamable_get_invalid_session', ip, sessionId: streamableSessionId });
|
|
248
|
+
jsonResponse(res, 404, { error: 'Unknown session' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
logEvent(silent, { event: 'streamable_get', ip, sessionId: streamableSessionId });
|
|
252
|
+
await transport.handleRequest(req, res);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const transport = new SSEServerTransport('/', res);
|
|
256
|
+
const sessionId = transport.sessionId;
|
|
257
|
+
sseSessions.set(sessionId, transport);
|
|
258
|
+
transport.onclose = () => {
|
|
259
|
+
logEvent(silent, { event: 'sse_closed', sessionId });
|
|
260
|
+
sseSessions.delete(sessionId);
|
|
261
|
+
};
|
|
262
|
+
// Connect a fresh server instance per SSE session to avoid state contamination
|
|
263
|
+
const sessionServer = createServer();
|
|
264
|
+
await sessionServer.connect(transport);
|
|
265
|
+
await transport.start(); // Mandatory to start the stream!
|
|
266
|
+
logEvent(silent, { event: 'sse_connected', ip, sessionId, headers: req.headers });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if (req.method === 'DELETE') {
|
|
270
|
+
// Streamable HTTP session termination — client signals it's done with the session.
|
|
271
|
+
if (streamableSessionId) {
|
|
272
|
+
const transport = streamableSessions.get(streamableSessionId);
|
|
273
|
+
if (transport) {
|
|
274
|
+
logEvent(silent, { event: 'streamable_delete', ip, sessionId: streamableSessionId });
|
|
275
|
+
await transport.handleRequest(req, res);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
jsonResponse(res, 404, { error: 'Unknown session' });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
jsonResponse(res, 400, { error: 'Mcp-Session-Id header required for DELETE' });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (req.method === 'POST') {
|
|
286
|
+
const sseSessionId = url.searchParams.get('sessionId');
|
|
287
|
+
if (sseSessionId) {
|
|
288
|
+
// Legacy SSE POST: messages flow over the open SSE stream identified by sessionId query param.
|
|
289
|
+
const transport = sseSessions.get(sseSessionId);
|
|
290
|
+
if (!transport) {
|
|
291
|
+
logEvent(silent, { event: 'sse_post_invalid_session', ip, sessionId: sseSessionId });
|
|
292
|
+
jsonResponse(res, 400, { error: 'Invalid sessionId' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
logEvent(silent, { event: 'sse_post', ip, sessionId: sseSessionId });
|
|
296
|
+
const release = await semaphore.acquire();
|
|
297
|
+
try {
|
|
298
|
+
await transport.handlePostMessage(req, res);
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
release();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// Streamable HTTP POST: either an initialize bootstrap (no Mcp-Session-Id yet)
|
|
306
|
+
// or a follow-up request on an existing session (Mcp-Session-Id in headers).
|
|
307
|
+
const body = await readRequestBody(req);
|
|
308
|
+
const release = await semaphore.acquire();
|
|
309
|
+
try {
|
|
310
|
+
if (streamableSessionId) {
|
|
311
|
+
const transport = streamableSessions.get(streamableSessionId);
|
|
312
|
+
if (!transport) {
|
|
313
|
+
logEvent(silent, { event: 'streamable_post_invalid_session', ip, sessionId: streamableSessionId });
|
|
314
|
+
jsonResponse(res, 404, { error: 'Unknown session' });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
logEvent(silent, { event: 'streamable_post', ip, sessionId: streamableSessionId });
|
|
318
|
+
await transport.handleRequest(req, res, body);
|
|
319
|
+
}
|
|
320
|
+
else if (isInitializeRequest(body)) {
|
|
321
|
+
// Per-session transport: each initialize starts a fresh session with its own
|
|
322
|
+
// server instance. The SDK assigns the session ID and we register the transport
|
|
323
|
+
// in the map via onsessioninitialized so subsequent POST/GET/DELETE can find it.
|
|
324
|
+
const transport = new StreamableHTTPServerTransport({
|
|
325
|
+
sessionIdGenerator: () => randomUUID(),
|
|
326
|
+
onsessioninitialized: (sid) => {
|
|
327
|
+
streamableSessions.set(sid, transport);
|
|
328
|
+
logEvent(silent, { event: 'streamable_session_initialized', sessionId: sid });
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
transport.onclose = () => {
|
|
332
|
+
if (transport.sessionId) {
|
|
333
|
+
streamableSessions.delete(transport.sessionId);
|
|
334
|
+
logEvent(silent, { event: 'streamable_session_closed', sessionId: transport.sessionId });
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const sessionServer = createServer();
|
|
338
|
+
await sessionServer.connect(transport);
|
|
339
|
+
logEvent(silent, { event: 'streamable_init', ip });
|
|
340
|
+
await transport.handleRequest(req, res, body);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
jsonResponse(res, 400, {
|
|
344
|
+
jsonrpc: '2.0',
|
|
345
|
+
error: { code: -32600, message: 'Invalid Request: missing Mcp-Session-Id and not an initialize request' },
|
|
346
|
+
id: null,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
release();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
jsonResponse(res, 405, { error: 'Method Not Allowed' });
|
|
357
|
+
}
|
|
156
358
|
}
|
|
157
359
|
catch (err) {
|
|
158
360
|
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
-
|
|
361
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
362
|
+
logEvent(silent, { event: 'transport_error', ip, method: req.method, message, stack });
|
|
160
363
|
if (!res.headersSent) {
|
|
161
364
|
jsonResponse(res, 500, { error: 'Internal Server Error' });
|
|
162
365
|
}
|
|
163
366
|
}
|
|
164
367
|
finally {
|
|
165
|
-
release?.();
|
|
166
368
|
logEvent(silent, {
|
|
167
|
-
event: '
|
|
369
|
+
event: 'request_finished',
|
|
168
370
|
ip,
|
|
169
371
|
method: req.method,
|
|
170
372
|
path: req.url,
|
|
@@ -185,7 +387,14 @@ export async function startHttp(opts) {
|
|
|
185
387
|
if (pruneTimer) {
|
|
186
388
|
clearInterval(pruneTimer);
|
|
187
389
|
}
|
|
188
|
-
|
|
390
|
+
for (const transport of streamableSessions.values()) {
|
|
391
|
+
await transport.close();
|
|
392
|
+
}
|
|
393
|
+
streamableSessions.clear();
|
|
394
|
+
for (const transport of sseSessions.values()) {
|
|
395
|
+
await transport.close();
|
|
396
|
+
}
|
|
397
|
+
sseSessions.clear();
|
|
189
398
|
httpServer.closeAllConnections();
|
|
190
399
|
await new Promise((resolve, reject) => httpServer.close(err => (err ? reject(err) : resolve())));
|
|
191
400
|
},
|
|
@@ -10,14 +10,33 @@ async function withServer(opts, fn) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
describe('http transport', () => {
|
|
13
|
-
it('responds to
|
|
13
|
+
it('responds to root tools/list after establishing SSE', async () => {
|
|
14
14
|
await withServer({ port: 0 }, async (url) => {
|
|
15
|
-
|
|
15
|
+
// 1. Establish SSE session
|
|
16
|
+
const sseRes = await fetch(`${url}/`, {
|
|
17
|
+
headers: { accept: 'text/event-stream' },
|
|
18
|
+
});
|
|
19
|
+
expect(sseRes.status).toBe(200);
|
|
20
|
+
const reader = sseRes.body?.getReader();
|
|
21
|
+
if (!reader) {
|
|
22
|
+
throw new Error('No reader');
|
|
23
|
+
}
|
|
24
|
+
const { value } = await reader.read();
|
|
25
|
+
const text = new TextDecoder().decode(value);
|
|
26
|
+
const sessionIdMatch = text.match(/sessionId=([a-zA-Z0-9-]+)/);
|
|
27
|
+
if (!sessionIdMatch) {
|
|
28
|
+
throw new Error(`No sessionId in SSE: ${text}`);
|
|
29
|
+
}
|
|
30
|
+
const sessionId = sessionIdMatch[1];
|
|
31
|
+
// 2. Post message using sessionId
|
|
32
|
+
const res = await fetch(`${url}/?sessionId=${sessionId}`, {
|
|
16
33
|
method: 'POST',
|
|
17
34
|
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
18
35
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
19
36
|
});
|
|
20
|
-
expect(res.status).
|
|
37
|
+
expect(res.status).toBe(202); // SSEServerTransport returns 202 for POSTs
|
|
38
|
+
// Cleanup
|
|
39
|
+
await reader.cancel();
|
|
21
40
|
});
|
|
22
41
|
});
|
|
23
42
|
it('serves /healthz returning {status:"ok"}', async () => {
|
|
@@ -34,52 +53,169 @@ describe('http transport', () => {
|
|
|
34
53
|
expect(res.status).toBe(404);
|
|
35
54
|
});
|
|
36
55
|
});
|
|
56
|
+
it('returns 404 for / by default', async () => {
|
|
57
|
+
await withServer({ port: 0 }, async (url) => {
|
|
58
|
+
const res = await fetch(`${url}/`);
|
|
59
|
+
expect(res.status).toBe(404);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
it('redirects plain GET / to rootRedirectUrl when set', async () => {
|
|
63
|
+
const rootRedirectUrl = 'https://example.com';
|
|
64
|
+
await withServer({ port: 0, rootRedirectUrl }, async (url) => {
|
|
65
|
+
const res = await fetch(`${url}/`, { redirect: 'manual' });
|
|
66
|
+
expect(res.status).toBe(302);
|
|
67
|
+
expect(res.headers.get('location')).toBe(rootRedirectUrl);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
it('routes MCP traffic to / even when rootRedirectUrl is set', async () => {
|
|
71
|
+
// The key co-existence test: a configured root redirect must NOT swallow
|
|
72
|
+
// MCP requests. POSTs always count as MCP; the redirect only fires for
|
|
73
|
+
// browser-style GETs.
|
|
74
|
+
await withServer({ port: 0, rootRedirectUrl: 'https://example.com' }, async (url) => {
|
|
75
|
+
const res = await fetch(`${url}/`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
jsonrpc: '2.0',
|
|
80
|
+
id: 1,
|
|
81
|
+
method: 'initialize',
|
|
82
|
+
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } },
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect(res.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
it('routes GET / with text/event-stream as MCP SSE even when rootRedirectUrl is set', async () => {
|
|
90
|
+
await withServer({ port: 0, rootRedirectUrl: 'https://example.com' }, async (url) => {
|
|
91
|
+
const res = await fetch(`${url}/`, {
|
|
92
|
+
headers: { accept: 'text/event-stream' },
|
|
93
|
+
redirect: 'manual',
|
|
94
|
+
});
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
expect(res.headers.get('content-type')).toMatch(/text\/event-stream/);
|
|
97
|
+
await res.body?.cancel();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
37
100
|
it('handles CORS preflight (OPTIONS)', async () => {
|
|
38
101
|
await withServer({ port: 0 }, async (url) => {
|
|
39
|
-
const res = await fetch(`${url}
|
|
102
|
+
const res = await fetch(`${url}/`, { method: 'OPTIONS' });
|
|
40
103
|
expect(res.status).toBe(204);
|
|
41
104
|
expect(res.headers.get('access-control-allow-methods')).toMatch(/POST/);
|
|
42
105
|
});
|
|
43
106
|
});
|
|
44
|
-
it('rejects
|
|
45
|
-
await withServer({ port: 0
|
|
46
|
-
const res = await fetch(`${url}
|
|
107
|
+
it('rejects POST without sessionId and not an initialize request', async () => {
|
|
108
|
+
await withServer({ port: 0 }, async (url) => {
|
|
109
|
+
const res = await fetch(`${url}/`, {
|
|
47
110
|
method: 'POST',
|
|
48
111
|
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
49
112
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
50
113
|
});
|
|
51
|
-
|
|
114
|
+
// Non-initialize POSTs without an Mcp-Session-Id header are invalid; the
|
|
115
|
+
// transport returns 400 Bad Request with a JSON-RPC error envelope.
|
|
116
|
+
expect(res.status).toBe(400);
|
|
117
|
+
const body = await res.json();
|
|
118
|
+
expect(body.error?.code).toBe(-32600);
|
|
119
|
+
expect(body.error?.message).toMatch(/Mcp-Session-Id|initialize/i);
|
|
52
120
|
});
|
|
53
121
|
});
|
|
54
|
-
it('accepts
|
|
55
|
-
await withServer({ port: 0
|
|
56
|
-
const res = await fetch(`${url}
|
|
122
|
+
it('accepts POST initialize and returns a Mcp-Session-Id', async () => {
|
|
123
|
+
await withServer({ port: 0 }, async (url) => {
|
|
124
|
+
const res = await fetch(`${url}/`, {
|
|
57
125
|
method: 'POST',
|
|
58
126
|
headers: {
|
|
59
127
|
'content-type': 'application/json',
|
|
60
|
-
'accept': 'application/json',
|
|
61
|
-
'authorization': 'Bearer secret',
|
|
128
|
+
'accept': 'application/json, text/event-stream',
|
|
62
129
|
},
|
|
63
|
-
body: JSON.stringify({
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
jsonrpc: '2.0',
|
|
132
|
+
id: 1,
|
|
133
|
+
method: 'initialize',
|
|
134
|
+
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } },
|
|
135
|
+
}),
|
|
64
136
|
});
|
|
65
|
-
expect(res.status).
|
|
66
|
-
expect(res.
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
expect(res.headers.get('mcp-session-id')).toMatch(/^[0-9a-f-]{36}$/);
|
|
67
139
|
});
|
|
68
140
|
});
|
|
69
|
-
it('
|
|
70
|
-
await withServer({ port: 0
|
|
71
|
-
const
|
|
141
|
+
it('rejects POST with invalid sessionId', async () => {
|
|
142
|
+
await withServer({ port: 0 }, async (url) => {
|
|
143
|
+
const res = await fetch(`${url}/?sessionId=nope`, {
|
|
72
144
|
method: 'POST',
|
|
73
145
|
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
74
146
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
75
147
|
});
|
|
148
|
+
expect(res.status).toBe(400);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
it('rejects SSE without token when authToken is set', async () => {
|
|
152
|
+
await withServer({ port: 0, authToken: 'secret' }, async (url) => {
|
|
153
|
+
const res = await fetch(`${url}/`, {
|
|
154
|
+
headers: { accept: 'text/event-stream' },
|
|
155
|
+
});
|
|
156
|
+
expect(res.status).toBe(401);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
it('accepts SSE with correct bearer token', async () => {
|
|
160
|
+
await withServer({ port: 0, authToken: 'secret' }, async (url) => {
|
|
161
|
+
const res = await fetch(`${url}/`, {
|
|
162
|
+
headers: {
|
|
163
|
+
accept: 'text/event-stream',
|
|
164
|
+
authorization: 'Bearer secret',
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(res.status).toBe(200);
|
|
168
|
+
await res.body?.cancel();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
it('serves /favicon.svg with image/svg+xml', async () => {
|
|
172
|
+
await withServer({ port: 0 }, async (url) => {
|
|
173
|
+
const res = await fetch(`${url}/favicon.svg`);
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(res.headers.get('content-type')).toBe('image/svg+xml');
|
|
176
|
+
const body = await res.text();
|
|
177
|
+
expect(body).toContain('<svg');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
it('serves /favicon.ico as PNG (modern browsers identify by magic bytes)', async () => {
|
|
181
|
+
await withServer({ port: 0 }, async (url) => {
|
|
182
|
+
const res = await fetch(`${url}/favicon.ico`);
|
|
183
|
+
expect(res.status).toBe(200);
|
|
184
|
+
expect(res.headers.get('content-type')).toBe('image/png');
|
|
185
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
186
|
+
// PNG magic: 89 50 4E 47 0D 0A 1A 0A
|
|
187
|
+
expect(body[0]).toBe(0x89);
|
|
188
|
+
expect(body[1]).toBe(0x50);
|
|
189
|
+
expect(body[2]).toBe(0x4E);
|
|
190
|
+
expect(body[3]).toBe(0x47);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
it('serves /apple-touch-icon.png', async () => {
|
|
194
|
+
await withServer({ port: 0 }, async (url) => {
|
|
195
|
+
const res = await fetch(`${url}/apple-touch-icon.png`);
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
expect(res.headers.get('content-type')).toBe('image/png');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
it('serves static assets even when authToken is set', async () => {
|
|
201
|
+
await withServer({ port: 0, authToken: 'secret' }, async (url) => {
|
|
202
|
+
const res = await fetch(`${url}/favicon.svg`);
|
|
203
|
+
expect(res.status).toBe(200);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
it('rate-limits MCP requests by IP', async () => {
|
|
207
|
+
await withServer({ port: 0, rateLimitPerMinute: 2 }, async (url) => {
|
|
208
|
+
const hit = async () => fetch(`${url}/`, {
|
|
209
|
+
headers: { accept: 'text/event-stream' },
|
|
210
|
+
});
|
|
76
211
|
const first = await hit();
|
|
77
212
|
const second = await hit();
|
|
78
213
|
const third = await hit();
|
|
79
|
-
expect(first.status).
|
|
80
|
-
expect(second.status).
|
|
214
|
+
expect(first.status).toBe(200);
|
|
215
|
+
expect(second.status).toBe(200);
|
|
81
216
|
expect(third.status).toBe(429);
|
|
82
|
-
|
|
217
|
+
await first.body?.cancel();
|
|
218
|
+
await second.body?.cancel();
|
|
83
219
|
});
|
|
84
220
|
});
|
|
85
221
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blueprint-chart/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Model Context Protocol server for authoring Blueprint Chart .bpc files with LLMs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "pirhoo",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
|
29
29
|
"bin",
|
|
30
|
+
"public",
|
|
30
31
|
"README.md",
|
|
31
32
|
"LICENSE"
|
|
32
33
|
],
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
"@napi-rs/canvas": "^0.1.71",
|
|
42
43
|
"@resvg/resvg-js": "^2.6.2",
|
|
43
44
|
"jsdom": "^26.1.0",
|
|
44
|
-
"zod": "^3.23.8"
|
|
45
|
+
"zod": "^3.23.8",
|
|
46
|
+
"zod-to-json-schema": "^3.25.2"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
47
49
|
"@eslint/js": "^9.39.4",
|
|
Binary file
|
|
Binary file
|