@blueprint-chart/mcp 0.1.1 → 0.1.4

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 (131) hide show
  1. package/README.md +31 -15
  2. package/dist/cli.js +15 -2
  3. package/dist/dsl/capabilityMatrix.d.ts +22 -0
  4. package/dist/dsl/capabilityMatrix.js +37 -0
  5. package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
  6. package/dist/dsl/capabilityMatrix.test.js +49 -0
  7. package/dist/dsl/chartTypes.d.ts +16 -0
  8. package/dist/dsl/chartTypes.js +37 -0
  9. package/dist/dsl/chartTypes.test.d.ts +1 -0
  10. package/dist/dsl/chartTypes.test.js +32 -0
  11. package/dist/dsl/dataKey.d.ts +25 -0
  12. package/dist/dsl/dataKey.js +42 -0
  13. package/dist/dsl/dataKey.test.d.ts +1 -0
  14. package/dist/dsl/dataKey.test.js +35 -0
  15. package/dist/dsl/goalRanking.d.ts +7 -0
  16. package/dist/dsl/goalRanking.js +76 -0
  17. package/dist/dsl/goalRanking.test.d.ts +1 -0
  18. package/dist/dsl/goalRanking.test.js +83 -0
  19. package/dist/dsl/parseErrorHints.d.ts +12 -0
  20. package/dist/dsl/parseErrorHints.js +32 -0
  21. package/dist/dsl/parseErrorHints.test.d.ts +1 -0
  22. package/dist/dsl/parseErrorHints.test.js +26 -0
  23. package/dist/dsl/semanticWarnings.d.ts +7 -0
  24. package/dist/dsl/semanticWarnings.js +66 -0
  25. package/dist/dsl/semanticWarnings.test.d.ts +1 -0
  26. package/dist/dsl/semanticWarnings.test.js +32 -0
  27. package/dist/dsl/suggest.d.ts +1 -0
  28. package/dist/dsl/suggest.js +66 -0
  29. package/dist/dsl/suggest.test.d.ts +1 -0
  30. package/dist/dsl/suggest.test.js +34 -0
  31. package/dist/dsl/universalProperties.d.ts +30 -0
  32. package/dist/dsl/universalProperties.js +52 -0
  33. package/dist/dsl/universalProperties.test.d.ts +1 -0
  34. package/dist/dsl/universalProperties.test.js +26 -0
  35. package/dist/dsl/validate.d.ts +10 -0
  36. package/dist/dsl/validate.js +68 -0
  37. package/dist/dsl/validate.test.d.ts +1 -0
  38. package/dist/dsl/validate.test.js +73 -0
  39. package/dist/errors.d.ts +20 -1
  40. package/dist/errors.js +1 -0
  41. package/dist/errors.test.js +21 -0
  42. package/dist/lib/zodToJsonSchema.d.ts +10 -5
  43. package/dist/lib/zodToJsonSchema.js +14 -6
  44. package/dist/links/buildUrls.d.ts +14 -0
  45. package/dist/links/buildUrls.js +20 -0
  46. package/dist/links/buildUrls.test.d.ts +1 -0
  47. package/dist/links/buildUrls.test.js +28 -0
  48. package/dist/links/editorConfig.d.ts +4 -0
  49. package/dist/links/editorConfig.js +15 -0
  50. package/dist/links/editorConfig.test.d.ts +1 -0
  51. package/dist/links/editorConfig.test.js +28 -0
  52. package/dist/links/encode.d.ts +11 -0
  53. package/dist/links/encode.js +19 -0
  54. package/dist/links/encode.test.d.ts +1 -0
  55. package/dist/links/encode.test.js +37 -0
  56. package/dist/parse.js +14 -6
  57. package/dist/parse.test.js +8 -0
  58. package/dist/prompts/authorChart.js +23 -18
  59. package/dist/prompts/authorChart.test.js +6 -0
  60. package/dist/render/diagnose.d.ts +19 -0
  61. package/dist/render/diagnose.js +100 -0
  62. package/dist/render/diagnose.test.d.ts +1 -0
  63. package/dist/render/diagnose.test.js +53 -0
  64. package/dist/render/frame.d.ts +10 -0
  65. package/dist/render/frame.js +10 -0
  66. package/dist/render/frame.test.d.ts +1 -0
  67. package/dist/render/frame.test.js +12 -0
  68. package/dist/render/jsdomEnv.d.ts +2 -1
  69. package/dist/render/jsdomEnv.js +14 -1
  70. package/dist/render/jsdomEnv.test.js +36 -2
  71. package/dist/render/renderSceneState.d.ts +5 -1
  72. package/dist/render/renderSceneState.js +4 -3
  73. package/dist/render/renderSceneState.test.js +13 -7
  74. package/dist/render/validatePipeline.d.ts +23 -0
  75. package/dist/render/validatePipeline.js +41 -0
  76. package/dist/render/validatePipeline.test.d.ts +1 -0
  77. package/dist/render/validatePipeline.test.js +34 -0
  78. package/dist/resources/docsReader.d.ts +4 -1
  79. package/dist/resources/docsReader.js +23 -6
  80. package/dist/resources/docsReader.test.js +27 -2
  81. package/dist/resources/index.d.ts +1 -1
  82. package/dist/resources/samples.d.ts +1 -2
  83. package/dist/server.d.ts +9 -0
  84. package/dist/server.js +75 -5
  85. package/dist/server.test.js +105 -4
  86. package/dist/tools/describeChartType.d.ts +41 -0
  87. package/dist/tools/describeChartType.js +143 -0
  88. package/dist/tools/describeChartType.test.d.ts +1 -0
  89. package/dist/tools/describeChartType.test.js +78 -0
  90. package/dist/tools/exportChart.d.ts +17 -0
  91. package/dist/tools/exportChart.js +31 -0
  92. package/dist/tools/exportChart.test.d.ts +1 -0
  93. package/dist/tools/exportChart.test.js +43 -0
  94. package/dist/tools/getExample.d.ts +20 -0
  95. package/dist/tools/getExample.js +55 -0
  96. package/dist/tools/getExample.test.d.ts +1 -0
  97. package/dist/tools/getExample.test.js +40 -0
  98. package/dist/tools/getGrammar.d.ts +17 -0
  99. package/dist/tools/getGrammar.js +38 -0
  100. package/dist/tools/getGrammar.test.d.ts +1 -0
  101. package/dist/tools/getGrammar.test.js +35 -0
  102. package/dist/tools/inspect.d.ts +8 -1
  103. package/dist/tools/inspect.js +40 -7
  104. package/dist/tools/inspect.test.js +62 -13
  105. package/dist/tools/listChartTypes.d.ts +14 -0
  106. package/dist/tools/listChartTypes.js +42 -0
  107. package/dist/tools/listChartTypes.test.d.ts +1 -0
  108. package/dist/tools/listChartTypes.test.js +42 -0
  109. package/dist/tools/listPalettes.d.ts +13 -0
  110. package/dist/tools/listPalettes.js +12 -0
  111. package/dist/tools/listPalettes.test.d.ts +1 -0
  112. package/dist/tools/listPalettes.test.js +15 -0
  113. package/dist/tools/recommend.js +3 -1
  114. package/dist/tools/recommend.test.js +40 -0
  115. package/dist/tools/render.d.ts +14 -12
  116. package/dist/tools/render.js +96 -28
  117. package/dist/tools/render.test.js +137 -1
  118. package/dist/tools/searchExamples.d.ts +28 -0
  119. package/dist/tools/searchExamples.js +54 -0
  120. package/dist/tools/searchExamples.test.d.ts +1 -0
  121. package/dist/tools/searchExamples.test.js +32 -0
  122. package/dist/tools/validate.d.ts +9 -3
  123. package/dist/tools/validate.js +11 -1
  124. package/dist/tools/validate.test.js +33 -11
  125. package/dist/transports/http.d.ts +4 -2
  126. package/dist/transports/http.js +232 -23
  127. package/dist/transports/http.test.js +158 -22
  128. package/package.json +5 -3
  129. package/public/apple-touch-icon.png +0 -0
  130. package/public/favicon.png +0 -0
  131. package/public/favicon.svg +9 -0
