@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.
@@ -0,0 +1,223 @@
1
+ import type { DataContext } from '../engine.js';
2
+ import type { EndpointConfig, PipeQueryConfig } from '../config/schema.js';
3
+ import type { SourceStatus } from '../server/sources/types.js';
4
+ import { SourceManager } from '../server/sources/manager.js';
5
+ import { query } from '../engine.js';
6
+
7
+ export interface ProviderSourceInfo {
8
+ name: string;
9
+ status: SourceStatus;
10
+ }
11
+
12
+ export interface ProviderSourceDescription {
13
+ name: string;
14
+ status: SourceStatus;
15
+ sample: unknown[];
16
+ fields: string[];
17
+ }
18
+
19
+ export interface Provider {
20
+ listSources(): Promise<ProviderSourceInfo[]>;
21
+ describeSource(name: string, sampleSize?: number): Promise<ProviderSourceDescription | undefined>;
22
+ listEndpoints(): Promise<Array<{ path: string; config: EndpointConfig }>>;
23
+ callEndpoint(path: string): Promise<unknown>;
24
+ runQuery(expression: string): Promise<unknown>;
25
+ dispose(): Promise<void>;
26
+ }
27
+
28
+ // ─── LocalProvider ──────────────────────────────────────────────────────────
29
+ // Boots its own SourceManager from a loaded config. Used when `pq mcp serve`
30
+ // runs standalone (no attached `pq serve` daemon).
31
+
32
+ export class LocalProvider implements Provider {
33
+ private sourceManager: SourceManager;
34
+ private endpoints: Map<string, EndpointConfig>;
35
+ private ready: Promise<void>;
36
+
37
+ constructor(private config: PipeQueryConfig, cwd: string) {
38
+ this.sourceManager = new SourceManager(cwd);
39
+ this.endpoints = new Map(Object.entries(config.endpoints));
40
+ this.ready = this.bootSources();
41
+ }
42
+
43
+ private async bootSources(): Promise<void> {
44
+ for (const [name, src] of Object.entries(this.config.sources)) {
45
+ try {
46
+ await this.sourceManager.addSource(name, src);
47
+ } catch (err) {
48
+ const msg = err instanceof Error ? err.message : String(err);
49
+ // Print to stderr; stdout is reserved for the MCP JSON-RPC stream in stdio mode.
50
+ process.stderr.write(`[pipequery-mcp] Source "${name}" failed to start: ${msg}\n`);
51
+ }
52
+ }
53
+ }
54
+
55
+ async listSources(): Promise<ProviderSourceInfo[]> {
56
+ await this.ready;
57
+ const statuses = this.sourceManager.getAllStatuses();
58
+ return Object.entries(statuses).map(([name, status]) => ({ name, status }));
59
+ }
60
+
61
+ async describeSource(name: string, sampleSize = 5): Promise<ProviderSourceDescription | undefined> {
62
+ await this.ready;
63
+ const status = this.sourceManager.getSourceStatus(name);
64
+ const data = this.sourceManager.getSourceData(name);
65
+ if (!status || !data) return undefined;
66
+
67
+ const sample = data.slice(0, sampleSize);
68
+ const fields = inferFields(data);
69
+ return { name, status, sample, fields };
70
+ }
71
+
72
+ async listEndpoints(): Promise<Array<{ path: string; config: EndpointConfig }>> {
73
+ await this.ready;
74
+ return [...this.endpoints].map(([path, config]) => ({ path, config }));
75
+ }
76
+
77
+ async callEndpoint(path: string): Promise<unknown> {
78
+ await this.ready;
79
+ const endpoint = this.endpoints.get(path);
80
+ if (!endpoint) {
81
+ throw new Error(`Endpoint "${path}" not found`);
82
+ }
83
+ const context = this.sourceManager.getContext();
84
+ return query(context, endpoint.query);
85
+ }
86
+
87
+ async runQuery(expression: string): Promise<unknown> {
88
+ await this.ready;
89
+ const context = this.sourceManager.getContext();
90
+ return query(context, expression);
91
+ }
92
+
93
+ async dispose(): Promise<void> {
94
+ await this.sourceManager.dispose();
95
+ }
96
+
97
+ // Exposed for the HTTP plugin that wants to mount the MCP server into an
98
+ // existing `createServer` instance; the caller passes in their own manager.
99
+ static withSourceManager(sourceManager: SourceManager, endpoints: Map<string, EndpointConfig>): Provider {
100
+ return new AttachedLocalProvider(sourceManager, endpoints);
101
+ }
102
+ }
103
+
104
+ class AttachedLocalProvider implements Provider {
105
+ constructor(private sourceManager: SourceManager, private endpoints: Map<string, EndpointConfig>) {}
106
+
107
+ async listSources(): Promise<ProviderSourceInfo[]> {
108
+ const statuses = this.sourceManager.getAllStatuses();
109
+ return Object.entries(statuses).map(([name, status]) => ({ name, status }));
110
+ }
111
+
112
+ async describeSource(name: string, sampleSize = 5): Promise<ProviderSourceDescription | undefined> {
113
+ const status = this.sourceManager.getSourceStatus(name);
114
+ const data = this.sourceManager.getSourceData(name);
115
+ if (!status || !data) return undefined;
116
+ return { name, status, sample: data.slice(0, sampleSize), fields: inferFields(data) };
117
+ }
118
+
119
+ async listEndpoints() {
120
+ return [...this.endpoints].map(([path, config]) => ({ path, config }));
121
+ }
122
+
123
+ async callEndpoint(path: string): Promise<unknown> {
124
+ const endpoint = this.endpoints.get(path);
125
+ if (!endpoint) throw new Error(`Endpoint "${path}" not found`);
126
+ return query(this.sourceManager.getContext(), endpoint.query);
127
+ }
128
+
129
+ async runQuery(expression: string): Promise<unknown> {
130
+ return query(this.sourceManager.getContext(), expression);
131
+ }
132
+
133
+ async dispose(): Promise<void> {
134
+ // Intentionally noop: the owning server disposes the SourceManager.
135
+ }
136
+ }
137
+
138
+ // ─── AttachedProvider ───────────────────────────────────────────────────────
139
+ // Talks to a running `pq serve` via its existing /api/_control/* endpoints.
140
+
141
+ export class AttachedProvider implements Provider {
142
+ constructor(private baseUrl: string) {
143
+ this.baseUrl = baseUrl.replace(/\/$/, '');
144
+ }
145
+
146
+ private async get<T>(path: string): Promise<T> {
147
+ const res = await fetch(`${this.baseUrl}${path}`);
148
+ if (!res.ok) throw new Error(`GET ${path} failed: HTTP ${res.status}`);
149
+ return res.json() as Promise<T>;
150
+ }
151
+
152
+ private async post<T>(path: string, body: unknown): Promise<T> {
153
+ const res = await fetch(`${this.baseUrl}${path}`, {
154
+ method: 'POST',
155
+ headers: { 'content-type': 'application/json' },
156
+ body: JSON.stringify(body),
157
+ });
158
+ if (!res.ok) throw new Error(`POST ${path} failed: HTTP ${res.status}`);
159
+ return res.json() as Promise<T>;
160
+ }
161
+
162
+ async listSources(): Promise<ProviderSourceInfo[]> {
163
+ const statuses = await this.get<Record<string, SourceStatus>>('/api/_control/sources');
164
+ return Object.entries(statuses).map(([name, status]) => ({ name, status }));
165
+ }
166
+
167
+ async describeSource(name: string, sampleSize = 5): Promise<ProviderSourceDescription | undefined> {
168
+ const res = await this.post<{
169
+ ok: boolean;
170
+ error?: string;
171
+ rowCount?: number;
172
+ sample?: unknown[];
173
+ status?: SourceStatus;
174
+ }>(`/api/_control/sources/${encodeURIComponent(name)}/test`, {});
175
+ if (!res.ok || !res.status || !res.sample) return undefined;
176
+ return {
177
+ name,
178
+ status: res.status,
179
+ sample: res.sample.slice(0, sampleSize),
180
+ fields: inferFields(res.sample),
181
+ };
182
+ }
183
+
184
+ async listEndpoints() {
185
+ const endpoints = await this.get<Record<string, EndpointConfig>>('/api/_control/endpoints');
186
+ return Object.entries(endpoints).map(([path, config]) => ({ path, config }));
187
+ }
188
+
189
+ async callEndpoint(path: string): Promise<unknown> {
190
+ // Just hit the actual public endpoint on the running server.
191
+ const normalized = path.startsWith('/') ? path : `/${path}`;
192
+ const res = await fetch(`${this.baseUrl}${normalized}`);
193
+ if (!res.ok) throw new Error(`GET ${normalized} failed: HTTP ${res.status}`);
194
+ return res.json();
195
+ }
196
+
197
+ async runQuery(expression: string): Promise<unknown> {
198
+ const res = await this.post<{ ok: boolean; result?: unknown; error?: string }>(
199
+ '/api/_control/query',
200
+ { query: expression },
201
+ );
202
+ if (!res.ok) throw new Error(res.error ?? 'Query failed');
203
+ return res.result;
204
+ }
205
+
206
+ async dispose(): Promise<void> {
207
+ // Stateless — nothing to clean up.
208
+ }
209
+ }
210
+
211
+ // ─── Helpers ────────────────────────────────────────────────────────────────
212
+
213
+ function inferFields(rows: unknown[]): string[] {
214
+ const fields = new Set<string>();
215
+ for (const row of rows.slice(0, 20)) {
216
+ if (row && typeof row === 'object' && !Array.isArray(row)) {
217
+ for (const k of Object.keys(row)) fields.add(k);
218
+ }
219
+ }
220
+ return [...fields];
221
+ }
222
+
223
+ export type { DataContext };
@@ -0,0 +1,138 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { Provider } from './provider.js';
4
+
5
+ export interface McpServerInfo {
6
+ name: string;
7
+ version: string;
8
+ }
9
+
10
+ const DEFAULT_INFO: McpServerInfo = {
11
+ name: 'pipequery',
12
+ version: '0.1.0',
13
+ };
14
+
15
+ /**
16
+ * Build an MCP server wired to the supplied provider.
17
+ *
18
+ * Tools exposed:
19
+ * - query run a PipeQuery expression against live sources
20
+ * - list_sources list configured sources and their health
21
+ * - describe_source return a sample + inferred field names for a source
22
+ * - list_endpoints list pre-configured endpoints and their stored queries
23
+ * - call_endpoint execute a pre-configured endpoint by path
24
+ */
25
+ export function createMcpServer(provider: Provider, info: McpServerInfo = DEFAULT_INFO): McpServer {
26
+ const mcp = new McpServer(info);
27
+
28
+ mcp.registerTool(
29
+ 'query',
30
+ {
31
+ title: 'Run a PipeQuery expression',
32
+ description:
33
+ 'Execute a PipeQuery pipe-based expression (e.g. `crypto | where(price > 100) | sort(price desc) | first(5)`) against all currently configured sources. Returns the computed result as JSON.',
34
+ inputSchema: {
35
+ expression: z
36
+ .string()
37
+ .min(1)
38
+ .describe('The PipeQuery expression. Source names used in the expression must be configured; call `list_sources` to see them.'),
39
+ },
40
+ },
41
+ async ({ expression }) => {
42
+ const result = await provider.runQuery(expression);
43
+ return toolResult(result);
44
+ },
45
+ );
46
+
47
+ mcp.registerTool(
48
+ 'list_sources',
49
+ {
50
+ title: 'List configured data sources',
51
+ description:
52
+ 'List every data source configured in pipequery.yaml along with its current health, row count, and last-fetch timestamp.',
53
+ },
54
+ async () => {
55
+ const sources = await provider.listSources();
56
+ return toolResult({ count: sources.length, sources });
57
+ },
58
+ );
59
+
60
+ mcp.registerTool(
61
+ 'describe_source',
62
+ {
63
+ title: 'Describe a data source',
64
+ description:
65
+ 'Return a sample of rows and the inferred field names for a single source. Useful before constructing a query.',
66
+ inputSchema: {
67
+ name: z.string().min(1).describe('Source name as returned by list_sources.'),
68
+ sample_size: z
69
+ .number()
70
+ .int()
71
+ .positive()
72
+ .max(100)
73
+ .optional()
74
+ .describe('Number of rows to include in the sample. Defaults to 5, max 100.'),
75
+ },
76
+ },
77
+ async ({ name, sample_size }) => {
78
+ const description = await provider.describeSource(name, sample_size);
79
+ if (!description) {
80
+ return toolError(`Source "${name}" not found`);
81
+ }
82
+ return toolResult(description);
83
+ },
84
+ );
85
+
86
+ mcp.registerTool(
87
+ 'list_endpoints',
88
+ {
89
+ title: 'List pre-configured query endpoints',
90
+ description:
91
+ 'Return every endpoint declared in pipequery.yaml along with its stored query expression. These endpoints can be invoked via `call_endpoint`.',
92
+ },
93
+ async () => {
94
+ const endpoints = await provider.listEndpoints();
95
+ return toolResult({ count: endpoints.length, endpoints });
96
+ },
97
+ );
98
+
99
+ mcp.registerTool(
100
+ 'call_endpoint',
101
+ {
102
+ title: 'Invoke a pre-configured endpoint',
103
+ description:
104
+ 'Execute the stored query for an endpoint path (e.g. `/api/top-coins`) and return its JSON result. Equivalent to the HTTP endpoint on `pq serve`, without needing a live HTTP call.',
105
+ inputSchema: {
106
+ path: z.string().min(1).describe('Endpoint path, exactly as declared in pipequery.yaml (typically begins with `/`).'),
107
+ },
108
+ },
109
+ async ({ path }) => {
110
+ try {
111
+ const result = await provider.callEndpoint(path);
112
+ return toolResult(result);
113
+ } catch (err) {
114
+ return toolError(err instanceof Error ? err.message : String(err));
115
+ }
116
+ },
117
+ );
118
+
119
+ return mcp;
120
+ }
121
+
122
+ // ─── Helpers ────────────────────────────────────────────────────────────────
123
+
124
+ function toolResult(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
125
+ return {
126
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
127
+ };
128
+ }
129
+
130
+ function toolError(message: string): {
131
+ content: Array<{ type: 'text'; text: string }>;
132
+ isError: true;
133
+ } {
134
+ return {
135
+ content: [{ type: 'text', text: message }],
136
+ isError: true,
137
+ };
138
+ }
@@ -0,0 +1,26 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import type { Provider } from './provider.js';
3
+ import { createMcpServer, type McpServerInfo } from './server.js';
4
+
5
+ export async function runStdio(provider: Provider, info?: McpServerInfo): Promise<void> {
6
+ const mcp = createMcpServer(provider, info);
7
+ const transport = new StdioServerTransport();
8
+ await mcp.connect(transport);
9
+
10
+ // Clean up on stdio close — Claude Desktop etc. close the child process
11
+ // by closing its stdin.
12
+ const cleanup = async () => {
13
+ try {
14
+ await mcp.close();
15
+ } finally {
16
+ await provider.dispose();
17
+ }
18
+ };
19
+
20
+ process.stdin.on('close', () => {
21
+ void cleanup().then(() => process.exit(0));
22
+ });
23
+
24
+ process.on('SIGINT', () => void cleanup().then(() => process.exit(0)));
25
+ process.on('SIGTERM', () => void cleanup().then(() => process.exit(0)));
26
+ }
@@ -0,0 +1,166 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { Kafka, logLevel, type Consumer, type SASLOptions } from 'kafkajs';
3
+ import type { KafkaSourceConfig } from '../../config/schema.js';
4
+ import type { SourceAdapter, SourceStatus } from './types.js';
5
+
6
+ const DEFAULT_MAX_BUFFER = 1000;
7
+
8
+ function expandEnv(input: string): string {
9
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
10
+ const value = process.env[name];
11
+ if (value === undefined) {
12
+ process.stderr.write(
13
+ `[pipequery] env var "${name}" referenced in kafka source is not set\n`,
14
+ );
15
+ return '';
16
+ }
17
+ return value;
18
+ });
19
+ }
20
+
21
+ function normalizeBrokers(brokers: string | string[]): string[] {
22
+ const list = Array.isArray(brokers) ? brokers : brokers.split(',');
23
+ return list.map((b) => expandEnv(b.trim())).filter(Boolean);
24
+ }
25
+
26
+ export class KafkaSourceAdapter implements SourceAdapter {
27
+ private data: unknown[] = [];
28
+ private kafka: Kafka | null = null;
29
+ private consumer: Consumer | null = null;
30
+ private lastFetch: Date | null = null;
31
+ private error?: string;
32
+ private connected = false;
33
+ private listeners = new Set<(data: unknown[]) => void>();
34
+ private readonly maxBuffer: number;
35
+ private readonly groupId: string;
36
+
37
+ constructor(private config: KafkaSourceConfig) {
38
+ this.maxBuffer = config.maxBuffer ?? DEFAULT_MAX_BUFFER;
39
+ // Auto-generate a per-process consumer group by default so each pipequery
40
+ // instance sees the full firehose. Users who want load-balanced consumption
41
+ // across replicas can set groupId explicitly in the yaml.
42
+ this.groupId = config.groupId ?? `pipequery-${randomBytes(6).toString('hex')}`;
43
+ }
44
+
45
+ async start(): Promise<void> {
46
+ const brokers = normalizeBrokers(this.config.brokers);
47
+ if (brokers.length === 0) {
48
+ throw new Error('Kafka source has no brokers configured');
49
+ }
50
+
51
+ this.kafka = new Kafka({
52
+ clientId: `pipequery-${this.config.topic}`,
53
+ brokers,
54
+ ssl: this.config.ssl ?? false,
55
+ sasl: this.config.sasl
56
+ ? ({
57
+ mechanism: this.config.sasl.mechanism,
58
+ username: expandEnv(this.config.sasl.username),
59
+ password: expandEnv(this.config.sasl.password),
60
+ } as SASLOptions)
61
+ : undefined,
62
+ // Silence kafkajs's own logger — we surface errors via SourceStatus.
63
+ logLevel: logLevel.ERROR,
64
+ });
65
+
66
+ this.consumer = this.kafka.consumer({ groupId: this.groupId });
67
+ await this.consumer.connect();
68
+ await this.consumer.subscribe({
69
+ topic: this.config.topic,
70
+ fromBeginning: this.config.fromBeginning ?? false,
71
+ });
72
+ this.connected = true;
73
+ this.error = undefined;
74
+
75
+ // Fire-and-forget; kafkajs's run() resolves immediately and keeps running
76
+ // in the background until disconnect().
77
+ void this.consumer.run({
78
+ eachMessage: async ({ message, partition }) => {
79
+ try {
80
+ const row = this.decode(message.value, message.key, message.timestamp, partition, message.offset);
81
+ this.data.push(row);
82
+ if (this.data.length > this.maxBuffer) {
83
+ this.data = this.data.slice(-this.maxBuffer);
84
+ }
85
+ this.lastFetch = new Date();
86
+ this.error = undefined;
87
+ this.notify();
88
+ } catch {
89
+ // Skip malformed messages rather than crashing the consumer.
90
+ }
91
+ },
92
+ });
93
+ }
94
+
95
+ stop(): void {
96
+ this.connected = false;
97
+ if (this.consumer) {
98
+ const consumer = this.consumer;
99
+ this.consumer = null;
100
+ // Fire-and-forget disconnect; SourceAdapter.stop() is sync, matching
101
+ // the rest of the adapters.
102
+ void consumer.disconnect().catch(() => {
103
+ // Swallow — we're shutting down anyway.
104
+ });
105
+ }
106
+ this.kafka = null;
107
+ }
108
+
109
+ getData(): unknown[] {
110
+ return this.data;
111
+ }
112
+
113
+ getStatus(): SourceStatus {
114
+ return {
115
+ healthy: this.connected && !this.error,
116
+ rowCount: this.data.length,
117
+ lastFetch: this.lastFetch,
118
+ error: this.error,
119
+ };
120
+ }
121
+
122
+ onUpdate(callback: (data: unknown[]) => void): () => void {
123
+ this.listeners.add(callback);
124
+ return () => this.listeners.delete(callback);
125
+ }
126
+
127
+ private decode(
128
+ value: Buffer | null,
129
+ key: Buffer | null,
130
+ timestamp: string,
131
+ partition: number,
132
+ offset: string,
133
+ ): Record<string, unknown> {
134
+ const base: Record<string, unknown> = {
135
+ _kafka_topic: this.config.topic,
136
+ _kafka_partition: partition,
137
+ _kafka_offset: offset,
138
+ _kafka_timestamp: Number.parseInt(timestamp, 10),
139
+ _kafka_key: key ? key.toString('utf8') : null,
140
+ };
141
+
142
+ if (!value) {
143
+ return { ...base, value: null };
144
+ }
145
+
146
+ const format = this.config.valueFormat ?? 'json';
147
+ if (format === 'string') {
148
+ return { ...base, value: value.toString('utf8') };
149
+ }
150
+ if (format === 'raw') {
151
+ return { ...base, value: value.toString('base64') };
152
+ }
153
+ // json
154
+ const decoded = JSON.parse(value.toString('utf8')) as unknown;
155
+ if (decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded)) {
156
+ // Spread the message body into the top-level row so queries can reference
157
+ // fields directly (e.g. `events | where(event_type == 'order.paid')`).
158
+ return { ...base, ...(decoded as Record<string, unknown>) };
159
+ }
160
+ return { ...base, value: decoded };
161
+ }
162
+
163
+ private notify(): void {
164
+ for (const cb of this.listeners) cb(this.data);
165
+ }
166
+ }
@@ -4,6 +4,10 @@ import { RestSourceAdapter } from './rest.js';
4
4
  import { WebSocketSourceAdapter } from './websocket.js';
5
5
  import { FileSourceAdapter } from './file.js';
6
6
  import { StaticSourceAdapter } from './static.js';
7
+ import { PostgresSourceAdapter } from './postgres.js';
8
+ import { MysqlSourceAdapter } from './mysql.js';
9
+ import { SqliteSourceAdapter } from './sqlite.js';
10
+ import { KafkaSourceAdapter } from './kafka.js';
7
11
 
8
12
  export type DataContext = Record<string, unknown[]>;
9
13
 
@@ -113,6 +117,14 @@ export class SourceManager {
113
117
  return new FileSourceAdapter(config, this.cwd);
114
118
  case 'static':
115
119
  return new StaticSourceAdapter(config);
120
+ case 'postgres':
121
+ return new PostgresSourceAdapter(config);
122
+ case 'mysql':
123
+ return new MysqlSourceAdapter(config);
124
+ case 'sqlite':
125
+ return new SqliteSourceAdapter(config, this.cwd);
126
+ case 'kafka':
127
+ return new KafkaSourceAdapter(config);
116
128
  default:
117
129
  throw new Error(`Unknown source type: ${(config as { type: string }).type}`);
118
130
  }