@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/README.md +88 -2
- package/dist/index.js +1009 -24
- package/package.json +10 -2
- package/src/commands/mcp.ts +87 -0
- package/src/commands/source.ts +63 -1
- package/src/config/schema.ts +108 -1
- package/src/docker/templates.ts +1 -1
- package/src/index.ts +37 -6
- package/src/mcp/http.ts +191 -0
- package/src/mcp/provider.ts +223 -0
- package/src/mcp/server.ts +138 -0
- package/src/mcp/stdio.ts +26 -0
- package/src/server/sources/kafka.ts +166 -0
- package/src/server/sources/manager.ts +12 -0
- package/src/server/sources/mysql.ts +128 -0
- package/src/server/sources/postgres.ts +139 -0
- package/src/server/sources/sqlite.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaultgradient/pipequery-cli",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/src/commands/source.ts
CHANGED
|
@@ -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
|
package/src/config/schema.ts
CHANGED
|
@@ -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 {
|
package/src/docker/templates.ts
CHANGED
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
|
|
70
|
-
.option('-i, --interval <interval>', 'Poll interval
|
|
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
|
package/src/mcp/http.ts
ADDED
|
@@ -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 };
|