@mason-data/cli 0.6.2 → 0.7.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 +71 -12
- package/dist/src/api/postgres/catalog.js +36 -1
- package/dist/src/api/postgres/server.js +133 -32
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cli/background.js +29 -5
- package/dist/src/cli/command.js +4 -3
- package/dist/src/cli/commands.js +259 -127
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +113 -20
- package/dist/src/cli/doctor.js +6 -3
- package/dist/src/cli/guides.js +32 -8
- package/dist/src/cli/init.js +58 -18
- package/dist/src/cli/machine.js +8 -3
- package/dist/src/cli/main.js +2 -2
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/preflight.js +16 -7
- package/dist/src/cli/project.js +5 -3
- package/dist/src/cli/registry.js +8 -5
- package/dist/src/cli/run-server.js +29 -106
- package/dist/src/cli/serve.js +6 -1
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +6 -4
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +7 -2
- package/dist/src/duck/executor.js +67 -26
- package/dist/src/duck/explain.js +128 -9
- package/dist/src/duck/functions.js +60 -5
- package/dist/src/duck/gaps.js +9 -4
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +206 -39
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +21 -8
- package/dist/src/duck/rewrite.js +1168 -68
- package/dist/src/duck/store.js +132 -56
- package/dist/src/duck/sync.js +90 -25
- package/dist/src/duck/verified.js +6 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +11 -0
- package/dist/src/web/app.js +167 -12
- package/dist/src/web/index.html +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason mcp` — AN MCP SERVER ON STDIO, IN MASON'S OWN WORDS (docs/DESIGN.md "An MCP server"). Chat agents (Claude
|
|
3
|
+
* Code, Claude Desktop, any Model Context Protocol client) reach tools over MCP. A generic Postgres MCP server works
|
|
4
|
+
* against Mason already, but hands the model one bare "run SQL" tool: it must guess which tables exist and how current
|
|
5
|
+
* each copy is, and a refusal reaches it as an error string. Mason knows all three, and every refusal's way out, so these
|
|
6
|
+
* tools say them: `tables`, `describe`, `query`, `explain`.
|
|
7
|
+
*
|
|
8
|
+
* It is a client of the Postgres API like any tool, connected as a `mason user` (the URL `mason user add` printed:
|
|
9
|
+
* `--url`, else `MASON_URL`), so it works the same beside `mason start` and against a `mason serve` elsewhere, and every
|
|
10
|
+
* statement is a row of the request log under `application_name` `mason-mcp/<client>`. Everything it reads comes over
|
|
11
|
+
* that wire: the tables and columns from the catalog (a real Postgres), the rows and how current each copy is from
|
|
12
|
+
* EXPLAIN (FORMAT JSON)'s `Mason.Copies` — the sync's own words, the same line `mason status` prints.
|
|
13
|
+
*
|
|
14
|
+
* STDOUT IS THE PROTOCOL'S: newline-delimited JSON-RPC 2.0 frames and nothing else (`CommandSpec.stdout`: no --json,
|
|
15
|
+
* every failure on stderr); what it says goes to stderr. A tool's failure is Mason's refusal (SQLSTATE, message,
|
|
16
|
+
* DETAIL, HINT) as an MCP tool error, never a stack.
|
|
17
|
+
*
|
|
18
|
+
* The protocol is written here, not taken from `@modelcontextprotocol/sdk`: that package brings seventeen runtime
|
|
19
|
+
* dependencies (an HTTP server, an OAuth library, two schema validators) for the stdio surface this file holds whole —
|
|
20
|
+
* initialize, ping, tools/list, tools/call, resources/list, resources/read, cancellation — as src/cdc/pg-wire.ts holds
|
|
21
|
+
* the Postgres client rather than `pg`.
|
|
22
|
+
*/
|
|
23
|
+
import { failed, parseCommand } from "./command.js";
|
|
24
|
+
import { whereIs } from "./state.js";
|
|
25
|
+
import { withPlaceholders } from "./pg-url.js";
|
|
26
|
+
import { maskedUrl, parseSource } from "./preflight.js";
|
|
27
|
+
import { version } from "./version.js";
|
|
28
|
+
import { PgConnection, PgError, pgIdent, pgLiteral, socketErrorText } from "../cdc/pg-wire.js";
|
|
29
|
+
import { qualifiedName } from "../duck/explain.js";
|
|
30
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
31
|
+
/** The URL a client is shown in the help: a user's, as mason user add prints it. */
|
|
32
|
+
const EXAMPLE_URL = "postgres://claude:<password>@127.0.0.1:5433/shop";
|
|
33
|
+
export const MCP = {
|
|
34
|
+
name: "mcp",
|
|
35
|
+
summary: "an MCP server on stdio, for Claude Code, Claude Desktop and other agents: the copy's tables, queries, refusals",
|
|
36
|
+
usage: `usage: mason mcp [--url <postgres://user:password@host:port/database>]
|
|
37
|
+
|
|
38
|
+
An MCP (Model Context Protocol) server on stdin and stdout, for Claude Code, Claude Desktop and any MCP client.
|
|
39
|
+
Its tools say what an agent would otherwise guess: the tables Mason serves, each one's columns, rows and how
|
|
40
|
+
current its copy is, and each statement's answer with its Postgres types — or Mason's refusal, with what to write
|
|
41
|
+
instead. It connects to the Postgres API as a mason user, by the URL mason user add printed (MASON_URL, else
|
|
42
|
+
--url), so it works beside mason start or against a mason serve elsewhere, and every statement it runs is in
|
|
43
|
+
the request log under application_name mason-mcp/<the client's name>. It serves until the client closes stdin.
|
|
44
|
+
Its stdout is the protocol's: it takes no --json, and a failure to start is said on stderr.
|
|
45
|
+
|
|
46
|
+
Its tools: tables; describe (a table); query (one statement; max_rows default 200, at most 10000; timeout_ms
|
|
47
|
+
default 30000, at most 300000); explain (a statement). Its resources: mason://help/agent and mason://help/mcp.
|
|
48
|
+
|
|
49
|
+
Claude Code (in the project's folder, with Mason running), the URL in the server's environment:
|
|
50
|
+
mason user add claude the URL to paste, printed once
|
|
51
|
+
claude mcp add mason --env MASON_URL="${EXAMPLE_URL}" -- mason mcp
|
|
52
|
+
or on its command line, where ps shows it to this machine's users and claude mcp add prints it:
|
|
53
|
+
claude mcp add mason -- mason mcp --url "${EXAMPLE_URL}"
|
|
54
|
+
|
|
55
|
+
Claude Desktop (claude_desktop_config.json). The app often starts servers without your shell's PATH, where neither
|
|
56
|
+
mason nor the node its first line asks for is found: name both by their absolute paths (which node, which mason):
|
|
57
|
+
{ "mcpServers": { "mason": { "command": "/absolute/path/to/node", "args": ["/absolute/path/to/mason", "mcp"],
|
|
58
|
+
"env": { "MASON_URL": "${EXAMPLE_URL}" } } } }
|
|
59
|
+
|
|
60
|
+
What the client runs:
|
|
61
|
+
MASON_URL="${EXAMPLE_URL}" mason mcp
|
|
62
|
+
mason mcp --url "${EXAMPLE_URL}"
|
|
63
|
+
|
|
64
|
+
--url <url> the Postgres API as a mason user: what mason user add printed (default MASON_URL); TLS as its sslmode
|
|
65
|
+
says (a host off this machine: require when it names none)
|
|
66
|
+
`,
|
|
67
|
+
options: { url: { type: "string" } },
|
|
68
|
+
stdout: "protocol",
|
|
69
|
+
};
|
|
70
|
+
/** What the server says while it serves: a log line on stderr (stdout is the protocol's), which an MCP client keeps in
|
|
71
|
+
* its own log (Claude Code: claude --debug; Claude Desktop: mcp-server-mason.log). A failure to START is failed()'s. */
|
|
72
|
+
const log = (m) => { process.stderr.write(`[mason mcp] ${m}\n`); };
|
|
73
|
+
const NEED_URL = "mason mcp needs a mason user's URL: MASON_URL, or --url";
|
|
74
|
+
const HELP_STEP = ["mason help mcp", "how to add mason mcp to Claude Code or Claude Desktop"];
|
|
75
|
+
const ADD_STEP = ["mason user add claude", "a user for the agent: its password and URL, printed once"];
|
|
76
|
+
/** Where `mason mcp` connects: the URL given, or why there is none (the project's state says what to run). */
|
|
77
|
+
async function theUrl(given, cwd) {
|
|
78
|
+
if (given !== undefined)
|
|
79
|
+
return { url: given, from: "--url" };
|
|
80
|
+
const env = process.env.MASON_URL;
|
|
81
|
+
if (env !== undefined && env !== "")
|
|
82
|
+
return { url: env, from: "MASON_URL" };
|
|
83
|
+
// no URL: what the project here says, never a user made silently (a password is the person's to hand out)
|
|
84
|
+
const w = await whereIs(cwd);
|
|
85
|
+
if (w.state === "up") {
|
|
86
|
+
return { message: NEED_URL, code: "refused",
|
|
87
|
+
why: `Mason is running for ${w.project.config.name} (${withPlaceholders(w.run.postgresUrl)}); an agent connects as a user of its own, and mason mcp makes none by itself`,
|
|
88
|
+
fix: "mason user add claude prints the URL, once; then MASON_URL=\"<that URL>\" in the client's config for mason mcp",
|
|
89
|
+
next: [ADD_STEP, HELP_STEP] };
|
|
90
|
+
}
|
|
91
|
+
if (w.state === "stopped") {
|
|
92
|
+
return { message: NEED_URL, code: "not_running", why: w.problem.problem,
|
|
93
|
+
fix: "start Mason, then add a user for the agent: mason user add prints its URL — or give the URL of a mason serve elsewhere",
|
|
94
|
+
next: [["mason start", "start it, in the background"], ADD_STEP, HELP_STEP] };
|
|
95
|
+
}
|
|
96
|
+
if (w.state === "bad_config") {
|
|
97
|
+
return { message: NEED_URL, code: "bad_config", why: w.problem.problem, fix: w.problem.fix ?? "repair .mason/config.json, or give the URL of a mason serve elsewhere", next: [HELP_STEP] };
|
|
98
|
+
}
|
|
99
|
+
return { message: NEED_URL, code: "usage",
|
|
100
|
+
why: "no Mason project here or above to add a user to",
|
|
101
|
+
fix: "in the project's folder (or beside mason serve: docker compose exec mason mason user add claude), mason user add <name> prints the URL",
|
|
102
|
+
next: [HELP_STEP] };
|
|
103
|
+
}
|
|
104
|
+
/** The URL read as a connection: the CLI's own reading (src/cli/preflight.ts parseSource, TLS as the URL's sslmode). */
|
|
105
|
+
function theSource(url, from) {
|
|
106
|
+
const p = parseSource(url);
|
|
107
|
+
if ("problem" in p)
|
|
108
|
+
return { message: `${from} is not a Postgres URL: ${p.problem}`, code: "usage", fix: `the URL mason user add printed: ${EXAMPLE_URL}`, next: [HELP_STEP] };
|
|
109
|
+
return p.source;
|
|
110
|
+
}
|
|
111
|
+
/** application_name's client part: the MCP client's own name, in letters, digits, `.`, `_` and `-` (Postgres keeps 63 bytes). */
|
|
112
|
+
export function clientPart(name) {
|
|
113
|
+
if (typeof name !== "string")
|
|
114
|
+
return null;
|
|
115
|
+
const clean = name.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
116
|
+
return clean === "" ? null : clean;
|
|
117
|
+
}
|
|
118
|
+
/** One connection to the Postgres API, as the user the URL names, under `applicationName`. */
|
|
119
|
+
function connect(s, applicationName) {
|
|
120
|
+
return PgConnection.connect({ host: s.host, port: s.port, database: s.database, user: s.user, password: s.password, sslmode: s.sslmode,
|
|
121
|
+
applicationName, connectTimeoutMs: 10_000, textForms: [] });
|
|
122
|
+
}
|
|
123
|
+
/** Why a connection failed, in the failure shape: nothing listening, a password refused, a database it does not serve. */
|
|
124
|
+
function connectFailure(e, s) {
|
|
125
|
+
const where = `${s.host}:${s.port}`;
|
|
126
|
+
if (e instanceof PgError) {
|
|
127
|
+
const hint = e.fields.H;
|
|
128
|
+
if (e.code === "28P01" || e.code === "28000") {
|
|
129
|
+
return { message: `${where} refused user ${s.user}: ${e.fields.M ?? e.message}`, code: "refused", why: hint,
|
|
130
|
+
fix: "the URL mason user add printed for this user: a user removed (mason user list), or added under another API key, connects no more",
|
|
131
|
+
next: [["mason user list", "the users, beside the run"], ADD_STEP] };
|
|
132
|
+
}
|
|
133
|
+
return { message: `${where} refused the connection: ${e.fields.M ?? e.message}${e.code ? ` (${e.code})` : ""}`, code: "refused", why: hint ?? e.detail,
|
|
134
|
+
fix: "the URL mason user add printed: its database is the project's", next: [HELP_STEP] };
|
|
135
|
+
}
|
|
136
|
+
const reason = e instanceof Error ? socketErrorText(e) : String(e);
|
|
137
|
+
const said = /ECONNREFUSED/.test(reason) ? `nothing is listening on ${where}` : reason.replace(/^connecting to \S+: /, "");
|
|
138
|
+
return { message: `could not connect to ${maskedUrl(s.url)}: ${said}`, code: "unreachable",
|
|
139
|
+
fix: "start Mason (mason start) or check the URL's host and port: mason status says where the Postgres API listens",
|
|
140
|
+
next: [["mason status", "whether Mason runs, and where its Postgres API listens"], HELP_STEP] };
|
|
141
|
+
}
|
|
142
|
+
// ------------------------------------------------------------------ the command
|
|
143
|
+
export async function mcp(argv) {
|
|
144
|
+
const f = parseCommand(MCP, argv);
|
|
145
|
+
if (typeof f === "number")
|
|
146
|
+
return f;
|
|
147
|
+
// every failure to start is said on stderr: stdout is the protocol's (MCP.stdout), --json or not
|
|
148
|
+
const json = false;
|
|
149
|
+
if (f.positionals.length)
|
|
150
|
+
return failed("mcp", { message: `it takes no argument (${f.positionals.join(" ")}): the URL goes in MASON_URL, or after --url`, code: "usage", usage: MCP.usage }, { json });
|
|
151
|
+
const u = await theUrl(f.values.url, process.cwd());
|
|
152
|
+
if ("message" in u)
|
|
153
|
+
return failed("mcp", u, { json });
|
|
154
|
+
const source = theSource(u.url, u.from);
|
|
155
|
+
if ("message" in source)
|
|
156
|
+
return failed("mcp", source, { json });
|
|
157
|
+
// the URL is checked once, before the protocol starts: a client that cannot connect is told now, in the failure shape
|
|
158
|
+
try {
|
|
159
|
+
(await connect(source, "mason-mcp")).close();
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return failed("mcp", connectFailure(e, source), { json });
|
|
163
|
+
}
|
|
164
|
+
log(`serving ${maskedUrl(source.url)} as ${source.user}, over MCP on stdio, until stdin closes`);
|
|
165
|
+
await serve(new McpServer(source), process.stdin, (line) => { process.stdout.write(`${line}\n`); });
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Reads newline-delimited JSON-RPC from `input` and writes each answer as one line through `write`, until the input
|
|
170
|
+
* ends and every request it carried is answered.
|
|
171
|
+
*/
|
|
172
|
+
async function serve(server, input, write) {
|
|
173
|
+
const pending = new Set();
|
|
174
|
+
// a frame is the protocol's, never a command's answer: one JSON value a line, so no newline inside it
|
|
175
|
+
const send = (message) => { const frame = JSON.stringify(message); write(frame); };
|
|
176
|
+
const take = (line) => {
|
|
177
|
+
if (line.trim() === "")
|
|
178
|
+
return;
|
|
179
|
+
const p = server.handle(line).then((answer) => { if (answer !== null)
|
|
180
|
+
send(answer); });
|
|
181
|
+
pending.add(p);
|
|
182
|
+
void p.finally(() => pending.delete(p));
|
|
183
|
+
};
|
|
184
|
+
let buf = "";
|
|
185
|
+
input.setEncoding("utf8");
|
|
186
|
+
await new Promise((resolve, reject) => {
|
|
187
|
+
input.on("data", (chunk) => {
|
|
188
|
+
buf += chunk;
|
|
189
|
+
for (let nl = buf.indexOf("\n"); nl >= 0; nl = buf.indexOf("\n")) {
|
|
190
|
+
take(buf.slice(0, nl).replace(/\r$/, ""));
|
|
191
|
+
buf = buf.slice(nl + 1);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
input.on("end", () => { take(buf); resolve(); });
|
|
195
|
+
input.on("error", reject);
|
|
196
|
+
});
|
|
197
|
+
await Promise.all(pending);
|
|
198
|
+
}
|
|
199
|
+
// ------------------------------------------------------------------ the protocol
|
|
200
|
+
/** The protocol versions this server speaks, the newest first: a client's is answered in kind, any other with the newest. */
|
|
201
|
+
export const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
202
|
+
/** JSON-RPC's own error codes, and MCP's for a resource it does not have. */
|
|
203
|
+
const RPC = { parse: -32700, invalidRequest: -32600, noMethod: -32601, invalidParams: -32602, internal: -32603, noResource: -32002 };
|
|
204
|
+
const isId = (x) => typeof x === "string" || (typeof x === "number" && Number.isInteger(x));
|
|
205
|
+
/** A tool's failure the model reads and can act on: a refusal (Postgres's fields), or what was wrong with its arguments. */
|
|
206
|
+
class ToolError extends Error {
|
|
207
|
+
structured;
|
|
208
|
+
constructor(text, structured) { super(text); this.structured = structured; }
|
|
209
|
+
}
|
|
210
|
+
/** An answer of Mason's this server could not read (EXPLAIN's JSON that does not parse): a bug to report, never a connection. */
|
|
211
|
+
class BadAnswer extends Error {
|
|
212
|
+
}
|
|
213
|
+
/** A request this server answers with a JSON-RPC error (an unknown tool or resource), not a tool's own failure. */
|
|
214
|
+
class RpcFailure extends Error {
|
|
215
|
+
code;
|
|
216
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
217
|
+
}
|
|
218
|
+
export const MAX_ROWS = { default: 200, max: 10_000 };
|
|
219
|
+
export const TIMEOUT_MS = { default: 30_000, max: 300_000 };
|
|
220
|
+
/** how many EXPLAINs `tables` runs at once, each on a connection of its own */
|
|
221
|
+
const COPIES_AT_ONCE = 4;
|
|
222
|
+
/** The tools, as tools/list says them: names in Mason's words, each argument with what it is. */
|
|
223
|
+
export const TOOLS = [
|
|
224
|
+
{
|
|
225
|
+
name: "tables",
|
|
226
|
+
title: "The tables Mason serves",
|
|
227
|
+
description: "The tables Mason serves, each a copy of a table of the application's Postgres database: about how many rows each holds, " +
|
|
228
|
+
"and how its copy is kept current (polled, streamed by logical replication, or not current, with when it last was). " +
|
|
229
|
+
"Start here: a statement can read only these, named as listed.",
|
|
230
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
231
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "describe",
|
|
235
|
+
title: "One table's columns",
|
|
236
|
+
description: "One table's columns with their Postgres types, its primary key and foreign keys, about how many rows it holds and how its copy is kept current.",
|
|
237
|
+
inputSchema: { type: "object", properties: { table: { type: "string", description: "the table as tables lists it (public.orders), or a bare name in public (orders)" } },
|
|
238
|
+
required: ["table"], additionalProperties: false },
|
|
239
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "query",
|
|
243
|
+
title: "Run a statement",
|
|
244
|
+
description: "Runs ONE SQL statement on Mason, which answers as Postgres does: write Postgres's SQL. Returns the columns with their Postgres types " +
|
|
245
|
+
"and the rows as Postgres's text; truncated is true when max_rows cut the answer. A statement Mason does not answer is refused with its " +
|
|
246
|
+
"SQLSTATE, and its hint says what to write instead: rewrite the statement and run it again. Mason is read-only: a write is refused.",
|
|
247
|
+
inputSchema: { type: "object", properties: {
|
|
248
|
+
sql: { type: "string", description: "one statement, in Postgres's SQL" },
|
|
249
|
+
max_rows: { type: "integer", minimum: 1, maximum: MAX_ROWS.max, default: MAX_ROWS.default, description: `the most rows to return (default ${MAX_ROWS.default}, at most ${MAX_ROWS.max}): aggregate rather than raise it` },
|
|
250
|
+
timeout_ms: { type: "integer", minimum: 1, maximum: TIMEOUT_MS.max, default: TIMEOUT_MS.default, description: `the statement's time limit in ms (default ${TIMEOUT_MS.default}, at most ${TIMEOUT_MS.max}): past it, SQLSTATE 57014` },
|
|
251
|
+
}, required: ["sql"], additionalProperties: false },
|
|
252
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "explain",
|
|
256
|
+
title: "How Mason answers a statement",
|
|
257
|
+
description: "How Mason answers one statement, as its EXPLAIN prints it: each copy the statement reads with its rows and how current it is, " +
|
|
258
|
+
"the SQL Mason runs for it, and the plan. analyze: true runs the statement too and adds each step's rows and time.",
|
|
259
|
+
inputSchema: { type: "object", properties: {
|
|
260
|
+
sql: { type: "string", description: "one statement, in Postgres's SQL (without EXPLAIN)" },
|
|
261
|
+
analyze: { type: "boolean", default: false, description: "run it, and say each step's rows and time" },
|
|
262
|
+
timeout_ms: { type: "integer", minimum: 1, maximum: TIMEOUT_MS.max, default: TIMEOUT_MS.default, description: `with analyze, the statement's time limit in ms (default ${TIMEOUT_MS.default})` },
|
|
263
|
+
}, required: ["sql"], additionalProperties: false },
|
|
264
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
/** The resources: the agent's guide and this command's help, as `mason help` prints them. */
|
|
268
|
+
export const RESOURCES = [
|
|
269
|
+
{ uri: "mason://help/agent", name: "agent", title: "Driving Mason from an agent", description: "mason help agent: the commands in order, their --json shapes, exit codes and failures", mimeType: "text/plain" },
|
|
270
|
+
{ uri: "mason://help/mcp", name: "mcp", title: "mason mcp", description: "mason help mcp: this server's tools, and how to add it to a client", mimeType: "text/plain" },
|
|
271
|
+
];
|
|
272
|
+
const INSTRUCTIONS = "Mason serves read-only copies of tables of an application's Postgres database, for analytical SQL (scans and aggregates). " +
|
|
273
|
+
"Call tables first, describe a table before querying it, and write Postgres's SQL. Each table says how current its copy is. " +
|
|
274
|
+
"When a statement is refused, its hint says what to write instead.";
|
|
275
|
+
/** The relations Mason serves, as the catalog lists them (a catalog read: no row of the request log). */
|
|
276
|
+
async function relations(pg) {
|
|
277
|
+
const r = await pg.query(`SELECT n.nspname, c.relname, c.relkind FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
278
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%' ORDER BY 1, 2`);
|
|
279
|
+
return r.rows.map(([schema, name, kind]) => ({ schema: schema, name: name, kind: kind === "v" || kind === "m" ? "view" : "table" }));
|
|
280
|
+
}
|
|
281
|
+
class McpServer {
|
|
282
|
+
applicationName = "mason-mcp";
|
|
283
|
+
source;
|
|
284
|
+
/** the requests being answered, by their id (a cancellation names one) */
|
|
285
|
+
calls = new Map();
|
|
286
|
+
constructor(source) { this.source = source; }
|
|
287
|
+
/** One line of input: the answer to write, or null for a notification (or a batch of them). */
|
|
288
|
+
async handle(line) {
|
|
289
|
+
let msg;
|
|
290
|
+
try {
|
|
291
|
+
msg = JSON.parse(line);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return rpcError(null, RPC.parse, "not JSON: one JSON-RPC message a line");
|
|
295
|
+
}
|
|
296
|
+
if (Array.isArray(msg)) {
|
|
297
|
+
if (msg.length === 0)
|
|
298
|
+
return rpcError(null, RPC.invalidRequest, "an empty batch");
|
|
299
|
+
const answers = (await Promise.all(msg.map((m) => this.one(m)))).filter((a) => a !== null);
|
|
300
|
+
return answers.length ? answers : null;
|
|
301
|
+
}
|
|
302
|
+
return this.one(msg);
|
|
303
|
+
}
|
|
304
|
+
async one(msg) {
|
|
305
|
+
if (typeof msg !== "object" || msg === null || Array.isArray(msg))
|
|
306
|
+
return rpcError(null, RPC.invalidRequest, "a JSON-RPC message is an object");
|
|
307
|
+
const m = msg;
|
|
308
|
+
// an answer to a request of ours: this server sends none, so there is nothing to answer
|
|
309
|
+
if (!("method" in m) && ("result" in m || "error" in m))
|
|
310
|
+
return null;
|
|
311
|
+
const hasId = "id" in m;
|
|
312
|
+
if (hasId && !isId(m.id))
|
|
313
|
+
return rpcError(null, RPC.invalidRequest, "a request's id is a string or an integer");
|
|
314
|
+
if (m.jsonrpc !== "2.0" || typeof m.method !== "string")
|
|
315
|
+
return rpcError(hasId ? m.id : null, RPC.invalidRequest, "not a JSON-RPC 2.0 request: jsonrpc \"2.0\" and a method");
|
|
316
|
+
const method = m.method;
|
|
317
|
+
if (hasId && method.startsWith("notifications/"))
|
|
318
|
+
return rpcError(m.id, RPC.invalidRequest, `${method} is a notification: it carries no id`);
|
|
319
|
+
if ("params" in m && (typeof m.params !== "object" || m.params === null || Array.isArray(m.params))) {
|
|
320
|
+
return hasId ? rpcError(m.id, RPC.invalidParams, "params is an object") : null;
|
|
321
|
+
}
|
|
322
|
+
const params = (m.params ?? {});
|
|
323
|
+
if (!hasId) {
|
|
324
|
+
this.notification(method, params);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
const id = m.id;
|
|
328
|
+
const key = `${typeof id}:${id}`;
|
|
329
|
+
const call = { connections: new Set(), cancelled: false };
|
|
330
|
+
this.calls.set(key, call);
|
|
331
|
+
try {
|
|
332
|
+
const result = await this.method(method, params, call);
|
|
333
|
+
if (call.cancelled)
|
|
334
|
+
return null;
|
|
335
|
+
if (result === undefined)
|
|
336
|
+
return rpcError(id, RPC.noMethod, `there is no method ${method}`);
|
|
337
|
+
return { jsonrpc: "2.0", id, result };
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
// a request the client cancelled is answered no further (MCP: the receiver SHOULD NOT send a response)
|
|
341
|
+
if (call.cancelled)
|
|
342
|
+
return null;
|
|
343
|
+
if (e instanceof RpcFailure)
|
|
344
|
+
return rpcError(id, e.code, e.message);
|
|
345
|
+
log(`${method} failed: ${e.message}`);
|
|
346
|
+
return rpcError(id, RPC.internal, e.message);
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
this.calls.delete(key);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** A notification: a cancellation ends its request's statement (Postgres's CancelRequest) and its connections. */
|
|
353
|
+
notification(method, params) {
|
|
354
|
+
if (method !== "notifications/cancelled")
|
|
355
|
+
return; // initialized, roots changed: nothing to do
|
|
356
|
+
const requestId = params.requestId;
|
|
357
|
+
const call = isId(requestId) ? this.calls.get(`${typeof requestId}:${requestId}`) : undefined;
|
|
358
|
+
if (call === undefined)
|
|
359
|
+
return; // answered already, or never asked: MCP says to ignore it
|
|
360
|
+
call.cancelled = true;
|
|
361
|
+
log(`request ${JSON.stringify(requestId)} cancelled by the client${typeof params.reason === "string" ? `: ${params.reason}` : ""}`);
|
|
362
|
+
for (const pg of call.connections) {
|
|
363
|
+
// the statement is cancelled where it runs, then its connection closed: the call's work ends at once
|
|
364
|
+
void pg.cancel().catch((e) => { log(`cancelling request ${JSON.stringify(requestId)}: ${e.message}`); })
|
|
365
|
+
.finally(() => pg.close());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** A method's result; undefined for a method this server does not have. */
|
|
369
|
+
async method(method, params, call) {
|
|
370
|
+
switch (method) {
|
|
371
|
+
case "initialize": {
|
|
372
|
+
const asked = params.protocolVersion;
|
|
373
|
+
const client = clientPart(params.clientInfo?.name);
|
|
374
|
+
this.applicationName = client === null ? "mason-mcp" : `mason-mcp/${client}`;
|
|
375
|
+
return {
|
|
376
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
377
|
+
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
|
|
378
|
+
serverInfo: { name: "mason", title: "Mason", version: version() },
|
|
379
|
+
instructions: INSTRUCTIONS,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
case "ping": return {};
|
|
383
|
+
case "tools/list": return { tools: TOOLS };
|
|
384
|
+
case "tools/call": return this.call(params, call);
|
|
385
|
+
case "resources/list": return { resources: RESOURCES };
|
|
386
|
+
case "resources/templates/list": return { resourceTemplates: [] };
|
|
387
|
+
case "resources/read": {
|
|
388
|
+
const r = RESOURCES.find((x) => x.uri === params.uri);
|
|
389
|
+
if (r === undefined)
|
|
390
|
+
throw new RpcFailure(RPC.noResource, `there is no resource ${JSON.stringify(params.uri)}: ${RESOURCES.map((x) => x.uri).join(", ")}`);
|
|
391
|
+
const { guideText } = await import("./guides.js");
|
|
392
|
+
return { contents: [{ uri: r.uri, mimeType: r.mimeType, text: r.name === "agent" ? await guideText("agent") : MCP.usage }] };
|
|
393
|
+
}
|
|
394
|
+
default: return undefined;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async call(params, call) {
|
|
398
|
+
const name = params.name;
|
|
399
|
+
if (typeof name !== "string")
|
|
400
|
+
throw new RpcFailure(RPC.invalidParams, `tools/call names its tool (name): ${TOOLS.map((t) => t.name).join(", ")}`);
|
|
401
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
402
|
+
if (tool === undefined)
|
|
403
|
+
throw new RpcFailure(RPC.invalidParams, `there is no tool ${JSON.stringify(name)}${didYouMean(name, TOOLS.map((t) => t.name))}: ${TOOLS.map((t) => t.name).join(", ")}`);
|
|
404
|
+
if (params.arguments !== undefined && (typeof params.arguments !== "object" || params.arguments === null || Array.isArray(params.arguments))) {
|
|
405
|
+
throw new RpcFailure(RPC.invalidParams, "a tool's arguments are an object");
|
|
406
|
+
}
|
|
407
|
+
const args = (params.arguments ?? {});
|
|
408
|
+
try {
|
|
409
|
+
const value = await this.tool(tool.name, args, call);
|
|
410
|
+
// EXPLAIN is lines of text, read as psql prints them; every other answer is its JSON
|
|
411
|
+
return { content: [{ type: "text", text: tool.name === "explain" ? String(value.plan) : pretty(value) }], structuredContent: value };
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
if (e instanceof ToolError)
|
|
415
|
+
return { content: [{ type: "text", text: e.message }], structuredContent: e.structured, isError: true };
|
|
416
|
+
if (e instanceof PgError)
|
|
417
|
+
return refusal(e, 0);
|
|
418
|
+
if (call.cancelled)
|
|
419
|
+
throw e;
|
|
420
|
+
// an answer of Mason's this server could not read, or a fault of its own: said so, to report — never "Mason is down"
|
|
421
|
+
if (e instanceof BadAnswer || e instanceof TypeError || e instanceof RangeError || e instanceof ReferenceError || e instanceof SyntaxError) {
|
|
422
|
+
log(`${tool.name}: ${e.stack ?? e.message}`);
|
|
423
|
+
const text = `mason mcp could not read Mason's answer: ${e.message}. This is a bug in mason mcp: report it, with the statement.`;
|
|
424
|
+
return { content: [{ type: "text", text }], structuredContent: { error: { message: text } }, isError: true };
|
|
425
|
+
}
|
|
426
|
+
// what is left is the connection's (pg-wire throws a PgError for every answer the server gave): Mason down, or
|
|
427
|
+
// restarting; said as a tool error, so the agent can say it and try again
|
|
428
|
+
const reason = e instanceof Error ? socketErrorText(e) : String(e);
|
|
429
|
+
log(`${tool.name}: ${reason}`);
|
|
430
|
+
const text = `Mason did not answer (${this.source.host}:${this.source.port}): ${reason}. mason status, beside the run, says whether it is up.`;
|
|
431
|
+
return { content: [{ type: "text", text }], structuredContent: { error: { message: text } }, isError: true };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async tool(name, args, call) {
|
|
435
|
+
switch (name) {
|
|
436
|
+
case "tables": {
|
|
437
|
+
argsOf(name, args, []);
|
|
438
|
+
return this.session(call, TIMEOUT_MS.default, (pg) => this.tables(pg, call));
|
|
439
|
+
}
|
|
440
|
+
case "describe": {
|
|
441
|
+
argsOf(name, args, ["table"]);
|
|
442
|
+
const table = text(args, "table");
|
|
443
|
+
return this.session(call, TIMEOUT_MS.default, (pg) => this.describe(pg, call, table));
|
|
444
|
+
}
|
|
445
|
+
case "query": {
|
|
446
|
+
argsOf(name, args, ["sql", "max_rows", "timeout_ms"]);
|
|
447
|
+
const sql = text(args, "sql");
|
|
448
|
+
const maxRows = int(args, "max_rows", MAX_ROWS.default, MAX_ROWS.max);
|
|
449
|
+
const timeoutMs = int(args, "timeout_ms", TIMEOUT_MS.default, TIMEOUT_MS.max);
|
|
450
|
+
return this.session(call, timeoutMs, (pg) => this.query(pg, sql, maxRows, timeoutMs));
|
|
451
|
+
}
|
|
452
|
+
case "explain": {
|
|
453
|
+
argsOf(name, args, ["sql", "analyze", "timeout_ms"]);
|
|
454
|
+
const sql = text(args, "sql");
|
|
455
|
+
if (args.analyze !== undefined && typeof args.analyze !== "boolean")
|
|
456
|
+
throw badArgument("analyze", "true or false");
|
|
457
|
+
const timeoutMs = int(args, "timeout_ms", TIMEOUT_MS.default, TIMEOUT_MS.max);
|
|
458
|
+
return this.session(call, timeoutMs, (pg) => this.explain(pg, sql, args.analyze === true, timeoutMs));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** A connection of this call's, closed when it ends; a cancellation of the call reaches it (`notification`). */
|
|
463
|
+
async open(call) {
|
|
464
|
+
const pg = await connect(this.source, this.applicationName);
|
|
465
|
+
call.connections.add(pg);
|
|
466
|
+
if (call.cancelled)
|
|
467
|
+
pg.close();
|
|
468
|
+
return pg;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* A connection for one tool call, closed after it: a statement's timeout or a dropped session never outlives the call,
|
|
472
|
+
* and a Mason restarted between calls is simply reached again. A server that never answers is let go 10 s past the limit.
|
|
473
|
+
*/
|
|
474
|
+
async session(call, timeoutMs, work) {
|
|
475
|
+
const pg = await this.open(call);
|
|
476
|
+
let timer;
|
|
477
|
+
const late = new Promise((_, reject) => {
|
|
478
|
+
timer = setTimeout(() => { pg.close(); reject(new Error(`no answer ${Math.round(timeoutMs / 1000) + 10} s after the statement was sent`)); }, timeoutMs + 10_000);
|
|
479
|
+
});
|
|
480
|
+
try {
|
|
481
|
+
return await Promise.race([work(pg), late]);
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
for (const c of call.connections)
|
|
486
|
+
c.close();
|
|
487
|
+
call.connections.clear();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** Each relation the catalog lists, named as a statement writes it, with its copy's rows and how it is kept current. */
|
|
491
|
+
async tables(pg, call) {
|
|
492
|
+
const all = await relations(pg);
|
|
493
|
+
const copies = await this.copies(pg, call, all.filter((t) => t.kind === "table"));
|
|
494
|
+
return { tables: all.map((t) => t.kind === "view" ? { table: qualifiedName(t), kind: "view" } : { table: qualifiedName(t), kind: "table", ...copyFacts(copies.get(qualifiedName(t))) }) };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* HOW EACH COPY IS KEPT CURRENT, and its rows: EXPLAIN (FORMAT JSON)'s `Mason.Copies`, the sync's own words (what
|
|
498
|
+
* `mason status` prints), read over the wire so a remote mason serve answers as a local run does. One EXPLAIN a table
|
|
499
|
+
* (a union of them all failed whole for one table it could not plan, and left a refusal in the request log the agent
|
|
500
|
+
* never sent), several at once on connections of this call's. In a run the catalog lists only the tables copied
|
|
501
|
+
* (src/duck/sync.ts schemaBuilt), so an EXPLAIN refused here is a table removed between the two reads, said as its
|
|
502
|
+
* kept line.
|
|
503
|
+
*/
|
|
504
|
+
async copies(pg, call, tables) {
|
|
505
|
+
const out = new Map();
|
|
506
|
+
const explainOne = async (on, t) => {
|
|
507
|
+
const key = qualifiedName(t);
|
|
508
|
+
let r;
|
|
509
|
+
try {
|
|
510
|
+
r = await on.extended(`EXPLAIN (FORMAT JSON) SELECT 1 FROM ${pgIdent(t.schema)}.${pgIdent(t.name)}`, 0);
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
if (!(e instanceof PgError))
|
|
514
|
+
throw e;
|
|
515
|
+
out.set(key, { error: e.fields.M ?? e.message });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const text = r.rows.map((row) => row[0] ?? "").join("\n");
|
|
519
|
+
let doc;
|
|
520
|
+
try {
|
|
521
|
+
doc = JSON.parse(text);
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
throw new BadAnswer(`EXPLAIN (FORMAT JSON) of ${key} is not JSON (${e.message}): ${text.slice(0, 200)}`);
|
|
525
|
+
}
|
|
526
|
+
const copy = doc[0]?.Mason?.Copies?.find((c) => c.Table === key);
|
|
527
|
+
if (copy !== undefined)
|
|
528
|
+
out.set(key, copy);
|
|
529
|
+
};
|
|
530
|
+
const queue = [...tables];
|
|
531
|
+
const worker = async (on) => { for (let t = queue.shift(); t !== undefined; t = queue.shift())
|
|
532
|
+
await explainOne(on, t); };
|
|
533
|
+
const extra = await Promise.all(Array.from({ length: Math.max(0, Math.min(COPIES_AT_ONCE, tables.length) - 1) }, () => this.open(call)));
|
|
534
|
+
await Promise.all([pg, ...extra].map(worker));
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* ONE RELATION THE CATALOG LISTS, found by its name as `tables` prints it (`public."Clients"`), as the catalog holds
|
|
539
|
+
* it (`public.Clients`), as a bare name in public (`orders`), or as Postgres reads a name (`sqlName`: quoting,
|
|
540
|
+
* folding): never a relation `tables` does not list (pg_class, information_schema). Read here, never by to_regclass:
|
|
541
|
+
* a name Postgres cannot read would be a refusal in the request log the agent never sent.
|
|
542
|
+
*/
|
|
543
|
+
async describe(pg, call, asked) {
|
|
544
|
+
const listed = await relations(pg);
|
|
545
|
+
const found = listed.find((t) => qualifiedName(t) === asked || `${t.schema}.${t.name}` === asked)
|
|
546
|
+
?? listed.find((t) => t.schema === "public" && (t.name === asked || qualifiedName(t) === `public.${asked}`))
|
|
547
|
+
?? read(sqlName(asked), listed);
|
|
548
|
+
if (found === undefined) {
|
|
549
|
+
const names = listed.map(qualifiedName);
|
|
550
|
+
// a bare name of a table in another schema: that table, with its schema (items is sales.items)
|
|
551
|
+
const elsewhere = listed.filter((t) => t.name.toLowerCase() === asked.replace(/^"|"$/g, "").toLowerCase()).map(qualifiedName);
|
|
552
|
+
const hint = elsewhere.length ? ` — did you mean ${elsewhere.map((n) => `\`${n}\``).join(" or ")}?` : didYouMean(asked, [...names, ...listed.filter((t) => t.schema === "public").map((t) => qualifiedName(t).slice("public.".length))]);
|
|
553
|
+
throw new ToolError(`there is no table ${JSON.stringify(asked)} Mason serves${hint}. The tables: ${names.join(", ") || "none yet"}.`, { error: { message: `there is no table ${JSON.stringify(asked)}`, tables: names } });
|
|
554
|
+
}
|
|
555
|
+
const { schema, name, kind } = found;
|
|
556
|
+
const rel = `${pgLiteral(qualifiedName(found))}::regclass`;
|
|
557
|
+
// pg_catalog.format_type, written out: these are the catalog's own OIDs (mason_wire_type names the wire's)
|
|
558
|
+
const columns = await pg.query(`SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), a.attnotnull, pg_catalog.col_description(a.attrelid, a.attnum)
|
|
559
|
+
FROM pg_catalog.pg_attribute a WHERE a.attrelid = ${rel} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`);
|
|
560
|
+
const key = await pg.query(`SELECT a.attname FROM pg_catalog.pg_index i JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
|
|
561
|
+
WHERE i.indrelid = ${rel} AND i.indisprimary ORDER BY array_position(i.indkey::int2[], a.attnum)`);
|
|
562
|
+
const foreign = await pg.query(`SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint WHERE conrelid = ${rel} AND contype = 'f' ORDER BY conname`);
|
|
563
|
+
const comment = (await pg.query(`SELECT pg_catalog.obj_description(${rel}, 'pg_class')`)).rows[0]?.[0] ?? null;
|
|
564
|
+
const copy = kind === "view" ? undefined : (await this.copies(pg, call, [{ schema, name }])).get(qualifiedName(found));
|
|
565
|
+
return {
|
|
566
|
+
table: qualifiedName(found), kind,
|
|
567
|
+
...(comment ? { comment } : {}),
|
|
568
|
+
columns: columns.rows.map(([n, type, notNull, c]) => ({ name: n, type, not_null: notNull === "t", ...(c ? { comment: c } : {}) })),
|
|
569
|
+
key: key.rows.map(([k]) => k),
|
|
570
|
+
foreign_keys: foreign.rows.map(([d]) => d),
|
|
571
|
+
...(kind === "view" ? {} : copyFacts(copy)),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async query(pg, sql, maxRows, timeoutMs) {
|
|
575
|
+
await pg.query(`SET statement_timeout = ${timeoutMs}`);
|
|
576
|
+
const r = await pg.extended(sql, maxRows);
|
|
577
|
+
if (r.empty)
|
|
578
|
+
throw badArgument("sql", "a statement (it holds only comments or whitespace)");
|
|
579
|
+
return { columns: await this.columns(pg, r), rows: r.rows, row_count: r.rows.length, truncated: r.suspended, ...(r.command ? { command: r.command } : {}) };
|
|
580
|
+
}
|
|
581
|
+
async explain(pg, sql, analyze, timeoutMs) {
|
|
582
|
+
await pg.query(`SET statement_timeout = ${timeoutMs}`);
|
|
583
|
+
const prefix = `EXPLAIN ${analyze ? "(ANALYZE) " : ""}`;
|
|
584
|
+
let r;
|
|
585
|
+
// a refusal's position is the statement's, not the EXPLAIN's around it
|
|
586
|
+
try {
|
|
587
|
+
r = await pg.extended(`${prefix}${sql}`, 0);
|
|
588
|
+
}
|
|
589
|
+
catch (e) {
|
|
590
|
+
if (e instanceof PgError)
|
|
591
|
+
throw new ToolAnswerError(refusal(e, prefix.length));
|
|
592
|
+
throw e;
|
|
593
|
+
}
|
|
594
|
+
if (r.empty)
|
|
595
|
+
throw badArgument("sql", "a statement (it holds only comments or whitespace)");
|
|
596
|
+
return { plan: r.rows.map((row) => row[0] ?? "").join("\n") };
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The answer's columns with their Postgres types: the RowDescription's OID and modifier named by the listener's
|
|
600
|
+
* catalog, whose `mason_wire_type` names the OID the wire sends for a type the source made (an enum, a citext) —
|
|
601
|
+
* src/api/postgres/catalog.ts `sourceTypeNames`, the one place a wire OID is named; format_type would give an OID the
|
|
602
|
+
* catalog holds too its catalog meaning. Asked again each call: the catalog is rebuilt when the copied tables change.
|
|
603
|
+
*/
|
|
604
|
+
async columns(pg, r) {
|
|
605
|
+
if (r.fields.length === 0)
|
|
606
|
+
return [];
|
|
607
|
+
const k = (f) => `${f.typeOid}:${f.typmod}`;
|
|
608
|
+
const distinct = [...new Map(r.fields.map((f) => [k(f), f])).values()];
|
|
609
|
+
const names = await pg.query(`SELECT ${distinct.map((f) => `mason_wire_type(${f.typeOid}, ${f.typmod})`).join(", ")}`);
|
|
610
|
+
const named = new Map(distinct.map((f, i) => [k(f), names.rows[0]?.[i] ?? String(f.typeOid)]));
|
|
611
|
+
return r.fields.map((f) => ({ name: f.name, type: named.get(k(f)) }));
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/** A table's name as Postgres reads one: `schema.name` or `name` (in public, the search_path's first), a part in double
|
|
615
|
+
* quotes as written (`""` a quote), any other folded to lower case; null for what is not a name of one or two parts. */
|
|
616
|
+
export function sqlName(text) {
|
|
617
|
+
const parts = [];
|
|
618
|
+
const s = text.trim();
|
|
619
|
+
let at = 0;
|
|
620
|
+
for (;;) {
|
|
621
|
+
if (s[at] === '"') {
|
|
622
|
+
let out = "", end = at + 1;
|
|
623
|
+
for (;; end++) {
|
|
624
|
+
if (end >= s.length)
|
|
625
|
+
return null; // an unclosed quote
|
|
626
|
+
if (s[end] === '"') {
|
|
627
|
+
if (s[end + 1] === '"') {
|
|
628
|
+
out += '"';
|
|
629
|
+
end++;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
out += s[end];
|
|
635
|
+
}
|
|
636
|
+
if (out === "")
|
|
637
|
+
return null;
|
|
638
|
+
parts.push(out);
|
|
639
|
+
at = end + 1;
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
const m = /^[A-Za-z_\u0080-\uffff][A-Za-z0-9_$\u0080-\uffff]*/.exec(s.slice(at));
|
|
643
|
+
if (m === null)
|
|
644
|
+
return null;
|
|
645
|
+
parts.push(m[0].toLowerCase());
|
|
646
|
+
at += m[0].length;
|
|
647
|
+
}
|
|
648
|
+
if (at === s.length)
|
|
649
|
+
break;
|
|
650
|
+
if (s[at] !== ".")
|
|
651
|
+
return null;
|
|
652
|
+
at++;
|
|
653
|
+
}
|
|
654
|
+
return parts.length <= 2 ? parts : null;
|
|
655
|
+
}
|
|
656
|
+
/** the listed relation a name read by `sqlName` names */
|
|
657
|
+
const read = (parts, listed) => {
|
|
658
|
+
if (parts === null)
|
|
659
|
+
return undefined;
|
|
660
|
+
const [schema, name] = parts.length === 1 ? ["public", parts[0]] : parts;
|
|
661
|
+
return listed.find((t) => t.schema === schema && t.name === name);
|
|
662
|
+
};
|
|
663
|
+
/** A tool's answer already made (explain's refusal, its position moved): thrown through the tool, returned as it is. */
|
|
664
|
+
class ToolAnswerError extends ToolError {
|
|
665
|
+
constructor(answer) { super(answer.content[0].text, answer.structuredContent ?? {}); }
|
|
666
|
+
}
|
|
667
|
+
/** A JSON-RPC error answer. */
|
|
668
|
+
function rpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { code, message } }; }
|
|
669
|
+
/** A copy's facts as the tools say them: about how many rows, how it is kept current, and what keeps it current now. */
|
|
670
|
+
function copyFacts(c) {
|
|
671
|
+
if (c === undefined)
|
|
672
|
+
return { rows_about: null, kept: "how it is kept current is unknown: Mason has no copy of it" };
|
|
673
|
+
if ("error" in c)
|
|
674
|
+
return { rows_about: null, kept: `how it is kept current could not be read: ${c.error}` };
|
|
675
|
+
return { rows_about: c["Estimated Rows"], kept: c["Kept Current"], kept_current_by: c["Kept Current By"] };
|
|
676
|
+
}
|
|
677
|
+
/** A refusal as the model reads it: Postgres's fields, as psql prints them, and the same as structured content.
|
|
678
|
+
* `shift`: the characters this server put before the statement (EXPLAIN's), taken off its position. */
|
|
679
|
+
function refusal(e, shift) {
|
|
680
|
+
const f = e.fields;
|
|
681
|
+
const position = f.P !== undefined && Number(f.P) > shift ? Number(f.P) - shift : undefined;
|
|
682
|
+
const error = { sqlstate: e.code, message: f.M ?? e.message, ...(f.D ? { detail: f.D } : {}), ...(f.H ? { hint: f.H } : {}), ...(position !== undefined ? { position } : {}) };
|
|
683
|
+
const text = [`${f.S ?? "ERROR"} ${e.code}: ${error.message}`, ...(f.D ? [`DETAIL: ${f.D}`] : []), ...(f.H ? [`HINT: ${f.H}`] : [])].join("\n");
|
|
684
|
+
return { content: [{ type: "text", text }], structuredContent: { error }, isError: true };
|
|
685
|
+
}
|
|
686
|
+
/** Arguments a tool does not take, said with the ones it does. */
|
|
687
|
+
function argsOf(tool, args, known) {
|
|
688
|
+
const stray = Object.keys(args).filter((k) => !known.includes(k));
|
|
689
|
+
if (stray.length) {
|
|
690
|
+
throw new ToolError(`${tool} takes ${known.length ? known.join(", ") : "no arguments"}, not ${stray.join(", ")}${didYouMean(stray[0], [...known])}`, { error: { message: `unknown argument ${stray.join(", ")}`, arguments: known } });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const badArgument = (name, what) => new ToolError(`${name} must be ${what}`, { error: { message: `${name} must be ${what}`, argument: name } });
|
|
694
|
+
function text(args, name) {
|
|
695
|
+
const v = args[name];
|
|
696
|
+
if (typeof v !== "string" || v.trim() === "" || v.includes("\0"))
|
|
697
|
+
throw badArgument(name, "a non-empty string, with no NUL");
|
|
698
|
+
return v;
|
|
699
|
+
}
|
|
700
|
+
function int(args, name, fallback, max) {
|
|
701
|
+
const v = args[name];
|
|
702
|
+
if (v === undefined)
|
|
703
|
+
return fallback;
|
|
704
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > max)
|
|
705
|
+
throw badArgument(name, `an integer from 1 to ${max}`);
|
|
706
|
+
return v;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* JSON for a model to read: an object or a list of scalars on one line when it is short, every other list an item a
|
|
710
|
+
* line — a result's rows each on a line of their own, never a cell a line.
|
|
711
|
+
*/
|
|
712
|
+
export function pretty(v, indent = "") {
|
|
713
|
+
const flat = JSON.stringify(v);
|
|
714
|
+
const scalar = (x) => x === null || typeof x !== "object";
|
|
715
|
+
if (Array.isArray(v)) {
|
|
716
|
+
if (v.length === 0 || v.every(scalar))
|
|
717
|
+
return flat;
|
|
718
|
+
return `[\n${v.map((x) => `${indent} ${pretty(x, `${indent} `)}`).join(",\n")}\n${indent}]`;
|
|
719
|
+
}
|
|
720
|
+
if (v !== null && typeof v === "object") {
|
|
721
|
+
if (flat.length <= 100 && Object.values(v).every((x) => scalar(x) || (Array.isArray(x) && x.every(scalar))))
|
|
722
|
+
return flat;
|
|
723
|
+
return `{\n${Object.entries(v).map(([k, x]) => `${indent} ${JSON.stringify(k)}: ${pretty(x, `${indent} `)}`).join(",\n")}\n${indent}}`;
|
|
724
|
+
}
|
|
725
|
+
return flat;
|
|
726
|
+
}
|