@ultimat3/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ // Enforcement for the dev server's two dangerous tools: `db.query` must be read-only and
2
+ // `db.migrate` must be pointed at a branch database.
3
+ //
4
+ // A description saying "read-only" is documentation; documentation is not a guarantee. The
5
+ // gate is structural: one statement, an allowed leading keyword, and no mutating keyword
6
+ // anywhere at statement level (which also catches a data-modifying CTE — `WITH x AS
7
+ // (INSERT ...) SELECT`, which reads like a SELECT and is not one).
8
+
9
+ import { McpNotBranchDbError, McpQueryRejectedError } from './errors';
10
+
11
+ /** Named in `db.query`'s `guards`, so a caller can see that layer 3 actually ran. */
12
+ export const PARSE_GUARD = 'parse:single-read';
13
+
14
+ /** Statements that may begin a read-only query. */
15
+ const READ_LEADERS = new Set(['select', 'with', 'explain', 'show', 'table', 'values']);
16
+
17
+ /**
18
+ * Keywords that make a statement a write. Includes transaction control (a session the tool
19
+ * left open is a lock held on a shared dev DB), `set`/`copy` (session mutation, file I/O),
20
+ * and `analyze` — `EXPLAIN ANALYZE` executes the plan and `ANALYZE t` rewrites statistics,
21
+ * so both are refused rather than special-cased. One rule, no exceptions to remember.
22
+ */
23
+ const WRITE_KEYWORDS = new Set([
24
+ 'alter',
25
+ 'analyze',
26
+ 'begin',
27
+ 'call',
28
+ 'cluster',
29
+ 'comment',
30
+ 'commit',
31
+ 'copy',
32
+ 'create',
33
+ 'deallocate',
34
+ 'declare',
35
+ 'delete',
36
+ 'discard',
37
+ 'do',
38
+ 'drop',
39
+ 'execute',
40
+ 'grant',
41
+ 'import',
42
+ 'insert',
43
+ 'listen',
44
+ 'lock',
45
+ 'merge',
46
+ 'move',
47
+ 'notify',
48
+ 'prepare',
49
+ 'reassign',
50
+ 'refresh',
51
+ 'reindex',
52
+ 'release',
53
+ 'rename',
54
+ 'reset',
55
+ 'revoke',
56
+ 'rollback',
57
+ 'savepoint',
58
+ 'security',
59
+ 'set',
60
+ 'start',
61
+ 'truncate',
62
+ 'unlisten',
63
+ 'update',
64
+ 'vacuum',
65
+ ]);
66
+
67
+ /**
68
+ * Function families a read may not call, matched as a PREFIX of a CALLED function name.
69
+ *
70
+ * The family is the unit, never the name. Refusing `pg_sleep` while admitting `pg_sleep_for` is a
71
+ * distinction only this parser draws, and an exact-name list admits by default: every spelling
72
+ * nobody thought to write down passes. A prefix refuses by default instead, so a member Postgres
73
+ * adds next release is covered on the day it ships.
74
+ *
75
+ * Each family is a ban this file already makes in some other spelling:
76
+ * - reach outside the database — the original list (`pg_read_*`, `pg_ls_*`, `lo_*`, `dblink`);
77
+ * - hold a lock, which `FOR UPDATE` is refused for below. The call is the worse of the two: a
78
+ * SESSION advisory lock is not released by the `ROLLBACK` layer 2 always runs, so it outlives
79
+ * the read on a pooled connection the app's own writers use;
80
+ * - mutate the server or the session — `set_config` is `SET` spelled as a call, and `SET` is a
81
+ * write keyword above;
82
+ * - burn the wall clock — layer 2's `statement_timeout` cannot interrupt embedded PGlite
83
+ * (single-threaded WASM), which is the database `x dev` runs, so this ban is the only one
84
+ * that holds there.
85
+ *
86
+ * The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
87
+ * column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
88
+ * a form where a quoted identifier keeps its content, because `"pg_advisory_lock"(1)` is the same
89
+ * call as `pg_advisory_lock(1)`.
90
+ */
91
+ const FORBIDDEN_FUNCTIONS = [
92
+ 'dblink',
93
+ 'lo_',
94
+ 'pg_advisory_',
95
+ 'pg_cancel_backend',
96
+ 'pg_ls_',
97
+ 'pg_read_',
98
+ 'pg_sleep',
99
+ 'pg_stat_file',
100
+ 'pg_stat_reset',
101
+ 'pg_terminate_backend',
102
+ 'pg_try_advisory_',
103
+ 'set_config',
104
+ ];
105
+
106
+ /** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
107
+ function forbiddenFamily(called: string): string | undefined {
108
+ return FORBIDDEN_FUNCTIONS.find((family) => called.startsWith(family));
109
+ }
110
+
111
+ /**
112
+ * A call: an identifier immediately before `(`. Schema qualification falls out of the scan —
113
+ * `pg_catalog.set_config(` matches on the last segment, which is the function being called.
114
+ */
115
+ const CALL_PATTERN = /([a-z_][a-z0-9_$]*)\s*\(/g;
116
+
117
+ /** Every function `sql` calls, lowercased. Read from the identifier-preserving strip. */
118
+ function calledFunctions(sql: string): readonly string[] {
119
+ const names: string[] = [];
120
+ for (const match of sql.toLowerCase().matchAll(CALL_PATTERN)) {
121
+ const name = match[1];
122
+ if (name !== undefined) names.push(name);
123
+ }
124
+ return names;
125
+ }
126
+
127
+ /**
128
+ * Throw unless `sql` is a single read-only statement. Every check runs on the *stripped* form
129
+ * (literals and comments blanked) so a keyword hiding in a string cannot fool it — but the
130
+ * string returned is the caller's own `sql`, verbatim apart from surrounding whitespace and
131
+ * one trailing `;`, because the caller *executes* this value. Returning the stripped form ran
132
+ * `select 'delete from posts' as note` as `select as note`.
133
+ */
134
+ export function assertReadOnlyQuery(sql: string): string {
135
+ const stripped = stripLiteralsAndComments(sql);
136
+ const statements = stripped
137
+ .split(';')
138
+ .map((s) => s.trim())
139
+ .filter((s) => s.length > 0);
140
+
141
+ if (statements.length === 0) {
142
+ throw rejected('the statement is empty', 'send one SELECT statement');
143
+ }
144
+ if (statements.length > 1) {
145
+ throw rejected(
146
+ `${statements.length} statements were sent; batching hides a write behind a read`,
147
+ 'send exactly one SELECT statement per db.query call',
148
+ );
149
+ }
150
+
151
+ const statement = statements[0] ?? '';
152
+ const words: readonly string[] = statement.toLowerCase().match(/[a-z_]+/g) ?? [];
153
+ const leader = words[0] ?? '';
154
+ if (!READ_LEADERS.has(leader)) {
155
+ throw rejected(
156
+ `statement begins with "${leader}", which is not a read`,
157
+ `begin with one of: ${[...READ_LEADERS].sort().join(', ')}`,
158
+ );
159
+ }
160
+ for (const word of words) {
161
+ if (WRITE_KEYWORDS.has(word)) {
162
+ throw rejected(
163
+ `the statement contains the mutating keyword "${word}"`,
164
+ // `db.migrate` applies pending migrations; it is not an INSERT/UPDATE/DELETE path, and
165
+ // there is no MCP tool that is. Data changes go through an action, which carries a policy.
166
+ 'db.query has no write path: change data by calling an action exposed with ' +
167
+ 'mcp: { expose: true }, and change schema with db.migrate after x db branch <name>',
168
+ );
169
+ }
170
+ }
171
+ // A family refuses a CALL, never a bare word: scanning every word rejected a column named
172
+ // `pg_sleep_for_seconds`, and scanning the blanked form missed `"pg_advisory_lock"(1)`, which
173
+ // is the same call wearing quotes. So this pass reads the form that keeps identifier content.
174
+ for (const called of calledFunctions(stripLiteralsAndComments(sql, 'keep'))) {
175
+ const family = forbiddenFamily(called);
176
+ if (family !== undefined) {
177
+ // The cause names what the author wrote AND the family it belongs to: the second half is
178
+ // the rule, and without it the next spelling looks like a different, arguable refusal.
179
+ throw rejected(
180
+ family === called
181
+ ? `the statement calls ${called}(), which db.query may not call`
182
+ : `the statement calls ${called}(), one of the ${family}* functions db.query may not call`,
183
+ 'query tables only: no file access, no locks, no session settings, no sleeps',
184
+ );
185
+ }
186
+ }
187
+ // `SELECT ... FOR UPDATE` takes row locks — a read that blocks other writers.
188
+ if (/\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/i.test(statement)) {
189
+ throw rejected(
190
+ 'the statement takes row locks (FOR UPDATE/SHARE)',
191
+ 'drop the locking clause; db.query may not hold locks',
192
+ );
193
+ }
194
+ return verbatim(sql);
195
+ }
196
+
197
+ /**
198
+ * The caller's bytes, minus surrounding whitespace and one trailing `;`. Anything past that
199
+ * semicolon is whitespace or a comment — the single-statement check above already proved the
200
+ * stripped form has nothing else there.
201
+ */
202
+ function verbatim(sql: string): string {
203
+ const trimmed = sql.trim();
204
+ return trimmed.endsWith(';') ? trimmed.slice(0, -1).trimEnd() : trimmed;
205
+ }
206
+
207
+ /** What the host knows about the connection `db.migrate` would run against. */
208
+ export interface DatabaseTarget {
209
+ /** Human-readable identity for the message. Never a connection string with a password. */
210
+ readonly label: string;
211
+ /** Branch name, or `null` when this is a shared/long-lived database. */
212
+ readonly branch: string | null;
213
+ /** True when this database backs production traffic. */
214
+ readonly production: boolean;
215
+ }
216
+
217
+ /**
218
+ * Throw unless `target` is a branch database. Two separate refusals so the message names
219
+ * the actual problem: production is never migratable from MCP at all, and a non-production
220
+ * shared database still is not a branch.
221
+ */
222
+ export function assertBranchDatabase(target: DatabaseTarget): string {
223
+ if (target.production) {
224
+ throw notBranch(
225
+ `"${target.label}" is a production database`,
226
+ 'run migrations in production through the migrate role: ROLE=migrate in your deploy hook',
227
+ );
228
+ }
229
+ if (target.branch === null) {
230
+ throw notBranch(
231
+ `"${target.label}" is not a branch database`,
232
+ 'x db branch <name>, then retry db.migrate',
233
+ );
234
+ }
235
+ return target.branch;
236
+ }
237
+
238
+ function rejected(cause: string, fix: string): McpQueryRejectedError {
239
+ return new McpQueryRejectedError({ cause, fix });
240
+ }
241
+
242
+ function notBranch(cause: string, fix: string): McpNotBranchDbError {
243
+ return new McpNotBranchDbError({ cause, fix });
244
+ }
245
+
246
+ /**
247
+ * Replace string/identifier literals and comments with spaces so keyword scanning cannot be
248
+ * fooled by `SELECT 'delete'` (a harmless literal that looks like a write) and cannot be
249
+ * evaded by hiding a second statement behind a block comment. Only word boundaries matter
250
+ * downstream, so collapsing each run to one space is enough.
251
+ *
252
+ * `identifiers: 'keep'` unwraps a double-quoted identifier to its content instead — the call
253
+ * scan needs it, because `"pg_advisory_lock"(1)` calls the function the blanked form hides.
254
+ * String and dollar-quoted literals are blanked in both modes: a literal is never a call.
255
+ */
256
+ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = 'blank'): string {
257
+ let out = '';
258
+ let i = 0;
259
+ while (i < sql.length) {
260
+ const two = sql.slice(i, i + 2);
261
+ if (two === '--') {
262
+ const end = sql.indexOf('\n', i);
263
+ i = end === -1 ? sql.length : end;
264
+ out += ' ';
265
+ continue;
266
+ }
267
+ if (two === '/*') {
268
+ const end = sql.indexOf('*/', i + 2);
269
+ i = end === -1 ? sql.length : end + 2;
270
+ out += ' ';
271
+ continue;
272
+ }
273
+ const char = sql[i];
274
+ if (char === "'" || char === '"') {
275
+ const end = skipQuoted(sql, i, char);
276
+ // Padded, never spliced in place: `select"pg_advisory_lock"(1)` must not fuse into one
277
+ // token, or the call the quotes were hiding stays hidden behind the leading keyword.
278
+ out += char === '"' && identifiers === 'keep' ? ` ${inner(sql.slice(i, end))} ` : ' ';
279
+ i = end;
280
+ continue;
281
+ }
282
+ if (char === '$') {
283
+ const tag = /^\$[a-z_]*\$/i.exec(sql.slice(i));
284
+ if (tag !== null) {
285
+ const marker = tag[0];
286
+ const end = sql.indexOf(marker, i + marker.length);
287
+ i = end === -1 ? sql.length : end + marker.length;
288
+ out += ' ';
289
+ continue;
290
+ }
291
+ }
292
+ out += char;
293
+ i += 1;
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /** A quoted run's content: the delimiters dropped, SQL's doubled-quote escape collapsed. */
299
+ function inner(run: string): string {
300
+ const closed = run.length > 1 && run.endsWith(run[0] ?? '');
301
+ return run.slice(1, closed ? -1 : undefined).replaceAll('""', '"');
302
+ }
303
+
304
+ /** Advance past a quoted run, honouring SQL's doubled-quote escape (`'it''s'`). */
305
+ function skipQuoted(sql: string, start: number, quote: string): number {
306
+ let i = start + 1;
307
+ while (i < sql.length) {
308
+ if (sql[i] === quote) {
309
+ if (sql[i + 1] === quote) {
310
+ i += 2;
311
+ continue;
312
+ }
313
+ return i + 1;
314
+ }
315
+ i += 1;
316
+ }
317
+ return sql.length;
318
+ }
@@ -0,0 +1,229 @@
1
+ // The tool catalog and the first two of the three security outcomes every MCP surface owes
2
+ // a caller. See `docs/architecture/11-ai-surface.md` § Security posture.
3
+ //
4
+ // OUTCOME 1 — hidden (role). A tool whose `visibleTo` excludes the caller is omitted from
5
+ // `tools/list` and answers ToolNotFound (`-32601`) on call, with the same message an absent
6
+ // tool gets. Never Forbidden: "Forbidden" confirms the tool exists, which turns an authz
7
+ // boundary into a catalog an agent can enumerate by probing. Hidden ≠ Forbidden.
8
+ //
9
+ // OUTCOME 2 — scope (capability). A tool the caller may SEE but whose `scope` its token does
10
+ // not carry is refused (`-32600`, `X_MCP_SCOPE_DENIED`). Being refused is correct here: the
11
+ // caller was shown the tool, so naming it leaks nothing, and the message can say which scope
12
+ // to obtain — hiding it would strand a well-behaved client that can legitimately fix this.
13
+ //
14
+ // OUTCOME 3 — policy (`X_FORBIDDEN`) belongs to the tool's own `handle`, which reaches
15
+ // `guard()` in @ultimat3/action. It is deliberately NOT here: the scope gate must decide
16
+ // before any policy runs against attacker-supplied input.
17
+ //
18
+ // The outcomes are orthogonal on purpose. A tool can be visible and still refused for a
19
+ // narrow token; a token holding every scope still cannot see a tool its role may not.
20
+
21
+ import type { Actor } from '@ultimat3/core';
22
+ import type { ArgIssue } from './validate-args';
23
+ import { validateArgs } from './validate-args';
24
+ import type { JsonSchema } from './wire';
25
+
26
+ /** Arbitrary role identifier — apps own their role vocabulary, the framework does not. */
27
+ export type McpRole = string;
28
+
29
+ /**
30
+ * The resolved caller behind one MCP request. `actor` is the framework-wide authz subject
31
+ * (`kind: 'agent'` for a token-authenticated agent) and is what a projected action hands
32
+ * to `policy` — which is why an MCP call and an HTTP call reach the same decision.
33
+ */
34
+ export interface McpCaller {
35
+ readonly actor: Actor;
36
+ /** Token scopes, checked by string membership against a tool's `scope`. */
37
+ readonly scopes: ReadonlySet<string>;
38
+ /** Absent = no role filter applies (the caller sees every unrestricted tool). */
39
+ readonly role?: McpRole;
40
+ }
41
+
42
+ /**
43
+ * Who may see a tool: a role list, or a predicate over the caller for a surface that derives
44
+ * visibility from something richer than a role name (`@ultimat3/admin` derives it from the
45
+ * actor's admin permissions).
46
+ *
47
+ * The predicate takes `McpCaller` and nothing else — it structurally CANNOT see call
48
+ * arguments, which is what makes "visibility is input-independent" an invariant rather than a
49
+ * convention. Two calls with different arguments therefore cannot reveal a tool's existence.
50
+ *
51
+ * Declarations (`mcp: { visibleTo: [...] }` on an action) stay a plain role list: a declared
52
+ * fact has to be static and serialisable for the manifest.
53
+ */
54
+ export type McpVisibility = readonly McpRole[] | ((caller: McpCaller) => boolean);
55
+
56
+ export type ContentBlock =
57
+ | { readonly type: 'text'; readonly text: string }
58
+ | { readonly type: 'resource'; readonly uri: string; readonly mimeType?: string };
59
+
60
+ /**
61
+ * `tools/call` result. `isError` flags an EXPECTED tool-level failure (a policy denied the
62
+ * action, a queue was unreachable) so the model sees it as an outcome to reason about.
63
+ * Malformed requests and unknown methods are JSON-RPC errors instead.
64
+ */
65
+ export interface McpToolResult {
66
+ readonly content: readonly ContentBlock[];
67
+ readonly isError?: boolean;
68
+ }
69
+
70
+ export type ToolArgs = Record<string, unknown>;
71
+
72
+ export interface McpTool<A extends ToolArgs = ToolArgs> {
73
+ readonly name: string;
74
+ readonly description: string;
75
+ /** The only argument contract. Handed verbatim to the agent by `tools/list`. */
76
+ readonly inputSchema: JsonSchema;
77
+ /** Required scope. Absent = no scope gate (the tool's own policy is the gate). */
78
+ readonly scope?: string;
79
+ /** Who may see and call this tool. Absent = everyone. See `McpVisibility`. */
80
+ readonly visibleTo?: McpVisibility;
81
+ /**
82
+ * Marks a tool that changes state. Drives the transport's rate-limit bucket and is
83
+ * asserted by tests over the dev server, so a new mutating tool cannot be metered as
84
+ * cheap read chatter by omission.
85
+ */
86
+ readonly destructive?: boolean;
87
+ // Method syntax (not a property) so a tool declared with narrower args stays assignable.
88
+ handle(args: A, caller: McpCaller): Promise<McpToolResult>;
89
+ }
90
+
91
+ export type AnyMcpTool = McpTool<ToolArgs>;
92
+
93
+ /** One `tools/list` row — complete and standalone, no follow-up fetch required. */
94
+ export interface ToolListEntry {
95
+ readonly name: string;
96
+ readonly description: string;
97
+ readonly inputSchema: JsonSchema;
98
+ }
99
+
100
+ /** Rate-limit class of a call. Derived from `destructive`, never declared twice. */
101
+ export type McpVerbClass = 'read' | 'write';
102
+
103
+ /**
104
+ * True when `caller` may see (and therefore call) `tool`. See OUTCOME 1 above.
105
+ *
106
+ * FAIL-CLOSED, three ways:
107
+ *
108
+ * 1. A role list admits only the roles it names, so a caller with no role matches none of
109
+ * them. The opposite — treating "no role" as "no filter applies" — hands an unroled
110
+ * connection every restricted tool in the catalog.
111
+ * 2. A predicate must return the literal `true`. Author-supplied code returning something
112
+ * merely truthy ("admin", 1, an object) would otherwise widen the gate by accident.
113
+ * 3. A predicate that THROWS denies. A predicate is app code that can fail for ordinary
114
+ * reasons (`@ultimat3/admin` builds a request context inside its own), and an escaping
115
+ * throw would answer `-32603` where a hidden tool answers `-32601` — a different error
116
+ * code is exactly what a prober reads as "this tool exists", which is the enumeration
117
+ * oracle OUTCOME 1 exists to remove. It would also break `list` outright for that
118
+ * caller, turning one broken audience into an empty catalog.
119
+ */
120
+ export function visibleToCaller(tool: AnyMcpTool, caller: McpCaller): boolean {
121
+ const visibility = tool.visibleTo;
122
+ if (visibility === undefined) return true;
123
+ if (typeof visibility === 'function') {
124
+ try {
125
+ return visibility(caller) === true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+ return caller.role !== undefined && visibility.includes(caller.role);
131
+ }
132
+
133
+ /** The outcome of the two gates plus validation, before a tool runs. */
134
+ export type ToolResolution =
135
+ | { readonly kind: 'ok'; readonly tool: AnyMcpTool; readonly args: ToolArgs }
136
+ | { readonly kind: 'not-found'; readonly name: string }
137
+ | { readonly kind: 'scope-denied'; readonly name: string; readonly scope: string }
138
+ | { readonly kind: 'invalid-args'; readonly name: string; readonly issues: readonly ArgIssue[] };
139
+
140
+ export class ToolRegistry {
141
+ private readonly tools = new Map<string, AnyMcpTool>();
142
+
143
+ register(tool: AnyMcpTool): this {
144
+ if (this.tools.has(tool.name)) {
145
+ throw new McpDuplicateToolError(tool.name);
146
+ }
147
+ this.tools.set(tool.name, tool);
148
+ return this;
149
+ }
150
+
151
+ registerAll(tools: readonly AnyMcpTool[]): this {
152
+ for (const tool of tools) this.register(tool);
153
+ return this;
154
+ }
155
+
156
+ /** Raw lookup with NO gate applied — the resolver owns the gates. */
157
+ get(name: string): AnyMcpTool | undefined {
158
+ return this.tools.get(name);
159
+ }
160
+
161
+ /**
162
+ * `tools/list` payload, role-filtered and name-sorted. Sorted because an agent diffs
163
+ * this catalog between runs and map insertion order is not a contract.
164
+ */
165
+ list(caller?: McpCaller): readonly ToolListEntry[] {
166
+ const all = [...this.tools.values()];
167
+ const visible = caller === undefined ? all : all.filter((t) => visibleToCaller(t, caller));
168
+ return visible
169
+ .map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
170
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
171
+ }
172
+
173
+ names(caller?: McpCaller): readonly string[] {
174
+ return this.list(caller).map((t) => t.name);
175
+ }
176
+
177
+ /**
178
+ * Both gates then validation, in the only order that is safe:
179
+ * 1. visibility → not-found (never reveals existence)
180
+ * 2. scope → scope-denied (safe: the caller was already shown the tool)
181
+ * 3. args → invalid-args
182
+ * 4. policy → inside `tool.handle`, which is why it is not here
183
+ * Validating before the gates would leak a schema to a caller that may not see the tool;
184
+ * running the policy before the scope gate would decide a refusal from attacker-supplied
185
+ * input. Absent and hidden collapse into ONE branch so the two cannot drift apart.
186
+ */
187
+ resolve(name: string, rawArgs: unknown, caller: McpCaller): ToolResolution {
188
+ const tool = this.tools.get(name);
189
+ if (tool === undefined || !visibleToCaller(tool, caller)) {
190
+ return { kind: 'not-found', name };
191
+ }
192
+ if (tool.scope !== undefined && !caller.scopes.has(tool.scope)) {
193
+ return { kind: 'scope-denied', name, scope: tool.scope };
194
+ }
195
+ const validation = validateArgs(tool.inputSchema, rawArgs ?? {});
196
+ if (!validation.ok) return { kind: 'invalid-args', name, issues: validation.issues };
197
+ return { kind: 'ok', tool, args: validation.value };
198
+ }
199
+
200
+ /**
201
+ * Rate-limit class of a call WITHOUT running it. Fail-closed: an unknown tool bills the
202
+ * strict bucket, because a probing client must never get the cheap one.
203
+ */
204
+ verbClass(name: string): McpVerbClass {
205
+ const tool = this.tools.get(name);
206
+ if (tool === undefined) return 'write';
207
+ return tool.destructive === true ? 'write' : 'read';
208
+ }
209
+ }
210
+
211
+ /** Registration is a boot-time programming error, so it throws rather than returning. */
212
+ class McpDuplicateToolError extends Error {
213
+ constructor(name: string) {
214
+ super(`MCP tool already registered: ${name}`);
215
+ this.name = 'McpDuplicateToolError';
216
+ }
217
+ }
218
+
219
+ /** Convenience constructor for a one-block text result. */
220
+ export function textResult(text: string, isError = false): McpToolResult {
221
+ const content: readonly ContentBlock[] = [{ type: 'text', text }];
222
+ // exactOptionalPropertyTypes: attach `isError` only when it is true.
223
+ return isError ? { content, isError: true } : { content };
224
+ }
225
+
226
+ /** JSON payload as a text block — stable 2-space form so an agent can diff two calls. */
227
+ export function jsonResult(value: unknown): McpToolResult {
228
+ return textResult(JSON.stringify(value, null, 2));
229
+ }