@vaultgradient/pipequery-cli 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultgradient/pipequery-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for PipeQuery — data sources, aggregations, APIs, and terminal dashboards",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,19 +14,27 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@fastify/cors": "^11.2.0",
17
+ "@modelcontextprotocol/sdk": "^1.29.0",
18
+ "better-sqlite3": "^12.9.0",
17
19
  "chalk": "^5.6.2",
18
20
  "chokidar": "^5.0.0",
19
21
  "commander": "^14.0.3",
20
22
  "csv-parse": "^6.2.1",
21
23
  "fastify": "^5.8.5",
22
24
  "ink": "^7.0.1",
25
+ "kafkajs": "^2.2.4",
26
+ "mysql2": "^3.22.2",
23
27
  "ora": "^9.3.0",
28
+ "pg": "^8.20.0",
24
29
  "react": "^19.2.5",
25
30
  "ws": "^8.20.0",
26
- "yaml": "^2.8.3"
31
+ "yaml": "^2.8.3",
32
+ "zod": "^4.3.6"
27
33
  },
28
34
  "devDependencies": {
35
+ "@types/better-sqlite3": "^7.6.13",
29
36
  "@types/node": "^25.6.0",
37
+ "@types/pg": "^8.20.0",
30
38
  "@types/react": "^19.2.14",
31
39
  "@types/ws": "^8.18.1",
32
40
  "tsup": "^8.5.1",
@@ -0,0 +1,87 @@
1
+ import { loadConfig } from '../config/loader.js';
2
+ import { log } from '../utils/logger.js';
3
+ import { AttachedProvider, LocalProvider, type Provider } from '../mcp/provider.js';
4
+ import { runStdio } from '../mcp/stdio.js';
5
+ import { runHttp } from '../mcp/http.js';
6
+ import { createMcpServer } from '../mcp/server.js';
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
9
+
10
+ interface McpServeOptions {
11
+ http?: boolean;
12
+ port?: number;
13
+ host?: string;
14
+ attach?: string;
15
+ authToken?: string;
16
+ }
17
+
18
+ export async function mcpServeCommand(opts: McpServeOptions): Promise<void> {
19
+ const provider = await buildProvider(opts.attach);
20
+ const isStdio = !opts.http;
21
+
22
+ if (isStdio) {
23
+ // In stdio mode, stdout is reserved for JSON-RPC. All logs go to stderr.
24
+ await runStdio(provider);
25
+ return;
26
+ }
27
+
28
+ const port = opts.port ?? 3001;
29
+ const host = opts.host ?? '127.0.0.1';
30
+ // CLI flag takes precedence over env var. No token => unauthenticated
31
+ // (runHttp warns loudly at startup).
32
+ const authToken = opts.authToken ?? process.env.PIPEQUERY_MCP_TOKEN;
33
+ const handle = await runHttp(provider, { port, host, authToken });
34
+
35
+ log.success(`PipeQuery MCP server listening on ${handle.url}`);
36
+ log.info('Connect any MCP client to the URL above (Claude, Cursor, custom agents).');
37
+ log.info('Press Ctrl+C to stop.');
38
+
39
+ const shutdown = async () => {
40
+ log.info('Shutting down MCP server...');
41
+ await handle.close();
42
+ process.exit(0);
43
+ };
44
+
45
+ process.on('SIGINT', () => void shutdown());
46
+ process.on('SIGTERM', () => void shutdown());
47
+ }
48
+
49
+ export async function mcpInspectCommand(opts: { attach?: string }): Promise<void> {
50
+ const provider = await buildProvider(opts.attach);
51
+ const mcp = createMcpServer(provider);
52
+
53
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
54
+ await mcp.connect(serverTransport);
55
+
56
+ const client = new Client({ name: 'pq-mcp-inspect', version: '0.1.0' });
57
+ await client.connect(clientTransport);
58
+
59
+ try {
60
+ const { tools } = await client.listTools();
61
+ const summary = {
62
+ server: 'pipequery',
63
+ toolCount: tools.length,
64
+ tools: tools.map((t) => ({
65
+ name: t.name,
66
+ title: t.title,
67
+ description: t.description,
68
+ inputSchema: t.inputSchema,
69
+ })),
70
+ };
71
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
72
+ } finally {
73
+ await client.close();
74
+ await mcp.close();
75
+ await provider.dispose();
76
+ }
77
+ }
78
+
79
+ async function buildProvider(attachUrl?: string): Promise<Provider> {
80
+ if (attachUrl) {
81
+ return new AttachedProvider(attachUrl);
82
+ }
83
+
84
+ const cwd = process.cwd();
85
+ const { config } = await loadConfig(cwd);
86
+ return new LocalProvider(config, cwd);
87
+ }
@@ -51,6 +51,16 @@ export async function sourceAddCommand(name: string, opts: {
51
51
  dataPath?: string;
52
52
  path?: string;
53
53
  watch?: boolean;
54
+ query?: string;
55
+ ssl?: string;
56
+ maxRows?: string;
57
+ readonly?: boolean;
58
+ brokers?: string;
59
+ topic?: string;
60
+ groupId?: string;
61
+ fromBeginning?: boolean;
62
+ valueFormat?: string;
63
+ maxBuffer?: string;
54
64
  }) {
55
65
  const cwd = process.cwd();
56
66
  const { config, path: configPath } = await loadConfig(cwd);
@@ -79,8 +89,60 @@ export async function sourceAddCommand(name: string, opts: {
79
89
  ...(opts.watch ? { watch: true } : {}),
80
90
  };
81
91
  break;
92
+ case 'postgres':
93
+ if (!opts.url) throw new Error('--url is required for Postgres sources');
94
+ if (!opts.query) throw new Error('--query is required for Postgres sources');
95
+ sourceConfig = {
96
+ type: 'postgres',
97
+ url: opts.url,
98
+ query: opts.query,
99
+ interval: opts.interval ?? '30s',
100
+ ...(opts.ssl === 'no-verify' ? { ssl: 'no-verify' as const } : {}),
101
+ ...(opts.ssl === 'false' ? { ssl: false as const } : {}),
102
+ ...(opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}),
103
+ };
104
+ break;
105
+ case 'mysql':
106
+ if (!opts.url) throw new Error('--url is required for MySQL sources');
107
+ if (!opts.query) throw new Error('--query is required for MySQL sources');
108
+ sourceConfig = {
109
+ type: 'mysql',
110
+ url: opts.url,
111
+ query: opts.query,
112
+ interval: opts.interval ?? '30s',
113
+ ...(opts.ssl === 'no-verify' ? { ssl: 'no-verify' as const } : {}),
114
+ ...(opts.ssl === 'false' ? { ssl: false as const } : {}),
115
+ ...(opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}),
116
+ };
117
+ break;
118
+ case 'sqlite':
119
+ if (!opts.path) throw new Error('--path is required for SQLite sources');
120
+ if (!opts.query) throw new Error('--query is required for SQLite sources');
121
+ sourceConfig = {
122
+ type: 'sqlite',
123
+ path: opts.path,
124
+ query: opts.query,
125
+ interval: opts.interval ?? '30s',
126
+ ...(opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}),
127
+ ...(opts.readonly === false ? { readonly: false } : {}),
128
+ };
129
+ break;
130
+ case 'kafka':
131
+ if (!opts.brokers) throw new Error('--brokers is required for Kafka sources (comma-separated)');
132
+ if (!opts.topic) throw new Error('--topic is required for Kafka sources');
133
+ sourceConfig = {
134
+ type: 'kafka',
135
+ brokers: opts.brokers,
136
+ topic: opts.topic,
137
+ ...(opts.groupId ? { groupId: opts.groupId } : {}),
138
+ ...(opts.fromBeginning ? { fromBeginning: true } : {}),
139
+ ...(opts.maxBuffer ? { maxBuffer: Number.parseInt(opts.maxBuffer, 10) } : {}),
140
+ ...(opts.valueFormat ? { valueFormat: opts.valueFormat as 'json' | 'string' | 'raw' } : {}),
141
+ ...(opts.ssl === 'true' ? { ssl: true } : {}),
142
+ };
143
+ break;
82
144
  default:
