@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,59 @@
1
+ // Wires the framework's own description functions into a `DevHost` and assembles the dev
2
+ // MCP server. Split from `dev-server.ts` so the tool definitions stay dependency-free and
3
+ // unit-testable against a fake host — this file is the only place that reaches into the
4
+ // primitive registries.
5
+
6
+ import { describeActions } from '@ultimat3/action';
7
+ import { FRAMEWORK_VERSION } from '@ultimat3/core';
8
+ import { describeEntities } from '@ultimat3/entity';
9
+ import { describeJobs, inspectJob, jobDriver } from '@ultimat3/jobs';
10
+ import { describeQueries } from '@ultimat3/query';
11
+ import type { DevCapabilities, DevHost, DevIntrospection } from './dev-server';
12
+ import { devTools } from './dev-server';
13
+ import type { FrameworkResourceProviders } from './resources';
14
+ import { frameworkResources } from './resources';
15
+ import { createMcpServer, type McpServer } from './server';
16
+
17
+ /**
18
+ * The description half of a real app's `DevHost`. `routes` and `policies` are supplied by
19
+ * the caller: the route table lives in `@ultimat3/render` and the policy catalog is
20
+ * assembled per app, both outside what this tier may import.
21
+ */
22
+ export function frameworkIntrospection(
23
+ extra: Pick<DevIntrospection, 'routes' | 'policies'>,
24
+ ): DevIntrospection {
25
+ return {
26
+ routes: extra.routes,
27
+ policies: extra.policies,
28
+ entities: () => describeEntities(),
29
+ actions: () => describeActions(),
30
+ queries: () => describeQueries(),
31
+ jobs: () => describeJobs(),
32
+ // inspectJob needs a driver; the ambient one is set at boot by the app's job config.
33
+ // Reported as a tool error rather than thrown, so `x mcp` stays usable without a queue.
34
+ jobInspect: (name: string) => {
35
+ const driver = jobDriver();
36
+ if (driver === undefined) return { error: 'no job driver configured' };
37
+ return inspectJob(driver, name);
38
+ },
39
+ };
40
+ }
41
+
42
+ export interface CreateDevServerInput {
43
+ readonly host: DevHost;
44
+ readonly resources?: FrameworkResourceProviders;
45
+ }
46
+
47
+ /** `x mcp serve` and `POST /mcp` in dev both build the server through this. */
48
+ export function createDevServer(input: CreateDevServerInput): McpServer {
49
+ return createMcpServer({
50
+ tools: devTools(input.host),
51
+ resources: frameworkResources(input.resources ?? {}),
52
+ serverInfo: { name: 'ultimate-dev', version: FRAMEWORK_VERSION },
53
+ });
54
+ }
55
+
56
+ /** Compose the two halves without spelling out the intersection at every call site. */
57
+ export function devHost(introspection: DevIntrospection, capabilities: DevCapabilities): DevHost {
58
+ return { ...introspection, ...capabilities };
59
+ }
@@ -0,0 +1,303 @@
1
+ // The built-in MCP dev server — the reason an agent needs no framework documentation.
2
+ //
3
+ // Documentation describes; these tools answer. An agent asks `routes.list` instead of
4
+ // reading a routing guide, `errors.explain` instead of searching a code, `verify.run`
5
+ // instead of guessing whether it is done. Every tool declares a scope, and the two that can
6
+ // change something (`db.migrate`, `tests.run`) say so in their own description so a model
7
+ // reading only the catalog still knows what it is holding.
8
+ //
9
+ // Data sources are a single injected `DevHost`. Route and policy description live in
10
+ // packages of this same tier, and the shell-side capabilities (db, tests, logs) belong to
11
+ // the CLI, so this file defines the interface and the CLI satisfies it.
12
+
13
+ import type { QueryLimits, QueryRows } from './query-limits';
14
+ import { capQueryRows, DEFAULT_QUERY_ROWS, QUERY_LIMITS, resolveQueryLimits } from './query-limits';
15
+ import type { DatabaseTarget } from './readonly-sql';
16
+ import { assertBranchDatabase, assertReadOnlyQuery, PARSE_GUARD } from './readonly-sql';
17
+ import type { AnyMcpTool, McpToolResult, ToolArgs } from './registry';
18
+ import { jsonResult, textResult } from './registry';
19
+ import type { JsonSchema } from './wire';
20
+ import { NO_ARGS } from './wire';
21
+
22
+ /** Scopes the dev server gates on. A token carries a subset; the rest is invisible. */
23
+ export const DEV_SCOPES = {
24
+ read: 'dev:read',
25
+ test: 'dev:test',
26
+ logs: 'dev:logs',
27
+ dbRead: 'db:read',
28
+ dbMigrate: 'db:migrate',
29
+ } as const;
30
+
31
+ export interface TestRun {
32
+ readonly passed: number;
33
+ readonly failed: number;
34
+ readonly skipped: number;
35
+ readonly durationMs: number;
36
+ readonly failures: readonly { readonly test: string; readonly message: string }[];
37
+ }
38
+
39
+ export interface MigrateResult {
40
+ readonly branch: string;
41
+ readonly applied: readonly string[];
42
+ readonly pending: readonly string[];
43
+ }
44
+
45
+ export interface QueueDepth {
46
+ readonly queue: string;
47
+ readonly pending: number;
48
+ readonly running: number;
49
+ readonly failed: number;
50
+ }
51
+
52
+ export interface ErrorExplanation {
53
+ readonly code: string;
54
+ readonly cause: string;
55
+ readonly fix: string;
56
+ readonly docs: string;
57
+ }
58
+
59
+ export interface VerifyStep {
60
+ readonly name: string;
61
+ readonly ok: boolean;
62
+ readonly detail?: string;
63
+ }
64
+
65
+ export interface VerifyResult {
66
+ readonly ok: boolean;
67
+ readonly steps: readonly VerifyStep[];
68
+ }
69
+
70
+ /** Description sources. Satisfied by `frameworkIntrospection` in a real app. */
71
+ export interface DevIntrospection {
72
+ routes(): unknown;
73
+ entities(): unknown;
74
+ actions(): unknown;
75
+ queries(): unknown;
76
+ policies(): unknown;
77
+ jobs(): unknown;
78
+ jobInspect(name: string): unknown;
79
+ }
80
+
81
+ /** Shell-side capabilities. Satisfied by the CLI, which owns the process and the DB. */
82
+ export interface DevCapabilities {
83
+ readonly database: DatabaseTarget;
84
+ /**
85
+ * Run one already-parsed read-only statement under layers 1–2 (a SELECT-only role, a
86
+ * `BEGIN READ ONLY` transaction, `limits.timeoutMs`) and return at most `limits.maxRows + 1`
87
+ * rows — the extra row is how the tool tells `truncated` without a second count query.
88
+ */
89
+ runQuery(sql: string, limits: QueryLimits): Promise<QueryRows>;
90
+ runMigrations(branch: string, dryRun: boolean): Promise<MigrateResult>;
91
+ queueDepth(): Promise<readonly QueueDepth[]>;
92
+ runTests(filter: string | undefined): Promise<TestRun>;
93
+ tailLogs(lines: number, role: string | undefined): Promise<readonly string[]>;
94
+ readManifest(): Promise<string>;
95
+ explainError(code: string): ErrorExplanation | undefined;
96
+ verify(fix: boolean): Promise<VerifyResult>;
97
+ }
98
+
99
+ export type DevHost = DevIntrospection & DevCapabilities;
100
+
101
+ const NAME_ARG: JsonSchema = {
102
+ type: 'object',
103
+ properties: { name: { type: 'string', description: 'Job name from jobs.inspect with no name.' } },
104
+ additionalProperties: false,
105
+ };
106
+
107
+ /** Every dev tool, in one array so `x mcp serve` and the HTTP transport share the catalog. */
108
+ export function devTools(host: DevHost): readonly AnyMcpTool[] {
109
+ return [
110
+ read(
111
+ 'routes.list',
112
+ 'Route table: url, render mode, offline strategy, hydrate, budget.',
113
+ NO_ARGS,
114
+ () => jsonResult(host.routes()),
115
+ ),
116
+
117
+ read('schema.describe', 'Entities with columns, types and invariants.', NO_ARGS, () =>
118
+ jsonResult(host.entities()),
119
+ ),
120
+
121
+ read(
122
+ 'policies.list',
123
+ 'Every policy: permission, subject, and where it is enforced.',
124
+ NO_ARGS,
125
+ () => jsonResult(host.policies()),
126
+ ),
127
+
128
+ read(
129
+ 'actions.describe',
130
+ 'Every action and query: input/output schema, policy, cache tags, MCP exposure.',
131
+ NO_ARGS,
132
+ () => jsonResult({ actions: host.actions(), queries: host.queries() }),
133
+ ),
134
+
135
+ read(
136
+ 'jobs.inspect',
137
+ 'Job definitions, retry policy and steps. Omit name for all jobs.',
138
+ NAME_ARG,
139
+ (args) => {
140
+ const name = args['name'];
141
+ return jsonResult(typeof name === 'string' ? host.jobInspect(name) : host.jobs());
142
+ },
143
+ ),
144
+
145
+ read('queue.depth', 'Pending, running and failed counts per queue.', NO_ARGS, async () =>
146
+ jsonResult(await host.queueDepth()),
147
+ ),
148
+
149
+ read('manifest.read', 'The generated x.manifest.json as text.', NO_ARGS, async () =>
150
+ textResult(await host.readManifest()),
151
+ ),
152
+
153
+ read(
154
+ 'errors.explain',
155
+ 'Explain a stable X_* error code: cause, exact fix command, docs link.',
156
+ {
157
+ type: 'object',
158
+ properties: { code: { type: 'string', description: 'e.g. X_DB_DRIFT' } },
159
+ required: ['code'],
160
+ additionalProperties: false,
161
+ },
162
+ (args) => {
163
+ const code = String(args['code']);
164
+ const explanation = host.explainError(code);
165
+ return explanation === undefined
166
+ ? textResult(`unknown error code: ${code}`, true)
167
+ : jsonResult(explanation);
168
+ },
169
+ ),
170
+
171
+ // ── gated: reads real data ────────────────────────────────────────────────
172
+ {
173
+ name: 'db.query',
174
+ description:
175
+ 'Run ONE read-only SQL statement. Writes, multiple statements, locking clauses ' +
176
+ 'and data-modifying CTEs are refused (X_MCP_QUERY_REJECTED), not merely discouraged. ' +
177
+ `Runs as a SELECT-only role in a READ ONLY transaction, capped at ${QUERY_LIMITS.maxRows} ` +
178
+ `rows, ${QUERY_LIMITS.maxBytes} bytes and ${QUERY_LIMITS.timeoutMs}ms; the answer's ` +
179
+ '`guards` names the defences that engaged and `truncatedBy` names any cap that bit.',
180
+ scope: DEV_SCOPES.dbRead,
181
+ destructive: false,
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ sql: { type: 'string', description: 'One SELECT/WITH/EXPLAIN/SHOW statement.' },
186
+ limit: {
187
+ type: 'integer',
188
+ minimum: 1,
189
+ maximum: QUERY_LIMITS.maxRows,
190
+ default: DEFAULT_QUERY_ROWS,
191
+ },
192
+ },
193
+ required: ['sql'],
194
+ additionalProperties: false,
195
+ },
196
+ async handle(args: ToolArgs) {
197
+ // Layer 3, here, before the host ever sees the string; layers 1–2 in the host; layer 4
198
+ // on the way out. The caps run in this handler rather than in the host because a host
199
+ // that forgets them is a host that answers a million rows into a model's context.
200
+ const statement = assertReadOnlyQuery(String(args['sql']));
201
+ const limits = resolveQueryLimits(args['limit']);
202
+ const rows = await host.runQuery(statement, limits);
203
+ return jsonResult(capQueryRows({ ...rows, guards: [PARSE_GUARD, ...rows.guards] }, limits));
204
+ },
205
+ },
206
+
207
+ // ── gated: changes state ──────────────────────────────────────────────────
208
+ {
209
+ name: 'db.migrate',
210
+ description:
211
+ 'Apply pending migrations to the current BRANCH database. Refuses a production or ' +
212
+ 'non-branch target (X_MCP_NOT_BRANCH_DB). Use ROLE=migrate to deploy.',
213
+ scope: DEV_SCOPES.dbMigrate,
214
+ destructive: true,
215
+ inputSchema: {
216
+ type: 'object',
217
+ properties: {
218
+ dryRun: { type: 'boolean', default: false, description: 'Plan only, apply nothing.' },
219
+ },
220
+ additionalProperties: false,
221
+ },
222
+ async handle(args: ToolArgs) {
223
+ const branch = assertBranchDatabase(host.database);
224
+ const dryRun = args['dryRun'] === true;
225
+ return jsonResult(await host.runMigrations(branch, dryRun));
226
+ },
227
+ },
228
+ {
229
+ name: 'tests.run',
230
+ description: 'Run the test suite (executes project code). Optional substring filter.',
231
+ scope: DEV_SCOPES.test,
232
+ destructive: true,
233
+ inputSchema: {
234
+ type: 'object',
235
+ properties: { filter: { type: 'string', description: 'Substring match on test path.' } },
236
+ additionalProperties: false,
237
+ },
238
+ async handle(args: ToolArgs) {
239
+ const filter = typeof args['filter'] === 'string' ? args['filter'] : undefined;
240
+ const run = await host.runTests(filter);
241
+ return { ...jsonResult(run), ...(run.failed > 0 ? { isError: true } : {}) };
242
+ },
243
+ },
244
+ {
245
+ name: 'verify.run',
246
+ description:
247
+ 'Run x verify: types, lint, boundaries, migrations, manifest drift, tests, budgets. ' +
248
+ 'This is the shippable contract. `fix: true` applies safe autofixes.',
249
+ scope: DEV_SCOPES.test,
250
+ destructive: true,
251
+ inputSchema: {
252
+ type: 'object',
253
+ properties: { fix: { type: 'boolean', default: false } },
254
+ additionalProperties: false,
255
+ },
256
+ async handle(args: ToolArgs) {
257
+ const result = await host.verify(args['fix'] === true);
258
+ return { ...jsonResult(result), ...(result.ok ? {} : { isError: true }) };
259
+ },
260
+ },
261
+ {
262
+ name: 'logs.tail',
263
+ description: 'Last N log lines, optionally for one runtime role (web/sync/worker/...).',
264
+ scope: DEV_SCOPES.logs,
265
+ destructive: false,
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ lines: { type: 'integer', minimum: 1, maximum: 2000, default: 100 },
270
+ role: {
271
+ type: 'string',
272
+ enum: ['web', 'sync', 'worker', 'scheduler', 'migrate', 'replicator'],
273
+ },
274
+ },
275
+ additionalProperties: false,
276
+ },
277
+ async handle(args: ToolArgs) {
278
+ const lines = typeof args['lines'] === 'number' ? args['lines'] : 100;
279
+ const role = typeof args['role'] === 'string' ? args['role'] : undefined;
280
+ return textResult((await host.tailLogs(lines, role)).join('\n'));
281
+ },
282
+ },
283
+ ];
284
+ }
285
+
286
+ /** Shorthand for the introspection tools: `dev:read`, non-destructive, no role filter. */
287
+ function read(
288
+ name: string,
289
+ description: string,
290
+ inputSchema: JsonSchema,
291
+ handle: (args: ToolArgs) => Promise<McpToolResult> | McpToolResult,
292
+ ): AnyMcpTool {
293
+ return {
294
+ name,
295
+ description,
296
+ inputSchema,
297
+ scope: DEV_SCOPES.read,
298
+ destructive: false,
299
+ async handle(args: ToolArgs) {
300
+ return await handle(args);
301
+ },
302
+ };
303
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,245 @@
1
+ // The X_* codes owned by @ultimat3/mcp. Each carries the exact next command, because the
2
+ // caller reading it is usually an agent with no human to ask.
3
+
4
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
+
6
+ export const MCP_ERROR_CODES = [
7
+ 'X_MCP_TOOL_UNKNOWN',
8
+ 'X_MCP_SCOPE_DENIED',
9
+ 'X_MCP_ARGS_INVALID',
10
+ 'X_MCP_PROTOCOL',
11
+ 'X_MCP_QUERY_REJECTED',
12
+ 'X_MCP_NOT_BRANCH_DB',
13
+ 'X_MCP_TOOL_UNSAFE',
14
+ 'X_MCP_TOOL_UNDECLARED',
15
+ 'X_MCP_TOOL_DUPLICATE',
16
+ 'X_MCP_SCOPE_UNKNOWN',
17
+ 'X_MCP_SCOPE_CONFLICT',
18
+ ] as const;
19
+
20
+ export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
21
+
22
+ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
23
+ X_MCP_TOOL_UNKNOWN: 'no such tool for this caller',
24
+ X_MCP_SCOPE_DENIED: "the connection's token does not carry the tool's scope",
25
+ X_MCP_ARGS_INVALID: 'tool arguments failed the input schema',
26
+ X_MCP_PROTOCOL: 'the MCP handshake or auth is wrong',
27
+ X_MCP_QUERY_REJECTED: 'db.query was not given one read-only statement',
28
+ X_MCP_NOT_BRANCH_DB: 'db.migrate was aimed at a database that is not a branch',
29
+ X_MCP_TOOL_UNSAFE: 'an MCP tool declares no policy',
30
+ X_MCP_TOOL_UNDECLARED: 'defineAppMcp lists a primitive that declares no MCP exposure',
31
+ X_MCP_TOOL_DUPLICATE: 'two primitives project to one MCP tool name',
32
+ X_MCP_SCOPE_UNKNOWN: 'defineAppMcp scopes a tool this server does not project',
33
+ X_MCP_SCOPE_CONFLICT: 'two scopes claim one MCP tool',
34
+ };
35
+
36
+ // Titles must be registered for `format()` to render the contract's first line. Every code above is
37
+ // owned here and none is borrowed, so the call is unconditional: a second package claiming one has
38
+ // to fail as X_ERROR_CODE_DUPLICATE, not quietly keep whichever title was registered first.
39
+ registerErrorCodes(
40
+ Object.fromEntries(Object.entries(MCP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
41
+ );
42
+
43
+ const docsFor = (code: McpErrorCode): string => `https://ultimate.dev/errors/${code}`;
44
+
45
+ /**
46
+ * OUTCOME 1 of three: a tool name reached the dispatcher that no VISIBLE tool answers to —
47
+ * the tool is absent, or it exists and this caller's role may never invoke it. One error for
48
+ * both, on purpose. Thrown by in-process callers; over the wire the same condition is
49
+ * `-32601` with the same message, so a role-hidden tool is indistinguishable from an absent
50
+ * one even for a caller holding every scope in the system.
51
+ */
52
+ export class McpToolUnknownError extends UltimateError {
53
+ constructor(input: { name: string; visible: readonly string[] }) {
54
+ super({
55
+ code: 'X_MCP_TOOL_UNKNOWN',
56
+ cause: `no MCP tool named "${input.name}" is visible to this caller (visible: ${
57
+ input.visible.length > 0 ? input.visible.join(', ') : 'none'
58
+ })`,
59
+ fix: 'call tools/list to read the catalog this caller may use',
60
+ docs: docsFor('X_MCP_TOOL_UNKNOWN'),
61
+ });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * OUTCOME 2 of three: the caller may SEE the tool, but the connection's token does not carry
67
+ * its scope. Named out loud rather than hidden — the caller can legitimately fix this, and
68
+ * hiding it would strand a well-behaved client. Scope belongs to the token, not to the
69
+ * actor's permissions: granting it takes effect on the next connection, not this one.
70
+ */
71
+ export class McpScopeDeniedError extends UltimateError {
72
+ readonly scope: string;
73
+
74
+ constructor(input: { name: string; scope: string }) {
75
+ super({
76
+ code: 'X_MCP_SCOPE_DENIED',
77
+ cause: `tool "${input.name}" requires scope "${input.scope}", which this connection's token does not carry`,
78
+ fix: `x token grant ${input.scope} # then reconnect: scopes are fixed for the life of a connection`,
79
+ docs: docsFor('X_MCP_SCOPE_DENIED'),
80
+ });
81
+ this.scope = input.scope;
82
+ }
83
+ }
84
+
85
+ /** Arguments failed the tool's declared JSON Schema — the schema the agent was handed. */
86
+ export class McpArgsInvalidError extends UltimateError {
87
+ constructor(input: { name: string; issues: readonly string[] }) {
88
+ super({
89
+ code: 'X_MCP_ARGS_INVALID',
90
+ cause: `arguments for "${input.name}" are invalid: ${input.issues.join('; ')}`,
91
+ fix: `re-read the tool's inputSchema from tools/list and resend`,
92
+ docs: docsFor('X_MCP_ARGS_INVALID'),
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * A hand-written tool reached `defineAppMcp` with no policy. Boot-time, because a server that
99
+ * starts and then refuses every call is indistinguishable from one that is merely broken —
100
+ * and a tool that starts and then allows every call is a second door into the data.
101
+ */
102
+ export class McpToolUnsafeError extends UltimateError {
103
+ constructor(input: { name: string }) {
104
+ super({
105
+ code: 'X_MCP_TOOL_UNSAFE',
106
+ cause: `tool "${input.name}" declares no policy; an unguarded tool is a second door into the data`,
107
+ fix: `add policy: '<resource>:<verb>' to the tool, reusing the permission its action uses`,
108
+ docs: docsFor('X_MCP_TOOL_UNSAFE'),
109
+ });
110
+ }
111
+ }
112
+
113
+ /**
114
+ * A primitive was NAMED in `defineAppMcp`'s `actions`/`queries` but never declared
115
+ * `mcp: { expose: true }`. Naming it there is the request to expose it, so the only two honest
116
+ * answers are "project it" and "refuse": filtering it out silently ships a catalog missing a tool
117
+ * its author believes is in it, and nothing fails until an agent asks for a tool that is not
118
+ * there. Boot-time, with every offender named at once so one edit closes all of them.
119
+ *
120
+ * `include: 'exposed'` sweeps the registries and therefore DOES filter — that list is every
121
+ * primitive the app registered, not a list anyone wrote out.
122
+ */
123
+ export class McpToolUndeclaredError extends UltimateError {
124
+ /** The offending primitive names, so a caller can report them without re-parsing `cause`. */
125
+ readonly names: readonly string[];
126
+
127
+ constructor(input: { names: readonly string[] }) {
128
+ const names = input.names.join(', ');
129
+ super({
130
+ code: 'X_MCP_TOOL_UNDECLARED',
131
+ cause: `listed in defineAppMcp but never declared mcp.expose: ${names}`,
132
+ fix:
133
+ "add mcp: { expose: true, description: '<what it does>' } beside the policy on each — " +
134
+ "or drop it from the list and let include: 'exposed' project what opted in",
135
+ docs: docsFor('X_MCP_TOOL_UNDECLARED'),
136
+ });
137
+ this.names = input.names;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Two primitives project to one tool name. Caught at boot rather than at first call: an agent
143
+ * asking for the name reaches whichever copy won, which is the worst failure mode available —
144
+ * a call that succeeds against the wrong handler and reports nothing.
145
+ */
146
+ export class McpToolDuplicateError extends UltimateError {
147
+ constructor(input: { name: string }) {
148
+ super({
149
+ code: 'X_MCP_TOOL_DUPLICATE',
150
+ cause: `two primitives project to the MCP tool "${input.name}"`,
151
+ fix: "rename one: the tool name is the primitive's export name, or the `tools` record key",
152
+ docs: docsFor('X_MCP_TOOL_DUPLICATE'),
153
+ });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * `defineAppMcp`'s `scopes:` names a tool the server does not project. Boot-time and loud:
159
+ * the alternative is a scope entry that quietly covers nothing, leaving the tool the author
160
+ * meant to gate reachable by every connection — a gate that reads as declared and never runs.
161
+ * The projected names travel with it, because the usual cause is a rename or a typo.
162
+ */
163
+ export class McpScopeUnknownError extends UltimateError {
164
+ /** The catalog as projected, so a caller can show it without re-parsing `cause`. */
165
+ readonly projected: readonly string[];
166
+
167
+ constructor(input: { scope: string; name: string; projected: readonly string[] }) {
168
+ const projected = input.projected.length > 0 ? input.projected.join(', ') : 'nothing';
169
+ super({
170
+ code: 'X_MCP_SCOPE_UNKNOWN',
171
+ cause: `scopes["${input.scope}"] names "${input.name}", which this server does not project (projected: ${projected})`,
172
+ fix: `in defineAppMcp, spell it as one of the projected names above — or drop "${input.name}" from scopes["${input.scope}"]`,
173
+ docs: docsFor('X_MCP_SCOPE_UNKNOWN'),
174
+ });
175
+ this.projected = input.projected;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Two scopes claim one tool. A tool carries ONE scope, so the second claim would either
181
+ * overwrite the first or be dropped — decided by object key order, which is not a security
182
+ * model. Refused at boot rather than resolved, because either resolution is a guess about
183
+ * which capability the author meant a token to need.
184
+ *
185
+ * The two claimants travel with it, as `McpScopeUnknownError` carries the projected catalog: the
186
+ * reader is usually an agent holding `--json`, and a sentence is not a field.
187
+ */
188
+ export class McpScopeConflictError extends UltimateError {
189
+ /** The two scopes that claimed the tool, in the order the map declared them. */
190
+ readonly scopes: readonly [string, string];
191
+
192
+ constructor(input: { name: string; scopes: readonly [string, string] }) {
193
+ super({
194
+ code: 'X_MCP_SCOPE_CONFLICT',
195
+ cause: `tool "${input.name}" is claimed by two scopes ("${input.scopes[0]}" and "${input.scopes[1]}"); a tool carries one`,
196
+ fix: `in defineAppMcp, keep "${input.name}" under the single scope a token must hold for it, and remove the other entry`,
197
+ docs: docsFor('X_MCP_SCOPE_CONFLICT'),
198
+ });
199
+ this.scopes = input.scopes;
200
+ }
201
+ }
202
+
203
+ /** A malformed envelope or an unsupported method — a client bug, not an authz outcome. */
204
+ export class McpProtocolError extends UltimateError {
205
+ constructor(input: { cause: string; fix?: string }) {
206
+ super({
207
+ code: 'X_MCP_PROTOCOL',
208
+ cause: input.cause,
209
+ fix: input.fix ?? `send a JSON-RPC 2.0 body: { jsonrpc: '2.0', id, method, params }`,
210
+ docs: docsFor('X_MCP_PROTOCOL'),
211
+ });
212
+ }
213
+ }
214
+
215
+ /**
216
+ * LAYER 3 of `db.query`'s four defences: the statement is not one read-only statement, so it
217
+ * never reaches the server. Separate from the migration refusal below because the two want
218
+ * different next commands, and a code that covers both tells the agent neither.
219
+ */
220
+ export class McpQueryRejectedError extends UltimateError {
221
+ constructor(input: { cause: string; fix: string }) {
222
+ super({
223
+ code: 'X_MCP_QUERY_REJECTED',
224
+ cause: `db.query refused: ${input.cause}`,
225
+ fix: input.fix,
226
+ docs: docsFor('X_MCP_QUERY_REJECTED'),
227
+ });
228
+ }
229
+ }
230
+
231
+ /**
232
+ * `db.migrate` was pointed at a database that is not a branch. Enforced, not documented — the
233
+ * dev server holds real credentials, and a migration is the one dev tool that cannot be undone
234
+ * by reading the error afterwards.
235
+ */
236
+ export class McpNotBranchDbError extends UltimateError {
237
+ constructor(input: { cause: string; fix: string }) {
238
+ super({
239
+ code: 'X_MCP_NOT_BRANCH_DB',
240
+ cause: `db.migrate refused: ${input.cause}`,
241
+ fix: input.fix,
242
+ docs: docsFor('X_MCP_NOT_BRANCH_DB'),
243
+ });
244
+ }
245
+ }
package/src/exposed.ts ADDED
@@ -0,0 +1,25 @@
1
+ // `include: 'exposed'` — project straight from the primitive registries.
2
+ //
3
+ // "Define once, project everywhere". The action and query registries already know which
4
+ // primitives declared `mcp: { expose: true }`; re-listing them in `defineAppMcp` copies that
5
+ // knowledge into a second place, and a copy is a thing that goes stale silently — an action
6
+ // gains `expose`, its tool never appears, and nothing fails.
7
+ //
8
+ // The adaptation itself lives in `projectable.ts`, shared verbatim with the written-out
9
+ // `actions:`/`queries:` list, so both routes run through `invoke` / `sourceFor` and the registry
10
+ // shortcut buys convenience while changing no execution path.
11
+
12
+ import { listActions } from '@ultimat3/action';
13
+ import { listQueries } from '@ultimat3/query';
14
+ import type { ProjectablePrimitive } from './from-action';
15
+ import { primitiveFromAction, primitiveFromQuery } from './projectable';
16
+
17
+ /**
18
+ * Every registered action and query, as projectable primitives. `toolsFrom` applies the opt-in
19
+ * filter, so this deliberately does NOT filter: one place decides what "exposed" means.
20
+ *
21
+ * Read eagerly, at the moment `defineAppMcp` runs — register the app's primitives first.
22
+ */
23
+ export function exposedPrimitives(): readonly ProjectablePrimitive[] {
24
+ return [...listActions().map(primitiveFromAction), ...listQueries().map(primitiveFromQuery)];
25
+ }