engine-dj-mcp 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/blobs/index.d.ts +337 -0
- package/dist/blobs/index.js +483 -0
- package/dist/blobs/qcompress.d.ts +44 -0
- package/dist/blobs/qcompress.js +146 -0
- package/dist/discovery.d.ts +36 -0
- package/dist/discovery.js +111 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +49 -0
- package/dist/guard.d.ts +31 -0
- package/dist/guard.js +236 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/library-select.d.ts +63 -0
- package/dist/library-select.js +97 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +34 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +20 -0
- package/dist/proc/query-client.d.ts +36 -0
- package/dist/proc/query-client.js +249 -0
- package/dist/proc/query-worker.d.ts +1 -0
- package/dist/proc/query-worker.js +72 -0
- package/dist/semantics.d.ts +43 -0
- package/dist/semantics.js +95 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +439 -0
- package/dist/sidecar/build.d.ts +19 -0
- package/dist/sidecar/build.js +85 -0
- package/dist/sidecar/schema.d.ts +25 -0
- package/dist/sidecar/schema.js +36 -0
- package/dist/store/connections.d.ts +28 -0
- package/dist/store/connections.js +116 -0
- package/dist/store/index-manager.d.ts +29 -0
- package/dist/store/index-manager.js +187 -0
- package/dist/tools/audit.d.ts +15 -0
- package/dist/tools/audit.js +148 -0
- package/dist/tools/libraries.d.ts +40 -0
- package/dist/tools/libraries.js +30 -0
- package/dist/tools/performance.d.ts +15 -0
- package/dist/tools/performance.js +47 -0
- package/dist/tools/refresh.d.ts +8 -0
- package/dist/tools/refresh.js +3 -0
- package/dist/tools/search.d.ts +60 -0
- package/dist/tools/search.js +328 -0
- package/dist/tools/sql.d.ts +14 -0
- package/dist/tools/sql.js +21 -0
- package/dist/tools/tracks.d.ts +12 -0
- package/dist/tools/tracks.js +49 -0
- package/package.json +53 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type EngineError } from "./errors.js";
|
|
2
|
+
export declare const SUPPORTED_SCHEMAS: readonly ["3.0.0", "3.0.1", "3.0.2"];
|
|
3
|
+
export interface LibraryInfo {
|
|
4
|
+
path: string;
|
|
5
|
+
uuid: string;
|
|
6
|
+
schema: [number, number, number];
|
|
7
|
+
supported: boolean;
|
|
8
|
+
trackCount: number | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function readLibraryInfo(mdbPath: string): LibraryInfo | EngineError;
|
|
11
|
+
export declare function defaultRoots(): string[];
|
|
12
|
+
/** One candidate path's read outcome: either a readable library, or why it
|
|
13
|
+
* currently is not. `path` is always the candidate location, even on
|
|
14
|
+
* failure, so a caller can correlate this against a previous successful
|
|
15
|
+
* probe of the same path. */
|
|
16
|
+
export interface LibraryProbe {
|
|
17
|
+
path: string;
|
|
18
|
+
info: LibraryInfo | null;
|
|
19
|
+
error: EngineError | null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Walks the same candidate paths as discoverLibraries(), but -- unlike it --
|
|
23
|
+
* reports every candidate that exists on disk, including ones readLibraryInfo
|
|
24
|
+
* could not read right now. discoverLibraries() drops those by design (see
|
|
25
|
+
* below); this is for a caller that needs to tell "not there" apart from
|
|
26
|
+
* "there but currently unreadable", e.g. to keep reporting a library that
|
|
27
|
+
* was seen before while Engine DJ holds a write lock on it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function probeLibraries(roots?: string[]): LibraryProbe[];
|
|
30
|
+
/**
|
|
31
|
+
* Reports only libraries it could actually read, by design: a permissions
|
|
32
|
+
* error (or a mid-write lock) on one candidate must not blank out every
|
|
33
|
+
* other one. Built on probeLibraries(); see that function for a version that
|
|
34
|
+
* also reports what could not be read and why.
|
|
35
|
+
*/
|
|
36
|
+
export declare function discoverLibraries(roots?: string[]): LibraryInfo[];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { err } from "./errors.js";
|
|
6
|
+
import { libraryCandidates } from "./paths.js";
|
|
7
|
+
export const SUPPORTED_SCHEMAS = ["3.0.0", "3.0.1", "3.0.2"];
|
|
8
|
+
export function readLibraryInfo(mdbPath) {
|
|
9
|
+
if (!existsSync(mdbPath)) {
|
|
10
|
+
return err("library_not_found", `No Engine library database at ${mdbPath}`);
|
|
11
|
+
}
|
|
12
|
+
let db;
|
|
13
|
+
try {
|
|
14
|
+
// A plain path, never a hand-built "file:" URI. SQLite's URI syntax
|
|
15
|
+
// treats `#` and `?` as delimiters, so a library under a folder named
|
|
16
|
+
// `Rock 'n' Roll #1 Mix` truncated at the `#` and failed to open --
|
|
17
|
+
// discoverLibraries then dropped it and the server reported
|
|
18
|
+
// library_not_found, while openQueryConnection on the same file worked.
|
|
19
|
+
// The `readOnly` flag is the actual guarantee; the `?mode=ro` in the URI
|
|
20
|
+
// was redundant with it, and duplicated store/connections.ts's escaping
|
|
21
|
+
// rules badly enough to drift.
|
|
22
|
+
db = new DatabaseSync(mdbPath, { readOnly: true });
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return err("library_busy", "Could not open the Engine library", {
|
|
26
|
+
detail: String(e.message),
|
|
27
|
+
retry_after_ms: 5000,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
// SELECT * on purpose: the Information column set differs between versions.
|
|
32
|
+
const stmt = db.prepare("SELECT * FROM Information LIMIT 1");
|
|
33
|
+
// Information.currentPlayedIndiciator is a 64-bit value on a real
|
|
34
|
+
// library (measured: -8676408967926364917, far outside
|
|
35
|
+
// Number.MAX_SAFE_INTEGER), and node:sqlite throws instead of returning
|
|
36
|
+
// it unless a statement opts into BigInt reads. Without this, SELECT *
|
|
37
|
+
// threw on every real library and discoverLibraries() silently dropped
|
|
38
|
+
// all of them. Only the small schema/id fields below are ever converted
|
|
39
|
+
// with Number(); nothing here forces an oversized column through it.
|
|
40
|
+
stmt.setReadBigInts(true);
|
|
41
|
+
const row = stmt.get();
|
|
42
|
+
if (!row)
|
|
43
|
+
return err("unsupported_schema", "Information table is empty");
|
|
44
|
+
const schema = [
|
|
45
|
+
Number(row.schemaVersionMajor ?? 0),
|
|
46
|
+
Number(row.schemaVersionMinor ?? 0),
|
|
47
|
+
Number(row.schemaVersionPatch ?? 0),
|
|
48
|
+
];
|
|
49
|
+
const supported = SUPPORTED_SCHEMAS.includes(schema.join("."));
|
|
50
|
+
let trackCount = null;
|
|
51
|
+
try {
|
|
52
|
+
trackCount = Number(db.prepare("SELECT COUNT(*) c FROM Track").get().c);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
trackCount = null; // 1.x has no Track table; still listable.
|
|
56
|
+
}
|
|
57
|
+
return { path: mdbPath, uuid: String(row.uuid ?? ""), schema, supported, trackCount };
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return err("unsupported_schema", "Could not read Information", {
|
|
61
|
+
detail: String(e.message),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function defaultRoots() {
|
|
69
|
+
const roots = [join(homedir(), "Music")];
|
|
70
|
+
try {
|
|
71
|
+
for (const vol of readdirSync("/Volumes"))
|
|
72
|
+
roots.push(join("/Volumes", vol));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// /Volumes does not exist off macOS; ignore.
|
|
76
|
+
}
|
|
77
|
+
return roots;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Walks the same candidate paths as discoverLibraries(), but -- unlike it --
|
|
81
|
+
* reports every candidate that exists on disk, including ones readLibraryInfo
|
|
82
|
+
* could not read right now. discoverLibraries() drops those by design (see
|
|
83
|
+
* below); this is for a caller that needs to tell "not there" apart from
|
|
84
|
+
* "there but currently unreadable", e.g. to keep reporting a library that
|
|
85
|
+
* was seen before while Engine DJ holds a write lock on it.
|
|
86
|
+
*/
|
|
87
|
+
export function probeLibraries(roots = defaultRoots()) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const root of roots) {
|
|
90
|
+
for (const candidate of libraryCandidates(root)) {
|
|
91
|
+
if (!existsSync(candidate))
|
|
92
|
+
continue;
|
|
93
|
+
const info = readLibraryInfo(candidate);
|
|
94
|
+
out.push("error" in info
|
|
95
|
+
? { path: candidate, info: null, error: info }
|
|
96
|
+
: { path: candidate, info, error: null });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Reports only libraries it could actually read, by design: a permissions
|
|
103
|
+
* error (or a mid-write lock) on one candidate must not blank out every
|
|
104
|
+
* other one. Built on probeLibraries(); see that function for a version that
|
|
105
|
+
* also reports what could not be read and why.
|
|
106
|
+
*/
|
|
107
|
+
export function discoverLibraries(roots = defaultRoots()) {
|
|
108
|
+
return probeLibraries(roots)
|
|
109
|
+
.map((p) => p.info)
|
|
110
|
+
.filter((info) => info !== null);
|
|
111
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery"];
|
|
2
|
+
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
3
|
+
export interface EngineError {
|
|
4
|
+
error: ErrorCode;
|
|
5
|
+
message: string;
|
|
6
|
+
detail?: string;
|
|
7
|
+
retry_after_ms?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function err(error: ErrorCode, message: string, extra?: Omit<EngineError, "error" | "message">): EngineError;
|
|
10
|
+
/**
|
|
11
|
+
* The single source for this text. It was previously written out by hand in
|
|
12
|
+
* three files, and the one place that built a structured error (connections.ts)
|
|
13
|
+
* threw away everything but `.message` -- forcing query-client.ts to re-stat
|
|
14
|
+
* the disk to work out which condition it was looking at.
|
|
15
|
+
*/
|
|
16
|
+
export declare const LIBRARY_NEEDS_RECOVERY_MESSAGE: string;
|
|
17
|
+
export declare function libraryNeedsRecovery(): EngineError;
|
|
18
|
+
/**
|
|
19
|
+
* An EngineError travelling as an exception, for the one place that has to
|
|
20
|
+
* throw: openQueryConnection runs inside the forked worker, where a return
|
|
21
|
+
* value has nowhere to go. The structured error rides along intact --
|
|
22
|
+
* across the IPC boundary too, since the worker forwards `engineError` in
|
|
23
|
+
* its startup-failure message -- so no caller has to re-derive the condition
|
|
24
|
+
* by string-matching a message or by going back to the filesystem.
|
|
25
|
+
*/
|
|
26
|
+
export declare class EngineErrorException extends Error {
|
|
27
|
+
readonly engineError: EngineError;
|
|
28
|
+
constructor(engineError: EngineError);
|
|
29
|
+
}
|
|
30
|
+
export declare function isEngineError(value: unknown): value is EngineError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const ERROR_CODES = [
|
|
2
|
+
"library_busy",
|
|
3
|
+
"library_not_found",
|
|
4
|
+
"unsupported_schema",
|
|
5
|
+
"query_timeout",
|
|
6
|
+
"query_process_crashed",
|
|
7
|
+
"index_stale",
|
|
8
|
+
"decode_failed",
|
|
9
|
+
"invalid_argument",
|
|
10
|
+
"library_needs_recovery",
|
|
11
|
+
];
|
|
12
|
+
export function err(error, message, extra = {}) {
|
|
13
|
+
return { error, message, ...extra };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The single source for this text. It was previously written out by hand in
|
|
17
|
+
* three files, and the one place that built a structured error (connections.ts)
|
|
18
|
+
* threw away everything but `.message` -- forcing query-client.ts to re-stat
|
|
19
|
+
* the disk to work out which condition it was looking at.
|
|
20
|
+
*/
|
|
21
|
+
export const LIBRARY_NEEDS_RECOVERY_MESSAGE = "The Engine library was closed uncleanly and has an unrecovered journal. " +
|
|
22
|
+
"Launch Engine DJ once so it can recover the library, then retry.";
|
|
23
|
+
export function libraryNeedsRecovery() {
|
|
24
|
+
return err("library_needs_recovery", LIBRARY_NEEDS_RECOVERY_MESSAGE);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An EngineError travelling as an exception, for the one place that has to
|
|
28
|
+
* throw: openQueryConnection runs inside the forked worker, where a return
|
|
29
|
+
* value has nowhere to go. The structured error rides along intact --
|
|
30
|
+
* across the IPC boundary too, since the worker forwards `engineError` in
|
|
31
|
+
* its startup-failure message -- so no caller has to re-derive the condition
|
|
32
|
+
* by string-matching a message or by going back to the filesystem.
|
|
33
|
+
*/
|
|
34
|
+
export class EngineErrorException extends Error {
|
|
35
|
+
engineError;
|
|
36
|
+
constructor(engineError) {
|
|
37
|
+
super(engineError.message);
|
|
38
|
+
this.engineError = engineError;
|
|
39
|
+
this.name = "EngineErrorException";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function isEngineError(value) {
|
|
43
|
+
if (typeof value !== "object" || value === null)
|
|
44
|
+
return false;
|
|
45
|
+
const v = value;
|
|
46
|
+
return (typeof v.message === "string" &&
|
|
47
|
+
typeof v.error === "string" &&
|
|
48
|
+
ERROR_CODES.includes(v.error));
|
|
49
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type EngineError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* run_sql executes through prepare(), which ignores everything after the first
|
|
4
|
+
* semicolon. exec() runs every statement and would let "SELECT 1; VACUUM INTO"
|
|
5
|
+
* slip past a leading-statement check, so run_sql must never use it. We still
|
|
6
|
+
* reject chained statements outright, because a query that relies on the tail
|
|
7
|
+
* being dropped is a query whose author misunderstood what will run.
|
|
8
|
+
*/
|
|
9
|
+
export declare function checkStatement(sql: string): EngineError | null;
|
|
10
|
+
/**
|
|
11
|
+
* Wraps rather than negotiates with the inner query. Appending a LIMIT only
|
|
12
|
+
* when the scanner found none at the top level let any caller-supplied
|
|
13
|
+
* LIMIT satisfy the check regardless of its size -- including one nested
|
|
14
|
+
* inside a subquery, which this scanner cannot distinguish from a
|
|
15
|
+
* top-level LIMIT at all (it has no parenthesis-depth tracking, by design,
|
|
16
|
+
* since it only needs to find keywords and quoted spans). For example,
|
|
17
|
+
* "SELECT * FROM Track WHERE id IN (SELECT id FROM Track LIMIT 1)" reads
|
|
18
|
+
* as "already limited" and was left untouched, however many rows the outer
|
|
19
|
+
* WHERE actually matched.
|
|
20
|
+
*
|
|
21
|
+
* Composing "SELECT * FROM (<sql>) LIMIT n" bounds the result no matter
|
|
22
|
+
* what the inner statement contains, because the wrapper is the outermost
|
|
23
|
+
* statement executed. A genuinely smaller inner LIMIT still wins: SQLite
|
|
24
|
+
* applies it to the subquery first, so fewer than n rows ever reach the
|
|
25
|
+
* wrapper's own LIMIT.
|
|
26
|
+
*
|
|
27
|
+
* Only SELECT and WITH can be wrapped this way -- PRAGMA (the only other
|
|
28
|
+
* statement checkStatement allows through) would become invalid SQL if
|
|
29
|
+
* wrapped, which is why the leading keyword is still checked here at all.
|
|
30
|
+
*/
|
|
31
|
+
export declare function enforceLimit(sql: string, limit: number): string;
|
package/dist/guard.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { err } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scans a SQL statement character by character, tracking string literals,
|
|
4
|
+
* identifiers, and comments. Returns analysis needed for validation.
|
|
5
|
+
*/
|
|
6
|
+
function scanStatement(sql) {
|
|
7
|
+
let i = 0;
|
|
8
|
+
let firstTokenIndex = -1;
|
|
9
|
+
let hasChainedStatement = false;
|
|
10
|
+
let semicolonCount = 0;
|
|
11
|
+
let lastSemicolonIndex = -1;
|
|
12
|
+
while (i < sql.length) {
|
|
13
|
+
const ch = sql[i];
|
|
14
|
+
// Skip whitespace
|
|
15
|
+
if (/\s/.test(ch)) {
|
|
16
|
+
i++;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
// Line comment: -- to newline
|
|
20
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
21
|
+
i += 2;
|
|
22
|
+
while (i < sql.length && sql[i] !== "\n")
|
|
23
|
+
i++;
|
|
24
|
+
if (i < sql.length)
|
|
25
|
+
i++; // skip newline
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
// Block comment: /* to */
|
|
29
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
30
|
+
i += 2;
|
|
31
|
+
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/"))
|
|
32
|
+
i++;
|
|
33
|
+
if (i < sql.length)
|
|
34
|
+
i += 2; // skip */
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Single-quoted string: '...' with '' as escaped quote
|
|
38
|
+
if (ch === "'") {
|
|
39
|
+
i++;
|
|
40
|
+
while (i < sql.length) {
|
|
41
|
+
if (sql[i] === "'") {
|
|
42
|
+
if (sql[i + 1] === "'") {
|
|
43
|
+
i += 2; // escaped quote ''
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
i++; // end of string
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Double-quoted identifier: "..." with "" as escaped quote
|
|
57
|
+
if (ch === '"') {
|
|
58
|
+
i++;
|
|
59
|
+
while (i < sql.length) {
|
|
60
|
+
if (sql[i] === '"') {
|
|
61
|
+
if (sql[i + 1] === '"') {
|
|
62
|
+
i += 2; // escaped quote ""
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
i++; // end of identifier
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Backtick-quoted identifier: `...`
|
|
76
|
+
if (ch === "`") {
|
|
77
|
+
i++;
|
|
78
|
+
while (i < sql.length && sql[i] !== "`")
|
|
79
|
+
i++;
|
|
80
|
+
if (i < sql.length)
|
|
81
|
+
i++; // skip closing backtick
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Bracket-quoted identifier: [...]
|
|
85
|
+
if (ch === "[") {
|
|
86
|
+
i++;
|
|
87
|
+
while (i < sql.length && sql[i] !== "]")
|
|
88
|
+
i++;
|
|
89
|
+
if (i < sql.length)
|
|
90
|
+
i++; // skip closing bracket
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Semicolon - statement separator (top-level, outside quotes/comments)
|
|
94
|
+
if (ch === ";") {
|
|
95
|
+
semicolonCount++;
|
|
96
|
+
lastSemicolonIndex = i;
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Real token found
|
|
101
|
+
if (firstTokenIndex === -1) {
|
|
102
|
+
firstTokenIndex = i;
|
|
103
|
+
}
|
|
104
|
+
const restOfStatement = sql.substring(i);
|
|
105
|
+
const wordMatch = restOfStatement.match(/^([a-zA-Z_]\w*)/);
|
|
106
|
+
// Skip this token
|
|
107
|
+
if (wordMatch) {
|
|
108
|
+
i += wordMatch[1].length;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Check for statement chaining: more than one semicolon, or real content after semicolon (besides whitespace/comments)
|
|
115
|
+
// We allow one optional trailing semicolon
|
|
116
|
+
if (semicolonCount > 1) {
|
|
117
|
+
hasChainedStatement = true;
|
|
118
|
+
}
|
|
119
|
+
else if (semicolonCount === 1 && lastSemicolonIndex !== -1) {
|
|
120
|
+
// Check if there's real content after the top-level semicolon (besides whitespace/comments)
|
|
121
|
+
const afterSemicolon = sql.substring(lastSemicolonIndex + 1);
|
|
122
|
+
let j = 0;
|
|
123
|
+
while (j < afterSemicolon.length) {
|
|
124
|
+
const c = afterSemicolon[j];
|
|
125
|
+
if (/\s/.test(c)) {
|
|
126
|
+
j++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (c === "-" && afterSemicolon[j + 1] === "-") {
|
|
130
|
+
j += 2;
|
|
131
|
+
while (j < afterSemicolon.length && afterSemicolon[j] !== "\n")
|
|
132
|
+
j++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (c === "/" && afterSemicolon[j + 1] === "*") {
|
|
136
|
+
j += 2;
|
|
137
|
+
while (j < afterSemicolon.length && !(afterSemicolon[j] === "*" && afterSemicolon[j + 1] === "/"))
|
|
138
|
+
j++;
|
|
139
|
+
j += 2;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Found real content after semicolon
|
|
143
|
+
hasChainedStatement = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { firstTokenIndex, hasChainedStatement, lastSemicolonIndex };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* run_sql executes through prepare(), which ignores everything after the first
|
|
151
|
+
* semicolon. exec() runs every statement and would let "SELECT 1; VACUUM INTO"
|
|
152
|
+
* slip past a leading-statement check, so run_sql must never use it. We still
|
|
153
|
+
* reject chained statements outright, because a query that relies on the tail
|
|
154
|
+
* being dropped is a query whose author misunderstood what will run.
|
|
155
|
+
*/
|
|
156
|
+
export function checkStatement(sql) {
|
|
157
|
+
const scan = scanStatement(sql);
|
|
158
|
+
// Reject chained statements
|
|
159
|
+
if (scan.hasChainedStatement) {
|
|
160
|
+
return err("invalid_argument", "Only a single SQL statement is allowed", { detail: sql });
|
|
161
|
+
}
|
|
162
|
+
// Extract first keyword
|
|
163
|
+
if (scan.firstTokenIndex === -1) {
|
|
164
|
+
return null; // Empty or only comments/whitespace - allow
|
|
165
|
+
}
|
|
166
|
+
const rest = sql.substring(scan.firstTokenIndex);
|
|
167
|
+
const keywordMatch = rest.match(/^([a-zA-Z_]\w*)/);
|
|
168
|
+
if (!keywordMatch)
|
|
169
|
+
return null;
|
|
170
|
+
const keyword = keywordMatch[1].toUpperCase();
|
|
171
|
+
// Reject VACUUM, ATTACH, DETACH
|
|
172
|
+
if (keyword === "VACUUM" || keyword === "ATTACH" || keyword === "DETACH") {
|
|
173
|
+
return err("invalid_argument", "VACUUM, ATTACH and DETACH are not permitted", { detail: sql });
|
|
174
|
+
}
|
|
175
|
+
// Check PRAGMA statements
|
|
176
|
+
if (keyword === "PRAGMA") {
|
|
177
|
+
// Find the pragma name - skip past PRAGMA keyword and whitespace
|
|
178
|
+
let pragmaStart = scan.firstTokenIndex + 6; // length of "PRAGMA"
|
|
179
|
+
while (pragmaStart < sql.length && /\s/.test(sql[pragmaStart]))
|
|
180
|
+
pragmaStart++;
|
|
181
|
+
// Skip optional schema qualifier (main., temp., side., engine.)
|
|
182
|
+
if (pragmaStart < sql.length) {
|
|
183
|
+
const schemaMatch = sql.substring(pragmaStart).match(/^(main|temp|side|engine)\./i);
|
|
184
|
+
if (schemaMatch) {
|
|
185
|
+
pragmaStart += schemaMatch[0].length;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Extract pragma name
|
|
189
|
+
const pragmaNameMatch = sql.substring(pragmaStart).match(/^([a-zA-Z_]\w*)/);
|
|
190
|
+
if (!pragmaNameMatch)
|
|
191
|
+
return null;
|
|
192
|
+
const pragmaName = pragmaNameMatch[1].toUpperCase();
|
|
193
|
+
const allowedPragmas = ["TABLE_INFO", "TABLE_LIST", "INDEX_LIST", "INDEX_INFO", "FOREIGN_KEY_LIST"];
|
|
194
|
+
if (!allowedPragmas.includes(pragmaName)) {
|
|
195
|
+
return err("invalid_argument", "Only read-only PRAGMA introspection is permitted", { detail: sql });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Wraps rather than negotiates with the inner query. Appending a LIMIT only
|
|
202
|
+
* when the scanner found none at the top level let any caller-supplied
|
|
203
|
+
* LIMIT satisfy the check regardless of its size -- including one nested
|
|
204
|
+
* inside a subquery, which this scanner cannot distinguish from a
|
|
205
|
+
* top-level LIMIT at all (it has no parenthesis-depth tracking, by design,
|
|
206
|
+
* since it only needs to find keywords and quoted spans). For example,
|
|
207
|
+
* "SELECT * FROM Track WHERE id IN (SELECT id FROM Track LIMIT 1)" reads
|
|
208
|
+
* as "already limited" and was left untouched, however many rows the outer
|
|
209
|
+
* WHERE actually matched.
|
|
210
|
+
*
|
|
211
|
+
* Composing "SELECT * FROM (<sql>) LIMIT n" bounds the result no matter
|
|
212
|
+
* what the inner statement contains, because the wrapper is the outermost
|
|
213
|
+
* statement executed. A genuinely smaller inner LIMIT still wins: SQLite
|
|
214
|
+
* applies it to the subquery first, so fewer than n rows ever reach the
|
|
215
|
+
* wrapper's own LIMIT.
|
|
216
|
+
*
|
|
217
|
+
* Only SELECT and WITH can be wrapped this way -- PRAGMA (the only other
|
|
218
|
+
* statement checkStatement allows through) would become invalid SQL if
|
|
219
|
+
* wrapped, which is why the leading keyword is still checked here at all.
|
|
220
|
+
*/
|
|
221
|
+
export function enforceLimit(sql, limit) {
|
|
222
|
+
const scan = scanStatement(sql);
|
|
223
|
+
// Extract first keyword
|
|
224
|
+
if (scan.firstTokenIndex === -1) {
|
|
225
|
+
return sql; // Empty or only comments/whitespace - return unchanged
|
|
226
|
+
}
|
|
227
|
+
const rest = sql.substring(scan.firstTokenIndex);
|
|
228
|
+
const keywordMatch = rest.match(/^([a-zA-Z_]\w*)/);
|
|
229
|
+
if (!keywordMatch)
|
|
230
|
+
return sql;
|
|
231
|
+
const keyword = keywordMatch[1].toUpperCase();
|
|
232
|
+
if (keyword !== "SELECT" && keyword !== "WITH")
|
|
233
|
+
return sql;
|
|
234
|
+
const trimmed = sql.trim().replace(/;\s*$/, "");
|
|
235
|
+
return `SELECT * FROM (${trimmed}) LIMIT ${limit}`;
|
|
236
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createServer } from "./server.js";
|
|
5
|
+
async function main() {
|
|
6
|
+
const server = await createServer();
|
|
7
|
+
await server.connect(new StdioServerTransport());
|
|
8
|
+
}
|
|
9
|
+
main().catch((e) => {
|
|
10
|
+
// stderr is not the protocol channel, so this is safe for stdio transport.
|
|
11
|
+
console.error("engine-dj-mcp failed to start:", e);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type EngineError } from "./errors.js";
|
|
3
|
+
import type { LibraryInfo } from "./discovery.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `library` argument every library-touching tool accepts. Optional
|
|
6
|
+
* everywhere: a DJ with one library must never have to name it.
|
|
7
|
+
*
|
|
8
|
+
* Both a uuid and a path are accepted because `list_libraries` reports both,
|
|
9
|
+
* and neither a person nor a model can be expected to know which of the two
|
|
10
|
+
* fields is the "real" identifier. The description says so explicitly --
|
|
11
|
+
* a model reads this string and nothing else before choosing what to pass.
|
|
12
|
+
*/
|
|
13
|
+
export declare const LIBRARY_ARG_DESCRIPTION: string;
|
|
14
|
+
export declare const LibraryArg: z.ZodOptional<z.ZodString>;
|
|
15
|
+
/**
|
|
16
|
+
* The default when no `library` was given: the supported library with the
|
|
17
|
+
* most tracks.
|
|
18
|
+
*
|
|
19
|
+
* Root-scan order -- the previous rule -- puts ~/Music ahead of /Volumes,
|
|
20
|
+
* so a DJ whose real collection lives on a USB drive got the near-empty
|
|
21
|
+
* local library that Engine DJ creates on install, with no way to ask for
|
|
22
|
+
* the other one. Track count is the one signal available at discovery time
|
|
23
|
+
* that actually tracks "the library this person works in".
|
|
24
|
+
*
|
|
25
|
+
* Ties break on root-scan order, so the choice is deterministic rather than
|
|
26
|
+
* dependent on Map or filesystem iteration order. `trackCount` is null only
|
|
27
|
+
* when the Track table could not be read at all (a 1.x library); such a
|
|
28
|
+
* library is never `supported`, but treat null as "fewer than zero tracks"
|
|
29
|
+
* anyway so a known count always beats an unknown one.
|
|
30
|
+
*
|
|
31
|
+
* Falls back to the first library of any kind -- including an unsupported
|
|
32
|
+
* one -- so that ensureFresh's specific, actionable `unsupported_schema`
|
|
33
|
+
* reaches the caller instead of the generic `library_not_found` that "no
|
|
34
|
+
* supported library" would otherwise collapse into.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pickDefaultLibrary(libs: readonly LibraryInfo[]): LibraryInfo | null;
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a caller-supplied `library` value: uuid first, then filesystem
|
|
39
|
+
* path. Returns null when it matches neither -- the caller decides what
|
|
40
|
+
* kind of error that is, since it also knows what else is (or is not) on
|
|
41
|
+
* this machine.
|
|
42
|
+
*
|
|
43
|
+
* uuid comparison is case- and whitespace-insensitive: Engine writes uuids
|
|
44
|
+
* in one case and a caller may well retype or re-case one. Path comparison
|
|
45
|
+
* goes through expandHome + resolve, so `~/Music/...` (the form
|
|
46
|
+
* list_libraries prints), the absolute form, and a path with a redundant
|
|
47
|
+
* `.` or trailing separator all name the same library.
|
|
48
|
+
*
|
|
49
|
+
* A path match is exact on the m.db file, not a prefix: a value that merely
|
|
50
|
+
* *contains* a library path must not select it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function findLibrary(libs: readonly LibraryInfo[], requested: string): LibraryInfo | null;
|
|
53
|
+
/**
|
|
54
|
+
* The error for a `library` value that matched nothing. It names what was
|
|
55
|
+
* passed and lists what is actually selectable, because the two ways to get
|
|
56
|
+
* here -- a typo, and a drive that is no longer mounted -- are told apart by
|
|
57
|
+
* seeing the list, not by being told "not found".
|
|
58
|
+
*
|
|
59
|
+
* Deliberately reuses `library_not_found` rather than introducing a code:
|
|
60
|
+
* the taxonomy is closed, and this is the same condition ("the library you
|
|
61
|
+
* are asking about is not here") arrived at from a different direction.
|
|
62
|
+
*/
|
|
63
|
+
export declare function libraryNotFound(requested: string, libs: readonly LibraryInfo[]): EngineError;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// src/library-select.ts
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { err } from "./errors.js";
|
|
5
|
+
import { expandHome, redactPath } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* The `library` argument every library-touching tool accepts. Optional
|
|
8
|
+
* everywhere: a DJ with one library must never have to name it.
|
|
9
|
+
*
|
|
10
|
+
* Both a uuid and a path are accepted because `list_libraries` reports both,
|
|
11
|
+
* and neither a person nor a model can be expected to know which of the two
|
|
12
|
+
* fields is the "real" identifier. The description says so explicitly --
|
|
13
|
+
* a model reads this string and nothing else before choosing what to pass.
|
|
14
|
+
*/
|
|
15
|
+
export const LIBRARY_ARG_DESCRIPTION = "Which library to use: either the uuid or the path reported by list_libraries " +
|
|
16
|
+
"(the reported ~/... form is accepted, as is the absolute path). Omit it to use " +
|
|
17
|
+
"the supported library holding the most tracks.";
|
|
18
|
+
export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
|
|
19
|
+
/**
|
|
20
|
+
* The default when no `library` was given: the supported library with the
|
|
21
|
+
* most tracks.
|
|
22
|
+
*
|
|
23
|
+
* Root-scan order -- the previous rule -- puts ~/Music ahead of /Volumes,
|
|
24
|
+
* so a DJ whose real collection lives on a USB drive got the near-empty
|
|
25
|
+
* local library that Engine DJ creates on install, with no way to ask for
|
|
26
|
+
* the other one. Track count is the one signal available at discovery time
|
|
27
|
+
* that actually tracks "the library this person works in".
|
|
28
|
+
*
|
|
29
|
+
* Ties break on root-scan order, so the choice is deterministic rather than
|
|
30
|
+
* dependent on Map or filesystem iteration order. `trackCount` is null only
|
|
31
|
+
* when the Track table could not be read at all (a 1.x library); such a
|
|
32
|
+
* library is never `supported`, but treat null as "fewer than zero tracks"
|
|
33
|
+
* anyway so a known count always beats an unknown one.
|
|
34
|
+
*
|
|
35
|
+
* Falls back to the first library of any kind -- including an unsupported
|
|
36
|
+
* one -- so that ensureFresh's specific, actionable `unsupported_schema`
|
|
37
|
+
* reaches the caller instead of the generic `library_not_found` that "no
|
|
38
|
+
* supported library" would otherwise collapse into.
|
|
39
|
+
*/
|
|
40
|
+
export function pickDefaultLibrary(libs) {
|
|
41
|
+
let best = null;
|
|
42
|
+
for (const lib of libs) {
|
|
43
|
+
if (!lib.supported)
|
|
44
|
+
continue;
|
|
45
|
+
if (best === null || (lib.trackCount ?? -1) > (best.trackCount ?? -1))
|
|
46
|
+
best = lib;
|
|
47
|
+
}
|
|
48
|
+
return best ?? libs[0] ?? null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a caller-supplied `library` value: uuid first, then filesystem
|
|
52
|
+
* path. Returns null when it matches neither -- the caller decides what
|
|
53
|
+
* kind of error that is, since it also knows what else is (or is not) on
|
|
54
|
+
* this machine.
|
|
55
|
+
*
|
|
56
|
+
* uuid comparison is case- and whitespace-insensitive: Engine writes uuids
|
|
57
|
+
* in one case and a caller may well retype or re-case one. Path comparison
|
|
58
|
+
* goes through expandHome + resolve, so `~/Music/...` (the form
|
|
59
|
+
* list_libraries prints), the absolute form, and a path with a redundant
|
|
60
|
+
* `.` or trailing separator all name the same library.
|
|
61
|
+
*
|
|
62
|
+
* A path match is exact on the m.db file, not a prefix: a value that merely
|
|
63
|
+
* *contains* a library path must not select it.
|
|
64
|
+
*/
|
|
65
|
+
export function findLibrary(libs, requested) {
|
|
66
|
+
const wanted = requested.trim();
|
|
67
|
+
if (!wanted)
|
|
68
|
+
return null;
|
|
69
|
+
const byUuid = libs.find((l) => l.uuid && l.uuid.toLowerCase() === wanted.toLowerCase());
|
|
70
|
+
if (byUuid)
|
|
71
|
+
return byUuid;
|
|
72
|
+
// resolve() turns a relative value into something rooted at the process
|
|
73
|
+
// cwd, which matches no library path -- exactly the intended outcome for
|
|
74
|
+
// a value that is neither a uuid nor a real path.
|
|
75
|
+
const wantedPath = resolve(expandHome(wanted));
|
|
76
|
+
return libs.find((l) => resolve(l.path) === wantedPath) ?? null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The error for a `library` value that matched nothing. It names what was
|
|
80
|
+
* passed and lists what is actually selectable, because the two ways to get
|
|
81
|
+
* here -- a typo, and a drive that is no longer mounted -- are told apart by
|
|
82
|
+
* seeing the list, not by being told "not found".
|
|
83
|
+
*
|
|
84
|
+
* Deliberately reuses `library_not_found` rather than introducing a code:
|
|
85
|
+
* the taxonomy is closed, and this is the same condition ("the library you
|
|
86
|
+
* are asking about is not here") arrived at from a different direction.
|
|
87
|
+
*/
|
|
88
|
+
export function libraryNotFound(requested, libs) {
|
|
89
|
+
const known = libs.filter((l) => l.uuid);
|
|
90
|
+
return err("library_not_found", `No Engine DJ library matches "${requested}"`, {
|
|
91
|
+
detail: known.length
|
|
92
|
+
? `Known libraries (uuid -- path): ${known
|
|
93
|
+
.map((l) => `${l.uuid} -- ${redactPath(l.path)}`)
|
|
94
|
+
.join("; ")}. Pass a uuid or a path exactly as list_libraries reports it.`
|
|
95
|
+
: "No Engine DJ library was discovered on this machine; call list_libraries to see what is visible.",
|
|
96
|
+
});
|
|
97
|
+
}
|