@vaultgradient/pipequery-cli 0.3.3 → 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 +87 -1
- package/dist/index.js +1008 -23
- 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/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/dist/index.js
CHANGED
|
@@ -83,7 +83,7 @@ async function initCommand() {
|
|
|
83
83
|
|
|
84
84
|
// src/commands/serve.ts
|
|
85
85
|
import { writeFile as writeFile3, mkdir, readFile as readFile3, unlink } from "fs/promises";
|
|
86
|
-
import { resolve as
|
|
86
|
+
import { resolve as resolve5 } from "path";
|
|
87
87
|
import { existsSync as existsSync3 } from "fs";
|
|
88
88
|
import { spawn } from "child_process";
|
|
89
89
|
|
|
@@ -236,11 +236,11 @@ var WebSocketSourceAdapter = class {
|
|
|
236
236
|
listeners = /* @__PURE__ */ new Set();
|
|
237
237
|
maxBuffer;
|
|
238
238
|
async start() {
|
|
239
|
-
return new Promise((
|
|
239
|
+
return new Promise((resolve10, reject) => {
|
|
240
240
|
this.ws = new WebSocket(this.config.url);
|
|
241
241
|
this.ws.on("open", () => {
|
|
242
242
|
this.error = void 0;
|
|
243
|
-
|
|
243
|
+
resolve10();
|
|
244
244
|
});
|
|
245
245
|
this.ws.on("message", (raw) => {
|
|
246
246
|
try {
|
|
@@ -394,6 +394,455 @@ var StaticSourceAdapter = class {
|
|
|
394
394
|
}
|
|
395
395
|
};
|
|
396
396
|
|
|
397
|
+
// src/server/sources/postgres.ts
|
|
398
|
+
import pg from "pg";
|
|
399
|
+
var DEFAULT_INTERVAL_MS = 3e4;
|
|
400
|
+
var DEFAULT_MAX_ROWS = 1e4;
|
|
401
|
+
function expandEnv(input) {
|
|
402
|
+
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
|
|
403
|
+
const value = process.env[name];
|
|
404
|
+
if (value === void 0) {
|
|
405
|
+
process.stderr.write(
|
|
406
|
+
`[pipequery] env var "${name}" referenced in postgres source URL is not set
|
|
407
|
+
`
|
|
408
|
+
);
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
return value;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
function resolveSsl(ssl) {
|
|
415
|
+
if (ssl === false) return false;
|
|
416
|
+
if (ssl === "no-verify") return { rejectUnauthorized: false };
|
|
417
|
+
return { rejectUnauthorized: true };
|
|
418
|
+
}
|
|
419
|
+
var PostgresSourceAdapter = class {
|
|
420
|
+
constructor(config) {
|
|
421
|
+
this.config = config;
|
|
422
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
|
|
423
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
|
|
424
|
+
}
|
|
425
|
+
config;
|
|
426
|
+
pool = null;
|
|
427
|
+
data = [];
|
|
428
|
+
timer = null;
|
|
429
|
+
lastFetch = null;
|
|
430
|
+
error;
|
|
431
|
+
listeners = /* @__PURE__ */ new Set();
|
|
432
|
+
intervalMs;
|
|
433
|
+
maxRows;
|
|
434
|
+
async start() {
|
|
435
|
+
const connectionString = expandEnv(this.config.url);
|
|
436
|
+
this.pool = new pg.Pool({
|
|
437
|
+
connectionString,
|
|
438
|
+
ssl: resolveSsl(this.config.ssl),
|
|
439
|
+
// Small pool — we're read-only polling, not a transactional app.
|
|
440
|
+
min: 0,
|
|
441
|
+
max: 2,
|
|
442
|
+
idleTimeoutMillis: 1e4
|
|
443
|
+
});
|
|
444
|
+
this.pool.on("error", (err) => {
|
|
445
|
+
this.error = err.message;
|
|
446
|
+
});
|
|
447
|
+
await this.fetch();
|
|
448
|
+
this.timer = setInterval(() => {
|
|
449
|
+
void this.fetch();
|
|
450
|
+
}, this.intervalMs);
|
|
451
|
+
}
|
|
452
|
+
stop() {
|
|
453
|
+
if (this.timer) {
|
|
454
|
+
clearInterval(this.timer);
|
|
455
|
+
this.timer = null;
|
|
456
|
+
}
|
|
457
|
+
if (this.pool) {
|
|
458
|
+
void this.pool.end();
|
|
459
|
+
this.pool = null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
getData() {
|
|
463
|
+
return this.data;
|
|
464
|
+
}
|
|
465
|
+
getStatus() {
|
|
466
|
+
return {
|
|
467
|
+
healthy: !this.error,
|
|
468
|
+
rowCount: this.data.length,
|
|
469
|
+
lastFetch: this.lastFetch,
|
|
470
|
+
error: this.error
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
onUpdate(callback) {
|
|
474
|
+
this.listeners.add(callback);
|
|
475
|
+
return () => this.listeners.delete(callback);
|
|
476
|
+
}
|
|
477
|
+
async refresh() {
|
|
478
|
+
await this.fetch();
|
|
479
|
+
}
|
|
480
|
+
async fetch() {
|
|
481
|
+
if (!this.pool) {
|
|
482
|
+
this.error = "Postgres pool not initialized";
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
|
|
487
|
+
const res = await this.pool.query(capQuery);
|
|
488
|
+
if (res.rows.length > this.maxRows) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`Query returned more than maxRows=${this.maxRows}. Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
this.data = res.rows;
|
|
494
|
+
this.lastFetch = /* @__PURE__ */ new Date();
|
|
495
|
+
this.error = void 0;
|
|
496
|
+
this.notify();
|
|
497
|
+
} catch (err) {
|
|
498
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
notify() {
|
|
502
|
+
for (const cb of this.listeners) cb(this.data);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
function stripTrailingSemicolon(sql) {
|
|
506
|
+
return sql.trim().replace(/;+\s*$/, "");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/server/sources/mysql.ts
|
|
510
|
+
import mysql from "mysql2/promise";
|
|
511
|
+
var DEFAULT_INTERVAL_MS2 = 3e4;
|
|
512
|
+
var DEFAULT_MAX_ROWS2 = 1e4;
|
|
513
|
+
function expandEnv2(input) {
|
|
514
|
+
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
|
|
515
|
+
const value = process.env[name];
|
|
516
|
+
if (value === void 0) {
|
|
517
|
+
process.stderr.write(
|
|
518
|
+
`[pipequery] env var "${name}" referenced in mysql source URL is not set
|
|
519
|
+
`
|
|
520
|
+
);
|
|
521
|
+
return "";
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
function resolveSsl2(ssl) {
|
|
527
|
+
if (ssl === false) return void 0;
|
|
528
|
+
if (ssl === "no-verify") return { rejectUnauthorized: false };
|
|
529
|
+
return { rejectUnauthorized: true };
|
|
530
|
+
}
|
|
531
|
+
var MysqlSourceAdapter = class {
|
|
532
|
+
constructor(config) {
|
|
533
|
+
this.config = config;
|
|
534
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS2;
|
|
535
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS2;
|
|
536
|
+
}
|
|
537
|
+
config;
|
|
538
|
+
pool = null;
|
|
539
|
+
data = [];
|
|
540
|
+
timer = null;
|
|
541
|
+
lastFetch = null;
|
|
542
|
+
error;
|
|
543
|
+
listeners = /* @__PURE__ */ new Set();
|
|
544
|
+
intervalMs;
|
|
545
|
+
maxRows;
|
|
546
|
+
async start() {
|
|
547
|
+
const uri = expandEnv2(this.config.url);
|
|
548
|
+
this.pool = mysql.createPool({
|
|
549
|
+
uri,
|
|
550
|
+
ssl: resolveSsl2(this.config.ssl),
|
|
551
|
+
connectionLimit: 2,
|
|
552
|
+
waitForConnections: true,
|
|
553
|
+
// mysql2 returns BigInt for large integers by default — stringify so the
|
|
554
|
+
// data serializes cleanly to JSON (which can't encode BigInt).
|
|
555
|
+
supportBigNumbers: true,
|
|
556
|
+
bigNumberStrings: true
|
|
557
|
+
});
|
|
558
|
+
await this.fetch();
|
|
559
|
+
this.timer = setInterval(() => {
|
|
560
|
+
void this.fetch();
|
|
561
|
+
}, this.intervalMs);
|
|
562
|
+
}
|
|
563
|
+
stop() {
|
|
564
|
+
if (this.timer) {
|
|
565
|
+
clearInterval(this.timer);
|
|
566
|
+
this.timer = null;
|
|
567
|
+
}
|
|
568
|
+
if (this.pool) {
|
|
569
|
+
void this.pool.end();
|
|
570
|
+
this.pool = null;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
getData() {
|
|
574
|
+
return this.data;
|
|
575
|
+
}
|
|
576
|
+
getStatus() {
|
|
577
|
+
return {
|
|
578
|
+
healthy: !this.error,
|
|
579
|
+
rowCount: this.data.length,
|
|
580
|
+
lastFetch: this.lastFetch,
|
|
581
|
+
error: this.error
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
onUpdate(callback) {
|
|
585
|
+
this.listeners.add(callback);
|
|
586
|
+
return () => this.listeners.delete(callback);
|
|
587
|
+
}
|
|
588
|
+
async refresh() {
|
|
589
|
+
await this.fetch();
|
|
590
|
+
}
|
|
591
|
+
async fetch() {
|
|
592
|
+
if (!this.pool) {
|
|
593
|
+
this.error = "MySQL pool not initialized";
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const capQuery = `SELECT * FROM (${stripTrailingSemicolon2(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
|
|
598
|
+
const [rows] = await this.pool.query(capQuery);
|
|
599
|
+
const asArray = Array.isArray(rows) ? rows : [];
|
|
600
|
+
if (asArray.length > this.maxRows) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`Query returned more than maxRows=${this.maxRows}. Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
this.data = asArray;
|
|
606
|
+
this.lastFetch = /* @__PURE__ */ new Date();
|
|
607
|
+
this.error = void 0;
|
|
608
|
+
this.notify();
|
|
609
|
+
} catch (err) {
|
|
610
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
notify() {
|
|
614
|
+
for (const cb of this.listeners) cb(this.data);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
function stripTrailingSemicolon2(sql) {
|
|
618
|
+
return sql.trim().replace(/;+\s*$/, "");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/server/sources/sqlite.ts
|
|
622
|
+
import { resolve as resolve4 } from "path";
|
|
623
|
+
import Database from "better-sqlite3";
|
|
624
|
+
var DEFAULT_INTERVAL_MS3 = 3e4;
|
|
625
|
+
var DEFAULT_MAX_ROWS3 = 1e4;
|
|
626
|
+
var IN_MEMORY = ":memory:";
|
|
627
|
+
var SqliteSourceAdapter = class {
|
|
628
|
+
constructor(config, cwd) {
|
|
629
|
+
this.config = config;
|
|
630
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS3;
|
|
631
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS3;
|
|
632
|
+
this.readonly = config.readonly ?? true;
|
|
633
|
+
this.resolvedPath = config.path === IN_MEMORY ? IN_MEMORY : resolve4(cwd, config.path);
|
|
634
|
+
}
|
|
635
|
+
config;
|
|
636
|
+
db = null;
|
|
637
|
+
data = [];
|
|
638
|
+
timer = null;
|
|
639
|
+
lastFetch = null;
|
|
640
|
+
error;
|
|
641
|
+
listeners = /* @__PURE__ */ new Set();
|
|
642
|
+
intervalMs;
|
|
643
|
+
maxRows;
|
|
644
|
+
resolvedPath;
|
|
645
|
+
readonly;
|
|
646
|
+
async start() {
|
|
647
|
+
const opts = this.resolvedPath === IN_MEMORY ? {} : { readonly: this.readonly, fileMustExist: this.readonly };
|
|
648
|
+
this.db = new Database(this.resolvedPath, opts);
|
|
649
|
+
if (this.resolvedPath !== IN_MEMORY && !this.readonly) {
|
|
650
|
+
this.db.pragma("journal_mode = WAL");
|
|
651
|
+
}
|
|
652
|
+
await this.fetch();
|
|
653
|
+
this.timer = setInterval(() => {
|
|
654
|
+
void this.fetch();
|
|
655
|
+
}, this.intervalMs);
|
|
656
|
+
}
|
|
657
|
+
stop() {
|
|
658
|
+
if (this.timer) {
|
|
659
|
+
clearInterval(this.timer);
|
|
660
|
+
this.timer = null;
|
|
661
|
+
}
|
|
662
|
+
if (this.db) {
|
|
663
|
+
this.db.close();
|
|
664
|
+
this.db = null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
getData() {
|
|
668
|
+
return this.data;
|
|
669
|
+
}
|
|
670
|
+
getStatus() {
|
|
671
|
+
return {
|
|
672
|
+
healthy: !this.error,
|
|
673
|
+
rowCount: this.data.length,
|
|
674
|
+
lastFetch: this.lastFetch,
|
|
675
|
+
error: this.error
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
onUpdate(callback) {
|
|
679
|
+
this.listeners.add(callback);
|
|
680
|
+
return () => this.listeners.delete(callback);
|
|
681
|
+
}
|
|
682
|
+
async refresh() {
|
|
683
|
+
await this.fetch();
|
|
684
|
+
}
|
|
685
|
+
async fetch() {
|
|
686
|
+
if (!this.db) {
|
|
687
|
+
this.error = "SQLite database not initialized";
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const capQuery = `SELECT * FROM (${stripTrailingSemicolon3(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
|
|
692
|
+
const rows = this.db.prepare(capQuery).all();
|
|
693
|
+
if (rows.length > this.maxRows) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`Query returned more than maxRows=${this.maxRows}. Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
this.data = rows;
|
|
699
|
+
this.lastFetch = /* @__PURE__ */ new Date();
|
|
700
|
+
this.error = void 0;
|
|
701
|
+
this.notify();
|
|
702
|
+
} catch (err) {
|
|
703
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
notify() {
|
|
707
|
+
for (const cb of this.listeners) cb(this.data);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
function stripTrailingSemicolon3(sql) {
|
|
711
|
+
return sql.trim().replace(/;+\s*$/, "");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/server/sources/kafka.ts
|
|
715
|
+
import { randomBytes } from "crypto";
|
|
716
|
+
import { Kafka, logLevel } from "kafkajs";
|
|
717
|
+
var DEFAULT_MAX_BUFFER = 1e3;
|
|
718
|
+
function expandEnv3(input) {
|
|
719
|
+
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
|
|
720
|
+
const value = process.env[name];
|
|
721
|
+
if (value === void 0) {
|
|
722
|
+
process.stderr.write(
|
|
723
|
+
`[pipequery] env var "${name}" referenced in kafka source is not set
|
|
724
|
+
`
|
|
725
|
+
);
|
|
726
|
+
return "";
|
|
727
|
+
}
|
|
728
|
+
return value;
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
function normalizeBrokers(brokers) {
|
|
732
|
+
const list = Array.isArray(brokers) ? brokers : brokers.split(",");
|
|
733
|
+
return list.map((b) => expandEnv3(b.trim())).filter(Boolean);
|
|
734
|
+
}
|
|
735
|
+
var KafkaSourceAdapter = class {
|
|
736
|
+
constructor(config) {
|
|
737
|
+
this.config = config;
|
|
738
|
+
this.maxBuffer = config.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
739
|
+
this.groupId = config.groupId ?? `pipequery-${randomBytes(6).toString("hex")}`;
|
|
740
|
+
}
|
|
741
|
+
config;
|
|
742
|
+
data = [];
|
|
743
|
+
kafka = null;
|
|
744
|
+
consumer = null;
|
|
745
|
+
lastFetch = null;
|
|
746
|
+
error;
|
|
747
|
+
connected = false;
|
|
748
|
+
listeners = /* @__PURE__ */ new Set();
|
|
749
|
+
maxBuffer;
|
|
750
|
+
groupId;
|
|
751
|
+
async start() {
|
|
752
|
+
const brokers = normalizeBrokers(this.config.brokers);
|
|
753
|
+
if (brokers.length === 0) {
|
|
754
|
+
throw new Error("Kafka source has no brokers configured");
|
|
755
|
+
}
|
|
756
|
+
this.kafka = new Kafka({
|
|
757
|
+
clientId: `pipequery-${this.config.topic}`,
|
|
758
|
+
brokers,
|
|
759
|
+
ssl: this.config.ssl ?? false,
|
|
760
|
+
sasl: this.config.sasl ? {
|
|
761
|
+
mechanism: this.config.sasl.mechanism,
|
|
762
|
+
username: expandEnv3(this.config.sasl.username),
|
|
763
|
+
password: expandEnv3(this.config.sasl.password)
|
|
764
|
+
} : void 0,
|
|
765
|
+
// Silence kafkajs's own logger — we surface errors via SourceStatus.
|
|
766
|
+
logLevel: logLevel.ERROR
|
|
767
|
+
});
|
|
768
|
+
this.consumer = this.kafka.consumer({ groupId: this.groupId });
|
|
769
|
+
await this.consumer.connect();
|
|
770
|
+
await this.consumer.subscribe({
|
|
771
|
+
topic: this.config.topic,
|
|
772
|
+
fromBeginning: this.config.fromBeginning ?? false
|
|
773
|
+
});
|
|
774
|
+
this.connected = true;
|
|
775
|
+
this.error = void 0;
|
|
776
|
+
void this.consumer.run({
|
|
777
|
+
eachMessage: async ({ message, partition }) => {
|
|
778
|
+
try {
|
|
779
|
+
const row = this.decode(message.value, message.key, message.timestamp, partition, message.offset);
|
|
780
|
+
this.data.push(row);
|
|
781
|
+
if (this.data.length > this.maxBuffer) {
|
|
782
|
+
this.data = this.data.slice(-this.maxBuffer);
|
|
783
|
+
}
|
|
784
|
+
this.lastFetch = /* @__PURE__ */ new Date();
|
|
785
|
+
this.error = void 0;
|
|
786
|
+
this.notify();
|
|
787
|
+
} catch {
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
stop() {
|
|
793
|
+
this.connected = false;
|
|
794
|
+
if (this.consumer) {
|
|
795
|
+
const consumer = this.consumer;
|
|
796
|
+
this.consumer = null;
|
|
797
|
+
void consumer.disconnect().catch(() => {
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
this.kafka = null;
|
|
801
|
+
}
|
|
802
|
+
getData() {
|
|
803
|
+
return this.data;
|
|
804
|
+
}
|
|
805
|
+
getStatus() {
|
|
806
|
+
return {
|
|
807
|
+
healthy: this.connected && !this.error,
|
|
808
|
+
rowCount: this.data.length,
|
|
809
|
+
lastFetch: this.lastFetch,
|
|
810
|
+
error: this.error
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
onUpdate(callback) {
|
|
814
|
+
this.listeners.add(callback);
|
|
815
|
+
return () => this.listeners.delete(callback);
|
|
816
|
+
}
|
|
817
|
+
decode(value, key, timestamp, partition, offset) {
|
|
818
|
+
const base = {
|
|
819
|
+
_kafka_topic: this.config.topic,
|
|
820
|
+
_kafka_partition: partition,
|
|
821
|
+
_kafka_offset: offset,
|
|
822
|
+
_kafka_timestamp: Number.parseInt(timestamp, 10),
|
|
823
|
+
_kafka_key: key ? key.toString("utf8") : null
|
|
824
|
+
};
|
|
825
|
+
if (!value) {
|
|
826
|
+
return { ...base, value: null };
|
|
827
|
+
}
|
|
828
|
+
const format = this.config.valueFormat ?? "json";
|
|
829
|
+
if (format === "string") {
|
|
830
|
+
return { ...base, value: value.toString("utf8") };
|
|
831
|
+
}
|
|
832
|
+
if (format === "raw") {
|
|
833
|
+
return { ...base, value: value.toString("base64") };
|
|
834
|
+
}
|
|
835
|
+
const decoded = JSON.parse(value.toString("utf8"));
|
|
836
|
+
if (decoded !== null && typeof decoded === "object" && !Array.isArray(decoded)) {
|
|
837
|
+
return { ...base, ...decoded };
|
|
838
|
+
}
|
|
839
|
+
return { ...base, value: decoded };
|
|
840
|
+
}
|
|
841
|
+
notify() {
|
|
842
|
+
for (const cb of this.listeners) cb(this.data);
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
397
846
|
// src/server/sources/manager.ts
|
|
398
847
|
var SourceManager = class {
|
|
399
848
|
constructor(cwd) {
|
|
@@ -479,6 +928,14 @@ var SourceManager = class {
|
|
|
479
928
|
return new FileSourceAdapter(config, this.cwd);
|
|
480
929
|
case "static":
|
|
481
930
|
return new StaticSourceAdapter(config);
|
|
931
|
+
case "postgres":
|
|
932
|
+
return new PostgresSourceAdapter(config);
|
|
933
|
+
case "mysql":
|
|
934
|
+
return new MysqlSourceAdapter(config);
|
|
935
|
+
case "sqlite":
|
|
936
|
+
return new SqliteSourceAdapter(config, this.cwd);
|
|
937
|
+
case "kafka":
|
|
938
|
+
return new KafkaSourceAdapter(config);
|
|
482
939
|
default:
|
|
483
940
|
throw new Error(`Unknown source type: ${config.type}`);
|
|
484
941
|
}
|
|
@@ -2665,8 +3122,8 @@ function printServerStartup(opts) {
|
|
|
2665
3122
|
// src/commands/serve.ts
|
|
2666
3123
|
async function serveCommand(opts) {
|
|
2667
3124
|
const cwd = process.cwd();
|
|
2668
|
-
const pqDir =
|
|
2669
|
-
const serverJsonPath =
|
|
3125
|
+
const pqDir = resolve5(cwd, ".pipequery");
|
|
3126
|
+
const serverJsonPath = resolve5(pqDir, "server.json");
|
|
2670
3127
|
if (opts.stop) {
|
|
2671
3128
|
return stopDaemon(serverJsonPath);
|
|
2672
3129
|
}
|
|
@@ -2678,7 +3135,7 @@ async function serveCommand(opts) {
|
|
|
2678
3135
|
}
|
|
2679
3136
|
const { app, sourceManager } = await createServer(config, configPath, cwd);
|
|
2680
3137
|
await mkdir(pqDir, { recursive: true });
|
|
2681
|
-
const logPath =
|
|
3138
|
+
const logPath = resolve5(pqDir, "server.log");
|
|
2682
3139
|
await writeFile3(serverJsonPath, JSON.stringify({ url: `http://localhost:${port}`, pid: process.pid, daemon: false, log: logPath }));
|
|
2683
3140
|
await app.listen({ port, host });
|
|
2684
3141
|
const sourceCount = sourceManager.getSourceNames().length;
|
|
@@ -2700,9 +3157,9 @@ async function serveCommand(opts) {
|
|
|
2700
3157
|
process.on("SIGTERM", shutdown);
|
|
2701
3158
|
}
|
|
2702
3159
|
async function startDaemon(cwd, port, host) {
|
|
2703
|
-
const pqDir =
|
|
2704
|
-
const serverJsonPath =
|
|
2705
|
-
const logPath =
|
|
3160
|
+
const pqDir = resolve5(cwd, ".pipequery");
|
|
3161
|
+
const serverJsonPath = resolve5(pqDir, "server.json");
|
|
3162
|
+
const logPath = resolve5(pqDir, "server.log");
|
|
2706
3163
|
if (existsSync3(serverJsonPath)) {
|
|
2707
3164
|
try {
|
|
2708
3165
|
const info2 = JSON.parse(await readFile3(serverJsonPath, "utf-8"));
|
|
@@ -2759,11 +3216,11 @@ import chalk4 from "chalk";
|
|
|
2759
3216
|
|
|
2760
3217
|
// src/utils/server-discovery.ts
|
|
2761
3218
|
import { readFile as readFile4 } from "fs/promises";
|
|
2762
|
-
import { resolve as
|
|
3219
|
+
import { resolve as resolve6 } from "path";
|
|
2763
3220
|
import { existsSync as existsSync4 } from "fs";
|
|
2764
3221
|
async function getServerUrl() {
|
|
2765
3222
|
const cwd = process.cwd();
|
|
2766
|
-
const serverJsonPath =
|
|
3223
|
+
const serverJsonPath = resolve6(cwd, ".pipequery", "server.json");
|
|
2767
3224
|
if (existsSync4(serverJsonPath)) {
|
|
2768
3225
|
try {
|
|
2769
3226
|
const info2 = JSON.parse(await readFile4(serverJsonPath, "utf-8"));
|
|
@@ -2906,8 +3363,60 @@ async function sourceAddCommand(name, opts) {
|
|
|
2906
3363
|
...opts.watch ? { watch: true } : {}
|
|
2907
3364
|
};
|
|
2908
3365
|
break;
|
|
3366
|
+
case "postgres":
|
|
3367
|
+
if (!opts.url) throw new Error("--url is required for Postgres sources");
|
|
3368
|
+
if (!opts.query) throw new Error("--query is required for Postgres sources");
|
|
3369
|
+
sourceConfig = {
|
|
3370
|
+
type: "postgres",
|
|
3371
|
+
url: opts.url,
|
|
3372
|
+
query: opts.query,
|
|
3373
|
+
interval: opts.interval ?? "30s",
|
|
3374
|
+
...opts.ssl === "no-verify" ? { ssl: "no-verify" } : {},
|
|
3375
|
+
...opts.ssl === "false" ? { ssl: false } : {},
|
|
3376
|
+
...opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}
|
|
3377
|
+
};
|
|
3378
|
+
break;
|
|
3379
|
+
case "mysql":
|
|
3380
|
+
if (!opts.url) throw new Error("--url is required for MySQL sources");
|
|
3381
|
+
if (!opts.query) throw new Error("--query is required for MySQL sources");
|
|
3382
|
+
sourceConfig = {
|
|
3383
|
+
type: "mysql",
|
|
3384
|
+
url: opts.url,
|
|
3385
|
+
query: opts.query,
|
|
3386
|
+
interval: opts.interval ?? "30s",
|
|
3387
|
+
...opts.ssl === "no-verify" ? { ssl: "no-verify" } : {},
|
|
3388
|
+
...opts.ssl === "false" ? { ssl: false } : {},
|
|
3389
|
+
...opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {}
|
|
3390
|
+
};
|
|
3391
|
+
break;
|
|
3392
|
+
case "sqlite":
|
|
3393
|
+
if (!opts.path) throw new Error("--path is required for SQLite sources");
|
|
3394
|
+
if (!opts.query) throw new Error("--query is required for SQLite sources");
|
|
3395
|
+
sourceConfig = {
|
|
3396
|
+
type: "sqlite",
|
|
3397
|
+
path: opts.path,
|
|
3398
|
+
query: opts.query,
|
|
3399
|
+
interval: opts.interval ?? "30s",
|
|
3400
|
+
...opts.maxRows ? { maxRows: Number.parseInt(opts.maxRows, 10) } : {},
|
|
3401
|
+
...opts.readonly === false ? { readonly: false } : {}
|
|
3402
|
+
};
|
|
3403
|
+
break;
|
|
3404
|
+
case "kafka":
|
|
3405
|
+
if (!opts.brokers) throw new Error("--brokers is required for Kafka sources (comma-separated)");
|
|
3406
|
+
if (!opts.topic) throw new Error("--topic is required for Kafka sources");
|
|
3407
|
+
sourceConfig = {
|
|
3408
|
+
type: "kafka",
|
|
3409
|
+
brokers: opts.brokers,
|
|
3410
|
+
topic: opts.topic,
|
|
3411
|
+
...opts.groupId ? { groupId: opts.groupId } : {},
|
|
3412
|
+
...opts.fromBeginning ? { fromBeginning: true } : {},
|
|
3413
|
+
...opts.maxBuffer ? { maxBuffer: Number.parseInt(opts.maxBuffer, 10) } : {},
|
|
3414
|
+
...opts.valueFormat ? { valueFormat: opts.valueFormat } : {},
|
|
3415
|
+
...opts.ssl === "true" ? { ssl: true } : {}
|
|
3416
|
+
};
|
|
3417
|
+
break;
|
|
2909
3418
|
default:
|
|
2910
|
-
throw new Error(`Unknown source type: ${opts.type}. Use: rest, websocket, file`);
|
|
3419
|
+
throw new Error(`Unknown source type: ${opts.type}. Use: rest, websocket, file, postgres, mysql, sqlite, kafka`);
|
|
2911
3420
|
}
|
|
2912
3421
|
config.sources[name] = sourceConfig;
|
|
2913
3422
|
await saveConfig(config, configPath);
|
|
@@ -3862,7 +4371,7 @@ async function dashboardCommand(opts) {
|
|
|
3862
4371
|
import React6 from "react";
|
|
3863
4372
|
import { render as render2 } from "ink";
|
|
3864
4373
|
import { existsSync as existsSync5, readFileSync } from "fs";
|
|
3865
|
-
import { resolve as
|
|
4374
|
+
import { resolve as resolve7 } from "path";
|
|
3866
4375
|
|
|
3867
4376
|
// src/tui/MonitorApp.tsx
|
|
3868
4377
|
import { useState as useState5, useEffect as useEffect4 } from "react";
|
|
@@ -4046,7 +4555,7 @@ function MonitorApp({ serverUrl }) {
|
|
|
4046
4555
|
// src/commands/monitor.ts
|
|
4047
4556
|
async function monitorCommand() {
|
|
4048
4557
|
const serverUrl = await getServerUrl();
|
|
4049
|
-
const discoveryPath =
|
|
4558
|
+
const discoveryPath = resolve7(process.cwd(), ".pipequery", "server.json");
|
|
4050
4559
|
let logPath;
|
|
4051
4560
|
if (existsSync5(discoveryPath)) {
|
|
4052
4561
|
try {
|
|
@@ -4058,7 +4567,7 @@ async function monitorCommand() {
|
|
|
4058
4567
|
}
|
|
4059
4568
|
}
|
|
4060
4569
|
if (!logPath) {
|
|
4061
|
-
const fallback =
|
|
4570
|
+
const fallback = resolve7(process.cwd(), ".pipequery", "server.log");
|
|
4062
4571
|
if (existsSync5(fallback)) {
|
|
4063
4572
|
logPath = fallback;
|
|
4064
4573
|
}
|
|
@@ -4078,7 +4587,7 @@ async function monitorCommand() {
|
|
|
4078
4587
|
|
|
4079
4588
|
// src/commands/remote.ts
|
|
4080
4589
|
import { writeFile as writeFile4 } from "fs/promises";
|
|
4081
|
-
import { resolve as
|
|
4590
|
+
import { resolve as resolve8 } from "path";
|
|
4082
4591
|
|
|
4083
4592
|
// src/docker/templates.ts
|
|
4084
4593
|
function generateDockerfile(port) {
|
|
@@ -4128,9 +4637,9 @@ async function remoteDeployCommand() {
|
|
|
4128
4637
|
const cwd = process.cwd();
|
|
4129
4638
|
const { config } = await loadConfig(cwd);
|
|
4130
4639
|
const port = config.server.port;
|
|
4131
|
-
await writeFile4(
|
|
4132
|
-
await writeFile4(
|
|
4133
|
-
await writeFile4(
|
|
4640
|
+
await writeFile4(resolve8(cwd, "Dockerfile"), generateDockerfile(port));
|
|
4641
|
+
await writeFile4(resolve8(cwd, "docker-compose.yaml"), generateDockerCompose(port));
|
|
4642
|
+
await writeFile4(resolve8(cwd, ".dockerignore"), generateDockerignore());
|
|
4134
4643
|
log.success("Generated Dockerfile, docker-compose.yaml, and .dockerignore");
|
|
4135
4644
|
log.info(`Run: docker compose up -d --build`);
|
|
4136
4645
|
log.info(`Then: pq remote connect http://localhost:${port}`);
|
|
@@ -4140,7 +4649,7 @@ async function loadOrCreateConfig(cwd) {
|
|
|
4140
4649
|
if (existing) {
|
|
4141
4650
|
return loadConfig(cwd);
|
|
4142
4651
|
}
|
|
4143
|
-
const configPath =
|
|
4652
|
+
const configPath = resolve8(cwd, "pipequery.yaml");
|
|
4144
4653
|
const config = {
|
|
4145
4654
|
server: { ...DEFAULT_CONFIG.server },
|
|
4146
4655
|
sources: {},
|
|
@@ -4188,6 +4697,479 @@ async function remoteStatusCommand() {
|
|
|
4188
4697
|
}
|
|
4189
4698
|
}
|
|
4190
4699
|
|
|
4700
|
+
// src/mcp/provider.ts
|
|
4701
|
+
var LocalProvider = class {
|
|
4702
|
+
constructor(config, cwd) {
|
|
4703
|
+
this.config = config;
|
|
4704
|
+
this.sourceManager = new SourceManager(cwd);
|
|
4705
|
+
this.endpoints = new Map(Object.entries(config.endpoints));
|
|
4706
|
+
this.ready = this.bootSources();
|
|
4707
|
+
}
|
|
4708
|
+
config;
|
|
4709
|
+
sourceManager;
|
|
4710
|
+
endpoints;
|
|
4711
|
+
ready;
|
|
4712
|
+
async bootSources() {
|
|
4713
|
+
for (const [name, src] of Object.entries(this.config.sources)) {
|
|
4714
|
+
try {
|
|
4715
|
+
await this.sourceManager.addSource(name, src);
|
|
4716
|
+
} catch (err) {
|
|
4717
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4718
|
+
process.stderr.write(`[pipequery-mcp] Source "${name}" failed to start: ${msg}
|
|
4719
|
+
`);
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
async listSources() {
|
|
4724
|
+
await this.ready;
|
|
4725
|
+
const statuses = this.sourceManager.getAllStatuses();
|
|
4726
|
+
return Object.entries(statuses).map(([name, status]) => ({ name, status }));
|
|
4727
|
+
}
|
|
4728
|
+
async describeSource(name, sampleSize = 5) {
|
|
4729
|
+
await this.ready;
|
|
4730
|
+
const status = this.sourceManager.getSourceStatus(name);
|
|
4731
|
+
const data = this.sourceManager.getSourceData(name);
|
|
4732
|
+
if (!status || !data) return void 0;
|
|
4733
|
+
const sample = data.slice(0, sampleSize);
|
|
4734
|
+
const fields = inferFields(data);
|
|
4735
|
+
return { name, status, sample, fields };
|
|
4736
|
+
}
|
|
4737
|
+
async listEndpoints() {
|
|
4738
|
+
await this.ready;
|
|
4739
|
+
return [...this.endpoints].map(([path, config]) => ({ path, config }));
|
|
4740
|
+
}
|
|
4741
|
+
async callEndpoint(path) {
|
|
4742
|
+
await this.ready;
|
|
4743
|
+
const endpoint2 = this.endpoints.get(path);
|
|
4744
|
+
if (!endpoint2) {
|
|
4745
|
+
throw new Error(`Endpoint "${path}" not found`);
|
|
4746
|
+
}
|
|
4747
|
+
const context = this.sourceManager.getContext();
|
|
4748
|
+
return query(context, endpoint2.query);
|
|
4749
|
+
}
|
|
4750
|
+
async runQuery(expression) {
|
|
4751
|
+
await this.ready;
|
|
4752
|
+
const context = this.sourceManager.getContext();
|
|
4753
|
+
return query(context, expression);
|
|
4754
|
+
}
|
|
4755
|
+
async dispose() {
|
|
4756
|
+
await this.sourceManager.dispose();
|
|
4757
|
+
}
|
|
4758
|
+
// Exposed for the HTTP plugin that wants to mount the MCP server into an
|
|
4759
|
+
// existing `createServer` instance; the caller passes in their own manager.
|
|
4760
|
+
static withSourceManager(sourceManager, endpoints) {
|
|
4761
|
+
return new AttachedLocalProvider(sourceManager, endpoints);
|
|
4762
|
+
}
|
|
4763
|
+
};
|
|
4764
|
+
var AttachedLocalProvider = class {
|
|
4765
|
+
constructor(sourceManager, endpoints) {
|
|
4766
|
+
this.sourceManager = sourceManager;
|
|
4767
|
+
this.endpoints = endpoints;
|
|
4768
|
+
}
|
|
4769
|
+
sourceManager;
|
|
4770
|
+
endpoints;
|
|
4771
|
+
async listSources() {
|
|
4772
|
+
const statuses = this.sourceManager.getAllStatuses();
|
|
4773
|
+
return Object.entries(statuses).map(([name, status]) => ({ name, status }));
|
|
4774
|
+
}
|
|
4775
|
+
async describeSource(name, sampleSize = 5) {
|
|
4776
|
+
const status = this.sourceManager.getSourceStatus(name);
|
|
4777
|
+
const data = this.sourceManager.getSourceData(name);
|
|
4778
|
+
if (!status || !data) return void 0;
|
|
4779
|
+
return { name, status, sample: data.slice(0, sampleSize), fields: inferFields(data) };
|
|
4780
|
+
}
|
|
4781
|
+
async listEndpoints() {
|
|
4782
|
+
return [...this.endpoints].map(([path, config]) => ({ path, config }));
|
|
4783
|
+
}
|
|
4784
|
+
async callEndpoint(path) {
|
|
4785
|
+
const endpoint2 = this.endpoints.get(path);
|
|
4786
|
+
if (!endpoint2) throw new Error(`Endpoint "${path}" not found`);
|
|
4787
|
+
return query(this.sourceManager.getContext(), endpoint2.query);
|
|
4788
|
+
}
|
|
4789
|
+
async runQuery(expression) {
|
|
4790
|
+
return query(this.sourceManager.getContext(), expression);
|
|
4791
|
+
}
|
|
4792
|
+
async dispose() {
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4795
|
+
var AttachedProvider = class {
|
|
4796
|
+
constructor(baseUrl) {
|
|
4797
|
+
this.baseUrl = baseUrl;
|
|
4798
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
4799
|
+
}
|
|
4800
|
+
baseUrl;
|
|
4801
|
+
async get(path) {
|
|
4802
|
+
const res = await fetch(`${this.baseUrl}${path}`);
|
|
4803
|
+
if (!res.ok) throw new Error(`GET ${path} failed: HTTP ${res.status}`);
|
|
4804
|
+
return res.json();
|
|
4805
|
+
}
|
|
4806
|
+
async post(path, body) {
|
|
4807
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
4808
|
+
method: "POST",
|
|
4809
|
+
headers: { "content-type": "application/json" },
|
|
4810
|
+
body: JSON.stringify(body)
|
|
4811
|
+
});
|
|
4812
|
+
if (!res.ok) throw new Error(`POST ${path} failed: HTTP ${res.status}`);
|
|
4813
|
+
return res.json();
|
|
4814
|
+
}
|
|
4815
|
+
async listSources() {
|
|
4816
|
+
const statuses = await this.get("/api/_control/sources");
|
|
4817
|
+
return Object.entries(statuses).map(([name, status]) => ({ name, status }));
|
|
4818
|
+
}
|
|
4819
|
+
async describeSource(name, sampleSize = 5) {
|
|
4820
|
+
const res = await this.post(`/api/_control/sources/${encodeURIComponent(name)}/test`, {});
|
|
4821
|
+
if (!res.ok || !res.status || !res.sample) return void 0;
|
|
4822
|
+
return {
|
|
4823
|
+
name,
|
|
4824
|
+
status: res.status,
|
|
4825
|
+
sample: res.sample.slice(0, sampleSize),
|
|
4826
|
+
fields: inferFields(res.sample)
|
|
4827
|
+
};
|
|
4828
|
+
}
|
|
4829
|
+
async listEndpoints() {
|
|
4830
|
+
const endpoints = await this.get("/api/_control/endpoints");
|
|
4831
|
+
return Object.entries(endpoints).map(([path, config]) => ({ path, config }));
|
|
4832
|
+
}
|
|
4833
|
+
async callEndpoint(path) {
|
|
4834
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
4835
|
+
const res = await fetch(`${this.baseUrl}${normalized}`);
|
|
4836
|
+
if (!res.ok) throw new Error(`GET ${normalized} failed: HTTP ${res.status}`);
|
|
4837
|
+
return res.json();
|
|
4838
|
+
}
|
|
4839
|
+
async runQuery(expression) {
|
|
4840
|
+
const res = await this.post(
|
|
4841
|
+
"/api/_control/query",
|
|
4842
|
+
{ query: expression }
|
|
4843
|
+
);
|
|
4844
|
+
if (!res.ok) throw new Error(res.error ?? "Query failed");
|
|
4845
|
+
return res.result;
|
|
4846
|
+
}
|
|
4847
|
+
async dispose() {
|
|
4848
|
+
}
|
|
4849
|
+
};
|
|
4850
|
+
function inferFields(rows) {
|
|
4851
|
+
const fields = /* @__PURE__ */ new Set();
|
|
4852
|
+
for (const row of rows.slice(0, 20)) {
|
|
4853
|
+
if (row && typeof row === "object" && !Array.isArray(row)) {
|
|
4854
|
+
for (const k of Object.keys(row)) fields.add(k);
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
return [...fields];
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
// src/mcp/stdio.ts
|
|
4861
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4862
|
+
|
|
4863
|
+
// src/mcp/server.ts
|
|
4864
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4865
|
+
import { z } from "zod";
|
|
4866
|
+
var DEFAULT_INFO = {
|
|
4867
|
+
name: "pipequery",
|
|
4868
|
+
version: "0.1.0"
|
|
4869
|
+
};
|
|
4870
|
+
function createMcpServer(provider, info2 = DEFAULT_INFO) {
|
|
4871
|
+
const mcp2 = new McpServer(info2);
|
|
4872
|
+
mcp2.registerTool(
|
|
4873
|
+
"query",
|
|
4874
|
+
{
|
|
4875
|
+
title: "Run a PipeQuery expression",
|
|
4876
|
+
description: "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.",
|
|
4877
|
+
inputSchema: {
|
|
4878
|
+
expression: z.string().min(1).describe("The PipeQuery expression. Source names used in the expression must be configured; call `list_sources` to see them.")
|
|
4879
|
+
}
|
|
4880
|
+
},
|
|
4881
|
+
async ({ expression }) => {
|
|
4882
|
+
const result = await provider.runQuery(expression);
|
|
4883
|
+
return toolResult(result);
|
|
4884
|
+
}
|
|
4885
|
+
);
|
|
4886
|
+
mcp2.registerTool(
|
|
4887
|
+
"list_sources",
|
|
4888
|
+
{
|
|
4889
|
+
title: "List configured data sources",
|
|
4890
|
+
description: "List every data source configured in pipequery.yaml along with its current health, row count, and last-fetch timestamp."
|
|
4891
|
+
},
|
|
4892
|
+
async () => {
|
|
4893
|
+
const sources = await provider.listSources();
|
|
4894
|
+
return toolResult({ count: sources.length, sources });
|
|
4895
|
+
}
|
|
4896
|
+
);
|
|
4897
|
+
mcp2.registerTool(
|
|
4898
|
+
"describe_source",
|
|
4899
|
+
{
|
|
4900
|
+
title: "Describe a data source",
|
|
4901
|
+
description: "Return a sample of rows and the inferred field names for a single source. Useful before constructing a query.",
|
|
4902
|
+
inputSchema: {
|
|
4903
|
+
name: z.string().min(1).describe("Source name as returned by list_sources."),
|
|
4904
|
+
sample_size: z.number().int().positive().max(100).optional().describe("Number of rows to include in the sample. Defaults to 5, max 100.")
|
|
4905
|
+
}
|
|
4906
|
+
},
|
|
4907
|
+
async ({ name, sample_size }) => {
|
|
4908
|
+
const description = await provider.describeSource(name, sample_size);
|
|
4909
|
+
if (!description) {
|
|
4910
|
+
return toolError(`Source "${name}" not found`);
|
|
4911
|
+
}
|
|
4912
|
+
return toolResult(description);
|
|
4913
|
+
}
|
|
4914
|
+
);
|
|
4915
|
+
mcp2.registerTool(
|
|
4916
|
+
"list_endpoints",
|
|
4917
|
+
{
|
|
4918
|
+
title: "List pre-configured query endpoints",
|
|
4919
|
+
description: "Return every endpoint declared in pipequery.yaml along with its stored query expression. These endpoints can be invoked via `call_endpoint`."
|
|
4920
|
+
},
|
|
4921
|
+
async () => {
|
|
4922
|
+
const endpoints = await provider.listEndpoints();
|
|
4923
|
+
return toolResult({ count: endpoints.length, endpoints });
|
|
4924
|
+
}
|
|
4925
|
+
);
|
|
4926
|
+
mcp2.registerTool(
|
|
4927
|
+
"call_endpoint",
|
|
4928
|
+
{
|
|
4929
|
+
title: "Invoke a pre-configured endpoint",
|
|
4930
|
+
description: "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.",
|
|
4931
|
+
inputSchema: {
|
|
4932
|
+
path: z.string().min(1).describe("Endpoint path, exactly as declared in pipequery.yaml (typically begins with `/`).")
|
|
4933
|
+
}
|
|
4934
|
+
},
|
|
4935
|
+
async ({ path }) => {
|
|
4936
|
+
try {
|
|
4937
|
+
const result = await provider.callEndpoint(path);
|
|
4938
|
+
return toolResult(result);
|
|
4939
|
+
} catch (err) {
|
|
4940
|
+
return toolError(err instanceof Error ? err.message : String(err));
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
);
|
|
4944
|
+
return mcp2;
|
|
4945
|
+
}
|
|
4946
|
+
function toolResult(data) {
|
|
4947
|
+
return {
|
|
4948
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
4949
|
+
};
|
|
4950
|
+
}
|
|
4951
|
+
function toolError(message) {
|
|
4952
|
+
return {
|
|
4953
|
+
content: [{ type: "text", text: message }],
|
|
4954
|
+
isError: true
|
|
4955
|
+
};
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
// src/mcp/stdio.ts
|
|
4959
|
+
async function runStdio(provider, info2) {
|
|
4960
|
+
const mcp2 = createMcpServer(provider, info2);
|
|
4961
|
+
const transport = new StdioServerTransport();
|
|
4962
|
+
await mcp2.connect(transport);
|
|
4963
|
+
const cleanup = async () => {
|
|
4964
|
+
try {
|
|
4965
|
+
await mcp2.close();
|
|
4966
|
+
} finally {
|
|
4967
|
+
await provider.dispose();
|
|
4968
|
+
}
|
|
4969
|
+
};
|
|
4970
|
+
process.stdin.on("close", () => {
|
|
4971
|
+
void cleanup().then(() => process.exit(0));
|
|
4972
|
+
});
|
|
4973
|
+
process.on("SIGINT", () => void cleanup().then(() => process.exit(0)));
|
|
4974
|
+
process.on("SIGTERM", () => void cleanup().then(() => process.exit(0)));
|
|
4975
|
+
}
|
|
4976
|
+
|
|
4977
|
+
// src/mcp/http.ts
|
|
4978
|
+
import { createServer as createHttpServer } from "http";
|
|
4979
|
+
import { randomUUID } from "crypto";
|
|
4980
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4981
|
+
async function runHttp(provider, opts) {
|
|
4982
|
+
const host = opts.host ?? "127.0.0.1";
|
|
4983
|
+
const port = opts.port;
|
|
4984
|
+
const mountPath = opts.path ?? "/mcp";
|
|
4985
|
+
const transports = /* @__PURE__ */ new Map();
|
|
4986
|
+
const authToken = opts.authToken;
|
|
4987
|
+
if (!authToken) {
|
|
4988
|
+
process.stderr.write(
|
|
4989
|
+
"[pipequery-mcp] WARNING: HTTP endpoint is UNAUTHENTICATED. Set --auth-token or PIPEQUERY_MCP_TOKEN for anything beyond localhost.\n"
|
|
4990
|
+
);
|
|
4991
|
+
}
|
|
4992
|
+
const attachTransport = async () => {
|
|
4993
|
+
const transport = new StreamableHTTPServerTransport({
|
|
4994
|
+
sessionIdGenerator: () => randomUUID(),
|
|
4995
|
+
onsessioninitialized: (sessionId) => {
|
|
4996
|
+
transports.set(sessionId, transport);
|
|
4997
|
+
},
|
|
4998
|
+
onsessionclosed: (sessionId) => {
|
|
4999
|
+
transports.delete(sessionId);
|
|
5000
|
+
}
|
|
5001
|
+
});
|
|
5002
|
+
const mcp2 = createMcpServer(provider, opts.info);
|
|
5003
|
+
await mcp2.connect(transport);
|
|
5004
|
+
return transport;
|
|
5005
|
+
};
|
|
5006
|
+
const server = createHttpServer(async (req, res) => {
|
|
5007
|
+
if (!req.url) {
|
|
5008
|
+
res.statusCode = 400;
|
|
5009
|
+
res.end("Bad Request");
|
|
5010
|
+
return;
|
|
5011
|
+
}
|
|
5012
|
+
const requestPath = req.url.split("?")[0];
|
|
5013
|
+
if (requestPath !== mountPath) {
|
|
5014
|
+
res.statusCode = 404;
|
|
5015
|
+
res.end("Not Found");
|
|
5016
|
+
return;
|
|
5017
|
+
}
|
|
5018
|
+
if (authToken && !isAuthorized(req, authToken)) {
|
|
5019
|
+
res.statusCode = 401;
|
|
5020
|
+
res.setHeader("content-type", "application/json");
|
|
5021
|
+
res.setHeader("www-authenticate", 'Bearer realm="pipequery-mcp"');
|
|
5022
|
+
res.end(
|
|
5023
|
+
JSON.stringify({
|
|
5024
|
+
jsonrpc: "2.0",
|
|
5025
|
+
error: { code: -32001, message: "Unauthorized: missing or invalid bearer token" },
|
|
5026
|
+
id: null
|
|
5027
|
+
})
|
|
5028
|
+
);
|
|
5029
|
+
return;
|
|
5030
|
+
}
|
|
5031
|
+
try {
|
|
5032
|
+
const body = await readJsonBody(req);
|
|
5033
|
+
const sessionId = getSessionId(req);
|
|
5034
|
+
let transport = sessionId ? transports.get(sessionId) : void 0;
|
|
5035
|
+
if (!transport) {
|
|
5036
|
+
transport = await attachTransport();
|
|
5037
|
+
}
|
|
5038
|
+
await transport.handleRequest(req, res, body);
|
|
5039
|
+
} catch (err) {
|
|
5040
|
+
process.stderr.write(
|
|
5041
|
+
`[pipequery-mcp] transport error: ${err instanceof Error ? err.stack ?? err.message : String(err)}
|
|
5042
|
+
`
|
|
5043
|
+
);
|
|
5044
|
+
if (!res.headersSent) {
|
|
5045
|
+
res.statusCode = 500;
|
|
5046
|
+
res.setHeader("content-type", "application/json");
|
|
5047
|
+
res.end(JSON.stringify({
|
|
5048
|
+
jsonrpc: "2.0",
|
|
5049
|
+
error: {
|
|
5050
|
+
code: -32603,
|
|
5051
|
+
message: err instanceof Error ? err.message : String(err)
|
|
5052
|
+
},
|
|
5053
|
+
id: null
|
|
5054
|
+
}));
|
|
5055
|
+
} else {
|
|
5056
|
+
res.end();
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
});
|
|
5060
|
+
await new Promise((resolve10) => server.listen(port, host, resolve10));
|
|
5061
|
+
return {
|
|
5062
|
+
url: `http://${host}:${port}${mountPath}`,
|
|
5063
|
+
async close() {
|
|
5064
|
+
await new Promise((resolve10, reject) => {
|
|
5065
|
+
server.close((err) => err ? reject(err) : resolve10());
|
|
5066
|
+
});
|
|
5067
|
+
for (const t of transports.values()) {
|
|
5068
|
+
await t.close();
|
|
5069
|
+
}
|
|
5070
|
+
transports.clear();
|
|
5071
|
+
await provider.dispose();
|
|
5072
|
+
}
|
|
5073
|
+
};
|
|
5074
|
+
}
|
|
5075
|
+
function isAuthorized(req, expected) {
|
|
5076
|
+
const header = req.headers["authorization"];
|
|
5077
|
+
const raw = typeof header === "string" ? header : Array.isArray(header) ? header[0] : void 0;
|
|
5078
|
+
if (!raw) return false;
|
|
5079
|
+
const match = raw.match(/^Bearer\s+(.+)$/i);
|
|
5080
|
+
if (!match) return false;
|
|
5081
|
+
const token = match[1].trim();
|
|
5082
|
+
return timingSafeEqual(token, expected);
|
|
5083
|
+
}
|
|
5084
|
+
function timingSafeEqual(a, b) {
|
|
5085
|
+
if (a.length !== b.length) return false;
|
|
5086
|
+
let diff = 0;
|
|
5087
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
5088
|
+
return diff === 0;
|
|
5089
|
+
}
|
|
5090
|
+
function getSessionId(req) {
|
|
5091
|
+
const header = req.headers["mcp-session-id"];
|
|
5092
|
+
if (typeof header === "string") return header;
|
|
5093
|
+
if (Array.isArray(header)) return header[0];
|
|
5094
|
+
return void 0;
|
|
5095
|
+
}
|
|
5096
|
+
async function readJsonBody(req) {
|
|
5097
|
+
const method = req.method?.toUpperCase();
|
|
5098
|
+
if (method === "GET" || method === "DELETE") return void 0;
|
|
5099
|
+
const chunks = [];
|
|
5100
|
+
for await (const chunk of req) {
|
|
5101
|
+
chunks.push(chunk);
|
|
5102
|
+
}
|
|
5103
|
+
if (chunks.length === 0) return void 0;
|
|
5104
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
5105
|
+
if (!text.trim()) return void 0;
|
|
5106
|
+
try {
|
|
5107
|
+
return JSON.parse(text);
|
|
5108
|
+
} catch {
|
|
5109
|
+
throw new Error("Invalid JSON body");
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
// src/commands/mcp.ts
|
|
5114
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5115
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
5116
|
+
async function mcpServeCommand(opts) {
|
|
5117
|
+
const provider = await buildProvider(opts.attach);
|
|
5118
|
+
const isStdio = !opts.http;
|
|
5119
|
+
if (isStdio) {
|
|
5120
|
+
await runStdio(provider);
|
|
5121
|
+
return;
|
|
5122
|
+
}
|
|
5123
|
+
const port = opts.port ?? 3001;
|
|
5124
|
+
const host = opts.host ?? "127.0.0.1";
|
|
5125
|
+
const authToken = opts.authToken ?? process.env.PIPEQUERY_MCP_TOKEN;
|
|
5126
|
+
const handle = await runHttp(provider, { port, host, authToken });
|
|
5127
|
+
log.success(`PipeQuery MCP server listening on ${handle.url}`);
|
|
5128
|
+
log.info("Connect any MCP client to the URL above (Claude, Cursor, custom agents).");
|
|
5129
|
+
log.info("Press Ctrl+C to stop.");
|
|
5130
|
+
const shutdown = async () => {
|
|
5131
|
+
log.info("Shutting down MCP server...");
|
|
5132
|
+
await handle.close();
|
|
5133
|
+
process.exit(0);
|
|
5134
|
+
};
|
|
5135
|
+
process.on("SIGINT", () => void shutdown());
|
|
5136
|
+
process.on("SIGTERM", () => void shutdown());
|
|
5137
|
+
}
|
|
5138
|
+
async function mcpInspectCommand(opts) {
|
|
5139
|
+
const provider = await buildProvider(opts.attach);
|
|
5140
|
+
const mcp2 = createMcpServer(provider);
|
|
5141
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
5142
|
+
await mcp2.connect(serverTransport);
|
|
5143
|
+
const client = new Client({ name: "pq-mcp-inspect", version: "0.1.0" });
|
|
5144
|
+
await client.connect(clientTransport);
|
|
5145
|
+
try {
|
|
5146
|
+
const { tools } = await client.listTools();
|
|
5147
|
+
const summary = {
|
|
5148
|
+
server: "pipequery",
|
|
5149
|
+
toolCount: tools.length,
|
|
5150
|
+
tools: tools.map((t) => ({
|
|
5151
|
+
name: t.name,
|
|
5152
|
+
title: t.title,
|
|
5153
|
+
description: t.description,
|
|
5154
|
+
inputSchema: t.inputSchema
|
|
5155
|
+
}))
|
|
5156
|
+
};
|
|
5157
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
5158
|
+
} finally {
|
|
5159
|
+
await client.close();
|
|
5160
|
+
await mcp2.close();
|
|
5161
|
+
await provider.dispose();
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
async function buildProvider(attachUrl) {
|
|
5165
|
+
if (attachUrl) {
|
|
5166
|
+
return new AttachedProvider(attachUrl);
|
|
5167
|
+
}
|
|
5168
|
+
const cwd = process.cwd();
|
|
5169
|
+
const { config } = await loadConfig(cwd);
|
|
5170
|
+
return new LocalProvider(config, cwd);
|
|
5171
|
+
}
|
|
5172
|
+
|
|
4191
5173
|
// src/commands/completion.ts
|
|
4192
5174
|
var BASH_COMPLETION = `###-begin-pq-completions-###
|
|
4193
5175
|
_pq_completions() {
|
|
@@ -4352,11 +5334,11 @@ function detectShell() {
|
|
|
4352
5334
|
|
|
4353
5335
|
// src/commands/stop.ts
|
|
4354
5336
|
import { readFile as readFile5, unlink as unlink2 } from "fs/promises";
|
|
4355
|
-
import { resolve as
|
|
5337
|
+
import { resolve as resolve9 } from "path";
|
|
4356
5338
|
import { existsSync as existsSync6 } from "fs";
|
|
4357
5339
|
async function stopCommand(opts) {
|
|
4358
5340
|
const cwd = process.cwd();
|
|
4359
|
-
const serverJsonPath =
|
|
5341
|
+
const serverJsonPath = resolve9(cwd, ".pipequery", "server.json");
|
|
4360
5342
|
if (!existsSync6(serverJsonPath)) {
|
|
4361
5343
|
log.error("No running server found.");
|
|
4362
5344
|
log.dim("Start one with: pq serve or pq serve -d");
|
|
@@ -4608,7 +5590,7 @@ program.command("serve").description("Start the PipeQuery server").option("-p, -
|
|
|
4608
5590
|
program.command("query <expression>").description("Run an ad-hoc PipeQuery expression").option("-f, --format <format>", "Output format: table, json", "table").action(queryCommand);
|
|
4609
5591
|
var source = program.command("source").description("Manage data sources");
|
|
4610
5592
|
source.command("list").description("List configured data sources").action(sourceListCommand);
|
|
4611
|
-
source.command("add <name>").description("Add a data source").requiredOption("-t, --type <type>", "Source type: rest, websocket, file").option("-u, --url <url>", "URL for REST
|
|
5593
|
+
source.command("add <name>").description("Add a data source").requiredOption("-t, --type <type>", "Source type: rest, websocket, file, postgres, mysql, sqlite, kafka").option("-u, --url <url>", "Connection URL for REST / WebSocket / Postgres / MySQL sources").option("-i, --interval <interval>", "Poll interval (e.g., 30s, 5m)", "30s").option("-d, --data-path <path>", "Dot-path to extract data from REST response").option("-p, --path <path>", "File path for file or sqlite sources").option("-w, --watch", "Watch file for changes (file sources)").option("-q, --query <sql>", "SELECT query for Postgres / MySQL / SQLite sources").option("--ssl <mode>", "Postgres / MySQL SSL: require (default), no-verify, false; Kafka: true / false").option("--max-rows <n>", "DB sources: safety cap on rows per fetch (default 10000)").option("--no-readonly", "SQLite: open the database read-write instead of read-only").option("-b, --brokers <list>", "Kafka: comma-separated bootstrap brokers (supports ${ENV_VAR})").option("--topic <topic>", "Kafka topic to subscribe to").option("--group-id <id>", "Kafka consumer group (default: ephemeral per-process)").option("--from-beginning", "Kafka: read topic from the start instead of latest").option("--value-format <fmt>", "Kafka value decoding: json (default), string, raw").option("--max-buffer <n>", "Kafka / WebSocket: ring buffer size (default 1000)").action(sourceAddCommand);
|
|
4612
5594
|
source.command("remove <name>").description("Remove a data source").action(sourceRemoveCommand);
|
|
4613
5595
|
source.command("test <name>").description("Test a data source and show sample data").action(sourceTestCommand);
|
|
4614
5596
|
var endpoint = program.command("endpoint").description("Manage API endpoints");
|
|
@@ -4621,6 +5603,9 @@ var remote = program.command("remote").description("Manage remote deployment");
|
|
|
4621
5603
|
remote.command("deploy").description("Generate Dockerfile and docker-compose.yaml").action(remoteDeployCommand);
|
|
4622
5604
|
remote.command("connect <url>").description("Connect CLI to a remote PipeQuery server").action(remoteConnectCommand);
|
|
4623
5605
|
remote.command("status").description("Check remote server health").action(remoteStatusCommand);
|
|
5606
|
+
var mcp = program.command("mcp").description("Model Context Protocol server for AI clients");
|
|
5607
|
+
mcp.command("serve").description("Start an MCP server exposing pipequery as tools (for Claude, Cursor, Copilot, etc.)").option("--http", "Serve over HTTP/SSE instead of stdio").option("-p, --port <port>", "HTTP port (when --http is set)", parseInt).option("-H, --host <host>", "HTTP host (when --http is set)").option("--attach <url>", "Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally").option("--auth-token <token>", "Require Authorization: Bearer <token> on HTTP requests. Falls back to $PIPEQUERY_MCP_TOKEN. Without either, the endpoint is unauthenticated.").action(mcpServeCommand);
|
|
5608
|
+
mcp.command("inspect").description("Print the MCP tool schemas as JSON (useful for directory submission / debugging)").option("--attach <url>", "Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally").action(mcpInspectCommand);
|
|
4624
5609
|
program.command("stop").description("Stop the running PipeQuery server (daemon or foreground)").option("-f, --force", "Force kill (SIGKILL instead of SIGTERM)").action(stopCommand);
|
|
4625
5610
|
program.command("completion").description("Generate shell completion script").option("-s, --shell <shell>", "Shell type: bash, zsh, fish").action(completionCommand);
|
|
4626
5611
|
if (process.argv.length <= 2) {
|