@ultimat3/mcp 1.2.0 → 3.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 +183 -0
- package/README.md +18 -4
- package/package.json +9 -8
- package/src/dev-host.ts +2 -2
- package/src/errors.ts +33 -3
- package/src/from-action.ts +27 -9
- package/src/index.ts +9 -2
- package/src/input-schema.ts +1 -0
- package/src/projectable.ts +5 -4
- package/src/query-limits.ts +14 -2
- package/src/readonly-sql.ts +81 -7
- package/src/registry.ts +27 -9
- package/src/resources.ts +8 -0
- package/src/server.ts +17 -9
- package/src/transport-http.ts +38 -10
- package/src/validate-args.ts +36 -4
- package/src/wire.ts +17 -3
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# @ultimat3/mcp — boundary
|
|
2
|
+
|
|
3
|
+
Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
|
|
4
|
+
query jobs realtime`. **Never** `render manifest ai pwa ui admin testing cli`.
|
|
5
|
+
|
|
6
|
+
Same-tier data (routes, manifest, policy catalog) arrives as an **injected thunk**, never an
|
|
7
|
+
import. The CLI wires it.
|
|
8
|
+
|
|
9
|
+
## Owns
|
|
10
|
+
|
|
11
|
+
| File | Job |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `wire.ts` | JSON-RPC types, error codes, protocol version, `JsonSchema` subset |
|
|
14
|
+
| `registry.ts` | catalog + the first two security outcomes (visibility, scope) |
|
|
15
|
+
| `audit.ts` | one structured line per `tools/call`, outcome → level |
|
|
16
|
+
| `validate-args.ts` | JSON-Schema-subset arg validation, applies defaults |
|
|
17
|
+
| `server.ts` | JSON-RPC dispatch, `classify` for rate-limit buckets |
|
|
18
|
+
| `from-action.ts` | action/query → tool; the "one authz system" projection; `toolsFrom` (sweep, skips) vs `toolsListed` (written out, refuses) |
|
|
19
|
+
| `resources.ts` | resources + prompts, stable `ultimate://` URIs |
|
|
20
|
+
| `dev-server.ts` | the 13 dev tools; depends only on an injected `DevHost` |
|
|
21
|
+
| `dev-host.ts` | wires `describe*` from entity/action/query/jobs into a `DevHost` |
|
|
22
|
+
| `transport-http.ts` | `POST /mcp` route descriptor, bearer → agent actor |
|
|
23
|
+
| `transport-stdio.ts` | NDJSON on stdin/stdout for `x mcp serve` |
|
|
24
|
+
| `app-tools.ts` | `defineAppMcp` — a generated app's own MCP surface, one call |
|
|
25
|
+
| `app-tool.ts` | the authored `tools: { name: {...} }` record → `ProjectablePrimitive` |
|
|
26
|
+
| `projectable.ts` | a real `action`/`query` → `ProjectablePrimitive`; the ONE adapter both the sweep and the written-out list use |
|
|
27
|
+
| `exposed.ts` | `include: 'exposed'` — the action/query registries → primitives |
|
|
28
|
+
| `scopes.ts` | the `scopes:` map — outcome 2's declaration surface; boot-time refusal of an unknown or doubly-claimed tool |
|
|
29
|
+
| `input-schema.ts` | Standard Schema → the `JsonSchema` subset `validate-args.ts` enforces |
|
|
30
|
+
| `readonly-sql.ts` | layer 3 of `db.query` — the single-read parse — and `db.migrate`'s branch check |
|
|
31
|
+
| `query-limits.ts` | layer 4 of `db.query` — the row, byte and timeout ceilings, and what truncation reports |
|
|
32
|
+
|
|
33
|
+
## Invariants
|
|
34
|
+
|
|
35
|
+
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim**, so a `defineAppMcp` file
|
|
36
|
+
imports one package. Never wrap, spread or re-declare it: `t` delegates to `schemaProvider()` on
|
|
37
|
+
every access, and a copy would freeze the provider at import time. `index.test.ts` asserts identity.
|
|
38
|
+
- Three outcomes, never blurred: role-hidden → `-32601` ToolNotFound with no `data`;
|
|
39
|
+
scope → `-32600` `X_MCP_SCOPE_DENIED` naming the scope; policy → an `isError` result
|
|
40
|
+
carrying `X_FORBIDDEN`. Swapping any two is an enumeration oracle.
|
|
41
|
+
- `visibleTo` is **fail-closed** three ways: a role list admits only the roles it names (a
|
|
42
|
+
caller with no role matches none), a predicate must return the literal `true`, and a
|
|
43
|
+
predicate that THROWS hides the tool. A predicate takes the caller — never the arguments —
|
|
44
|
+
so existence cannot be probed by varying input. `tools/list` is answered per caller: one
|
|
45
|
+
`McpCaller` per HTTP request, one per stdio connection.
|
|
46
|
+
- `visibleTo` declared in a primitive's `mcp` block is carried through `exposed.ts`'s
|
|
47
|
+
`exposureOf` to the projected tool — outcome 1's only declaration surface for a projected
|
|
48
|
+
primitive. Dropping it there silently disables outcome 1 for every projected tool: nothing
|
|
49
|
+
fails, every caller simply sees every tool.
|
|
50
|
+
- Resolve order is visibility → scope → args → policy. Validating first leaks a schema;
|
|
51
|
+
running the policy first decides a refusal from attacker-supplied input.
|
|
52
|
+
- **A key's membership of a declared schema is `Object.hasOwn(properties, key)`, never
|
|
53
|
+
`properties[key] === undefined`.** The arguments of a `tools/call` are NAMED by the caller, and
|
|
54
|
+
`Object.prototype` supplies a value for `constructor`, `toString`, `hasOwnProperty` and
|
|
55
|
+
`__proto__` on every plain object — so the index read answered "declared" for four names no
|
|
56
|
+
schema declares, and `validate-args.ts` accepted them past an `additionalProperties: false` that
|
|
57
|
+
forbids them and then dropped them. Third instance of the class in the framework, after
|
|
58
|
+
`@ultimat3/i18n`'s catalog lookup and `@ultimat3/schema`'s `coerce`. Its twin: a validated key
|
|
59
|
+
lands on the result through `Object.defineProperty`, because `out[key] = v` for `__proto__` runs
|
|
60
|
+
the setter on `Object.prototype` and re-prototypes the record instead of adding a key.
|
|
61
|
+
- **Anything a tool RETURNS is rendered totally.** `jsonResult` is handed an action's own return
|
|
62
|
+
value, and `JSON.stringify` answers `undefined` for a handler that returned nothing (a
|
|
63
|
+
`ContentBlock.text` that is not a string is an invalid frame) and THROWS on a bigint, a cycle or
|
|
64
|
+
a `toJSON` the value carries. Unreadable is an ordinary `isError` result, never an escape past
|
|
65
|
+
`server.ts`'s catch, which would report a bug in the tool for a fault in the rendering.
|
|
66
|
+
`query-limits.ts`' `rowBytes` holds the same line one layer earlier: a row the driver decoded
|
|
67
|
+
into a bigint costs `Infinity` and is cut by the byte ceiling, rather than raising inside the cap
|
|
68
|
+
that exists to protect the answer.
|
|
69
|
+
- **A thrown value is read with `stringField` from `@ultimat3/core`, never `typeof e.code ===
|
|
70
|
+
'string'`.** `asFrameworkError` reads four fields off whatever an app's handler threw; each read
|
|
71
|
+
is a getter call, or a `Proxy` trap, inside the catch block that owes the caller a response — and
|
|
72
|
+
a probe that raises there leaves the JSON-RPC request with no answer at all, not even the
|
|
73
|
+
`-32603` the transport promises for a genuine bug.
|
|
74
|
+
- A framework error rendered into a tool result is **byte-identical to
|
|
75
|
+
`UltimateError.format()`** — one denial must not read one way over MCP and another in the
|
|
76
|
+
terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
|
|
77
|
+
- Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments
|
|
78
|
+
or row data — a denial reason naming a row is a leak wearing an audit line's clothes.
|
|
79
|
+
- `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
|
|
80
|
+
above — the first over hand-built tools (each gate in isolation), the second over what an app
|
|
81
|
+
actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
|
|
82
|
+
weaken them. A gate can only refuse what a declaration can reach, so a new gate needs a test
|
|
83
|
+
in BOTH: the registry half passes while the declaration surface silently drops the field.
|
|
84
|
+
- **`isExposed` and `exposureOf` both delegate to `isMcpExposed` in `@ultimat3/core`.** That is the
|
|
85
|
+
framework's one answer to "did this opt in?", shared with `action`, `query`, `ai` and `manifest`
|
|
86
|
+
— five packages that cannot import each other, which is how three spellings of the check shipped
|
|
87
|
+
and why the pin lives in `@ultimat3/cli`. Never spell `=== true` inline here again.
|
|
88
|
+
- Exposure is declared at the primitive, never in `defineAppMcp`. A primitive NAMED in
|
|
89
|
+
`actions:`/`queries:` without `mcp: { expose: true }` is `X_MCP_TOOL_UNDECLARED` at boot —
|
|
90
|
+
a written-out list is a request, so filtering it would ship a catalog missing a tool its
|
|
91
|
+
author believes is there. `include: 'exposed'` sweeps the registries and therefore skips,
|
|
92
|
+
because that list is every primitive the app registered, not one anyone wrote out.
|
|
93
|
+
`actions:` and `queries:` go through **one** `toolsListed` call over the concatenation: it
|
|
94
|
+
collects every offender before throwing, so one boot names all of them and one edit closes
|
|
95
|
+
all of them. Two calls would throw on the first array and never examine the second.
|
|
96
|
+
- `actions:`/`queries:` take the **real primitives** (`actions: [publishPost]`), adapted by
|
|
97
|
+
`projectable.ts` into the same `ProjectablePrimitive` the registry sweep builds. They took
|
|
98
|
+
`ProjectablePrimitive` alone until 2026-08, which no `action()` or `query()` satisfies — they
|
|
99
|
+
carry `as`/`tool`, never `run` — so listing one was a TS2741 and the only value that could
|
|
100
|
+
reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake. A gate that no declaration can reach
|
|
101
|
+
refuses nothing. `ProjectablePrimitive` stays in the union for surfaces that build a catalog
|
|
102
|
+
programmatically (`@ultimat3/admin`); `isAction`/`isQuery` read each package's private
|
|
103
|
+
declaration store, so a look-alike falls through instead of borrowing `invoke`.
|
|
104
|
+
- The adapter is **one function with two callers**, never a copy per route: the written-out list
|
|
105
|
+
and `include: 'exposed'` land on the same `run` — `invoke` for an action, `sourceFor` for a
|
|
106
|
+
query. Writing a primitive out NAMES a tool; it never re-shapes or re-runs one. An action
|
|
107
|
+
with no export name is `X_ACTION_UNREGISTERED` rather than a tool called `''`, which no
|
|
108
|
+
`tools/call` and no `scopes:` entry could ever address.
|
|
109
|
+
- **This package NAMES a tool, it never derives one.** `primitive.mcp?.name ?? primitive.name` in
|
|
110
|
+
`from-action.ts` is the whole rule, fed the verbatim export name by `projectable.ts` — a
|
|
111
|
+
transform here would be a second spelling of a name that is already an addressable identity.
|
|
112
|
+
Every surface that PUBLISHES the name owes the same string: `action.tool()`, `query.tool()`,
|
|
113
|
+
`x-ultimate.mcpTool`, `ActionDescriptor.mcp.tool`. The three action publishers snake_cased it
|
|
114
|
+
through `toToolName` until 2026-08, so a spec-reading agent called `publish_post` and got
|
|
115
|
+
`-32601` from a catalog holding `publishPost`, and nothing noticed because no test compared the
|
|
116
|
+
served name to a published one. `cross-surface.test.ts` is that comparison — it reads the
|
|
117
|
+
catalog off `tools/list` and drives a `tools/call` with the name OpenAPI published, so a
|
|
118
|
+
publisher that re-derives is a failing test and not a wiki note.
|
|
119
|
+
- Every boot-time refusal in `defineAppMcp` is an `UltimateError` with a code, never a bare
|
|
120
|
+
throw: `X_MCP_TOOL_UNDECLARED`, `X_MCP_TOOL_UNSAFE`, `X_MCP_TOOL_DUPLICATE`,
|
|
121
|
+
`X_MCP_SCOPE_UNKNOWN`, `X_MCP_SCOPE_CONFLICT`. The caller reading them is usually an agent
|
|
122
|
+
that needs `{ code, cause, fix }`.
|
|
123
|
+
- `scopes:` is refused at boot two ways, both because the alternative ships a tool silently
|
|
124
|
+
ungated: a name no projected tool answers to is `X_MCP_SCOPE_UNKNOWN` (a typo, a rename, a
|
|
125
|
+
primitive never listed); one tool claimed by two scopes is `X_MCP_SCOPE_CONFLICT` — a tool
|
|
126
|
+
carries exactly one, and object key order is not a security model.
|
|
127
|
+
- The **projection** invents no `scope` — `toolFromAction` cannot know what a token means.
|
|
128
|
+
`defineAppMcp`'s `scopes:` may attach one afterward, as a capability of the CONNECTION; that
|
|
129
|
+
is not a second authz path, because the scope gate decides before the policy runs and never
|
|
130
|
+
reads the input. A hand-written app tool is the same: its `policy` reaches `guard()` from
|
|
131
|
+
`@ultimat3/action`, which is the one authz path that reads the input — never a second check
|
|
132
|
+
written for MCP.
|
|
133
|
+
- **A URI is taken once.** `ResourceRegistry.register` throws `X_MCP_RESOURCE_DUPLICATE` on a
|
|
134
|
+
second claim, exactly as `ToolRegistry.register` throws on a second tool name — one package
|
|
135
|
+
cannot answer "this name is taken" two ways. `Map.set` made the answer whichever provider was
|
|
136
|
+
wired last, and a `ultimate://` URI is quoted in AGENTS.md files.
|
|
137
|
+
- **`\'` inside a string literal is refused** (`readonly-sql.ts`). `E'\''` is one quote to
|
|
138
|
+
Postgres and `standard_conforming_strings` decides the plain spelling, so the two readings
|
|
139
|
+
disagree about where the string ends — which is exactly where a `;` hides.
|
|
140
|
+
`select E'\'' ; drop table posts --'` was accepted as one read-only statement and handed back
|
|
141
|
+
verbatim to run. Only inside a single-quoted run: a comment, a `$tag$` body and a quoted
|
|
142
|
+
identifier are unambiguous and still read fine.
|
|
143
|
+
- **An unterminated run is refused, never swallowed** (`readonly-sql.ts`). The stripper blanks what
|
|
144
|
+
it believes is inside a literal, so an opening delimiter that never closes hid the whole tail:
|
|
145
|
+
`select '; delete from members` counted one statement with no mutating keyword and was handed
|
|
146
|
+
back to run. All four forms now throw `X_MCP_QUERY_REJECTED` — `'`, `"`, `$tag$`, `/* */`. Not
|
|
147
|
+
exploitable through Postgres (a syntax error either way); the point is that this layer must not
|
|
148
|
+
be the thing that waves it through. `@ultimat3/admin`'s `/_x` panel failed CLOSED here where this
|
|
149
|
+
failed open, which is how it was found — a second, differently-behaved copy of one rule.
|
|
150
|
+
- `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
|
|
151
|
+
(`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
|
|
152
|
+
next commands).
|
|
153
|
+
- Banned SQL functions are matched as a **prefix of a CALLED function name**, so the family is the
|
|
154
|
+
unit and a spelling nobody wrote down is refused rather than admitted — an exact-name list let
|
|
155
|
+
`pg_sleep_for` past a ban on `pg_sleep`, and `set_config` past `SET`, which is already a write
|
|
156
|
+
keyword. Add a family, never a name. The unit is the call (`name` before `(`), never a bare word:
|
|
157
|
+
a word scan refused a column named `pg_sleep_for_seconds`. The call scan reads a strip that KEEPS
|
|
158
|
+
quoted-identifier content, because `"pg_advisory_lock"(1)` is the same call as the bare spelling —
|
|
159
|
+
the keyword scan still reads the blanked form, so `select "update" from t` stays a column. Two of the families exist because the same ban is already
|
|
160
|
+
made elsewhere in another spelling: `pg_advisory_*` is `FOR UPDATE`'s ban and the worse breach
|
|
161
|
+
(a session lock survives layer 2's `ROLLBACK`, so it outlives the read on a pooled connection —
|
|
162
|
+
proved live in `packages/testing/src/db-integration.test.ts`), and `pg_sleep*` is the one ban
|
|
163
|
+
that still holds on embedded PGlite, whose single WASM thread cannot honour a statement timeout.
|
|
164
|
+
- `db.query` is defended four ways: a SELECT-only role and `BEGIN READ ONLY` in `@ultimat3/db`
|
|
165
|
+
(the CLI wires them — this package must never import `db`), the parse here, and the caps here.
|
|
166
|
+
`limit` is a request, never a permission: `resolveQueryLimits` clamps it into a hard 1000.
|
|
167
|
+
- The caps run in the **tool**, not the host. A host that forgets them answers a million rows
|
|
168
|
+
into a model's context. `guards` names the layers that engaged; a layer that could not engage
|
|
169
|
+
is absent from the list, never assumed present.
|
|
170
|
+
- **Every authentication answer lands before `request.json()`.** A missing token, a token
|
|
171
|
+
`resolveToken` rejects and a non-agent actor all return before the body is read: parsing first
|
|
172
|
+
answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
|
|
173
|
+
SAME rejected token, which is precisely the oracle the pre-parse 401 exists to remove. The parse
|
|
174
|
+
error still exists — it is what an authenticated agent gets.
|
|
175
|
+
- `transport-stdio.ts` never writes stdout except the wire. Diagnostics → stderr.
|
|
176
|
+
- New mutating tool ⇒ set `destructive: true`, or it is metered as cheap read chatter.
|
|
177
|
+
|
|
178
|
+
## Commands
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
bun test packages/mcp
|
|
182
|
+
bun run --filter @ultimat3/mcp typecheck
|
|
183
|
+
```
|
package/README.md
CHANGED
|
@@ -40,12 +40,14 @@ the list**, never assumed. Truncation is never silent.
|
|
|
40
40
|
## One authz system, two surfaces
|
|
41
41
|
|
|
42
42
|
Every `action` with `mcp: { expose: true }` becomes a tool for free, and the tool's `handle`
|
|
43
|
-
|
|
43
|
+
reaches the **same `invoke`** the HTTP route reaches — the projection's `run` is that call with
|
|
44
|
+
`surface: 'mcp'`, nothing more. Policy evaluation lives inside `invoke`. (An action has no `.run`
|
|
45
|
+
member; `run` is the projection seam, and a query's half of it is `sourceFor`.)
|
|
44
46
|
|
|
45
47
|
```
|
|
46
|
-
HTTP POST /api/
|
|
47
|
-
|
|
48
|
-
MCP tools/call publishPost
|
|
48
|
+
HTTP POST /api/posts/publish ─┐
|
|
49
|
+
├─→ invoke(action, input, { surface, actor }) ─→ policy ─→ handler
|
|
50
|
+
MCP tools/call publishPost ─┘
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
`mcp: { visibleTo: [...] }` on the action or query travels with the projection too — the only
|
|
@@ -142,6 +144,17 @@ One adapter serves both routes, so a written-out primitive runs through the same
|
|
|
142
144
|
An action that was never handed to `defineApi` has no export name, and is
|
|
143
145
|
`X_ACTION_UNREGISTERED` rather than a tool called `''` that nothing could call.
|
|
144
146
|
|
|
147
|
+
**The tool name is the export name, verbatim** — `publishPost`, never `publish_post`. This server
|
|
148
|
+
answers `tools/call` for that name and no other, so every surface that PUBLISHES a name has to
|
|
149
|
+
publish the same one: `action.tool()`, `query.tool()`, `x-ultimate.mcpTool` in `openapi.json`, and
|
|
150
|
+
`ActionDescriptor.mcp.tool`. The projection reads `primitive.mcp?.name ?? primitive.name`, so the
|
|
151
|
+
export name is the **default** and `mcp.name` is an explicit override — unreachable from `action()`
|
|
152
|
+
or `query()`, whose declarations carry no `name` field, and available only to a hand-authored
|
|
153
|
+
`ProjectablePrimitive` passed to `defineAppMcp`'s `tools:`. The three action publishers snake_cased
|
|
154
|
+
the name `As of 2026-08`, so an agent reading the spec called a tool the catalog never contained and
|
|
155
|
+
got ToolNotFound.
|
|
156
|
+
`src/cross-surface.test.ts` is what makes a fourth spelling a failing test rather than a note.
|
|
157
|
+
|
|
145
158
|
A hand-written tool's `policy` is a permission, evaluated through the same `guard()` an
|
|
146
159
|
HTTP request goes through, so a tool cannot acquire a second authz path. A tool without one
|
|
147
160
|
is `X_MCP_TOOL_UNSAFE` at boot, and an unmarked tool is metered as a write.
|
|
@@ -200,3 +213,4 @@ inside their own handler.
|
|
|
200
213
|
| `X_MCP_PROTOCOL` | malformed envelope, unknown method, bad auth header |
|
|
201
214
|
| `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
|
|
202
215
|
| `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
|
|
216
|
+
| `X_MCP_RESOURCE_DUPLICATE` | two resources claim one `ultimate://` URI — refused at registration, as a duplicate tool name is |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "MCP server, dev tools, and the action-to-tool projection — 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
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,12 +31,12 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/action": "
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/entity": "
|
|
36
|
-
"@ultimat3/jobs": "
|
|
37
|
-
"@ultimat3/policy": "
|
|
38
|
-
"@ultimat3/query": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/action": "3.0.0",
|
|
35
|
+
"@ultimat3/core": "3.0.0",
|
|
36
|
+
"@ultimat3/entity": "3.0.0",
|
|
37
|
+
"@ultimat3/jobs": "3.0.0",
|
|
38
|
+
"@ultimat3/policy": "3.0.0",
|
|
39
|
+
"@ultimat3/query": "3.0.0",
|
|
40
|
+
"@ultimat3/schema": "3.0.0"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/dev-host.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// primitive registries.
|
|
5
5
|
|
|
6
6
|
import { describeActions } from '@ultimat3/action';
|
|
7
|
-
import {
|
|
7
|
+
import { frameworkVersion } from '@ultimat3/core';
|
|
8
8
|
import { describeEntities } from '@ultimat3/entity';
|
|
9
9
|
import { describeJobs, inspectJob, jobDriver } from '@ultimat3/jobs';
|
|
10
10
|
import { describeQueries } from '@ultimat3/query';
|
|
@@ -49,7 +49,7 @@ export function createDevServer(input: CreateDevServerInput): McpServer {
|
|
|
49
49
|
return createMcpServer({
|
|
50
50
|
tools: devTools(input.host),
|
|
51
51
|
resources: frameworkResources(input.resources ?? {}),
|
|
52
|
-
serverInfo: { name: 'ultimate-dev', version:
|
|
52
|
+
serverInfo: { name: 'ultimate-dev', version: frameworkVersion() },
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
|
package/src/errors.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const MCP_ERROR_CODES = [
|
|
|
13
13
|
'X_MCP_TOOL_UNSAFE',
|
|
14
14
|
'X_MCP_TOOL_UNDECLARED',
|
|
15
15
|
'X_MCP_TOOL_DUPLICATE',
|
|
16
|
+
'X_MCP_RESOURCE_DUPLICATE',
|
|
16
17
|
'X_MCP_SCOPE_UNKNOWN',
|
|
17
18
|
'X_MCP_SCOPE_CONFLICT',
|
|
18
19
|
] as const;
|
|
@@ -29,6 +30,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
|
|
|
29
30
|
X_MCP_TOOL_UNSAFE: 'an MCP tool declares no policy',
|
|
30
31
|
X_MCP_TOOL_UNDECLARED: 'defineAppMcp lists a primitive that declares no MCP exposure',
|
|
31
32
|
X_MCP_TOOL_DUPLICATE: 'two primitives project to one MCP tool name',
|
|
33
|
+
X_MCP_RESOURCE_DUPLICATE: 'two resources claim one MCP resource URI',
|
|
32
34
|
X_MCP_SCOPE_UNKNOWN: 'defineAppMcp scopes a tool this server does not project',
|
|
33
35
|
X_MCP_SCOPE_CONFLICT: 'two scopes claim one MCP tool',
|
|
34
36
|
};
|
|
@@ -75,7 +77,7 @@ export class McpScopeDeniedError extends UltimateError {
|
|
|
75
77
|
super({
|
|
76
78
|
code: 'X_MCP_SCOPE_DENIED',
|
|
77
79
|
cause: `tool "${input.name}" requires scope "${input.scope}", which this connection's token does not carry`,
|
|
78
|
-
fix: `
|
|
80
|
+
fix: `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop "${input.scope}" from defineAppMcp({ scopes }); scopes are fixed for the life of a connection`,
|
|
79
81
|
docs: docsFor('X_MCP_SCOPE_DENIED'),
|
|
80
82
|
});
|
|
81
83
|
this.scope = input.scope;
|
|
@@ -154,6 +156,23 @@ export class McpToolDuplicateError extends UltimateError {
|
|
|
154
156
|
}
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Two resources claim one `ultimate://` URI. The twin of `McpToolDuplicateError`, and refused for
|
|
161
|
+
* the same reason: a URI is quoted in AGENTS.md files, so `resources/read` reaching whichever copy
|
|
162
|
+
* was wired last is a read that succeeds against the wrong document and reports nothing. Silent
|
|
163
|
+
* replacement also made the answer depend on registration order, which is not a contract.
|
|
164
|
+
*/
|
|
165
|
+
export class McpResourceDuplicateError extends UltimateError {
|
|
166
|
+
constructor(input: { uri: string }) {
|
|
167
|
+
super({
|
|
168
|
+
code: 'X_MCP_RESOURCE_DUPLICATE',
|
|
169
|
+
cause: `two resources are registered at "${input.uri}"; a URI addresses one document`,
|
|
170
|
+
fix: `give one of them its own URI — register({ uri: '${input.uri}-<what-it-is>', … }) — or drop the duplicate registration`,
|
|
171
|
+
docs: docsFor('X_MCP_RESOURCE_DUPLICATE'),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
157
176
|
/**
|
|
158
177
|
* `defineAppMcp`'s `scopes:` names a tool the server does not project. Boot-time and loud:
|
|
159
178
|
* the alternative is a scope entry that quietly covers nothing, leaving the tool the author
|
|
@@ -216,12 +235,19 @@ export class McpProtocolError extends UltimateError {
|
|
|
216
235
|
* LAYER 3 of `db.query`'s four defences: the statement is not one read-only statement, so it
|
|
217
236
|
* never reaches the server. Separate from the migration refusal below because the two want
|
|
218
237
|
* different next commands, and a code that covers both tells the agent neither.
|
|
238
|
+
*
|
|
239
|
+
* The cause is the problem ALONE — no `db.query refused:` frame. The guard has two callers on two
|
|
240
|
+
* surfaces (the MCP tool, and `@ultimat3/admin`'s `/_x` DB panel), and only the caller knows which
|
|
241
|
+
* reader it is talking to: the panel added its own frame and rendered `refused: db.query refused:
|
|
242
|
+
* …`, naming an MCP tool to a developer in a browser who never called one. Nothing is lost —
|
|
243
|
+
* `X_MCP_QUERY_REJECTED`'s TITLE already says `db.query`, and `format()` renders it above the
|
|
244
|
+
* cause, so the tool is named once, by the code, wherever the error surfaces.
|
|
219
245
|
*/
|
|
220
246
|
export class McpQueryRejectedError extends UltimateError {
|
|
221
247
|
constructor(input: { cause: string; fix: string }) {
|
|
222
248
|
super({
|
|
223
249
|
code: 'X_MCP_QUERY_REJECTED',
|
|
224
|
-
cause:
|
|
250
|
+
cause: input.cause,
|
|
225
251
|
fix: input.fix,
|
|
226
252
|
docs: docsFor('X_MCP_QUERY_REJECTED'),
|
|
227
253
|
});
|
|
@@ -232,12 +258,16 @@ export class McpQueryRejectedError extends UltimateError {
|
|
|
232
258
|
* `db.migrate` was pointed at a database that is not a branch. Enforced, not documented — the
|
|
233
259
|
* dev server holds real credentials, and a migration is the one dev tool that cannot be undone
|
|
234
260
|
* by reading the error afterwards.
|
|
261
|
+
*
|
|
262
|
+
* Unframed for the same reason as the refusal above, and it is the same defect even with one
|
|
263
|
+
* caller today: the title says `db.migrate`, so a `db.migrate refused:` cause said it twice. Two
|
|
264
|
+
* adjacent errors in one file cannot answer "who names the surface" two different ways.
|
|
235
265
|
*/
|
|
236
266
|
export class McpNotBranchDbError extends UltimateError {
|
|
237
267
|
constructor(input: { cause: string; fix: string }) {
|
|
238
268
|
super({
|
|
239
269
|
code: 'X_MCP_NOT_BRANCH_DB',
|
|
240
|
-
cause:
|
|
270
|
+
cause: input.cause,
|
|
241
271
|
fix: input.fix,
|
|
242
272
|
docs: docsFor('X_MCP_NOT_BRANCH_DB'),
|
|
243
273
|
});
|
package/src/from-action.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
// The free-tool projection: an `action` (or `query`) with `mcp.expose` becomes an MCP tool
|
|
2
2
|
// at zero authorization cost.
|
|
3
3
|
//
|
|
4
|
-
// The whole claim rests on one line in `handle` below: the tool calls `
|
|
5
|
-
//
|
|
4
|
+
// The whole claim rests on one line in `handle` below: the tool calls `primitive.run(...)`, and
|
|
5
|
+
// `projectable.ts` builds that `run` out of `invoke(target, …, { surface: 'mcp' })` — the SAME
|
|
6
|
+
// function `toRoute`'s handler calls for an HTTP request, differing only in the surface it
|
|
7
|
+
// declares. (An action has NO `.run` member — it is `as`/`tool`/`openapi`/`job`/`contract` and
|
|
8
|
+
// the callable itself; this comment named one until 2026-08. `run` is `ProjectablePrimitive`'s,
|
|
9
|
+
// the seam, and a query's half of it is `sourceFor`.) Policy evaluation lives inside `invoke`, so
|
|
6
10
|
// there is nothing here to keep in sync and no second authz system to drift. This
|
|
7
11
|
// projection therefore INVENTS no `scope`: a scope is a capability of the connection's
|
|
8
12
|
// token, which a projection cannot know anything about. An app that wants one names the
|
|
9
13
|
// tool in `defineAppMcp`'s `scopes:` map (see `scopes.ts`) — declared once, next to the
|
|
10
14
|
// other tools that same token capability covers, never guessed from the action.
|
|
11
15
|
//
|
|
12
|
-
// `
|
|
13
|
-
//
|
|
16
|
+
// `toWireSchema` (`input-schema.ts`), reached through `projectable.ts`, owns the schema half of
|
|
17
|
+
// what THIS server publishes; this file owns the execution half. It is deliberately not
|
|
18
|
+
// `toMcpTool` in @ultimat3/action — which this comment claimed until 2026-08, while
|
|
19
|
+
// `projectable.ts` had always called `toWireSchema`. The two are not interchangeable:
|
|
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.
|
|
14
26
|
|
|
15
27
|
import type { Actor } from '@ultimat3/core';
|
|
28
|
+
import { isMcpExposed } from '@ultimat3/core';
|
|
16
29
|
import { McpToolUndeclaredError } from './errors';
|
|
17
30
|
import type { AnyMcpTool, McpCaller, McpRole, McpToolResult, ToolArgs } from './registry';
|
|
18
31
|
import { jsonResult } from './registry';
|
|
@@ -48,7 +61,7 @@ export interface ProjectablePrimitive {
|
|
|
48
61
|
readonly name: string;
|
|
49
62
|
readonly description?: string;
|
|
50
63
|
readonly mcp?: McpExposure;
|
|
51
|
-
/** JSON Schema of the input,
|
|
64
|
+
/** JSON Schema of the input, narrowed to the wire subset by `toWireSchema`. */
|
|
52
65
|
readonly inputJsonSchema?: JsonSchema;
|
|
53
66
|
/** True for a mutation. Drives the rate-limit bucket; queries set it false. */
|
|
54
67
|
readonly mutates?: boolean;
|
|
@@ -56,9 +69,13 @@ export interface ProjectablePrimitive {
|
|
|
56
69
|
run(args: { input: unknown; actor: Actor }): Promise<unknown>;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing.
|
|
74
|
+
* `isMcpExposed` from `@ultimat3/core` is the one predicate — the manifest fact, the OpenAPI
|
|
75
|
+
* hint and both tool projections all ask it, so none of them can answer differently.
|
|
76
|
+
*/
|
|
60
77
|
export function isExposed(primitive: ProjectablePrimitive): boolean {
|
|
61
|
-
return primitive.mcp
|
|
78
|
+
return isMcpExposed(primitive.mcp);
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
/**
|
|
@@ -80,8 +97,9 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
|
|
|
80
97
|
...(visibleTo !== undefined ? { visibleTo } : {}),
|
|
81
98
|
// No `scope` from here: see the header. `defineAppMcp`'s `scopes:` map may add one.
|
|
82
99
|
async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
|
|
83
|
-
// ONE authz system, TWO surfaces.
|
|
84
|
-
// kind 'user'
|
|
100
|
+
// ONE authz system, TWO surfaces. This `run` IS `invoke`; the HTTP route reaches the
|
|
101
|
+
// same `invoke` with an actor of kind 'user', MCP arrives with kind 'agent'. Same
|
|
102
|
+
// policy, same decision.
|
|
85
103
|
const output = await primitive.run({ input: args, actor: caller.actor });
|
|
86
104
|
return jsonResult(output);
|
|
87
105
|
},
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export {
|
|
|
37
37
|
McpNotBranchDbError,
|
|
38
38
|
McpProtocolError,
|
|
39
39
|
McpQueryRejectedError,
|
|
40
|
+
McpResourceDuplicateError,
|
|
40
41
|
McpScopeConflictError,
|
|
41
42
|
McpScopeDeniedError,
|
|
42
43
|
McpScopeUnknownError,
|
|
@@ -105,7 +106,13 @@ export type {
|
|
|
105
106
|
McpRouteDescriptor,
|
|
106
107
|
ResolvedToken,
|
|
107
108
|
} from './transport-http';
|
|
108
|
-
export {
|
|
109
|
+
export {
|
|
110
|
+
bearerToken,
|
|
111
|
+
DEFAULT_MCP_BODY_LIMIT_BYTES,
|
|
112
|
+
isAgentActor,
|
|
113
|
+
MCP_RATE_LIMITS,
|
|
114
|
+
mcpHttpRoute,
|
|
115
|
+
} from './transport-http';
|
|
109
116
|
export type { StdioTransportInput } from './transport-stdio';
|
|
110
117
|
export { serveStdio } from './transport-stdio';
|
|
111
118
|
export type { ArgIssue, ArgValidation } from './validate-args';
|
|
@@ -119,7 +126,7 @@ export type {
|
|
|
119
126
|
ServerInfo,
|
|
120
127
|
} from './wire';
|
|
121
128
|
export {
|
|
122
|
-
|
|
129
|
+
defaultServerInfo,
|
|
123
130
|
errorResponse,
|
|
124
131
|
INTERNAL_ERROR,
|
|
125
132
|
INVALID_PARAMS,
|
package/src/input-schema.ts
CHANGED
|
@@ -34,6 +34,7 @@ function narrow(source: RichJsonSchema): JsonSchema {
|
|
|
34
34
|
...(source.maximum === undefined ? {} : { maximum: source.maximum }),
|
|
35
35
|
...(source.minLength === undefined ? {} : { minLength: source.minLength }),
|
|
36
36
|
...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
|
|
37
|
+
...(source.pattern === undefined ? {} : { pattern: source.pattern }),
|
|
37
38
|
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
|
|
38
39
|
};
|
|
39
40
|
}
|
package/src/projectable.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { AnyAction } from '@ultimat3/action';
|
|
7
7
|
import { actionName, invoke, isAction } from '@ultimat3/action';
|
|
8
|
-
import { withChildContext } from '@ultimat3/core';
|
|
8
|
+
import { isMcpExposed, withChildContext } from '@ultimat3/core';
|
|
9
9
|
import type { AnyQuery } from '@ultimat3/query';
|
|
10
10
|
import { isQuery, queryName, sourceFor } from '@ultimat3/query';
|
|
11
11
|
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
@@ -73,8 +73,9 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* An action and a query declare MCP exposure with the same fields, so one typed path reads
|
|
76
|
-
* both. Narrow on purpose
|
|
77
|
-
* accident — an undeclared `mcp` block yields
|
|
76
|
+
* both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
|
|
77
|
+
* `expose: true` counts, so nothing is exposed by accident — an undeclared `mcp` block yields
|
|
78
|
+
* no exposure at all.
|
|
78
79
|
*
|
|
79
80
|
* `visibleTo` travels with it, and must: it is OUTCOME 1's only declaration surface for a
|
|
80
81
|
* projected primitive. Dropping it here — which this function did until 2026-08 — left
|
|
@@ -85,7 +86,7 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
|
85
86
|
function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined {
|
|
86
87
|
if (declared === undefined) return undefined;
|
|
87
88
|
return {
|
|
88
|
-
expose: declared
|
|
89
|
+
expose: isMcpExposed(declared),
|
|
89
90
|
...(declared.description === undefined ? {} : { description: declared.description }),
|
|
90
91
|
...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
|
|
91
92
|
};
|
package/src/query-limits.ts
CHANGED
|
@@ -56,9 +56,21 @@ export function resolveQueryLimits(requested: unknown): QueryLimits {
|
|
|
56
56
|
|
|
57
57
|
const encoder = new TextEncoder();
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Serialised size of one row, plus the separator it costs inside the JSON array.
|
|
61
|
+
*
|
|
62
|
+
* A row the driver decoded into something JSON cannot hold — a bigint from an `int8` column, a
|
|
63
|
+
* cycle — costs `Infinity`, which is not a fudge: the row cannot be returned to the agent at all,
|
|
64
|
+
* and the ceiling it blows is already the one whose answer ("select fewer columns") is the right
|
|
65
|
+
* one. Raising instead would take down the tool that owes the agent a reply, from inside the cap
|
|
66
|
+
* that exists to protect it.
|
|
67
|
+
*/
|
|
60
68
|
function rowBytes(row: readonly unknown[]): number {
|
|
61
|
-
|
|
69
|
+
try {
|
|
70
|
+
return encoder.encode(JSON.stringify(row)).length + 1;
|
|
71
|
+
} catch {
|
|
72
|
+
return Number.POSITIVE_INFINITY;
|
|
73
|
+
}
|
|
62
74
|
}
|
|
63
75
|
|
|
64
76
|
/**
|
package/src/readonly-sql.ts
CHANGED
|
@@ -163,8 +163,10 @@ export function assertReadOnlyQuery(sql: string): string {
|
|
|
163
163
|
`the statement contains the mutating keyword "${word}"`,
|
|
164
164
|
// `db.migrate` applies pending migrations; it is not an INSERT/UPDATE/DELETE path, and
|
|
165
165
|
// there is no MCP tool that is. Data changes go through an action, which carries a policy.
|
|
166
|
+
// `create`, not the bare name: `x db branch` takes a VERB from a closed set, and the
|
|
167
|
+
// bare form this used to hand out now resolves to nothing.
|
|
166
168
|
'db.query has no write path: change data by calling an action exposed with ' +
|
|
167
|
-
'mcp: { expose: true }, and change schema with db.migrate after x db branch <name>',
|
|
169
|
+
'mcp: { expose: true }, and change schema with db.migrate after x db branch create <name>',
|
|
168
170
|
);
|
|
169
171
|
}
|
|
170
172
|
}
|
|
@@ -229,7 +231,14 @@ export function assertBranchDatabase(target: DatabaseTarget): string {
|
|
|
229
231
|
if (target.branch === null) {
|
|
230
232
|
throw notBranch(
|
|
231
233
|
`"${target.label}" is not a branch database`,
|
|
232
|
-
|
|
234
|
+
// The verb is load-bearing: `x db branch <name>` is now X_CLI_UNKNOWN_COMMAND, and before
|
|
235
|
+
// the verbs existed it CREATED whatever word followed — including `ls`.
|
|
236
|
+
//
|
|
237
|
+
// The guidance rides behind `#`, never a comma: a fix line is pasted into a shell whole, and
|
|
238
|
+
// `#` is the one joiner that leaves the command in front of it runnable. `<name>` stays a
|
|
239
|
+
// placeholder — the branch name is the caller's to choose, which is why `@ultimat3/db` ships
|
|
240
|
+
// this same line, same slot, in `branchNameInvalid`'s X_SQL_UNSAFE fix.
|
|
241
|
+
'x db branch create <name> # then retry db.migrate',
|
|
233
242
|
);
|
|
234
243
|
}
|
|
235
244
|
return target.branch;
|
|
@@ -239,6 +248,20 @@ function rejected(cause: string, fix: string): McpQueryRejectedError {
|
|
|
239
248
|
return new McpQueryRejectedError({ cause, fix });
|
|
240
249
|
}
|
|
241
250
|
|
|
251
|
+
/**
|
|
252
|
+
* A delimiter that never closes: the scanner would blank everything after it, so the tail — `;`
|
|
253
|
+
* and any write keyword in it — vanishes from every check below. `select '; delete from members`
|
|
254
|
+
* counted one statement, contained no mutating word, and was handed back verbatim to run.
|
|
255
|
+
*
|
|
256
|
+
* Refused, not swallowed, for the same reason `\'` is: this layer's job is to refuse what it
|
|
257
|
+
* cannot read. Postgres would answer a syntax error either way, and that is exactly the
|
|
258
|
+
* dependency on the layer below that four defences exist not to have. Over-refusing costs a
|
|
259
|
+
* malformed query a clearer message; under-refusing costs the guarantee.
|
|
260
|
+
*/
|
|
261
|
+
function unterminated(what: string, fix: string): McpQueryRejectedError {
|
|
262
|
+
return rejected(`the statement ends inside ${what}, so the rest of it cannot be read`, fix);
|
|
263
|
+
}
|
|
264
|
+
|
|
242
265
|
function notBranch(cause: string, fix: string): McpNotBranchDbError {
|
|
243
266
|
return new McpNotBranchDbError({ cause, fix });
|
|
244
267
|
}
|
|
@@ -266,13 +289,16 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
|
|
|
266
289
|
}
|
|
267
290
|
if (two === '/*') {
|
|
268
291
|
const end = sql.indexOf('*/', i + 2);
|
|
269
|
-
|
|
292
|
+
if (end === -1) {
|
|
293
|
+
throw unterminated('a /* block comment', 'close it with */, or use -- to the end of line');
|
|
294
|
+
}
|
|
295
|
+
i = end + 2;
|
|
270
296
|
out += ' ';
|
|
271
297
|
continue;
|
|
272
298
|
}
|
|
273
299
|
const char = sql[i];
|
|
274
300
|
if (char === "'" || char === '"') {
|
|
275
|
-
const end = skipQuoted(sql, i, char);
|
|
301
|
+
const end = char === "'" ? skipSingleQuoted(sql, i) : skipQuoted(sql, i, char);
|
|
276
302
|
// Padded, never spliced in place: `select"pg_advisory_lock"(1)` must not fuse into one
|
|
277
303
|
// token, or the call the quotes were hiding stays hidden behind the leading keyword.
|
|
278
304
|
out += char === '"' && identifiers === 'keep' ? ` ${inner(sql.slice(i, end))} ` : ' ';
|
|
@@ -284,7 +310,13 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
|
|
|
284
310
|
if (tag !== null) {
|
|
285
311
|
const marker = tag[0];
|
|
286
312
|
const end = sql.indexOf(marker, i + marker.length);
|
|
287
|
-
|
|
313
|
+
if (end === -1) {
|
|
314
|
+
throw unterminated(
|
|
315
|
+
`a ${marker} dollar-quoted body`,
|
|
316
|
+
`close it with the same tag: ${marker} … ${marker}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
i = end + marker.length;
|
|
288
320
|
out += ' ';
|
|
289
321
|
continue;
|
|
290
322
|
}
|
|
@@ -301,7 +333,49 @@ function inner(run: string): string {
|
|
|
301
333
|
return run.slice(1, closed ? -1 : undefined).replaceAll('""', '"');
|
|
302
334
|
}
|
|
303
335
|
|
|
304
|
-
/**
|
|
336
|
+
/**
|
|
337
|
+
* Advance past a single-quoted run, and refuse the one sequence whose meaning this scanner cannot
|
|
338
|
+
* decide: a backslash immediately before a quote.
|
|
339
|
+
*
|
|
340
|
+
* `E'\''` is ONE quote to Postgres — the backslash escapes it, the third quote closes the string —
|
|
341
|
+
* so what follows is real statement text. A scanner that knows only the doubled-quote escape reads
|
|
342
|
+
* the same bytes as a string that is still open, blanks the rest of the line, and counts one
|
|
343
|
+
* statement: `select E'\'' ; drop table posts --'` was accepted and handed back verbatim to run.
|
|
344
|
+
* Guessing the other way is no better, since `standard_conforming_strings` (a session setting this
|
|
345
|
+
* tool cannot see) decides whether a PLAIN `'a\'` closes there. So the sequence is refused rather
|
|
346
|
+
* than parsed under one of two readings — `''` embeds a quote under both.
|
|
347
|
+
*
|
|
348
|
+
* `\\` is consumed as a pair on purpose: both readings agree that `E'\\'` ends at that quote, so
|
|
349
|
+
* refusing it would cost an ordinary read nothing is wrong with.
|
|
350
|
+
*/
|
|
351
|
+
function skipSingleQuoted(sql: string, start: number): number {
|
|
352
|
+
let i = start + 1;
|
|
353
|
+
while (i < sql.length) {
|
|
354
|
+
const char = sql[i];
|
|
355
|
+
if (char === '\\') {
|
|
356
|
+
if (sql[i + 1] === "'") {
|
|
357
|
+
throw rejected(
|
|
358
|
+
String.raw`the statement contains \' inside a string literal, which ends the string ` +
|
|
359
|
+
'under one Postgres setting and not the other',
|
|
360
|
+
String.raw`double the quote instead of escaping it: 'it''s' rather than E'it\'s'`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
i += 2;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (char === "'") {
|
|
367
|
+
if (sql[i + 1] === "'") {
|
|
368
|
+
i += 2;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
return i + 1;
|
|
372
|
+
}
|
|
373
|
+
i += 1;
|
|
374
|
+
}
|
|
375
|
+
throw unterminated('a string literal', "close the quote, or double it to embed one: 'it''s'");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Advance past a quoted identifier, honouring SQL's doubled-quote escape (`"a""b"`). */
|
|
305
379
|
function skipQuoted(sql: string, start: number, quote: string): number {
|
|
306
380
|
let i = start + 1;
|
|
307
381
|
while (i < sql.length) {
|
|
@@ -314,5 +388,5 @@ function skipQuoted(sql: string, start: number, quote: string): number {
|
|
|
314
388
|
}
|
|
315
389
|
i += 1;
|
|
316
390
|
}
|
|
317
|
-
|
|
391
|
+
throw unterminated('a quoted identifier', 'close the quote: select "column name" from posts');
|
|
318
392
|
}
|
package/src/registry.ts
CHANGED
|
@@ -138,13 +138,13 @@ export type ToolResolution =
|
|
|
138
138
|
| { readonly kind: 'invalid-args'; readonly name: string; readonly issues: readonly ArgIssue[] };
|
|
139
139
|
|
|
140
140
|
export class ToolRegistry {
|
|
141
|
-
|
|
141
|
+
readonly #tools = new Map<string, AnyMcpTool>();
|
|
142
142
|
|
|
143
143
|
register(tool: AnyMcpTool): this {
|
|
144
|
-
if (this
|
|
144
|
+
if (this.#tools.has(tool.name)) {
|
|
145
145
|
throw new McpDuplicateToolError(tool.name);
|
|
146
146
|
}
|
|
147
|
-
this
|
|
147
|
+
this.#tools.set(tool.name, tool);
|
|
148
148
|
return this;
|
|
149
149
|
}
|
|
150
150
|
|
|
@@ -155,7 +155,7 @@ export class ToolRegistry {
|
|
|
155
155
|
|
|
156
156
|
/** Raw lookup with NO gate applied — the resolver owns the gates. */
|
|
157
157
|
get(name: string): AnyMcpTool | undefined {
|
|
158
|
-
return this
|
|
158
|
+
return this.#tools.get(name);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
|
@@ -163,7 +163,7 @@ export class ToolRegistry {
|
|
|
163
163
|
* this catalog between runs and map insertion order is not a contract.
|
|
164
164
|
*/
|
|
165
165
|
list(caller?: McpCaller): readonly ToolListEntry[] {
|
|
166
|
-
const all = [...this
|
|
166
|
+
const all = [...this.#tools.values()];
|
|
167
167
|
const visible = caller === undefined ? all : all.filter((t) => visibleToCaller(t, caller));
|
|
168
168
|
return visible
|
|
169
169
|
.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
|
|
@@ -185,7 +185,7 @@ export class ToolRegistry {
|
|
|
185
185
|
* input. Absent and hidden collapse into ONE branch so the two cannot drift apart.
|
|
186
186
|
*/
|
|
187
187
|
resolve(name: string, rawArgs: unknown, caller: McpCaller): ToolResolution {
|
|
188
|
-
const tool = this
|
|
188
|
+
const tool = this.#tools.get(name);
|
|
189
189
|
if (tool === undefined || !visibleToCaller(tool, caller)) {
|
|
190
190
|
return { kind: 'not-found', name };
|
|
191
191
|
}
|
|
@@ -202,7 +202,7 @@ export class ToolRegistry {
|
|
|
202
202
|
* strict bucket, because a probing client must never get the cheap one.
|
|
203
203
|
*/
|
|
204
204
|
verbClass(name: string): McpVerbClass {
|
|
205
|
-
const tool = this
|
|
205
|
+
const tool = this.#tools.get(name);
|
|
206
206
|
if (tool === undefined) return 'write';
|
|
207
207
|
return tool.destructive === true ? 'write' : 'read';
|
|
208
208
|
}
|
|
@@ -223,7 +223,25 @@ export function textResult(text: string, isError = false): McpToolResult {
|
|
|
223
223
|
return isError ? { content, isError: true } : { content };
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* JSON payload as a text block — stable 2-space form so an agent can diff two calls.
|
|
228
|
+
*
|
|
229
|
+
* TOTAL, because the value is an app's: `toolFromAction` hands an action's own return value
|
|
230
|
+
* straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
|
|
231
|
+
* a `text` that is not a string is an invalid MCP frame — and THROWS on a bigint, a cycle or a
|
|
232
|
+
* `toJSON` the value carries. A throw would leave the server's catch reporting a bug in the tool
|
|
233
|
+
* for a fault in the rendering, so the unreadable case is an ordinary `isError` result: the same
|
|
234
|
+
* three-line shape every other expected failure comes back as, and one an agent can act on.
|
|
235
|
+
*/
|
|
227
236
|
export function jsonResult(value: unknown): McpToolResult {
|
|
228
|
-
|
|
237
|
+
let text: string | undefined;
|
|
238
|
+
try {
|
|
239
|
+
text = JSON.stringify(value, null, 2);
|
|
240
|
+
} catch {
|
|
241
|
+
return textResult(
|
|
242
|
+
'the tool ran, but its result is not JSON (a bigint, a cycle, or a toJSON that threw) — the tool has to return a JSON-serialisable value',
|
|
243
|
+
true,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return textResult(text ?? 'null');
|
|
229
247
|
}
|
package/src/resources.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// injected as thunks because `@ultimat3/manifest` and `@ultimat3/render` sit in this same
|
|
6
6
|
// tier — the CLI wires them, this package only defines the shape and the URIs.
|
|
7
7
|
|
|
8
|
+
import { McpResourceDuplicateError } from './errors';
|
|
8
9
|
import type { JsonSchema } from './wire';
|
|
9
10
|
|
|
10
11
|
/** Stable URIs. These are quoted in AGENTS.md files, so treat them as public API. */
|
|
@@ -126,7 +127,14 @@ export function frameworkResources(providers: FrameworkResourceProviders): reado
|
|
|
126
127
|
export class ResourceRegistry {
|
|
127
128
|
private readonly resources = new Map<string, McpResource>();
|
|
128
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Refuses a second claim on a URI, exactly as `ToolRegistry.register` refuses a second claim on
|
|
132
|
+
* a tool name — one package cannot answer "this name is taken" two ways. `Map.set` made the
|
|
133
|
+
* answer whichever provider was wired last, which for a URI quoted in an AGENTS.md file is a
|
|
134
|
+
* read that succeeds against the wrong document.
|
|
135
|
+
*/
|
|
129
136
|
register(resource: McpResource): this {
|
|
137
|
+
if (this.resources.has(resource.uri)) throw new McpResourceDuplicateError(resource);
|
|
130
138
|
this.resources.set(resource.uri, resource);
|
|
131
139
|
return this;
|
|
132
140
|
}
|
package/src/server.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// and an already-resolved caller, and returns a response or `null` for a notification.
|
|
4
4
|
// Both transports (http, stdio) and every test drive this one function.
|
|
5
5
|
|
|
6
|
+
import { stringField } from '@ultimat3/core';
|
|
6
7
|
import { formatIssues } from '@ultimat3/schema';
|
|
7
8
|
import { auditToolCall, outcomeForCode } from './audit';
|
|
8
9
|
import { McpScopeDeniedError } from './errors';
|
|
@@ -12,7 +13,7 @@ import type { McpPrompt, McpResource } from './resources';
|
|
|
12
13
|
import { ResourceRegistry } from './resources';
|
|
13
14
|
import type { JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
14
15
|
import {
|
|
15
|
-
|
|
16
|
+
defaultServerInfo,
|
|
16
17
|
errorResponse,
|
|
17
18
|
INTERNAL_ERROR,
|
|
18
19
|
INVALID_PARAMS,
|
|
@@ -49,7 +50,7 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
|
|
|
49
50
|
tools,
|
|
50
51
|
resources,
|
|
51
52
|
input.prompts ?? [],
|
|
52
|
-
input.serverInfo ??
|
|
53
|
+
input.serverInfo ?? defaultServerInfo(),
|
|
53
54
|
);
|
|
54
55
|
}
|
|
55
56
|
|
|
@@ -242,14 +243,21 @@ interface FrameworkError {
|
|
|
242
243
|
* transport must stay independent of which package threw.
|
|
243
244
|
*/
|
|
244
245
|
function asFrameworkError(error: unknown): FrameworkError | undefined {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
// `stringField` from `@ultimat3/core`, never `typeof e.code === 'string'`: the value is whatever
|
|
247
|
+
// an app's handler, its driver or its SDK threw, so each read is a getter call or a `Proxy`
|
|
248
|
+
// trap. This runs inside the catch block that owes the caller an answer, and a probe that
|
|
249
|
+
// raises here leaves the JSON-RPC request with no response at all — not even the `-32603` the
|
|
250
|
+
// header promises for a genuine bug.
|
|
251
|
+
const code = stringField(error, 'code');
|
|
252
|
+
if (code === undefined || !code.startsWith('X_')) return undefined;
|
|
248
253
|
return {
|
|
249
|
-
code
|
|
250
|
-
title:
|
|
251
|
-
cause:
|
|
252
|
-
fix
|
|
254
|
+
code,
|
|
255
|
+
title: stringField(error, 'title') ?? '',
|
|
256
|
+
cause: stringField(error, 'cause') ?? 'unknown',
|
|
257
|
+
// A substituted fix is still a fix an agent will act on, so it has to be runnable. `see docs`
|
|
258
|
+
// named no docs and no command; `code` is already narrowed to an `X_` string by the guard
|
|
259
|
+
// above, so the substitute is the one command that explains exactly this code.
|
|
260
|
+
fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
|
|
253
261
|
};
|
|
254
262
|
}
|
|
255
263
|
|
package/src/transport-http.ts
CHANGED
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
// refuse what a human would be allowed.
|
|
14
14
|
|
|
15
15
|
import type { Actor } from '@ultimat3/core';
|
|
16
|
+
import { readWithinLimit } from '@ultimat3/core';
|
|
16
17
|
import type { McpCaller, McpRole, McpVerbClass } from './registry';
|
|
17
18
|
import type { McpServer } from './server';
|
|
18
19
|
import type { JsonRpcResponse } from './wire';
|
|
19
20
|
import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The same 1 MiB `@ultimat3/http`'s `bodyLimitBytes` defaults to. This descriptor is driven from a
|
|
24
|
+
* bare `Request` and never passes through that pipeline, so without a cap here Bun's 128 MiB
|
|
25
|
+
* default was the only ceiling — and `x mcp serve` and `createServer` both pass no
|
|
26
|
+
* `maxRequestBodySize`.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_MCP_BODY_LIMIT_BYTES = 1_048_576;
|
|
29
|
+
|
|
21
30
|
/** Requests per minute per token, by class. Reads are cheap; a write may run migrations. */
|
|
22
31
|
export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
|
|
23
32
|
read: 120,
|
|
@@ -40,6 +49,8 @@ export interface McpHttpTransportInput {
|
|
|
40
49
|
resolveToken(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
41
50
|
/** Route path. Overridable so an app can mount a second, app-scoped surface. */
|
|
42
51
|
readonly path?: string;
|
|
52
|
+
/** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
|
|
53
|
+
readonly bodyLimitBytes?: number | undefined;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
export interface McpRouteDescriptor {
|
|
@@ -55,6 +66,7 @@ const JSON_HEADERS = { 'content-type': 'application/json' } as const;
|
|
|
55
66
|
|
|
56
67
|
export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
57
68
|
const { server } = input;
|
|
69
|
+
const bodyLimitBytes = input.bodyLimitBytes ?? DEFAULT_MCP_BODY_LIMIT_BYTES;
|
|
58
70
|
|
|
59
71
|
return {
|
|
60
72
|
method: 'POST',
|
|
@@ -63,24 +75,40 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
63
75
|
rateLimitClass: (body) => server.classify(body),
|
|
64
76
|
|
|
65
77
|
async handle(request: Request): Promise<Response> {
|
|
78
|
+
// Every authentication answer lands BEFORE the body is read. Parsing first meant a caller
|
|
79
|
+
// holding a rejected token still learned whether its JSON was well formed — `400 parse
|
|
80
|
+
// error` for one payload and `401` for the next is exactly the oracle the 401 exists to
|
|
81
|
+
// remove, and it costs nothing to close: the body is not an input to any of these.
|
|
66
82
|
const token = bearerToken(request);
|
|
67
|
-
if (token === null)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
if (token === null) return unauthorized();
|
|
84
|
+
|
|
85
|
+
const resolved = await input.resolveToken(token);
|
|
86
|
+
if (resolved === null) return unauthorized();
|
|
87
|
+
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
88
|
+
|
|
89
|
+
// Read through the counting reader, never `request.json()`: the cap has to be enforced
|
|
90
|
+
// WHILE the bytes arrive, or a `transfer-encoding: chunked` payload is materialised in full
|
|
91
|
+
// before anything measures it. Core owns the reader so this and `UltimateRequest.#read`
|
|
92
|
+
// cannot drift.
|
|
93
|
+
const read = await readWithinLimit(request.body, bodyLimitBytes);
|
|
94
|
+
if ('over' in read) {
|
|
95
|
+
return json(
|
|
96
|
+
errorResponse(
|
|
97
|
+
null,
|
|
98
|
+
INVALID_REQUEST,
|
|
99
|
+
`request body is at least ${read.over} bytes, limit is ${bodyLimitBytes}`,
|
|
100
|
+
),
|
|
101
|
+
413,
|
|
102
|
+
);
|
|
71
103
|
}
|
|
72
104
|
|
|
73
105
|
let body: unknown;
|
|
74
106
|
try {
|
|
75
|
-
body =
|
|
107
|
+
body = JSON.parse(new TextDecoder().decode(read.bytes));
|
|
76
108
|
} catch {
|
|
77
109
|
return json(errorResponse(null, PARSE_ERROR, 'request body is not valid JSON'), 400);
|
|
78
110
|
}
|
|
79
111
|
|
|
80
|
-
const resolved = await input.resolveToken(token);
|
|
81
|
-
if (resolved === null) return unauthorized();
|
|
82
|
-
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
83
|
-
|
|
84
112
|
const caller: McpCaller = {
|
|
85
113
|
actor: resolved.actor,
|
|
86
114
|
scopes: resolved.scopes,
|
|
@@ -131,7 +159,7 @@ function unauthorized(): Response {
|
|
|
131
159
|
JSON.stringify({
|
|
132
160
|
code: 'X_MCP_PROTOCOL',
|
|
133
161
|
cause: 'missing or unrecognised bearer token',
|
|
134
|
-
fix: '
|
|
162
|
+
fix: "send Authorization: Bearer <token> with a token the app's resolveToken(token) resolves to { actor, scopes } — it is the only issuer this route has",
|
|
135
163
|
}),
|
|
136
164
|
{
|
|
137
165
|
status: 401,
|
package/src/validate-args.ts
CHANGED
|
@@ -78,29 +78,43 @@ function object(
|
|
|
78
78
|
const out: Record<string, unknown> = {};
|
|
79
79
|
|
|
80
80
|
for (const key of Object.keys(source)) {
|
|
81
|
-
|
|
81
|
+
// `Object.hasOwn`, never `properties[key] === undefined`: the second walks the prototype
|
|
82
|
+
// chain, so `constructor`, `toString` and `__proto__` read as DECLARED on every schema and an
|
|
83
|
+
// argument named after one was accepted past an `additionalProperties: false` that forbids it,
|
|
84
|
+
// then silently dropped. Same discriminator as the loop below, which always had it right.
|
|
85
|
+
if (!Object.hasOwn(properties, key)) {
|
|
82
86
|
if (schema.additionalProperties === false) {
|
|
83
87
|
issues.push({ path: join(path, key), message: 'unknown property' });
|
|
84
88
|
continue;
|
|
85
89
|
}
|
|
86
|
-
out
|
|
90
|
+
put(out, key, source[key]);
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
for (const [key, child] of Object.entries(properties)) {
|
|
90
94
|
const at = join(path, key);
|
|
91
95
|
const present = Object.hasOwn(source, key) && source[key] !== undefined;
|
|
92
96
|
if (!present) {
|
|
93
|
-
if (child.default !== undefined) out
|
|
97
|
+
if (child.default !== undefined) put(out, key, child.default);
|
|
94
98
|
else if (schema.required?.includes(key) === true) {
|
|
95
99
|
issues.push({ path: at, message: 'is required' });
|
|
96
100
|
}
|
|
97
101
|
continue;
|
|
98
102
|
}
|
|
99
|
-
out
|
|
103
|
+
put(out, key, walk(child, source[key], at, issues));
|
|
100
104
|
}
|
|
101
105
|
return out;
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
/**
|
|
109
|
+
* One validated key onto the result. `out[key] = value` is not an assignment for exactly one
|
|
110
|
+
* name: `__proto__` runs `Object.prototype`'s setter and REPLACES the object's prototype instead
|
|
111
|
+
* of adding a key, so a caller-chosen argument name decides what the handler's `args.isAdmin`
|
|
112
|
+
* reads. `defineProperty` writes a plain own data property whatever the name is.
|
|
113
|
+
*/
|
|
114
|
+
function put(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
115
|
+
Object.defineProperty(out, key, { value, writable: true, enumerable: true, configurable: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
104
118
|
function array(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
|
|
105
119
|
if (!Array.isArray(input)) {
|
|
106
120
|
issues.push({ path, message: 'must be an array' });
|
|
@@ -122,9 +136,27 @@ function string(schema: JsonSchema, input: unknown, path: string, issues: ArgIss
|
|
|
122
136
|
if (schema.maxLength !== undefined && input.length > schema.maxLength) {
|
|
123
137
|
issues.push({ path, message: `must be at most ${schema.maxLength} characters` });
|
|
124
138
|
}
|
|
139
|
+
if (schema.pattern !== undefined) matchesPattern(schema.pattern, input, path, issues);
|
|
125
140
|
return input;
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
/**
|
|
144
|
+
* A pattern this server cannot COMPILE is refused, not skipped. `tools/list` published it, so an
|
|
145
|
+
* agent has already been told the rule — passing a call the server cannot check is the silent-pass
|
|
146
|
+
* this whole module exists to prevent. Every framework-projected pattern is a `RegExp.source` and
|
|
147
|
+
* compiles; only a hand-written tool can reach the second branch, and that is its author's bug.
|
|
148
|
+
*/
|
|
149
|
+
function matchesPattern(pattern: string, input: string, path: string, issues: ArgIssue[]): void {
|
|
150
|
+
let compiled: RegExp;
|
|
151
|
+
try {
|
|
152
|
+
compiled = new RegExp(pattern);
|
|
153
|
+
} catch {
|
|
154
|
+
issues.push({ path, message: `declares a pattern this server cannot compile: ${pattern}` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!compiled.test(input)) issues.push({ path, message: `must match ${pattern}` });
|
|
158
|
+
}
|
|
159
|
+
|
|
128
160
|
function number(schema: JsonSchema, input: unknown, path: string, issues: ArgIssue[]): unknown {
|
|
129
161
|
if (typeof input !== 'number' || Number.isNaN(input)) {
|
|
130
162
|
issues.push({ path, message: 'must be a number' });
|
package/src/wire.ts
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Reference: https://modelcontextprotocol.io/specification (2025-06-18).
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { frameworkVersion } from '@ultimat3/core';
|
|
9
9
|
|
|
10
10
|
/** MCP protocol version advertised on `initialize`. */
|
|
11
11
|
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Identity advertised on `initialize` unless the host overrides it. A call and not a constant:
|
|
15
|
+
* `frameworkVersion()` resolves on first use, so importing this module cannot be what stops a
|
|
16
|
+
* compiled single-file binary from booting.
|
|
17
|
+
*/
|
|
18
|
+
export function defaultServerInfo(): ServerInfo {
|
|
19
|
+
return { name: 'ultimate', version: frameworkVersion() };
|
|
20
|
+
}
|
|
15
21
|
|
|
16
22
|
export interface ServerInfo {
|
|
17
23
|
readonly name: string;
|
|
@@ -75,6 +81,14 @@ export interface JsonSchema {
|
|
|
75
81
|
readonly maximum?: number;
|
|
76
82
|
readonly minLength?: number;
|
|
77
83
|
readonly maxLength?: number;
|
|
84
|
+
/**
|
|
85
|
+
* A `RegExp.source` string, JSON Schema semantics (a partial match unless it anchors itself).
|
|
86
|
+
* In the subset because `validate-args.ts` enforces it: without it `tools/list` published
|
|
87
|
+
* `{minLength,maxLength}` for a field whose OpenAPI component carried the pattern, so an HTTP
|
|
88
|
+
* client and an MCP agent held different contracts for one declaration and the agent's only
|
|
89
|
+
* way to learn the format was `X_INPUT_INVALID` from the action's own parse.
|
|
90
|
+
*/
|
|
91
|
+
readonly pattern?: string;
|
|
78
92
|
readonly anyOf?: readonly JsonSchema[];
|
|
79
93
|
}
|
|
80
94
|
|