@blueprint-chart/mcp 0.1.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/LICENSE +21 -0
- package/README.md +240 -0
- package/bin/blueprint-chart-mcp.js +15 -0
- package/bin/loader.mjs +36 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +123 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +16 -0
- package/dist/errors.test.d.ts +1 -0
- package/dist/errors.test.js +23 -0
- package/dist/lib/zodToJsonSchema.d.ts +8 -0
- package/dist/lib/zodToJsonSchema.js +9 -0
- package/dist/parse.d.ts +5 -0
- package/dist/parse.js +25 -0
- package/dist/parse.test.d.ts +1 -0
- package/dist/parse.test.js +29 -0
- package/dist/prompts/authorChart.d.ts +12 -0
- package/dist/prompts/authorChart.js +35 -0
- package/dist/prompts/authorChart.test.d.ts +1 -0
- package/dist/prompts/authorChart.test.js +13 -0
- package/dist/render/jsdomEnv.d.ts +12 -0
- package/dist/render/jsdomEnv.js +28 -0
- package/dist/render/jsdomEnv.test.d.ts +1 -0
- package/dist/render/jsdomEnv.test.js +22 -0
- package/dist/render/rasterize.d.ts +5 -0
- package/dist/render/rasterize.js +14 -0
- package/dist/render/rasterize.test.d.ts +1 -0
- package/dist/render/rasterize.test.js +24 -0
- package/dist/render/renderSceneState.d.ts +21 -0
- package/dist/render/renderSceneState.js +71 -0
- package/dist/render/renderSceneState.test.d.ts +1 -0
- package/dist/render/renderSceneState.test.js +18 -0
- package/dist/render/textShim.d.ts +12 -0
- package/dist/render/textShim.js +78 -0
- package/dist/render/textShim.test.d.ts +1 -0
- package/dist/render/textShim.test.js +31 -0
- package/dist/resources/docsReader.d.ts +14 -0
- package/dist/resources/docsReader.js +50 -0
- package/dist/resources/docsReader.test.d.ts +1 -0
- package/dist/resources/docsReader.test.js +24 -0
- package/dist/resources/index.d.ts +6 -0
- package/dist/resources/index.js +11 -0
- package/dist/resources/samples.d.ts +13 -0
- package/dist/resources/samples.js +21 -0
- package/dist/resources/samples.test.d.ts +1 -0
- package/dist/resources/samples.test.js +18 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +86 -0
- package/dist/server.test.d.ts +1 -0
- package/dist/server.test.js +68 -0
- package/dist/smoke.test.d.ts +1 -0
- package/dist/smoke.test.js +11 -0
- package/dist/tools/inspect.d.ts +26 -0
- package/dist/tools/inspect.js +37 -0
- package/dist/tools/inspect.test.d.ts +1 -0
- package/dist/tools/inspect.test.js +29 -0
- package/dist/tools/recommend.d.ts +21 -0
- package/dist/tools/recommend.js +17 -0
- package/dist/tools/recommend.test.d.ts +1 -0
- package/dist/tools/recommend.test.js +33 -0
- package/dist/tools/render.d.ts +35 -0
- package/dist/tools/render.js +63 -0
- package/dist/tools/render.test.d.ts +1 -0
- package/dist/tools/render.test.js +39 -0
- package/dist/tools/validate.d.ts +13 -0
- package/dist/tools/validate.js +13 -0
- package/dist/tools/validate.test.d.ts +1 -0
- package/dist/tools/validate.test.js +27 -0
- package/dist/transports/http.d.ts +21 -0
- package/dist/transports/http.js +193 -0
- package/dist/transports/http.test.d.ts +1 -0
- package/dist/transports/http.test.js +85 -0
- package/dist/transports/stdio.d.ts +1 -0
- package/dist/transports/stdio.js +7 -0
- package/package.json +70 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
2
|
+
import { createServer as createHttpServer, } from 'node:http';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createServer } from '../server.js';
|
|
5
|
+
class Semaphore {
|
|
6
|
+
available;
|
|
7
|
+
waiters = [];
|
|
8
|
+
constructor(max) {
|
|
9
|
+
this.available = max;
|
|
10
|
+
}
|
|
11
|
+
async acquire() {
|
|
12
|
+
if (this.available > 0) {
|
|
13
|
+
this.available -= 1;
|
|
14
|
+
return () => this.release();
|
|
15
|
+
}
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
this.waiters.push(() => {
|
|
18
|
+
this.available -= 1;
|
|
19
|
+
resolve(() => this.release());
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
release() {
|
|
24
|
+
this.available += 1;
|
|
25
|
+
const next = this.waiters.shift();
|
|
26
|
+
if (next) {
|
|
27
|
+
next();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class TokenBucketLimiter {
|
|
32
|
+
maxPerMinute;
|
|
33
|
+
buckets = new Map();
|
|
34
|
+
constructor(maxPerMinute) {
|
|
35
|
+
this.maxPerMinute = maxPerMinute;
|
|
36
|
+
}
|
|
37
|
+
consume(key) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const bucket = this.buckets.get(key) ?? { tokens: this.maxPerMinute, lastRefill: now };
|
|
40
|
+
const elapsed = now - bucket.lastRefill;
|
|
41
|
+
if (elapsed > 0) {
|
|
42
|
+
const refilled = (elapsed / 60_000) * this.maxPerMinute;
|
|
43
|
+
bucket.tokens = Math.min(this.maxPerMinute, bucket.tokens + refilled);
|
|
44
|
+
bucket.lastRefill = now;
|
|
45
|
+
}
|
|
46
|
+
if (bucket.tokens < 1) {
|
|
47
|
+
this.buckets.set(key, bucket);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
bucket.tokens -= 1;
|
|
51
|
+
this.buckets.set(key, bucket);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
prune() {
|
|
55
|
+
const cutoff = Date.now() - 5 * 60_000;
|
|
56
|
+
for (const [key, bucket] of this.buckets) {
|
|
57
|
+
if (bucket.lastRefill < cutoff) {
|
|
58
|
+
this.buckets.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function getClientIp(req, trustProxy) {
|
|
64
|
+
if (trustProxy) {
|
|
65
|
+
const xff = req.headers['x-forwarded-for'];
|
|
66
|
+
if (typeof xff === 'string' && xff.length > 0) {
|
|
67
|
+
return xff.split(',')[0].trim();
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(xff) && xff[0]) {
|
|
70
|
+
return xff[0].split(',')[0].trim();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
74
|
+
}
|
|
75
|
+
function applyCors(res, origin, allowed) {
|
|
76
|
+
if (allowed === '*') {
|
|
77
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
78
|
+
}
|
|
79
|
+
else if (origin && allowed.includes(origin)) {
|
|
80
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
81
|
+
res.setHeader('Vary', 'Origin');
|
|
82
|
+
}
|
|
83
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
84
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, Accept');
|
|
85
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
86
|
+
}
|
|
87
|
+
function logEvent(silent, fields) {
|
|
88
|
+
if (silent) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
process.stderr.write(`${JSON.stringify({ ts: new Date().toISOString(), ...fields })}\n`);
|
|
92
|
+
}
|
|
93
|
+
function jsonResponse(res, status, body) {
|
|
94
|
+
res.statusCode = status;
|
|
95
|
+
res.setHeader('Content-Type', 'application/json');
|
|
96
|
+
res.end(JSON.stringify(body));
|
|
97
|
+
}
|
|
98
|
+
export async function startHttp(opts) {
|
|
99
|
+
const transport = new StreamableHTTPServerTransport({
|
|
100
|
+
sessionIdGenerator: () => randomUUID(),
|
|
101
|
+
});
|
|
102
|
+
const server = createServer();
|
|
103
|
+
await server.connect(transport);
|
|
104
|
+
const allowedOrigins = opts.allowedOrigins ?? '*';
|
|
105
|
+
const trustProxy = opts.trustProxy ?? false;
|
|
106
|
+
const silent = opts.silent ?? false;
|
|
107
|
+
const semaphore = new Semaphore(opts.maxConcurrentRequests ?? 16);
|
|
108
|
+
const limiter = opts.rateLimitPerMinute && opts.rateLimitPerMinute > 0
|
|
109
|
+
? new TokenBucketLimiter(opts.rateLimitPerMinute)
|
|
110
|
+
: undefined;
|
|
111
|
+
const pruneTimer = limiter ? setInterval(() => limiter.prune(), 60_000) : undefined;
|
|
112
|
+
if (pruneTimer && typeof pruneTimer.unref === 'function') {
|
|
113
|
+
pruneTimer.unref();
|
|
114
|
+
}
|
|
115
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
116
|
+
const started = Date.now();
|
|
117
|
+
const origin = req.headers.origin;
|
|
118
|
+
const ip = getClientIp(req, trustProxy);
|
|
119
|
+
applyCors(res, origin, allowedOrigins);
|
|
120
|
+
// CORS preflight
|
|
121
|
+
if (req.method === 'OPTIONS') {
|
|
122
|
+
res.statusCode = 204;
|
|
123
|
+
res.end();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Health check (Railway / load-balancer liveness probe)
|
|
127
|
+
if (req.url === '/healthz' || req.url === '/health') {
|
|
128
|
+
jsonResponse(res, 200, { status: 'ok' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!req.url?.startsWith('/mcp')) {
|
|
132
|
+
jsonResponse(res, 404, { error: 'Not Found' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Bearer token auth (off if MCP_AUTH_TOKEN not set)
|
|
136
|
+
if (opts.authToken) {
|
|
137
|
+
const auth = req.headers.authorization;
|
|
138
|
+
if (auth !== `Bearer ${opts.authToken}`) {
|
|
139
|
+
logEvent(silent, { event: 'auth_rejected', ip, method: req.method });
|
|
140
|
+
jsonResponse(res, 401, { error: 'Unauthorized' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Per-IP rate limit
|
|
145
|
+
if (limiter && !limiter.consume(ip)) {
|
|
146
|
+
logEvent(silent, { event: 'rate_limited', ip });
|
|
147
|
+
res.setHeader('Retry-After', '60');
|
|
148
|
+
jsonResponse(res, 429, { error: 'Too Many Requests' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
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
|
+
try {
|
|
155
|
+
await transport.handleRequest(req, res);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
159
|
+
logEvent(silent, { event: 'transport_error', ip, method: req.method, message });
|
|
160
|
+
if (!res.headersSent) {
|
|
161
|
+
jsonResponse(res, 500, { error: 'Internal Server Error' });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
release?.();
|
|
166
|
+
logEvent(silent, {
|
|
167
|
+
event: 'request',
|
|
168
|
+
ip,
|
|
169
|
+
method: req.method,
|
|
170
|
+
path: req.url,
|
|
171
|
+
status: res.statusCode,
|
|
172
|
+
durationMs: Date.now() - started,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
await new Promise(resolve => httpServer.listen(opts.port, opts.host ?? '127.0.0.1', resolve));
|
|
177
|
+
const address = httpServer.address();
|
|
178
|
+
if (!address || typeof address === 'string') {
|
|
179
|
+
throw new Error('http server has no port');
|
|
180
|
+
}
|
|
181
|
+
const url = `http://${opts.host ?? '127.0.0.1'}:${address.port}`;
|
|
182
|
+
return {
|
|
183
|
+
url,
|
|
184
|
+
close: async () => {
|
|
185
|
+
if (pruneTimer) {
|
|
186
|
+
clearInterval(pruneTimer);
|
|
187
|
+
}
|
|
188
|
+
await transport.close();
|
|
189
|
+
httpServer.closeAllConnections();
|
|
190
|
+
await new Promise((resolve, reject) => httpServer.close(err => (err ? reject(err) : resolve())));
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { startHttp } from './http';
|
|
3
|
+
async function withServer(opts, fn) {
|
|
4
|
+
const handle = await startHttp({ silent: true, ...opts });
|
|
5
|
+
try {
|
|
6
|
+
return await fn(handle.url);
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
await handle.close();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
describe('http transport', () => {
|
|
13
|
+
it('responds to /mcp tools/list with status < 500', async () => {
|
|
14
|
+
await withServer({ port: 0 }, async (url) => {
|
|
15
|
+
const res = await fetch(`${url}/mcp`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
18
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
19
|
+
});
|
|
20
|
+
expect(res.status).toBeLessThan(500);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it('serves /healthz returning {status:"ok"}', async () => {
|
|
24
|
+
await withServer({ port: 0 }, async (url) => {
|
|
25
|
+
const res = await fetch(`${url}/healthz`);
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
const body = await res.json();
|
|
28
|
+
expect(body.status).toBe('ok');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
it('returns 404 for unknown paths', async () => {
|
|
32
|
+
await withServer({ port: 0 }, async (url) => {
|
|
33
|
+
const res = await fetch(`${url}/nope`);
|
|
34
|
+
expect(res.status).toBe(404);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it('handles CORS preflight (OPTIONS)', async () => {
|
|
38
|
+
await withServer({ port: 0 }, async (url) => {
|
|
39
|
+
const res = await fetch(`${url}/mcp`, { method: 'OPTIONS' });
|
|
40
|
+
expect(res.status).toBe(204);
|
|
41
|
+
expect(res.headers.get('access-control-allow-methods')).toMatch(/POST/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
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`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
50
|
+
});
|
|
51
|
+
expect(res.status).toBe(401);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
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`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
'accept': 'application/json',
|
|
61
|
+
'authorization': 'Bearer secret',
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
64
|
+
});
|
|
65
|
+
expect(res.status).toBeLessThan(500);
|
|
66
|
+
expect(res.status).not.toBe(401);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('rate-limits /mcp by IP', async () => {
|
|
70
|
+
await withServer({ port: 0, rateLimitPerMinute: 2 }, async (url) => {
|
|
71
|
+
const hit = async () => fetch(`${url}/mcp`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
|
|
75
|
+
});
|
|
76
|
+
const first = await hit();
|
|
77
|
+
const second = await hit();
|
|
78
|
+
const third = await hit();
|
|
79
|
+
expect(first.status).not.toBe(429);
|
|
80
|
+
expect(second.status).not.toBe(429);
|
|
81
|
+
expect(third.status).toBe(429);
|
|
82
|
+
expect(third.headers.get('retry-after')).toBe('60');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startStdio(): Promise<void>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
2
|
+
import { createServer } from '../server';
|
|
3
|
+
export async function startStdio() {
|
|
4
|
+
const server = createServer();
|
|
5
|
+
const transport = new StdioServerTransport();
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blueprint-chart/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol server for authoring Blueprint Chart .bpc files with LLMs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "pirhoo",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"homepage": "https://github.com/blueprint-chart/mcp#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/blueprint-chart/mcp.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/blueprint-chart/mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"blueprint-chart-mcp": "./bin/blueprint-chart-mcp.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"bin",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": true
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"dev": "tsx src/cli.ts",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"test:update-golden": "UPDATE_GOLDEN=1 vitest run test/golden",
|
|
43
|
+
"lint": "eslint .",
|
|
44
|
+
"lint:fix": "eslint . --fix",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@blueprint-chart/docs": "0.1.20",
|
|
49
|
+
"@blueprint-chart/lib": "0.1.19",
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
51
|
+
"@napi-rs/canvas": "^0.1.71",
|
|
52
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
53
|
+
"jsdom": "^26.1.0",
|
|
54
|
+
"zod": "^3.23.8"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^9.39.4",
|
|
58
|
+
"@stylistic/eslint-plugin": "^4.4.1",
|
|
59
|
+
"@types/jsdom": "^21.1.7",
|
|
60
|
+
"@types/node": "^22.10.0",
|
|
61
|
+
"eslint": "^9.18.0",
|
|
62
|
+
"tsx": "^4.19.2",
|
|
63
|
+
"typescript": "^5.7.0",
|
|
64
|
+
"typescript-eslint": "^8.59.4",
|
|
65
|
+
"vitest": "^3.2.4"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=22"
|
|
69
|
+
}
|
|
70
|
+
}
|