@ultimat3/mcp 22.15.0 → 24.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.
- package/CLAUDE.md +18 -12
- package/README.md +7 -4
- package/package.json +10 -9
- package/src/app-tool.ts +1 -2
- package/src/app-tools.ts +7 -6
- package/src/framework-error.ts +30 -2
- package/src/from-action.ts +5 -11
- package/src/index.ts +3 -2
- package/src/list-params.ts +9 -4
- package/src/meta-surface.ts +3 -1
- package/src/projectable.ts +28 -8
- package/src/readonly-sql-calls.ts +106 -0
- package/src/readonly-sql.ts +26 -97
- package/src/server.ts +4 -3
- package/src/transport-http.ts +87 -9
- package/src/validate-args.ts +12 -4
- package/src/wire.ts +4 -40
- package/src/input-schema.ts +0 -140
package/CLAUDE.md
CHANGED
|
@@ -29,8 +29,7 @@ import. The CLI wires it.
|
|
|
29
29
|
| `projectable.ts` | a real `action`/`query` → `ProjectablePrimitive`; the ONE adapter both the sweep and the written-out list use |
|
|
30
30
|
| `exposed.ts` | `include: 'exposed'` — the action/query registries → primitives |
|
|
31
31
|
| `scopes.ts` | the `scopes:` map — outcome 2's declaration surface; boot-time refusal of an unknown or doubly-claimed tool |
|
|
32
|
-
| `
|
|
33
|
-
| `readonly-sql.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check |
|
|
32
|
+
| `readonly-sql.ts` · `readonly-sql-calls.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check · the banned call families |
|
|
34
33
|
| `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
|
|
35
34
|
| `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
|
|
36
35
|
| `list-params.ts` | `listParams` whitelist → the schema `manage_resource` enforces (flat keys: `status_eq`) |
|
|
@@ -102,15 +101,17 @@ import. The CLI wires it.
|
|
|
102
101
|
- **A declared `pattern` is compiled once per schema NODE, never per `tools/call`** — a `WeakMap`
|
|
103
102
|
keyed on the node (never on the pattern string, which grows forever), caching an uncompilable
|
|
104
103
|
pattern's `null` too. `compiledPatternCount()` is the test-only probe; `validate-args.test.ts`
|
|
105
|
-
asserts it does not climb over 100 calls.
|
|
104
|
+
asserts it does not climb over 100 calls. Compiled WITH `x-ultimate-pattern-flags` (schema's),
|
|
105
|
+
`lastIndex` reset.
|
|
106
106
|
- **`format` is NOT in the wire subset**, and `wire.ts` types it `never` so re-adding it does not
|
|
107
107
|
compile. It names a rule whose meaning lives in `@ultimat3/schema` (`uuid`, `email`,
|
|
108
108
|
`iana-time-zone`), and this package cannot check it without a second definition of each that can
|
|
109
109
|
only drift from the action's own parse. `tools/list` published it and `validate-args.ts` ignored
|
|
110
|
-
it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true`
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
it, so a tool declaring `t.uuid` accepted `"not-a-uuid"` with `ok: true`. `pattern` is the
|
|
111
|
+
opposite case and is kept: the rule travels with the schema. The narrowing is `@ultimat3/schema`'s
|
|
112
|
+
`toWireSchema` (tier 0, so `@ultimat3/action`'s `.tool()` publishes the same document) and the
|
|
113
|
+
type is its `WireJsonSchema`, which `wire.ts` names `JsonSchema`; `validate-args-subset.test.ts`
|
|
114
|
+
asserts every published keyword is one this server enforces, at any depth.
|
|
114
115
|
- **A hand-written app tool parses its own input**, in the slot `invoke` puts it: parse, then
|
|
115
116
|
`guard()`, then `handle`. A projected action re-parses inside `invoke`; `app-tool.ts` had no second
|
|
116
117
|
parse, so `handle` was handed whatever the wire subset let through — typed `InferOutput<TInput>`,
|
|
@@ -131,7 +132,9 @@ import. The CLI wires it.
|
|
|
131
132
|
`-32603` the transport promises for a genuine bug.
|
|
132
133
|
- A framework error rendered into a tool result is **byte-identical to
|
|
133
134
|
`UltimateError.format()`** — one denial must not read one way over MCP and another in the
|
|
134
|
-
terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
|
|
135
|
+
terminal. `server.ts` renders it; the test pins it against `format()`, never a literal. Except a
|
|
136
|
+
hidden 5xx for `'caller'` (`forAudience`, `As of 2026-10`): `statusFor(code) >= 500` and not core's
|
|
137
|
+
`hasPublicCause` → `HIDDEN_CAUSE` + `x errors explain`, tool results and resource reads alike.
|
|
135
138
|
- Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments or row
|
|
136
139
|
data. **On both surfaces**: `mcp.tool-call.<outcome>` and `mcp.resource-read.<outcome>` — two
|
|
137
140
|
events, one `LEVEL` table, so an alert can tell a URI walk from a tool-name walk. `resources/list`
|
|
@@ -223,7 +226,9 @@ import. The CLI wires it.
|
|
|
223
226
|
`pg_terminate_backend`, `pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
|
|
224
227
|
`pg_wal_replay_*`); replication (`pg_logical_*`, `pg_create_*`, `pg_drop_*`, `pg_replication_*` —
|
|
225
228
|
advancing a slot is a write no `ROLLBACK` undoes); `pg_file_*`; `txid_current` /
|
|
226
|
-
`pg_current_xact_id
|
|
229
|
+
`pg_current_xact_id`; `pg_import_*`; SQL run from a string (`*_to_xml*`, `ts_stat`, `ts_rewrite`). `U&"…"` is
|
|
230
|
+
refused, never decoded; a keyword is a whole identifier (`set2` is a column). Catalog VIEWS
|
|
231
|
+
(`pg_replication_slots`) are read `from`, never called.
|
|
227
232
|
- Banned SQL functions are matched as a **prefix of a CALLED function name** — add a family, never
|
|
228
233
|
a name (`pg_sleep_for` passed an exact `pg_sleep` ban). The unit is the call (`name(`), so a
|
|
229
234
|
column `pg_sleep_for_seconds` is fine; the call scan keeps quoted-identifier content
|
|
@@ -251,12 +256,13 @@ import. The CLI wires it.
|
|
|
251
256
|
`resolveToken` rejects and a non-agent actor all return before the body is read: parsing first
|
|
252
257
|
answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
|
|
253
258
|
SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
|
|
254
|
-
error still exists — it is what an authenticated agent gets.
|
|
259
|
+
error still exists — it is what an authenticated agent gets. Failures spend a per-ADDRESS bucket
|
|
260
|
+
(`seen.address`), and an exhausted address is `429` BEFORE `resolveToken` — a valid guess too.
|
|
255
261
|
- **`transport-stdio.ts`'s default `write` is AWAITED** — fd 1 is a pipe, and an unawaited
|
|
256
262
|
`Bun.stdout.write` lost the tail of a 4 MB frame at exit. It is also the loop's only
|
|
257
263
|
back-pressure; only a child-process test can see it.
|
|
258
|
-
- **A schema property lands through `Object.defineProperty` too** —
|
|
259
|
-
`
|
|
264
|
+
- **A schema property lands through `Object.defineProperty` too** — `@ultimat3/schema`'s
|
|
265
|
+
`wire-schema.ts`, the twin of `validate-args.ts`'s `put` on the schema side. `out[key] = …` for
|
|
260
266
|
a property named `__proto__` runs `Object.prototype`'s setter and re-prototypes the published
|
|
261
267
|
`properties` record instead of adding a key, so a field the tool author declared vanishes from
|
|
262
268
|
`tools/list` and from what the arg validator reads back.
|
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ A generated app exposes its own MCP surface with one call, so the user's agents
|
|
|
91
91
|
user's app:
|
|
92
92
|
|
|
93
93
|
```ts
|
|
94
|
-
// apps/admin/
|
|
94
|
+
// apps/admin/app/admin/mcp.ts — under app/, so the app scan imports it
|
|
95
95
|
import { defineAppMcp, t } from '@ultimat3/mcp';
|
|
96
96
|
|
|
97
97
|
export const mcp = defineAppMcp({
|
|
@@ -226,7 +226,9 @@ meta tool name taken by an app tool (`X_MCP_TOOL_DUPLICATE`).
|
|
|
226
226
|
maxLimit: 100 } }`. Keys are FLAT — `status_eq`, `createdAt_gt`, `sort: '-createdAt'`, `fields`,
|
|
227
227
|
`cursor` (opaque keyset), `limit` — because a query's input travels as a query string; the query's
|
|
228
228
|
own `input` declares them and implements them. `describe_resource` publishes the whitelist and
|
|
229
|
-
`manage_resource`
|
|
229
|
+
`manage_resource` admits the whitelist plus the keys the input `required`s and refuses anything
|
|
230
|
+
else, an optional input key the whitelist left out included (an `isError` result carrying
|
|
231
|
+
`X_INPUT_INVALID`), before the query runs; a
|
|
230
232
|
whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boot.
|
|
231
233
|
|
|
232
234
|
### What a client is told beyond the schema
|
|
@@ -239,8 +241,8 @@ whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boo
|
|
|
239
241
|
| `initialize.instructions` | `defineAppMcp({ instructions })` / `createMcpServer({ instructions })` — a string, or `(caller) => string \| undefined` | none; a function that throws or answers blank sends none |
|
|
240
242
|
| `title` | `mcp: { title }` · hand-written `title` | none |
|
|
241
243
|
| `annotations` | `deriveAnnotations(primitive)`, then `mcp: { annotations }` key by key | query `{ readOnlyHint: true }`; action `{ readOnlyHint: false, destructiveHint: true, idempotentHint: <idempotent> }`; `openWorldHint` only when declared |
|
|
242
|
-
| `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }` — `
|
|
243
|
-
| `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block | absent |
|
|
244
|
+
| `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }`, or as the row itself for a `single: true` read (which answers one row or `X_NOT_FOUND`, as its route does) — `toWireOutputSchema` (from `@ultimat3/schema`) / `toRowsOutputSchema`, structure only (no bounds, patterns or `additionalProperties`: a client refuses a result that misses its schema) | none |
|
|
245
|
+
| `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block — a list query's rows under `rows`, a `single: true` query's row unwrapped | absent |
|
|
244
246
|
|
|
245
247
|
A tool's text is **compact JSON** (`jsonResult`; the 2-space form until 22.10). The meta tools
|
|
246
248
|
carry their own hints: `list_resources` and `describe_resource` read-only, `manage_resource` a
|
|
@@ -290,6 +292,7 @@ did not.
|
|
|
290
292
|
|---|---|---|
|
|
291
293
|
| the body cap | `mcpHttpRoute({ bodyLimitBytes })` · `defineAppMcp({ bodyLimitBytes })` | `DEFAULT_MCP_BODY_LIMIT_BYTES`, 1 MiB |
|
|
292
294
|
| the numbers | `mcpHttpRoute({ rateLimits })` · `defineAppMcp({ rateLimits })` | `MCP_RATE_LIMITS` |
|
|
295
|
+
| failed authentications per address | `rateLimits: { unauthenticated }`, keyed on `handle(request, { address })` | `MCP_UNAUTHENTICATED_LIMIT`, 20 / minute; past it the address is `429` before `resolveToken` runs. No `address`, not metered here |
|
|
293
296
|
| where they are counted | `mcpHttpRoute({ rateLimitStore })` · `defineAppMcp({ rateLimitStore })` | a per-**process** memory store — N replicas behind one URL each enforce the full allowance, so a fleet passes `postgresRateLimitStore({ executor })` |
|
|
294
297
|
| OAuth discovery | `mcpHttpRoute({ oauth })` · `defineAppMcp({ oauth })` | absent: the 401 is `Bearer realm="ultimate-mcp"` |
|
|
295
298
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,13 +32,13 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/http": "
|
|
38
|
-
"@ultimat3/jobs": "
|
|
39
|
-
"@ultimat3/policy": "
|
|
40
|
-
"@ultimat3/query": "
|
|
41
|
-
"@ultimat3/schema": "
|
|
35
|
+
"@ultimat3/action": "24.0.0",
|
|
36
|
+
"@ultimat3/core": "24.0.0",
|
|
37
|
+
"@ultimat3/entity": "24.0.0",
|
|
38
|
+
"@ultimat3/http": "24.0.0",
|
|
39
|
+
"@ultimat3/jobs": "24.0.0",
|
|
40
|
+
"@ultimat3/policy": "24.0.0",
|
|
41
|
+
"@ultimat3/query": "24.0.0",
|
|
42
|
+
"@ultimat3/schema": "24.0.0"
|
|
42
43
|
}
|
|
43
44
|
}
|
package/src/app-tool.ts
CHANGED
|
@@ -16,11 +16,10 @@ import { useContext } from '@ultimat3/core';
|
|
|
16
16
|
import type { KnownPermission } from '@ultimat3/policy';
|
|
17
17
|
import { can } from '@ultimat3/policy';
|
|
18
18
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
|
-
import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
|
|
19
|
+
import { formatIssues, toValidationIssues, toWireSchema, validateAsync } from '@ultimat3/schema';
|
|
20
20
|
import { asCallerContext } from './caller-context';
|
|
21
21
|
import { McpToolUnsafeError } from './errors';
|
|
22
22
|
import type { ProjectablePrimitive } from './from-action';
|
|
23
|
-
import { toWireSchema } from './input-schema';
|
|
24
23
|
import type { McpRole, McpToolAnnotations } from './registry';
|
|
25
24
|
|
|
26
25
|
export interface AppToolArgs<TInput extends StandardSchemaV1> {
|
package/src/app-tools.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
|
|
|
20
20
|
import type { McpOAuth } from './oauth-metadata';
|
|
21
21
|
import type { ListedPrimitive } from './projectable';
|
|
22
22
|
import { asProjectable } from './projectable';
|
|
23
|
-
import type { AnyMcpTool
|
|
23
|
+
import type { AnyMcpTool } from './registry';
|
|
24
24
|
import type { McpPrompt, McpResource } from './resources';
|
|
25
25
|
import { toPrompts } from './resources';
|
|
26
26
|
import type { McpScopes } from './scopes';
|
|
@@ -28,7 +28,7 @@ import { withScopes } from './scopes';
|
|
|
28
28
|
import type { CreateMcpServerInput } from './server';
|
|
29
29
|
import { createMcpServer, type McpServer } from './server';
|
|
30
30
|
import type { McpInstructions } from './server-voice';
|
|
31
|
-
import type { McpRouteDescriptor, ResolvedToken } from './transport-http';
|
|
31
|
+
import type { McpRateLimits, McpRouteDescriptor, ResolvedToken } from './transport-http';
|
|
32
32
|
import { mcpHttpRoute } from './transport-http';
|
|
33
33
|
|
|
34
34
|
/** Schema map behind the authored `tools` record; inferred per tool, never written by hand. */
|
|
@@ -133,10 +133,11 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
|
|
|
133
133
|
*/
|
|
134
134
|
readonly bodyLimitBytes?: number | undefined;
|
|
135
135
|
/**
|
|
136
|
-
* Requests per minute per caller, by class
|
|
137
|
-
*
|
|
136
|
+
* Requests per minute per caller, by class — defaults to `MCP_RATE_LIMITS` — and failed
|
|
137
|
+
* authentications per minute per address (`unauthenticated`). ENFORCED by the route, so this is
|
|
138
|
+
* the app's one knob over what an agent, or a stranger guessing tokens, may spend here.
|
|
138
139
|
*/
|
|
139
|
-
readonly rateLimits?:
|
|
140
|
+
readonly rateLimits?: McpRateLimits | undefined;
|
|
140
141
|
/**
|
|
141
142
|
* Where those buckets are counted. Forwarded rather than left to the route's per-process default
|
|
142
143
|
* because an app behind N replicas has no other way to reach it — and N processes each holding
|
|
@@ -169,7 +170,7 @@ export interface AppMcp {
|
|
|
169
170
|
* Project an app's primitives into a ready MCP server.
|
|
170
171
|
*
|
|
171
172
|
* ```ts
|
|
172
|
-
* // apps/admin/
|
|
173
|
+
* // apps/admin/app/admin/mcp.ts
|
|
173
174
|
* export const mcp = defineAppMcp({
|
|
174
175
|
* name: 'acme-admin',
|
|
175
176
|
* include: 'exposed',
|
package/src/framework-error.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// A value a tool THREW, read as a framework error (code, title, cause, fix) or rejected as a bug —
|
|
2
2
|
// and its rendering, byte-identical to `UltimateError.format()`. Split from `server.ts`, whose two
|
|
3
3
|
// dispatch paths (a flat `tools/call` and `manage_resource`) and resource reads all share it.
|
|
4
|
+
// Also what a REMOTE caller may read of it: a 5xx cause only when core's `hasPublicCause` says so.
|
|
4
5
|
|
|
5
6
|
import type { ErrorAudience } from '@ultimat3/core';
|
|
6
7
|
import {
|
|
7
8
|
ERROR_DOCS_URL,
|
|
8
9
|
FRAMEWORK_CODE,
|
|
9
10
|
fixFor,
|
|
11
|
+
hasPublicCause,
|
|
10
12
|
isUltimateError,
|
|
13
|
+
renderFixShellArg,
|
|
11
14
|
singleLine,
|
|
12
15
|
stringField,
|
|
13
16
|
} from '@ultimat3/core';
|
|
17
|
+
import { statusFor } from '@ultimat3/http';
|
|
14
18
|
|
|
15
19
|
export interface FrameworkError {
|
|
16
20
|
readonly code: string;
|
|
@@ -64,6 +68,29 @@ export function asFrameworkError(error: unknown): FrameworkError | undefined {
|
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The fixed sentence a remote caller reads in place of a hidden cause. The real one is in the
|
|
73
|
+
* audit line this call wrote and in the error monitor, never on the wire.
|
|
74
|
+
*/
|
|
75
|
+
export const HIDDEN_CAUSE =
|
|
76
|
+
'the server failed while handling this call; the details are in this process\u2019s logs';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What THIS audience may read of a thrown error — the verdict `@ultimat3/http`'s `toProblem` gives
|
|
80
|
+
* the problem document, asked of the same two tables. A remote caller (`'caller'`) of a code whose
|
|
81
|
+
* status is 5xx gets the cause only when core's `hasPublicCause` says so: `X_DB_STATEMENT_FAILED`
|
|
82
|
+
* carries the server's message and the statement, and an MCP client is a remote client. The
|
|
83
|
+
* developer's `fix` is withheld with it — a driver's fix is built from the same statement — and
|
|
84
|
+
* `callerFix`, authored for the caller, still goes. `'developer'` (the dev server) keeps both, as
|
|
85
|
+
* HTTP's `dev: true` does: the reader is the author fixing their own SQL.
|
|
86
|
+
*/
|
|
87
|
+
export function forAudience(error: FrameworkError, audience: ErrorAudience): FrameworkError {
|
|
88
|
+
if (audience === 'developer') return error;
|
|
89
|
+
if (statusFor(error.code) < 500 || hasPublicCause(error.code)) return error;
|
|
90
|
+
const fix = `x errors explain ${renderFixShellArg(error.code, '<code>')} --json`;
|
|
91
|
+
return { ...error, cause: HIDDEN_CAUSE, fix };
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
/**
|
|
68
95
|
* The agent-readable form, BYTE-IDENTICAL to `UltimateError.format({ audience })` — plus
|
|
69
96
|
* `{ docs: true }` when the error names a page of its own — so one denial reads the same over MCP
|
|
@@ -85,10 +112,11 @@ export function renderFrameworkError(
|
|
|
85
112
|
error.title === ''
|
|
86
113
|
? singleLine(error.code)
|
|
87
114
|
: `${singleLine(error.code)}: ${singleLine(error.title)}`;
|
|
115
|
+
const shown = forAudience(error, audience);
|
|
88
116
|
const lines = [
|
|
89
117
|
head,
|
|
90
|
-
` cause: ${singleLine(
|
|
91
|
-
` fix: ${singleLine(fixFor(
|
|
118
|
+
` cause: ${singleLine(shown.cause)}`,
|
|
119
|
+
` fix: ${singleLine(fixFor(shown, audience))}`,
|
|
92
120
|
];
|
|
93
121
|
if (error.docs !== undefined) lines.push(` docs: ${singleLine(error.docs)}`);
|
|
94
122
|
return lines.join('\n');
|
package/src/from-action.ts
CHANGED
|
@@ -13,16 +13,10 @@
|
|
|
13
13
|
// tool in `defineAppMcp`'s `scopes:` map (see `scopes.ts`) — declared once, next to the
|
|
14
14
|
// other tools that same token capability covers, never guessed from the action.
|
|
15
15
|
//
|
|
16
|
-
// `toWireSchema`
|
|
17
|
-
// what THIS server publishes; this file owns the execution half.
|
|
18
|
-
// `
|
|
19
|
-
// `
|
|
20
|
-
// `toMcpTool` emits the full draft-07 vocabulary and `toWireSchema` narrows to the subset
|
|
21
|
-
// `validate-args.ts` can ENFORCE, so a keyword in one and not the other is a contract an agent
|
|
22
|
-
// is judged against and was never shown. `pattern` was exactly that until 2026-08.
|
|
23
|
-
// `action` is tier 3 and this package is tier 4, so the two cannot share one function today; the
|
|
24
|
-
// shared home would be `@ultimat3/schema`. `packages/mcp/src/cross-surface.test.ts` is what keeps
|
|
25
|
-
// them from diverging again.
|
|
16
|
+
// `toWireSchema` from `@ultimat3/schema`, reached through `projectable.ts`, owns the schema half
|
|
17
|
+
// of what THIS server publishes; this file owns the execution half. `@ultimat3/action`'s
|
|
18
|
+
// `.tool()` publishes through the same function, so the two cannot describe one declaration
|
|
19
|
+
// differently — `packages/mcp/src/cross-surface.test.ts` holds them to it.
|
|
26
20
|
|
|
27
21
|
import type { Actor } from '@ultimat3/core';
|
|
28
22
|
import { isMcpExposed } from '@ultimat3/core';
|
|
@@ -85,7 +79,7 @@ export interface ProjectablePrimitive {
|
|
|
85
79
|
/** The action honours an idempotency key — a repeat is answered, not re-run. */
|
|
86
80
|
readonly idempotent?: boolean;
|
|
87
81
|
/**
|
|
88
|
-
* The `outputSchema` to publish, already narrowed (`
|
|
82
|
+
* The `outputSchema` to publish, already narrowed (`toWireOutputSchema`) with an OBJECT root.
|
|
89
83
|
* Absent: no `outputSchema`, no `structuredContent` — only the text block.
|
|
90
84
|
*/
|
|
91
85
|
readonly outputJsonSchema?: JsonSchema;
|
package/src/index.ts
CHANGED
|
@@ -90,7 +90,6 @@ export {
|
|
|
90
90
|
toolsListed,
|
|
91
91
|
} from './from-action';
|
|
92
92
|
export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
|
|
93
|
-
export { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
|
|
94
93
|
export type { ListFilterOp, McpListParams } from './list-params';
|
|
95
94
|
export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
|
|
96
95
|
export {
|
|
@@ -126,7 +125,7 @@ export {
|
|
|
126
125
|
protectedResourceMetadata,
|
|
127
126
|
} from './oauth-metadata';
|
|
128
127
|
export type { ListedPrimitive } from './projectable';
|
|
129
|
-
export { asProjectable } from './projectable';
|
|
128
|
+
export { asProjectable, toRowsOutputSchema } from './projectable';
|
|
130
129
|
export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
|
|
131
130
|
export {
|
|
132
131
|
capQueryRows,
|
|
@@ -182,6 +181,7 @@ export { assertMcpSurfaceBudget, measureMcpSurface } from './surface-budget';
|
|
|
182
181
|
export type {
|
|
183
182
|
McpHttpTransportInput,
|
|
184
183
|
McpProtectedResource,
|
|
184
|
+
McpRateLimits,
|
|
185
185
|
McpRequestOrigin,
|
|
186
186
|
McpRouteDescriptor,
|
|
187
187
|
ResolvedToken,
|
|
@@ -189,6 +189,7 @@ export type {
|
|
|
189
189
|
export {
|
|
190
190
|
DEFAULT_MCP_BODY_LIMIT_BYTES,
|
|
191
191
|
MCP_RATE_LIMITS,
|
|
192
|
+
MCP_UNAUTHENTICATED_LIMIT,
|
|
192
193
|
mcpHttpRoute,
|
|
193
194
|
} from './transport-http';
|
|
194
195
|
export type { StdioTransportInput } from './transport-stdio';
|
package/src/list-params.ts
CHANGED
|
@@ -90,13 +90,18 @@ export function listParamsProperties(spec: McpListParams): readonly [string, Jso
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* The schema `manage_resource` validates a list call against: the
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* The schema `manage_resource` validates a list call against: the whitelist, NARROWED, plus the
|
|
94
|
+
* input keys the query REQUIRES (a `projectId` the list cannot run without), and nothing else
|
|
95
|
+
* admitted. Decided by provenance — the author's `required` and the author's whitelist — never by
|
|
96
|
+
* whether a key looks like a filter: an optional `status_in` or `includeDeleted` the whitelist
|
|
97
|
+
* left out was admitted until 2026-10. `Object.fromEntries` (a define, never `[k] =`) because a
|
|
98
|
+
* field name is the app's string and `__proto__` must land as a key.
|
|
96
99
|
*/
|
|
97
100
|
export function listParamsSchema(spec: McpListParams, base: JsonSchema): JsonSchema {
|
|
101
|
+
const declared = base.properties ?? {};
|
|
102
|
+
const required = (base.required ?? []).filter((key) => Object.hasOwn(declared, key));
|
|
98
103
|
const properties = Object.fromEntries([
|
|
99
|
-
...
|
|
104
|
+
...required.map((key): [string, JsonSchema] => [key, declared[key] ?? {}]),
|
|
100
105
|
...listParamsProperties(spec),
|
|
101
106
|
]);
|
|
102
107
|
return {
|
package/src/meta-surface.ts
CHANGED
|
@@ -299,7 +299,9 @@ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
|
299
299
|
function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
|
|
300
300
|
return {
|
|
301
301
|
name: tool.name,
|
|
302
|
-
|
|
302
|
+
// `=== true`, as `ToolRegistry.verbClass` meters it: an omitted `destructive` is a read in the
|
|
303
|
+
// rate limiter, so listing it as an action gave one tool two answers.
|
|
304
|
+
kind: tool.destructive === true ? 'action' : 'query',
|
|
303
305
|
description: tool.description,
|
|
304
306
|
...(tool.confirms === true ? { confirms: true } : {}),
|
|
305
307
|
...(tool.scope === undefined ? {} : { scope: tool.scope }),
|
package/src/projectable.ts
CHANGED
|
@@ -17,14 +17,16 @@ import {
|
|
|
17
17
|
isQuery,
|
|
18
18
|
guardBeforeInput as queryGuardBeforeInput,
|
|
19
19
|
queryName,
|
|
20
|
+
readAnswer,
|
|
20
21
|
sourceFor,
|
|
21
22
|
} from '@ultimat3/query';
|
|
23
|
+
import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
|
|
22
24
|
import { asCallerContext } from './caller-context';
|
|
23
25
|
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
24
26
|
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
25
|
-
import { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
|
|
26
27
|
import type { McpListParams } from './list-params';
|
|
27
28
|
import type { McpToolAnnotations } from './registry';
|
|
29
|
+
import type { JsonSchema } from './wire';
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* What `defineAppMcp`'s `actions:`/`queries:` accept.
|
|
@@ -60,7 +62,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
60
62
|
// `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
|
|
61
63
|
// `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
|
|
62
64
|
const keyed = target.describe().idempotent;
|
|
63
|
-
const output =
|
|
65
|
+
const output = toWireOutputSchema(target.output);
|
|
64
66
|
return {
|
|
65
67
|
name,
|
|
66
68
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
@@ -87,17 +89,22 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
87
89
|
|
|
88
90
|
export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
89
91
|
const exposure = exposureOf(target.mcp);
|
|
90
|
-
//
|
|
91
|
-
// answers
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// A `single: true` read answers ONE row, or not-found — what its route answers (404) and what
|
|
93
|
+
// `tool().read()` answers. Handing an agent `[]` for a missing id said "found, and empty".
|
|
94
|
+
const single = target.single === true;
|
|
95
|
+
// Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape. A list
|
|
96
|
+
// read answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent`
|
|
97
|
+
// is an object by the spec; a single read's answer already IS the object.
|
|
98
|
+
const output = single ? toWireOutputSchema(target.rows) : toRowsOutputSchema(target.rows);
|
|
94
99
|
const name = queryName(target);
|
|
95
100
|
return {
|
|
96
101
|
name,
|
|
97
102
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
98
103
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
99
104
|
inputJsonSchema: toWireSchema(target.input),
|
|
100
|
-
...(output === undefined
|
|
105
|
+
...(output === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { outputJsonSchema: output, ...(single ? {} : { outputWrap: 'rows' }) }),
|
|
101
108
|
mutates: false,
|
|
102
109
|
// `sourceFor`'s opening gate, on the surface it runs on ('server'), asked before `invalid-args`.
|
|
103
110
|
admit: (actor: Actor) =>
|
|
@@ -112,11 +119,24 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
|
112
119
|
// cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
|
|
113
120
|
// not a TTL.
|
|
114
121
|
const source = await sourceFor(target, input);
|
|
115
|
-
|
|
122
|
+
// `@ultimat3/query`'s one rule for what a read answers: rows, or a single read's row.
|
|
123
|
+
return readAnswer(target, await source.execute());
|
|
116
124
|
}),
|
|
117
125
|
};
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/** `{ rows: [<row>] }` — a query's answer as `structuredContent`. `undefined` when no row schema. */
|
|
129
|
+
export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
|
|
130
|
+
if (rows === undefined) return undefined;
|
|
131
|
+
const row = toWireOutputSchema(rows);
|
|
132
|
+
if (row === undefined) return undefined;
|
|
133
|
+
return {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: { rows: { type: 'array', items: row } },
|
|
136
|
+
required: ['rows'],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
120
140
|
/**
|
|
121
141
|
* An action and a query declare MCP exposure with the same fields, so one typed path reads
|
|
122
142
|
* both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The function families `db.query` may not call, and the scan that finds a call. Split from
|
|
2
|
+
// `readonly-sql.ts` for the size ceiling. On embedded PGlite — `x dev`'s database — this ban is the
|
|
3
|
+
// only layer holding: `statement_timeout` cannot interrupt it, and no SELECT-only role need exist
|
|
4
|
+
// there. So what this scan cannot read is refused by the caller, never decoded.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Function families a read may not call, matched as a PREFIX of a CALLED function name.
|
|
8
|
+
*
|
|
9
|
+
* The family is the unit, never the name. Refusing `pg_sleep` while admitting `pg_sleep_for` is a
|
|
10
|
+
* distinction only this parser draws, and an exact-name list admits by default: every spelling
|
|
11
|
+
* nobody thought to write down passes. A prefix refuses by default instead, so a member Postgres
|
|
12
|
+
* adds next release is covered on the day it ships.
|
|
13
|
+
*
|
|
14
|
+
* Each family is a ban `readonly-sql.ts` already makes in some other spelling:
|
|
15
|
+
* - reach outside the database — the original list (`pg_read_*`, `pg_ls_*`, `lo_*`, `dblink`);
|
|
16
|
+
* - hold a lock, which `FOR UPDATE` is refused for. The call is the worse of the two: a SESSION
|
|
17
|
+
* advisory lock is not released by the `ROLLBACK` layer 2 always runs, so it outlives the read
|
|
18
|
+
* on a pooled connection the app's own writers use;
|
|
19
|
+
* - mutate the server or the session — `set_config` is `SET` spelled as a call, and `SET` is a
|
|
20
|
+
* write keyword;
|
|
21
|
+
* - burn the wall clock — layer 2's `statement_timeout` cannot interrupt embedded PGlite
|
|
22
|
+
* (single-threaded WASM), which is the database `x dev` runs;
|
|
23
|
+
* - ADVANCE A SEQUENCE (`nextval`, `setval`) — a write that leaves no keyword behind, and one
|
|
24
|
+
* `ROLLBACK` does not undo: a consumed sequence value is gone, so a read can silently burn the
|
|
25
|
+
* next id a real insert would have taken. `currval`/`lastval` read the session and stay legal.
|
|
26
|
+
* `txid_current`/`pg_current_xact_id` are the same ban one level down: they ASSIGN a real
|
|
27
|
+
* transaction id to a read, and a rollback does not give it back;
|
|
28
|
+
* - PUBLISH A MESSAGE — `pg_notify` is `NOTIFY` spelled as a call. The keyword scan cannot see
|
|
29
|
+
* it: it is one token;
|
|
30
|
+
* - CONTROL THE SERVER (`pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
|
|
31
|
+
* `pg_wal_replay_*`) — the family `pg_cancel_backend`/`pg_terminate_backend` established;
|
|
32
|
+
* - CONSUME THE REPLICATION STREAM (`pg_create_*`, `pg_drop_*`, `pg_replication_*`,
|
|
33
|
+
* `pg_logical_*`) — `pg_logical_slot_get_changes` advances a slot's confirmed position. The
|
|
34
|
+
* catalog VIEWS beside them (`pg_replication_slots`) are read `from`, never called;
|
|
35
|
+
* - WRITE A FILE (`pg_file_*`) — the other half of `pg_read_*`;
|
|
36
|
+
* - WRITE THE CATALOG (`pg_import_*`) — `pg_import_system_collations` inserts `pg_collation` rows;
|
|
37
|
+
* - RUN A QUERY HANDED TO IT AS TEXT (`query_to_xml*`, `cursor_to_xml*`, `table_to_xml*`,
|
|
38
|
+
* `schema_to_xml*`, `database_to_xml*`, `ts_stat`, `ts_rewrite`) — the statement is a string
|
|
39
|
+
* literal, which every pass of the scan blanks by design, so whatever it calls is invisible.
|
|
40
|
+
*
|
|
41
|
+
* The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
|
|
42
|
+
* column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
|
|
43
|
+
* a form where a quoted identifier keeps its content, because `"pg_advisory_lock"(1)` is the same
|
|
44
|
+
* call as `pg_advisory_lock(1)`.
|
|
45
|
+
*/
|
|
46
|
+
const FORBIDDEN_FUNCTIONS = [
|
|
47
|
+
'cursor_to_xml',
|
|
48
|
+
'database_to_xml',
|
|
49
|
+
'dblink',
|
|
50
|
+
'lo_',
|
|
51
|
+
'nextval',
|
|
52
|
+
'pg_advisory_',
|
|
53
|
+
'pg_cancel_backend',
|
|
54
|
+
'pg_create_',
|
|
55
|
+
'pg_current_xact_id',
|
|
56
|
+
'pg_drop_',
|
|
57
|
+
'pg_file_',
|
|
58
|
+
'pg_import_',
|
|
59
|
+
'pg_logical_',
|
|
60
|
+
'pg_ls_',
|
|
61
|
+
'pg_notify',
|
|
62
|
+
'pg_promote',
|
|
63
|
+
'pg_read_',
|
|
64
|
+
'pg_reload_',
|
|
65
|
+
'pg_replication_',
|
|
66
|
+
'pg_rotate_',
|
|
67
|
+
'pg_sleep',
|
|
68
|
+
'pg_stat_file',
|
|
69
|
+
'pg_stat_reset',
|
|
70
|
+
// Not reachable from `pg_stat_reset`: the extension spells the same reset with the statistics
|
|
71
|
+
// view's name in the middle, so a prefix of one is not a prefix of the other.
|
|
72
|
+
'pg_stat_statements_reset',
|
|
73
|
+
'pg_switch_',
|
|
74
|
+
'pg_terminate_backend',
|
|
75
|
+
'pg_try_advisory_',
|
|
76
|
+
'pg_wal_replay_',
|
|
77
|
+
'query_to_xml',
|
|
78
|
+
'schema_to_xml',
|
|
79
|
+
'set_config',
|
|
80
|
+
'setval',
|
|
81
|
+
'table_to_xml',
|
|
82
|
+
'ts_rewrite',
|
|
83
|
+
'ts_stat',
|
|
84
|
+
'txid_current',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
|
|
88
|
+
export function forbiddenFamily(called: string): string | undefined {
|
|
89
|
+
return FORBIDDEN_FUNCTIONS.find((family) => called.startsWith(family));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A call: an identifier immediately before `(`. Schema qualification falls out of the scan —
|
|
94
|
+
* `pg_catalog.set_config(` matches on the last segment, which is the function being called.
|
|
95
|
+
*/
|
|
96
|
+
const CALL_PATTERN = /([a-z_][a-z0-9_$]*)\s*\(/g;
|
|
97
|
+
|
|
98
|
+
/** Every function `sql` calls, lowercased. Read from the identifier-preserving strip. */
|
|
99
|
+
export function calledFunctions(sql: string): readonly string[] {
|
|
100
|
+
const names: string[] = [];
|
|
101
|
+
for (const match of sql.toLowerCase().matchAll(CALL_PATTERN)) {
|
|
102
|
+
const name = match[1];
|
|
103
|
+
if (name !== undefined) names.push(name);
|
|
104
|
+
}
|
|
105
|
+
return names;
|
|
106
|
+
}
|
package/src/readonly-sql.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (INSERT ...) SELECT`, which reads like a SELECT and is not one).
|
|
8
8
|
|
|
9
9
|
import { McpNotBranchDbError, McpQueryRejectedError } from './errors';
|
|
10
|
+
import { calledFunctions, forbiddenFamily } from './readonly-sql-calls';
|
|
10
11
|
|
|
11
12
|
/** Named in `db.query`'s `guards`, so a caller can see that layer 3 actually ran. */
|
|
12
13
|
export const PARSE_GUARD = 'parse:single-read';
|
|
@@ -68,102 +69,6 @@ const WRITE_KEYWORDS = new Set([
|
|
|
68
69
|
'vacuum',
|
|
69
70
|
]);
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
* Function families a read may not call, matched as a PREFIX of a CALLED function name.
|
|
73
|
-
*
|
|
74
|
-
* The family is the unit, never the name. Refusing `pg_sleep` while admitting `pg_sleep_for` is a
|
|
75
|
-
* distinction only this parser draws, and an exact-name list admits by default: every spelling
|
|
76
|
-
* nobody thought to write down passes. A prefix refuses by default instead, so a member Postgres
|
|
77
|
-
* adds next release is covered on the day it ships.
|
|
78
|
-
*
|
|
79
|
-
* Each family is a ban this file already makes in some other spelling:
|
|
80
|
-
* - reach outside the database — the original list (`pg_read_*`, `pg_ls_*`, `lo_*`, `dblink`);
|
|
81
|
-
* - hold a lock, which `FOR UPDATE` is refused for below. The call is the worse of the two: a
|
|
82
|
-
* SESSION advisory lock is not released by the `ROLLBACK` layer 2 always runs, so it outlives
|
|
83
|
-
* the read on a pooled connection the app's own writers use;
|
|
84
|
-
* - mutate the server or the session — `set_config` is `SET` spelled as a call, and `SET` is a
|
|
85
|
-
* write keyword above;
|
|
86
|
-
* - burn the wall clock — layer 2's `statement_timeout` cannot interrupt embedded PGlite
|
|
87
|
-
* (single-threaded WASM), which is the database `x dev` runs, so this ban is the only one
|
|
88
|
-
* that holds there;
|
|
89
|
-
* - ADVANCE A SEQUENCE (`nextval`, `setval`) — a write that leaves no keyword behind, and one
|
|
90
|
-
* `ROLLBACK` does not undo: a consumed sequence value is gone, so a read can silently burn the
|
|
91
|
-
* next id a real insert would have taken. `currval`/`lastval` read the session and stay legal.
|
|
92
|
-
* `txid_current`/`pg_current_xact_id` are the same ban one level down: they ASSIGN a real
|
|
93
|
-
* transaction id to a read, and a rollback does not give it back;
|
|
94
|
-
* - PUBLISH A MESSAGE — `pg_notify` is `NOTIFY` spelled as a call, and `notify`, `listen` and
|
|
95
|
-
* `unlisten` are all write keywords above. The keyword scan cannot see it: it is one token;
|
|
96
|
-
* - CONTROL THE SERVER (`pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
|
|
97
|
-
* `pg_wal_replay_*`) — the family `pg_cancel_backend`/`pg_terminate_backend` already
|
|
98
|
-
* established, in the spellings that reconfigure or fail over the server rather than a backend;
|
|
99
|
-
* - CONSUME THE REPLICATION STREAM (`pg_create_*`, `pg_drop_*`, `pg_replication_*`,
|
|
100
|
-
* `pg_logical_*`) — `pg_logical_slot_get_changes` advances a slot's confirmed position, so the
|
|
101
|
-
* changes it returned are gone for the real consumer. Exactly the `nextval` argument: a write
|
|
102
|
-
* with no keyword, and no `ROLLBACK` undoes it. The catalog VIEWS beside them
|
|
103
|
-
* (`pg_replication_slots`, `pg_stat_replication`) are read `from`, never called, so the call
|
|
104
|
-
* scan never sees them;
|
|
105
|
-
* - WRITE A FILE (`pg_file_*`) — the other half of `pg_read_*`, which was banned from the start.
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
* The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
|
|
109
|
-
* column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
|
|
110
|
-
* a form where a quoted identifier keeps its content, because `"pg_advisory_lock"(1)` is the same
|
|
111
|
-
* call as `pg_advisory_lock(1)`.
|
|
112
|
-
*/
|
|
113
|
-
const FORBIDDEN_FUNCTIONS = [
|
|
114
|
-
'dblink',
|
|
115
|
-
'lo_',
|
|
116
|
-
'nextval',
|
|
117
|
-
'pg_advisory_',
|
|
118
|
-
'pg_cancel_backend',
|
|
119
|
-
'pg_create_',
|
|
120
|
-
'pg_current_xact_id',
|
|
121
|
-
'pg_drop_',
|
|
122
|
-
'pg_file_',
|
|
123
|
-
'pg_logical_',
|
|
124
|
-
'pg_ls_',
|
|
125
|
-
'pg_notify',
|
|
126
|
-
'pg_promote',
|
|
127
|
-
'pg_read_',
|
|
128
|
-
'pg_reload_',
|
|
129
|
-
'pg_replication_',
|
|
130
|
-
'pg_rotate_',
|
|
131
|
-
'pg_sleep',
|
|
132
|
-
'pg_stat_file',
|
|
133
|
-
'pg_stat_reset',
|
|
134
|
-
// Not reachable from `pg_stat_reset`: the extension spells the same reset with the statistics
|
|
135
|
-
// view's name in the middle, so a prefix of one is not a prefix of the other.
|
|
136
|
-
'pg_stat_statements_reset',
|
|
137
|
-
'pg_switch_',
|
|
138
|
-
'pg_terminate_backend',
|
|
139
|
-
'pg_try_advisory_',
|
|
140
|
-
'pg_wal_replay_',
|
|
141
|
-
'set_config',
|
|
142
|
-
'setval',
|
|
143
|
-
'txid_current',
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
/** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
|
|
147
|
-
function forbiddenFamily(called: string): string | undefined {
|
|
148
|
-
return FORBIDDEN_FUNCTIONS.find((family) => called.startsWith(family));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* A call: an identifier immediately before `(`. Schema qualification falls out of the scan —
|
|
153
|
-
* `pg_catalog.set_config(` matches on the last segment, which is the function being called.
|
|
154
|
-
*/
|
|
155
|
-
const CALL_PATTERN = /([a-z_][a-z0-9_$]*)\s*\(/g;
|
|
156
|
-
|
|
157
|
-
/** Every function `sql` calls, lowercased. Read from the identifier-preserving strip. */
|
|
158
|
-
function calledFunctions(sql: string): readonly string[] {
|
|
159
|
-
const names: string[] = [];
|
|
160
|
-
for (const match of sql.toLowerCase().matchAll(CALL_PATTERN)) {
|
|
161
|
-
const name = match[1];
|
|
162
|
-
if (name !== undefined) names.push(name);
|
|
163
|
-
}
|
|
164
|
-
return names;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
72
|
/**
|
|
168
73
|
* Throw unless `sql` is a single read-only statement. Every check runs on the *stripped* form
|
|
169
74
|
* (literals and comments blanked) so a keyword hiding in a string cannot fool it — but the
|
|
@@ -189,7 +94,9 @@ export function assertReadOnlyQuery(sql: string): string {
|
|
|
189
94
|
}
|
|
190
95
|
|
|
191
96
|
const statement = statements[0] ?? '';
|
|
192
|
-
|
|
97
|
+
// A word is what Postgres lexes as ONE identifier — a letter, `_` or non-ASCII first, then
|
|
98
|
+
// digits and `$` too. `[a-z_]+` split `set2` at the digit and refused a column as `SET`.
|
|
99
|
+
const words: readonly string[] = statement.toLowerCase().match(WORD) ?? [];
|
|
193
100
|
const leader = words[0] ?? '';
|
|
194
101
|
if (!READ_LEADERS.has(leader)) {
|
|
195
102
|
throw rejected(
|
|
@@ -336,6 +243,15 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
|
|
|
336
243
|
continue;
|
|
337
244
|
}
|
|
338
245
|
const char = sql[i];
|
|
246
|
+
if (char === '"' && unicodeEscaped(sql, i)) {
|
|
247
|
+
// Refused, never decoded: `U&"pg\005fsleep"` is `pg_sleep` to Postgres, and a decoder here
|
|
248
|
+
// is a second reading of the lexer's escape grammar (`UESCAPE` included) that can drift.
|
|
249
|
+
throw rejected(
|
|
250
|
+
'the statement quotes an identifier with Unicode escapes (U&"…"), which this check ' +
|
|
251
|
+
'cannot read',
|
|
252
|
+
'spell the identifier plainly: select "column name" from posts',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
339
255
|
if (char === "'" || char === '"') {
|
|
340
256
|
const end = char === "'" ? skipSingleQuoted(sql, i) : skipQuoted(sql, i, char);
|
|
341
257
|
// Padded, never spliced in place: `select"pg_advisory_lock"(1)` must not fuse into one
|
|
@@ -372,8 +288,21 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
|
|
|
372
288
|
}
|
|
373
289
|
|
|
374
290
|
const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
|
|
291
|
+
const WORD = /[a-z_\u0080-\uffff][a-z0-9_$\u0080-\uffff]*/g;
|
|
375
292
|
const DOLLAR_TAG = /^\$(?:[A-Za-z_\u0080-\uffff][A-Za-z0-9_\u0080-\uffff]*)?\$/;
|
|
376
293
|
|
|
294
|
+
/**
|
|
295
|
+
* `U&"` opening at a token boundary — the lexer's `xuistart`, with no space inside. `menu&"m"` is
|
|
296
|
+
* the column `menu` and an operator, so the `u` must not continue an identifier.
|
|
297
|
+
*/
|
|
298
|
+
function unicodeEscaped(sql: string, quote: number): boolean {
|
|
299
|
+
return (
|
|
300
|
+
sql[quote - 1] === '&' &&
|
|
301
|
+
(sql[quote - 2] === 'u' || sql[quote - 2] === 'U') &&
|
|
302
|
+
!IDENTIFIER_CHAR.test(sql[quote - 3] ?? '')
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
377
306
|
/**
|
|
378
307
|
* Where a `--` comment ends: the first CR **or** LF, or the end of the input.
|
|
379
308
|
*
|
package/src/server.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { ErrorAudience } from '@ultimat3/core';
|
|
|
7
7
|
import { fixFor } from '@ultimat3/core';
|
|
8
8
|
import { auditResourceRead, auditToolCall, outcomeForResult } from './audit';
|
|
9
9
|
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
10
|
-
import { asFrameworkError } from './framework-error';
|
|
10
|
+
import { asFrameworkError, forAudience } from './framework-error';
|
|
11
11
|
import { metaCall } from './meta-call';
|
|
12
12
|
import { META_UNKNOWN_FIX } from './meta-errors';
|
|
13
13
|
import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
|
|
@@ -450,8 +450,9 @@ export class McpServer {
|
|
|
450
450
|
auditResourceRead({ uri, outcome: 'ok', caller });
|
|
451
451
|
return resultResponse(id, { contents: [contents] });
|
|
452
452
|
} catch (error) {
|
|
453
|
-
const
|
|
454
|
-
if (
|
|
453
|
+
const thrown = asFrameworkError(error);
|
|
454
|
+
if (thrown !== undefined) {
|
|
455
|
+
const framework = forAudience(thrown, this.voice.errorAudience);
|
|
455
456
|
auditResourceRead({ uri, outcome: 'failed', caller, code: framework.code });
|
|
456
457
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
457
458
|
code: framework.code,
|
package/src/transport-http.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Actor, Clock } from '@ultimat3/core';
|
|
23
23
|
import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
|
|
24
|
-
import type { RateLimitStore } from '@ultimat3/http';
|
|
24
|
+
import type { Bucket, RateLimitStore } from '@ultimat3/http';
|
|
25
25
|
import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
|
|
26
26
|
import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
|
|
27
27
|
import type { McpOAuth } from './oauth-metadata';
|
|
@@ -57,6 +57,18 @@ export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
|
|
|
57
57
|
write: 20,
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Failed authentications per minute per ADDRESS. An address past it is refused before
|
|
62
|
+
* `resolveToken` runs, so guessing tokens is bounded and a valid guess is answered the same 429.
|
|
63
|
+
*/
|
|
64
|
+
export const MCP_UNAUTHENTICATED_LIMIT = 20;
|
|
65
|
+
|
|
66
|
+
/** `rateLimits` as declared: the two per-actor classes, and the per-address failure allowance. */
|
|
67
|
+
export type McpRateLimits = Readonly<Record<McpVerbClass, number>> & {
|
|
68
|
+
/** Failed authentications per minute per address. Defaults to `MCP_UNAUTHENTICATED_LIMIT`. */
|
|
69
|
+
readonly unauthenticated?: number | undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
60
72
|
/** What a token resolves to. `null` = unauthenticated, answered 401 with no catalog. */
|
|
61
73
|
export interface ResolvedToken {
|
|
62
74
|
readonly actor: Actor;
|
|
@@ -75,8 +87,11 @@ export interface McpHttpTransportInput {
|
|
|
75
87
|
readonly path?: string;
|
|
76
88
|
/** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
|
|
77
89
|
readonly bodyLimitBytes?: number | undefined;
|
|
78
|
-
/**
|
|
79
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Requests per minute per caller, by class — defaults to `MCP_RATE_LIMITS` — and failed
|
|
92
|
+
* authentications per minute per address (`unauthenticated`, `MCP_UNAUTHENTICATED_LIMIT`).
|
|
93
|
+
*/
|
|
94
|
+
readonly rateLimits?: McpRateLimits | undefined;
|
|
80
95
|
/**
|
|
81
96
|
* Where the buckets are counted. Defaults to a per-PROCESS memory store, which is the honest
|
|
82
97
|
* default for `x mcp serve` and a lie for N replicas behind one URL — each would enforce the
|
|
@@ -100,6 +115,12 @@ export interface McpHttpTransportInput {
|
|
|
100
115
|
export interface McpRequestOrigin {
|
|
101
116
|
/** The PUBLIC origin, `https://www.example.com`. Defaults to the request URL's own. */
|
|
102
117
|
readonly origin?: string | undefined;
|
|
118
|
+
/**
|
|
119
|
+
* The caller's address as the host resolved it (a trusted proxy's `x-forwarded-for` honoured) —
|
|
120
|
+
* what failed authentications are counted against. Absent, they are not counted here: one
|
|
121
|
+
* shared key would let a stranger's failures lock every agent out.
|
|
122
|
+
*/
|
|
123
|
+
readonly address?: string | undefined;
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
/** The metadata half of an `oauth` route: where to serve it and what to answer. */
|
|
@@ -116,7 +137,7 @@ export interface McpRouteDescriptor {
|
|
|
116
137
|
/** Bucket for one already-parsed body. Metering only — never an authz decision. */
|
|
117
138
|
rateLimitClass(body: unknown): McpVerbClass;
|
|
118
139
|
/** Requests per minute per caller, by class — what `handle` actually spends against. */
|
|
119
|
-
readonly limits:
|
|
140
|
+
readonly limits: McpRateLimits;
|
|
120
141
|
/** Present exactly when the route was built with `oauth`. */
|
|
121
142
|
readonly protectedResource?: McpProtectedResource;
|
|
122
143
|
handle(request: Request, seen?: McpRequestOrigin): Promise<Response>;
|
|
@@ -144,7 +165,7 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
144
165
|
input.bodyLimitBytes ?? DEFAULT_MCP_BODY_LIMIT_BYTES,
|
|
145
166
|
0,
|
|
146
167
|
);
|
|
147
|
-
const limits = input.rateLimits ?? MCP_RATE_LIMITS;
|
|
168
|
+
const limits: McpRateLimits = input.rateLimits ?? MCP_RATE_LIMITS;
|
|
148
169
|
const clock = input.clock ?? systemClock;
|
|
149
170
|
const store = input.rateLimitStore ?? memoryRateLimitStore();
|
|
150
171
|
// Built once, at construction: `toBucket` refuses an unusable pair (`X_RATE_LIMIT_INVALID`), and
|
|
@@ -159,6 +180,20 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
159
180
|
windowMs: MCP_RATE_LIMIT_WINDOW_MS,
|
|
160
181
|
});
|
|
161
182
|
|
|
183
|
+
// A COUNT of failures, screened where it is declared: `toBucket` alone would read `NaN` as a
|
|
184
|
+
// bound only at its own check, and `??` never fires for one.
|
|
185
|
+
const unauthenticated = finiteCount(
|
|
186
|
+
'mcpHttpRoute',
|
|
187
|
+
'rateLimits.unauthenticated',
|
|
188
|
+
limits.unauthenticated ?? MCP_UNAUTHENTICATED_LIMIT,
|
|
189
|
+
1,
|
|
190
|
+
);
|
|
191
|
+
const failureBucket = toBucket('mcpHttpRoute rateLimits.unauthenticated', {
|
|
192
|
+
limit: unauthenticated,
|
|
193
|
+
windowMs: MCP_RATE_LIMIT_WINDOW_MS,
|
|
194
|
+
});
|
|
195
|
+
const failures = failureMeter(store, failureBucket, clock);
|
|
196
|
+
|
|
162
197
|
const path = input.path ?? '/mcp';
|
|
163
198
|
const oauth = input.oauth;
|
|
164
199
|
if (oauth !== undefined) assertMcpOAuth(oauth);
|
|
@@ -193,12 +228,26 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
193
228
|
// remove, and it costs nothing to close: the body is not an input to any of these.
|
|
194
229
|
const resource = resourceOf(request, seen);
|
|
195
230
|
const metadataUrl = resource === undefined ? undefined : metadataUrlFor(resource);
|
|
231
|
+
// An address that has spent its failures is refused BEFORE the credential store is asked:
|
|
232
|
+
// counting only after the fact turned a 401 into a 429 and still answered a valid guess.
|
|
233
|
+
const address = seen?.address;
|
|
234
|
+
const exhausted = await failures.refusing(address);
|
|
235
|
+
if (exhausted !== undefined) return exhausted;
|
|
196
236
|
const token = bearerToken(request);
|
|
197
|
-
if (token === null)
|
|
237
|
+
if (token === null) {
|
|
238
|
+
return (
|
|
239
|
+
(await failures.spend(address)) ??
|
|
240
|
+
unauthorized(bearerChallenge(metadataUrl, false, challengeScopes))
|
|
241
|
+
);
|
|
242
|
+
}
|
|
198
243
|
|
|
199
244
|
const resolved = await input.resolveToken(token);
|
|
200
|
-
if (resolved === null)
|
|
201
|
-
return
|
|
245
|
+
if (resolved === null) {
|
|
246
|
+
return (
|
|
247
|
+
(await failures.spend(address)) ??
|
|
248
|
+
unauthorized(bearerChallenge(metadataUrl, true, challengeScopes))
|
|
249
|
+
);
|
|
250
|
+
}
|
|
202
251
|
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
203
252
|
|
|
204
253
|
// Read through the counting reader, never `request.json()`: the cap has to be enforced
|
|
@@ -275,6 +324,31 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
275
324
|
};
|
|
276
325
|
}
|
|
277
326
|
|
|
327
|
+
/**
|
|
328
|
+
* The per-address failure allowance. `refusing` reads the bucket (`RateLimitStore.peek`, which
|
|
329
|
+
* writes nothing) and answers the 429 when not one more failure fits; `spend` charges one
|
|
330
|
+
* failure and answers the 429 when that one did not fit. `undefined` from either: carry on.
|
|
331
|
+
*/
|
|
332
|
+
function failureMeter(store: RateLimitStore, bucket: Bucket, clock: Clock) {
|
|
333
|
+
const keyOf = (address: string): string => `mcp:unauthenticated|ip:${address}`;
|
|
334
|
+
const refusal = (retryAfterSeconds: number): Response =>
|
|
335
|
+
throttled('unauthenticated', bucket.capacity, retryAfterSeconds);
|
|
336
|
+
return {
|
|
337
|
+
async refusing(address: string | undefined): Promise<Response | undefined> {
|
|
338
|
+
if (address === undefined) return undefined;
|
|
339
|
+
// READ ONLY: this runs on every request, an agent's included, and a write per call would be
|
|
340
|
+
// a Postgres upsert on the hot path of every authenticated one.
|
|
341
|
+
const peek = await store.peek(keyOf(address), bucket, clock.now().getTime());
|
|
342
|
+
return peek.remaining >= 1 ? undefined : refusal(peek.retryAfterSeconds);
|
|
343
|
+
},
|
|
344
|
+
async spend(address: string | undefined): Promise<Response | undefined> {
|
|
345
|
+
if (address === undefined) return undefined;
|
|
346
|
+
const spent = await store.take(keyOf(address), bucket, 1, clock.now().getTime());
|
|
347
|
+
return spent.allowed ? undefined : refusal(spent.retryAfterSeconds);
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
278
352
|
/** `Authorization: Bearer <token>`, the only accepted form. No query-string tokens. */
|
|
279
353
|
export function bearerToken(request: Request): string | null {
|
|
280
354
|
const header = request.headers.get('authorization');
|
|
@@ -298,7 +372,11 @@ export function isAgentActor(actor: Actor): boolean {
|
|
|
298
372
|
* call to answer. `retry-after` carries the same number the `fix:` line names, because an agent
|
|
299
373
|
* reads one of the two and must not get different answers from them.
|
|
300
374
|
*/
|
|
301
|
-
function throttled(
|
|
375
|
+
function throttled(
|
|
376
|
+
verbClass: keyof McpRateLimits,
|
|
377
|
+
limit: number,
|
|
378
|
+
retryAfterSeconds: number,
|
|
379
|
+
): Response {
|
|
302
380
|
const error = new McpRateLimitedError({ verbClass, limit, retryAfterSeconds });
|
|
303
381
|
return new Response(JSON.stringify({ code: error.code, cause: error.cause, fix: error.fix }), {
|
|
304
382
|
status: 429,
|
package/src/validate-args.ts
CHANGED
|
@@ -174,7 +174,8 @@ export const compiledPatternCount = (): number => compilations;
|
|
|
174
174
|
* A pattern this server cannot COMPILE is refused, not skipped. `tools/list` published it, so an
|
|
175
175
|
* agent has already been told the rule — passing a call the server cannot check is the silent-pass
|
|
176
176
|
* this whole module exists to prevent. Every framework-projected pattern is a `RegExp.source` and
|
|
177
|
-
* compiles; only a hand-written tool can reach the second branch, and that is its
|
|
177
|
+
* its own flags and compiles; only a hand-written tool can reach the second branch, and that is its
|
|
178
|
+
* author's bug.
|
|
178
179
|
*/
|
|
179
180
|
function matchesPattern(
|
|
180
181
|
schema: JsonSchema,
|
|
@@ -183,21 +184,28 @@ function matchesPattern(
|
|
|
183
184
|
path: string,
|
|
184
185
|
issues: ArgIssue[],
|
|
185
186
|
): void {
|
|
187
|
+
// The flags `@ultimat3/schema` published beside the source. Without them `/^[a-z]+$/i` refused
|
|
188
|
+
// `ABC` and `/^\p{L}+$/u` refused `é` here, while the action's own parse accepted both.
|
|
189
|
+
const flags = schema['x-ultimate-pattern-flags'] ?? '';
|
|
190
|
+
const shown = flags === '' ? pattern : `/${pattern}/${flags}`;
|
|
186
191
|
let compiled = compiledPatterns.get(schema);
|
|
187
192
|
if (compiled === undefined) {
|
|
188
193
|
compilations += 1;
|
|
189
194
|
try {
|
|
190
|
-
compiled = new RegExp(pattern);
|
|
195
|
+
compiled = new RegExp(pattern, flags);
|
|
191
196
|
} catch {
|
|
192
197
|
compiled = null;
|
|
193
198
|
}
|
|
194
199
|
compiledPatterns.set(schema, compiled);
|
|
195
200
|
}
|
|
196
201
|
if (compiled === null) {
|
|
197
|
-
issues.push({ path, message: `declares a pattern this server cannot compile: ${
|
|
202
|
+
issues.push({ path, message: `declares a pattern this server cannot compile: ${shown}` });
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
// A `g` or `y` RegExp carries `lastIndex` between calls, and this one is cached per node: the
|
|
206
|
+
// second `.test()` of an accepted value would answer false. Reset, as the schema's tester does.
|
|
207
|
+
compiled.lastIndex = 0;
|
|
208
|
+
if (!compiled.test(input)) issues.push({ path, message: `must match ${shown}` });
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
function number(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
|
package/src/wire.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Reference: https://modelcontextprotocol.io/specification (2025-06-18).
|
|
7
7
|
|
|
8
8
|
import { frameworkVersion } from '@ultimat3/core';
|
|
9
|
+
import type { WireJsonSchema } from '@ultimat3/schema';
|
|
9
10
|
|
|
10
11
|
/** MCP protocol version advertised on `initialize`. */
|
|
11
12
|
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
@@ -62,47 +63,10 @@ export const INVALID_PARAMS = -32602;
|
|
|
62
63
|
export const INTERNAL_ERROR = -32603;
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* The JSON Schema subset
|
|
66
|
-
*
|
|
66
|
+
* The JSON Schema subset a tool publishes and `validate-args.ts` enforces — `@ultimat3/schema`'s
|
|
67
|
+
* `WireJsonSchema`, declared there so `@ultimat3/action`'s `.tool()` speaks the same one.
|
|
67
68
|
*/
|
|
68
|
-
export
|
|
69
|
-
readonly type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'null';
|
|
70
|
-
readonly title?: string;
|
|
71
|
-
readonly description?: string;
|
|
72
|
-
readonly properties?: Readonly<Record<string, JsonSchema>>;
|
|
73
|
-
readonly required?: readonly string[];
|
|
74
|
-
readonly additionalProperties?: boolean;
|
|
75
|
-
readonly items?: JsonSchema;
|
|
76
|
-
readonly enum?: readonly (string | number | boolean | null)[];
|
|
77
|
-
readonly const?: string | number | boolean | null;
|
|
78
|
-
readonly default?: unknown;
|
|
79
|
-
/**
|
|
80
|
-
* NOT in the subset, and deliberately absent: `format` NAMES a rule whose meaning lives in
|
|
81
|
-
* `@ultimat3/schema` (`uuid`, `email`, `iana-time-zone`, `bcp47-locale`), and this package
|
|
82
|
-
* cannot enforce it without a second definition of each one — which would drift from the parse
|
|
83
|
-
* the action itself runs and refuse values that surface accepts. So it is dropped by
|
|
84
|
-
* `input-schema.ts` rather than published unchecked: an agent told `format: 'uuid'` by a server
|
|
85
|
-
* that accepted `"not-a-uuid"` obeyed a rule nothing checked. `pattern` below is the opposite
|
|
86
|
-
* case — the rule travels WITH the schema as a source string, so it is enforceable and is kept.
|
|
87
|
-
*
|
|
88
|
-
* `readonly format?: never;` states it in the type: a narrow() that starts copying `format`
|
|
89
|
-
* again fails to compile rather than shipping a silent pass.
|
|
90
|
-
*/
|
|
91
|
-
readonly format?: never;
|
|
92
|
-
readonly minimum?: number;
|
|
93
|
-
readonly maximum?: number;
|
|
94
|
-
readonly minLength?: number;
|
|
95
|
-
readonly maxLength?: number;
|
|
96
|
-
/**
|
|
97
|
-
* A `RegExp.source` string, JSON Schema semantics (a partial match unless it anchors itself).
|
|
98
|
-
* In the subset because `validate-args.ts` enforces it: without it `tools/list` published
|
|
99
|
-
* `{minLength,maxLength}` for a field whose OpenAPI component carried the pattern, so an HTTP
|
|
100
|
-
* client and an MCP agent held different contracts for one declaration and the agent's only
|
|
101
|
-
* way to learn the format was `X_INPUT_INVALID` from the action's own parse.
|
|
102
|
-
*/
|
|
103
|
-
readonly pattern?: string;
|
|
104
|
-
readonly anyOf?: readonly JsonSchema[];
|
|
105
|
-
}
|
|
69
|
+
export type JsonSchema = WireJsonSchema;
|
|
106
70
|
|
|
107
71
|
/** An empty-object schema — the honest shape for a no-argument tool. */
|
|
108
72
|
export const NO_ARGS: JsonSchema = { type: 'object', properties: {}, additionalProperties: false };
|
package/src/input-schema.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// A Standard Schema (`t.object({...})`, Zod, Valibot) -> the JSON Schema subset the wire speaks.
|
|
2
|
-
//
|
|
3
|
-
// Two `JsonSchema` types exist on purpose. `@ultimat3/schema` emits the full draft-07 vocabulary;
|
|
4
|
-
// `wire.ts` declares the narrow subset `validate-args.ts` can actually ENFORCE. Handing an agent a
|
|
5
|
-
// keyword the server ignores is worse than omitting it — the agent obeys a rule nothing checks and
|
|
6
|
-
// gets a silent pass. So this is a real projection, not a cast: a keyword outside the subset is
|
|
7
|
-
// dropped here, and `tools/list` publishes only what the resolver will hold a call to.
|
|
8
|
-
//
|
|
9
|
-
// `format` is the keyword that rule is easiest to get wrong on: it is expressive, and it is a NAME
|
|
10
|
-
// whose meaning lives in `@ultimat3/schema` — enforcing it here would be a second definition of
|
|
11
|
-
// `uuid`/`email`/`iana-time-zone` that can only drift from the parse the action itself runs. So it
|
|
12
|
-
// is dropped, and `wire.ts` types it `never` so re-adding it does not compile. `pattern` is kept
|
|
13
|
-
// for the mirror-image reason: the rule travels with the schema, so this server can hold a call
|
|
14
|
-
// to it. A tool needing a format enforced declares a `pattern` beside it.
|
|
15
|
-
|
|
16
|
-
import type { JsonSchema as RichJsonSchema } from '@ultimat3/schema';
|
|
17
|
-
import { toMcpInputSchema } from '@ultimat3/schema';
|
|
18
|
-
import type { JsonSchema } from './wire';
|
|
19
|
-
|
|
20
|
-
/** Introspect any Standard Schema and narrow the result to the wire subset. */
|
|
21
|
-
export function toWireSchema(schema: unknown): JsonSchema {
|
|
22
|
-
return narrow(toMcpInputSchema(schema));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function narrow(source: RichJsonSchema): JsonSchema {
|
|
26
|
-
// exactOptionalPropertyTypes: every field is attached only when it is present, never as
|
|
27
|
-
// an explicit `undefined` — `tools/list` serialises this object verbatim.
|
|
28
|
-
return {
|
|
29
|
-
...(source.type === undefined ? {} : { type: source.type }),
|
|
30
|
-
...(source.title === undefined ? {} : { title: source.title }),
|
|
31
|
-
...(source.description === undefined ? {} : { description: source.description }),
|
|
32
|
-
...(source.properties === undefined ? {} : { properties: narrowProperties(source.properties) }),
|
|
33
|
-
...(source.required === undefined ? {} : { required: source.required }),
|
|
34
|
-
...narrowAdditional(source.additionalProperties),
|
|
35
|
-
...(source.items === undefined ? {} : { items: narrow(source.items) }),
|
|
36
|
-
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
37
|
-
...(source.const === undefined ? {} : { const: source.const }),
|
|
38
|
-
...(source.default === undefined ? {} : { default: source.default }),
|
|
39
|
-
...(source.minimum === undefined ? {} : { minimum: source.minimum }),
|
|
40
|
-
...(source.maximum === undefined ? {} : { maximum: source.maximum }),
|
|
41
|
-
...(source.minLength === undefined ? {} : { minLength: source.minLength }),
|
|
42
|
-
...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
|
|
43
|
-
...(source.pattern === undefined ? {} : { pattern: source.pattern }),
|
|
44
|
-
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function narrowProperties(
|
|
49
|
-
properties: Readonly<Record<string, RichJsonSchema>>,
|
|
50
|
-
): Readonly<Record<string, JsonSchema>> {
|
|
51
|
-
const out: Record<string, JsonSchema> = {};
|
|
52
|
-
for (const [key, child] of Object.entries(properties)) {
|
|
53
|
-
// `out[key] = …` is not an assignment for exactly one name — `__proto__` runs
|
|
54
|
-
// `Object.prototype`'s setter and RE-PROTOTYPES this record instead of adding a key, so a
|
|
55
|
-
// field an author declared disappears from `tools/list` and from what `validate-args.ts`
|
|
56
|
-
// reads back. The twin of `validate-args.ts`'s `put`, on the schema side.
|
|
57
|
-
Object.defineProperty(out, key, {
|
|
58
|
-
value: narrow(child),
|
|
59
|
-
writable: true,
|
|
60
|
-
enumerable: true,
|
|
61
|
-
configurable: true,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
return out;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* A record schema says "extra keys, shaped like this". The wire subset can only say yes or no,
|
|
69
|
-
* and `validate-args.ts` reads `false` as "reject unknown keys" — so an unrepresentable value
|
|
70
|
-
* schema becomes `true` (permit) rather than a rejection the agent was never warned about.
|
|
71
|
-
*/
|
|
72
|
-
function narrowAdditional(value: boolean | RichJsonSchema | undefined): {
|
|
73
|
-
readonly additionalProperties?: boolean;
|
|
74
|
-
} {
|
|
75
|
-
if (value === undefined) return {};
|
|
76
|
-
return { additionalProperties: typeof value === 'boolean' ? value : true };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* A Standard Schema → the `outputSchema` a tool publishes, or `undefined` when its root is not an
|
|
81
|
-
* object (MCP's `structuredContent` is one) or it cannot be introspected.
|
|
82
|
-
*
|
|
83
|
-
* STRUCTURE ONLY — type, properties, required, items, enum, const, anyOf, title, description. A
|
|
84
|
-
* client validates `structuredContent` against this document and refuses the call on a miss, so
|
|
85
|
-
* every keyword here is a promise about a value the server produced: a bound, a pattern or
|
|
86
|
-
* `additionalProperties: false` on a query's rows (read from the database, never re-parsed) is a
|
|
87
|
-
* promise nothing enforces on the way out. Fewer constraints is the honest projection of an output.
|
|
88
|
-
*/
|
|
89
|
-
export function toOutputSchema(schema: unknown): JsonSchema | undefined {
|
|
90
|
-
let rich: RichJsonSchema;
|
|
91
|
-
try {
|
|
92
|
-
rich = toMcpInputSchema(schema);
|
|
93
|
-
} catch {
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
if (rich.type !== 'object') return undefined;
|
|
97
|
-
return shape(rich);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** `{ rows: [<row>] }` — a query's answer as `structuredContent`. `undefined` when no row schema. */
|
|
101
|
-
export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
|
|
102
|
-
if (rows === undefined) return undefined;
|
|
103
|
-
const row = toOutputSchema(rows);
|
|
104
|
-
if (row === undefined) return undefined;
|
|
105
|
-
return {
|
|
106
|
-
type: 'object',
|
|
107
|
-
properties: { rows: { type: 'array', items: row } },
|
|
108
|
-
required: ['rows'],
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function shape(source: RichJsonSchema): JsonSchema {
|
|
113
|
-
return {
|
|
114
|
-
...(source.type === undefined ? {} : { type: source.type }),
|
|
115
|
-
...(source.title === undefined ? {} : { title: source.title }),
|
|
116
|
-
...(source.description === undefined ? {} : { description: source.description }),
|
|
117
|
-
...(source.properties === undefined ? {} : { properties: shapeProperties(source.properties) }),
|
|
118
|
-
...(source.required === undefined ? {} : { required: source.required }),
|
|
119
|
-
...(source.items === undefined ? {} : { items: shape(source.items) }),
|
|
120
|
-
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
121
|
-
...(source.const === undefined ? {} : { const: source.const }),
|
|
122
|
-
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(shape) }),
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function shapeProperties(
|
|
127
|
-
properties: Readonly<Record<string, RichJsonSchema>>,
|
|
128
|
-
): Readonly<Record<string, JsonSchema>> {
|
|
129
|
-
const out: Record<string, JsonSchema> = {};
|
|
130
|
-
for (const [key, child] of Object.entries(properties)) {
|
|
131
|
-
// `narrowProperties`' reason: `out['__proto__'] = …` re-prototypes instead of adding a key.
|
|
132
|
-
Object.defineProperty(out, key, {
|
|
133
|
-
value: shape(child),
|
|
134
|
-
writable: true,
|
|
135
|
-
enumerable: true,
|
|
136
|
-
configurable: true,
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
return out;
|
|
140
|
-
}
|