@@ -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 transport = new StreamableHTTPServerTransport({
100
- sessionIdGenerator: () => randomUUID(),
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 origin = req.headers.origin;
161
+ const rawUrl = req.url ?? '/';
162
+ const hostHeader = req.headers.host ?? 'unknown';
118
163
  const ip = getClientIp(req, trustProxy);
119
- applyCors(res, origin, allowedOrigins);
120
- // CORS preflight
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
- // Health check (Railway / load-balancer liveness probe)
127
- if (req.url === '/healthz' || req.url === '/health') {
128
- jsonResponse(res, 200, { status: 'ok' });
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
- if (!req.url?.startsWith('/mcp')) {
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
- await transport.handleRequest(req, res);
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
- logEvent(silent, { event: 'transport_error', ip, method: req.method, message });
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: 'request',
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
- await transport.close();
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 /mcp tools/list with status < 500', async () => {
13
+ it('responds to root tools/list after establishing SSE', async () => {
14
14
  await withServer({ port: 0 }, async (url) => {
15
- const res = await fetch(`${url}/mcp`, {
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).toBeLessThan(500);
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}/mcp`, { method: 'OPTIONS' });
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 /mcp without token when authToken is set', async () => {
45
- await withServer({ port: 0, authToken: 'secret' }, async (url) => {
46
- const res = await fetch(`${url}/mcp`, {
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
- expect(res.status).toBe(401);
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 /mcp with correct bearer token', async () => {
55
- await withServer({ port: 0, authToken: 'secret' }, async (url) => {
56
- const res = await fetch(`${url}/mcp`, {
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({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
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).toBeLessThan(500);
66
- expect(res.status).not.toBe(401);
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('rate-limits /mcp by IP', async () => {
70
- await withServer({ port: 0, rateLimitPerMinute: 2 }, async (url) => {
71
- const hit = async () => fetch(`${url}/mcp`, {
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).not.toBe(429);
80
- expect(second.status).not.toBe(429);
214
+ expect(first.status).toBe(200);
215
+ expect(second.status).toBe(200);
81
216
  expect(third.status).toBe(429);
82
- expect(third.headers.get('retry-after')).toBe('60');
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.1",
3
+ "version": "0.1.4",
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
  ],
@@ -36,12 +37,13 @@
36
37
  },
37
38
  "dependencies": {
38
39
  "@blueprint-chart/docs": "0.1.20",
39
- "@blueprint-chart/lib": "0.1.19",
40
+ "@blueprint-chart/lib": "0.1.25",
40
41
  "@modelcontextprotocol/sdk": "^1.0.0",
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
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 112 112" width="112" height="112">
2
+ <title>Blueprint Chart</title>
3
+ <circle cx="56" cy="56" r="52" fill="#163A65"/>
4
+ <g transform="translate(34.494,15.440) scale(0.895291)">
5
+ <path fill="#ffffff" opacity="0.5" d="M 20.042969 34.607422 A 28 28 0 0 1 23.072266 34.779297 A 28 28 0 0 0 20.042969 34.607422 z M 23.34375 34.810547 A 28 28 0 0 1 26.171875 35.292969 A 28 28 0 0 0 23.34375 34.810547 z M 16.570312 34.84375 A 28 28 0 0 0 15.474609 35.033203 A 28 28 0 0 1 16.570312 34.84375 z M 26.390625 35.341797 A 28 28 0 0 1 29.316406 36.189453 A 28 28 0 0 0 26.390625 35.341797 z M 13.128906 35.511719 A 28 28 0 0 0 11.904297 35.873047 A 28 28 0 0 1 13.128906 35.511719 z M 29.373047 36.210938 A 28 28 0 0 1 32.103516 37.347656 A 28 28 0 0 0 29.373047 36.210938 z M 9.7832031 36.609375 A 28 28 0 0 0 8.5820312 37.117188 A 28 28 0 0 1 9.7832031 36.609375 z M 32.392578 37.486328 A 28 28 0 0 1 34.939453 38.90625 A 28 28 0 0 0 32.392578 37.486328 z M 6.5742188 38.128906 A 28 28 0 0 0 5.4511719 38.765625 A 28 28 0 0 1 6.5742188 38.128906 z M 35.041016 38.966797 A 28 28 0 0 1 37.523438 40.738281 A 28 28 0 0 0 35.041016 38.966797 z M 3.5273438 40.080078 A 28 28 0 0 0 2.5761719 40.771484 A 28 28 0 0 1 3.5273438 40.080078 z M 37.658203 40.851562 A 28 28 0 0 1 39.804688 42.783203 A 28 28 0 0 0 37.658203 40.851562 z M 40.011719 42.990234 A 28 28 0 0 1 42.013672 45.251953 A 28 28 0 0 0 40.011719 42.990234 z M 42.013672 45.251953 A 28 28 0 0 1 20.042969 56 A 28 28 0 0 1 14.232422 55.3125 A 16 16 0 0 1 16 62.607422 A 16 16 0 0 1 0 78.607422 L 0 82.121094 A 28 28 0 0 0 20.042969 90.607422 A 28 28 0 0 0 48.042969 62.607422 A 28 28 0 0 0 42.013672 45.251953 z M 1.3007812 46.667969 A 16 16 0 0 1 1.6191406 46.697266 A 16 16 0 0 0 1.3007812 46.667969 z M 4.2558594 47.189453 A 16 16 0 0 1 4.4355469 47.242188 A 16 16 0 0 0 4.2558594 47.189453 z "/>
6
+ <path fill="#ffffff" opacity="0.85" d="M 6.0637516 38.376047 A 28 28 0 0 1 20.042969 34.615234 A 28 28 0 0 1 42.013672 45.259765 A 28 28 0 0 0 48.042969 28.007812 A 28 28 0 0 0 20.042969 0.0078125 A 28 28 0 0 0 0 8.4941407 L 0 16.007812 A 12 12 0 0 1 12 28.007812 A 12 12 0 0 1 6.0613693 38.364443 "/>
7
+ <path fill="#ffffff" d="M 20.042969 34.607422 A 28 28 0 0 0 5.0878906 38.970703 A 28 28 0 0 1 6.0644531 38.376953 L 6.0605469 38.365234 A 12 12 0 0 1 0 40.007812 L 0 43.09375 L 0 43.101562 L 0 46.607422 A 16 16 0 0 1 2.8847656 46.873047 A 16 16 0 0 1 2.9199219 46.878906 A 16 16 0 0 1 5.7011719 47.662109 A 16 16 0 0 1 5.7324219 47.673828 A 16 16 0 0 1 8.3300781 48.955078 A 16 16 0 0 1 8.3398438 48.958984 A 16 16 0 0 1 8.3457031 48.962891 A 16 16 0 0 1 10.662109 50.6875 A 16 16 0 0 1 10.697266 50.71875 A 16 16 0 0 1 12.660156 52.837891 A 16 16 0 0 1 12.667969 52.845703 A 16 16 0 0 1 12.673828 52.855469 A 16 16 0 0 1 14.228516 55.306641 A 16 16 0 0 1 14.232422 55.3125 A 28 28 0 0 0 20.042969 56 A 28 28 0 0 0 42.013672 45.251953 A 28 28 0 0 0 20.042969 34.607422 z M 4.7695312 39.177734 A 28 28 0 0 0 2.5390625 40.798828 A 28 28 0 0 1 4.7695312 39.177734 z M 2.1816406 41.091797 A 28 28 0 0 0 0.11914062 42.984375 A 28 28 0 0 1 2.1816406 41.091797 z "/>
8
+ </g>
9
+ </svg>