83
- throw new Error(`Unknown source type: ${opts.type}. Use: rest, websocket, file`);
145
+ throw new Error(`Unknown source type: ${opts.type}. Use: rest, websocket, file, postgres, mysql, sqlite, kafka`);
84
146
  }
85
147
 
86
148
  // Save to config
@@ -21,7 +21,11 @@ export type SourceConfig =
21
21
  | RestSourceConfig
22
22
  | WebSocketSourceConfig
23
23
  | FileSourceConfig
24
- | StaticSourceConfig;
24
+ | StaticSourceConfig
25
+ | PostgresSourceConfig
26
+ | MysqlSourceConfig
27
+ | SqliteSourceConfig
28
+ | KafkaSourceConfig;
25
29
 
26
30
  export interface RestSourceConfig {
27
31
  type: 'rest';
@@ -50,6 +54,109 @@ export interface StaticSourceConfig {
50
54
  data: unknown[];
51
55
  }
52
56
 
57
+ export interface PostgresSourceConfig {
58
+ type: 'postgres';
59
+ /**
60
+ * Postgres connection URL. Supports `${ENV_VAR}` interpolation so
61
+ * credentials can live in the environment instead of the yaml.
62
+ * Example: `postgres://${DB_USER}:${DB_PASS}@db.internal:5432/app`.
63
+ */
64
+ url: string;
65
+ /**
66
+ * The SELECT query to run on each poll. Must be a single statement.
67
+ * It's the caller's responsibility to add LIMIT/WHERE clauses that
68
+ * bound the result set; `maxRows` below is a hard safety cap.
69
+ */
70
+ query: string;
71
+ /** Poll interval, e.g. "30s", "5m". Defaults to "30s". */
72
+ interval?: string;
73
+ /**
74
+ * SSL policy. `"require"` verifies the cert (production default for
75
+ * most hosted Postgres). `"no-verify"` skips verification (useful for
76
+ * self-signed dev setups). `false` disables TLS entirely.
77
+ */
78
+ ssl?: 'require' | 'no-verify' | false;
79
+ /** Safety cap. If the query returns more rows, the fetch errors. Default 10000. */
80
+ maxRows?: number;
81
+ }
82
+
83
+ export interface MysqlSourceConfig {
84
+ type: 'mysql';
85
+ /**
86
+ * MySQL connection URL. Supports `${ENV_VAR}` interpolation so
87
+ * credentials can live in the environment instead of the yaml.
88
+ * Example: `mysql://${DB_USER}:${DB_PASS}@db.internal:3306/app`.
89
+ */
90
+ url: string;
91
+ /** The SELECT query to run on each poll. */
92
+ query: string;
93
+ /** Poll interval, e.g. "30s", "5m". Defaults to "30s". */
94
+ interval?: string;
95
+ /**
96
+ * SSL policy. `"require"` verifies the cert (production default).
97
+ * `"no-verify"` skips verification. `false` disables TLS.
98
+ */
99
+ ssl?: 'require' | 'no-verify' | false;
100
+ /** Safety cap. Default 10000. */
101
+ maxRows?: number;
102
+ }
103
+
104
+ export interface KafkaSourceConfig {
105
+ type: 'kafka';
106
+ /**
107
+ * Comma-separated list of Kafka bootstrap brokers, or an array. Supports
108
+ * `${ENV_VAR}` interpolation. Example: `"broker1:9092,broker2:9092"`.
109
+ */
110
+ brokers: string | string[];
111
+ /** Topic to subscribe to. Only one topic per source; use multiple sources for multiple topics. */
112
+ topic: string;
113
+ /**
114
+ * Consumer group ID. If omitted, an ephemeral per-process group is generated
115
+ * so this pipequery instance sees the full firehose. Set explicitly to share
116
+ * a partitioned subscription across pipequery replicas.
117
+ */
118
+ groupId?: string;
119
+ /** Start reading from the topic's beginning on first connect. Default false (latest). */
120
+ fromBeginning?: boolean;
121
+ /** Ring buffer size. Oldest messages are evicted. Default 1000. */
122
+ maxBuffer?: number;
123
+ /**
124
+ * How to decode the message value.
125
+ * - `"json"` (default): `JSON.parse(buffer.toString())`; non-JSON messages are skipped.
126
+ * - `"string"`: UTF-8 string, stored as `{ value }`.
127
+ * - `"raw"`: base64-encoded bytes, stored as `{ value }`.
128
+ */
129
+ valueFormat?: 'json' | 'string' | 'raw';
130
+ /** SSL / TLS toggle. Default false (plaintext). */
131
+ ssl?: boolean;
132
+ /**
133
+ * SASL authentication. Supports `${ENV_VAR}` interpolation in username/password.
134
+ */
135
+ sasl?: {
136
+ mechanism: 'plain' | 'scram-sha-256' | 'scram-sha-512';
137
+ username: string;
138
+ password: string;
139
+ };
140
+ }
141
+
142
+ export interface SqliteSourceConfig {
143
+ type: 'sqlite';
144
+ /**
145
+ * Path to the SQLite database file, relative to the directory
146
+ * containing pipequery.yaml. Use `:memory:` for an in-memory database
147
+ * (useful for tests; data will not persist across restarts).
148
+ */
149
+ path: string;
150
+ /** The SELECT query to run on each poll. */
151
+ query: string;
152
+ /** Poll interval, e.g. "30s", "5m". Defaults to "30s". */
153
+ interval?: string;
154
+ /** Open the database read-only. Recommended for query-only sources. Default true. */
155
+ readonly?: boolean;
156
+ /** Safety cap. Default 10000. */
157
+ maxRows?: number;
158
+ }
159
+
53
160
  // ─── Endpoint config ─────────────────────────────────────────────────────────
54
161
 
55
162
  export interface EndpointConfig {
@@ -7,7 +7,7 @@ COPY pipequery.yaml .
7
7
  COPY data/ data/ 2>/dev/null || true
8
8
 
9
9
  # Install PipeQuery CLI
10
- RUN npm install -g @andreadito/pq
10
+ RUN npm install -g @vaultgradient/pipequery-cli
11
11
 
12
12
  EXPOSE ${port}
13
13
 
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { endpointListCommand, endpointAddCommand, endpointRemoveCommand } from '
8
8
  import { dashboardCommand } from './commands/dashboard.js';
9
9
  import { monitorCommand } from './commands/monitor.js';
10
10
  import { remoteDeployCommand, remoteConnectCommand, remoteStatusCommand } from './commands/remote.js';
11
+ import { mcpServeCommand, mcpInspectCommand } from './commands/mcp.js';
11
12
  import { completionCommand } from './commands/completion.js';
12
13
  import { stopCommand } from './commands/stop.js';
13
14
  import { startRepl } from './commands/repl.js';
@@ -65,12 +66,22 @@ source
65
66
  source
66
67
  .command('add <name>')
67
68
  .description('Add a data source')
68
- .requiredOption('-t, --type <type>', 'Source type: rest, websocket, file')
69
- .option('-u, --url <url>', 'URL for REST or WebSocket sources')
70
- .option('-i, --interval <interval>', 'Poll interval for REST (e.g., 30s, 5m)', '30s')
71
- .option('-d, --data-path <path>', 'Dot-path to extract data from response')
72
- .option('-p, --path <path>', 'File path for file sources')
73
- .option('-w, --watch', 'Watch file for changes')
69
+ .requiredOption('-t, --type <type>', 'Source type: rest, websocket, file, postgres, mysql, sqlite, kafka')
70
+ .option('-u, --url <url>', 'Connection URL for REST / WebSocket / Postgres / MySQL sources')
71
+ .option('-i, --interval <interval>', 'Poll interval (e.g., 30s, 5m)', '30s')
72
+ .option('-d, --data-path <path>', 'Dot-path to extract data from REST response')
73
+ .option('-p, --path <path>', 'File path for file or sqlite sources')
74
+ .option('-w, --watch', 'Watch file for changes (file sources)')
75
+ .option('-q, --query <sql>', 'SELECT query for Postgres / MySQL / SQLite sources')
76
+ .option('--ssl <mode>', 'Postgres / MySQL SSL: require (default), no-verify, false; Kafka: true / false')
77
+ .option('--max-rows <n>', 'DB sources: safety cap on rows per fetch (default 10000)')
78
+ .option('--no-readonly', 'SQLite: open the database read-write instead of read-only')
79
+ .option('-b, --brokers <list>', 'Kafka: comma-separated bootstrap brokers (supports ${ENV_VAR})')
80
+ .option('--topic <topic>', 'Kafka topic to subscribe to')
81
+ .option('--group-id <id>', 'Kafka consumer group (default: ephemeral per-process)')
82
+ .option('--from-beginning', 'Kafka: read topic from the start instead of latest')
83
+ .option('--value-format <fmt>', 'Kafka value decoding: json (default), string, raw')
84
+ .option('--max-buffer <n>', 'Kafka / WebSocket: ring buffer size (default 1000)')
74
85
  .action(sourceAddCommand);
75
86
 
76
87
  source
@@ -138,6 +149,26 @@ remote
138
149
  .description('Check remote server health')
139
150
  .action(remoteStatusCommand);
140
151
 
152
+ // ─── pq mcp ──────────────────────────────────────────────────────────────────
153
+
154
+ const mcp = program.command('mcp').description('Model Context Protocol server for AI clients');
155
+
156
+ mcp
157
+ .command('serve')
158
+ .description('Start an MCP server exposing pipequery as tools (for Claude, Cursor, Copilot, etc.)')
159
+ .option('--http', 'Serve over HTTP/SSE instead of stdio')
160
+ .option('-p, --port <port>', 'HTTP port (when --http is set)', parseInt)
161
+ .option('-H, --host <host>', 'HTTP host (when --http is set)')
162
+ .option('--attach <url>', 'Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally')
163
+ .option('--auth-token <token>', 'Require Authorization: Bearer <token> on HTTP requests. Falls back to $PIPEQUERY_MCP_TOKEN. Without either, the endpoint is unauthenticated.')
164
+ .action(mcpServeCommand);
165
+
166
+ mcp
167
+ .command('inspect')
168
+ .description('Print the MCP tool schemas as JSON (useful for directory submission / debugging)')
169
+ .option('--attach <url>', 'Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally')
170
+ .action(mcpInspectCommand);
171
+
141
172
  // ─── pq stop ────────────────────────────────────────────────────────────────
142
173
 
143
174
  program
@@ -0,0 +1,191 @@
1
+ import { createServer as createHttpServer } from 'node:http';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import type { Provider } from './provider.js';
6
+ import { createMcpServer, type McpServerInfo } from './server.js';
7
+
8
+ export interface HttpServerOptions {
9
+ host?: string;
10
+ port: number;
11
+ path?: string;
12
+ info?: McpServerInfo;
13
+ /**
14
+ * Require clients to send `Authorization: Bearer <token>` matching this
15
+ * value. When undefined, the endpoint is UNAUTHENTICATED and the server
16
+ * prints a loud stderr warning at startup. Intended for localhost /
17
+ * trusted-network deployments only when auth is unset.
18
+ *
19
+ * For hosted deployments, always set this (via `--auth-token` flag or
20
+ * the `PIPEQUERY_MCP_TOKEN` environment variable).
21
+ */
22
+ authToken?: string;
23
+ }
24
+
25
+ export interface HttpServerHandle {
26
+ close(): Promise<void>;
27
+ url: string;
28
+ }
29
+
30
+ /**
31
+ * Stand up a standalone HTTP server hosting the MCP Streamable HTTP transport.
32
+ *
33
+ * Stateful mode: each client gets a session ID on its first (initialize)
34
+ * request and reuses it for subsequent tool calls. Sessions are in-memory;
35
+ * this is single-instance by design. Multi-tenant / horizontally-scaled
36
+ * hosting is out of scope for Phase 0.
37
+ */
38
+ export async function runHttp(provider: Provider, opts: HttpServerOptions): Promise<HttpServerHandle> {
39
+ const host = opts.host ?? '127.0.0.1';
40
+ const port = opts.port;
41
+ const mountPath = opts.path ?? '/mcp';
42
+
43
+ const transports = new Map<string, StreamableHTTPServerTransport>();
44
+ const authToken = opts.authToken;
45
+
46
+ if (!authToken) {
47
+ process.stderr.write(
48
+ '[pipequery-mcp] WARNING: HTTP endpoint is UNAUTHENTICATED. ' +
49
+ 'Set --auth-token or PIPEQUERY_MCP_TOKEN for anything beyond localhost.\n',
50
+ );
51
+ }
52
+
53
+ const attachTransport = async (): Promise<StreamableHTTPServerTransport> => {
54
+ const transport = new StreamableHTTPServerTransport({
55
+ sessionIdGenerator: () => randomUUID(),
56
+ onsessioninitialized: (sessionId) => {
57
+ transports.set(sessionId, transport);
58
+ },
59
+ onsessionclosed: (sessionId) => {
60
+ transports.delete(sessionId);
61
+ },
62
+ });
63
+ const mcp = createMcpServer(provider, opts.info);
64
+ await mcp.connect(transport);
65
+ return transport;
66
+ };
67
+
68
+ const server = createHttpServer(async (req, res) => {
69
+ if (!req.url) {
70
+ res.statusCode = 400;
71
+ res.end('Bad Request');
72
+ return;
73
+ }
74
+
75
+ const requestPath = req.url.split('?')[0];
76
+ if (requestPath !== mountPath) {
77
+ res.statusCode = 404;
78
+ res.end('Not Found');
79
+ return;
80
+ }
81
+
82
+ // Bearer-token auth check (only when a token is configured).
83
+ if (authToken && !isAuthorized(req, authToken)) {
84
+ res.statusCode = 401;
85
+ res.setHeader('content-type', 'application/json');
86
+ res.setHeader('www-authenticate', 'Bearer realm="pipequery-mcp"');
87
+ res.end(
88
+ JSON.stringify({
89
+ jsonrpc: '2.0',
90
+ error: { code: -32001, message: 'Unauthorized: missing or invalid bearer token' },
91
+ id: null,
92
+ }),
93
+ );
94
+ return;
95
+ }
96
+
97
+ try {
98
+ const body = await readJsonBody(req);
99
+ const sessionId = getSessionId(req);
100
+ let transport = sessionId ? transports.get(sessionId) : undefined;
101
+ if (!transport) {
102
+ // No session yet — this should be an initialize request. Mint a new transport.
103
+ transport = await attachTransport();
104
+ }
105
+ await transport.handleRequest(req, res, body);
106
+ } catch (err) {
107
+ process.stderr.write(
108
+ `[pipequery-mcp] transport error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`,
109
+ );
110
+ if (!res.headersSent) {
111
+ res.statusCode = 500;
112
+ res.setHeader('content-type', 'application/json');
113
+ res.end(JSON.stringify({
114
+ jsonrpc: '2.0',
115
+ error: {
116
+ code: -32603,
117
+ message: err instanceof Error ? err.message : String(err),
118
+ },
119
+ id: null,
120
+ }));
121
+ } else {
122
+ res.end();
123
+ }
124
+ }
125
+ });
126
+
127
+ await new Promise<void>((resolve) => server.listen(port, host, resolve));
128
+
129
+ return {
130
+ url: `http://${host}:${port}${mountPath}`,
131
+ async close() {
132
+ await new Promise<void>((resolve, reject) => {
133
+ server.close((err) => (err ? reject(err) : resolve()));
134
+ });
135
+ for (const t of transports.values()) {
136
+ await t.close();
137
+ }
138
+ transports.clear();
139
+ await provider.dispose();
140
+ },
141
+ };
142
+ }
143
+
144
+ function isAuthorized(req: IncomingMessage, expected: string): boolean {
145
+ const header = req.headers['authorization'];
146
+ const raw = typeof header === 'string' ? header : Array.isArray(header) ? header[0] : undefined;
147
+ if (!raw) return false;
148
+ // Format: "Bearer <token>", case-insensitive scheme.
149
+ const match = raw.match(/^Bearer\s+(.+)$/i);
150
+ if (!match) return false;
151
+ const token = match[1].trim();
152
+ // Constant-time comparison — avoids timing oracles on short tokens.
153
+ return timingSafeEqual(token, expected);
154
+ }
155
+
156
+ function timingSafeEqual(a: string, b: string): boolean {
157
+ if (a.length !== b.length) return false;
158
+ let diff = 0;
159
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
160
+ return diff === 0;
161
+ }
162
+
163
+ function getSessionId(req: IncomingMessage): string | undefined {
164
+ const header = req.headers['mcp-session-id'];
165
+ if (typeof header === 'string') return header;
166
+ if (Array.isArray(header)) return header[0];
167
+ return undefined;
168
+ }
169
+
170
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
171
+ const method = req.method?.toUpperCase();
172
+ if (method === 'GET' || method === 'DELETE') return undefined;
173
+
174
+ const chunks: Buffer[] = [];
175
+ for await (const chunk of req) {
176
+ chunks.push(chunk as Buffer);
177
+ }
178
+ if (chunks.length === 0) return undefined;
179
+
180
+ const text = Buffer.concat(chunks).toString('utf8');
181
+ if (!text.trim()) return undefined;
182
+
183
+ try {
184
+ return JSON.parse(text);
185
+ } catch {
186
+ throw new Error('Invalid JSON body');
187
+ }
188
+ }
189
+
190
+ // Suppress unused-import warning in some configurations; ServerResponse is part of the signature readers care about.
191
+ export type { ServerResponse };