@ultimat3/mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/package.json +41 -0
- package/src/app-tool.ts +103 -0
- package/src/app-tools.ts +190 -0
- package/src/audit.ts +92 -0
- package/src/dev-host.ts +59 -0
- package/src/dev-server.ts +303 -0
- package/src/errors.ts +245 -0
- package/src/exposed.ts +25 -0
- package/src/from-action.ts +123 -0
- package/src/index.ts +133 -0
- package/src/input-schema.ts +59 -0
- package/src/projectable.ts +99 -0
- package/src/query-limits.ts +101 -0
- package/src/readonly-sql.ts +318 -0
- package/src/registry.ts +229 -0
- package/src/resources.ts +166 -0
- package/src/scopes.ts +71 -0
- package/src/server.ts +268 -0
- package/src/transport-http.ts +145 -0
- package/src/transport-stdio.ts +76 -0
- package/src/validate-args.ts +161 -0
- package/src/wire.ts +115 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# @ultimat3/mcp 🤖
|
|
2
|
+
|
|
3
|
+
The MCP surface. An agent that can reach this package needs no framework documentation — it
|
|
4
|
+
asks instead of guessing.
|
|
5
|
+
|
|
6
|
+
## The dev server: `x mcp serve`
|
|
7
|
+
|
|
8
|
+
| Tool | Scope | Answers |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `routes.list` | `dev:read` | route table — url, render mode, offline strategy, hydrate, budget |
|
|
11
|
+
| `schema.describe` | `dev:read` | entities with columns, types, invariants |
|
|
12
|
+
| `policies.list` | `dev:read` | every policy: permission, subject, enforcement points |
|
|
13
|
+
| `actions.describe` | `dev:read` | actions + queries: input/output schema, policy, cache tags, MCP exposure |
|
|
14
|
+
| `jobs.inspect` | `dev:read` | job definitions, retry policy, steps (omit `name` for all) |
|
|
15
|
+
| `queue.depth` | `dev:read` | pending / running / failed per queue |
|
|
16
|
+
| `manifest.read` | `dev:read` | `x.manifest.json` verbatim |
|
|
17
|
+
| `errors.explain` | `dev:read` | stable `X_*` code → cause + exact fix command + docs |
|
|
18
|
+
| `db.query` | `db:read` | **read-only, enforced four ways** — SELECT-only role, `BEGIN READ ONLY`, one-statement parse, 5s/1000-row/256 KiB caps |
|
|
19
|
+
| `db.migrate` | `db:migrate` | **branch DB only** — refuses production and any non-branch target |
|
|
20
|
+
| `tests.run` | `dev:test` | runs the suite (executes project code) |
|
|
21
|
+
| `verify.run` | `dev:test` | `x verify` — the shippable contract |
|
|
22
|
+
| `logs.tail` | `dev:logs` | last N lines, optionally per runtime role |
|
|
23
|
+
|
|
24
|
+
`db.query` and `db.migrate` are gated *and* say so in their own description, so a model that
|
|
25
|
+
reads only the catalog still knows what it is holding.
|
|
26
|
+
|
|
27
|
+
### `db.query`'s four layers
|
|
28
|
+
|
|
29
|
+
| Layer | Mechanism | Where |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| 1. Role | `ultimate_readonly` — `NOLOGIN`, `SELECT` on every table present and future, nothing on sequences; assumed with `SET LOCAL ROLE` inside the transaction, never via a second connection string | `@ultimat3/db` |
|
|
32
|
+
| 2. Transaction | `BEGIN READ ONLY` … `ROLLBACK` on one reserved connection — Postgres refuses the write even if a grant is wrong | `@ultimat3/db` |
|
|
33
|
+
| 3. Parse | one statement, a read leader, no mutating keyword at statement level, no lock — clause **or** `pg_advisory_*` call — and no call into a banned function family, matched by prefix of the called name — quoted and schema-qualified spellings included — so a new spelling is refused by default and a column sharing a prefix is not; on a form with literals and comments blanked | `readonly-sql.ts` |
|
|
34
|
+
| 4. Limits | `SET LOCAL statement_timeout`, a hard 1000-row ceiling (`limit` clamps into it, never past it) and a 256 KiB byte cap | `query-limits.ts` |
|
|
35
|
+
|
|
36
|
+
The answer carries `guards` — the layers that actually engaged — plus `truncatedBy` and `bytes`.
|
|
37
|
+
A layer that could not engage (a managed Postgres that refuses `CREATE ROLE`) is **absent from
|
|
38
|
+
the list**, never assumed. Truncation is never silent.
|
|
39
|
+
|
|
40
|
+
## One authz system, two surfaces
|
|
41
|
+
|
|
42
|
+
Every `action` with `mcp: { expose: true }` becomes a tool for free, and the tool's `handle`
|
|
43
|
+
calls the **same `action.run`** the HTTP route calls. Policy evaluation lives inside `run`.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
HTTP POST /api/publishPost ─┐
|
|
47
|
+
├─→ action.run({ input, actor }) ─→ policy ─→ handler
|
|
48
|
+
MCP tools/call publishPost ─┘
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`mcp: { visibleTo: [...] }` on the action or query travels with the projection too — the only
|
|
52
|
+
declaration surface outcome 1 has for a projected tool. Catalog audience, never authz.
|
|
53
|
+
|
|
54
|
+
The projection itself declares **no `scope`** — a projection cannot know what a token means.
|
|
55
|
+
`defineAppMcp`'s `scopes:` map (below) may attach one afterward, as a capability of the
|
|
56
|
+
CONNECTION rather than a second gate: it decides before the policy runs and never reads the
|
|
57
|
+
input, so the two cannot disagree. There is no MCP-specific authorization code to review
|
|
58
|
+
beyond it.
|
|
59
|
+
|
|
60
|
+
## Security posture: three outcomes, hidden ≠ forbidden
|
|
61
|
+
|
|
62
|
+
| Refused by | Declared by | Answer | Wire |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| role | `visibleTo` | omitted from `tools/list`, **ToolNotFound** on call, no `data` | `-32601` |
|
|
65
|
+
| scope | `scope` | **Forbidden**, naming the missing scope + a runnable fix | `-32600`, `X_MCP_SCOPE_DENIED` |
|
|
66
|
+
| policy | the primitive's own `policy` | `isError` result carrying code/cause/fix | `X_FORBIDDEN` |
|
|
67
|
+
|
|
68
|
+
Forbidden confirms a tool exists, which turns an authz boundary into a catalog an agent can
|
|
69
|
+
enumerate by probing. So a role-hidden tool is indistinguishable from an absent one — even
|
|
70
|
+
for a caller holding every scope in the system. A scope refusal is the opposite case: the
|
|
71
|
+
caller was already shown the tool and can legitimately fix this, so hiding it would only
|
|
72
|
+
strand a well-behaved client.
|
|
73
|
+
|
|
74
|
+
| Rule | Detail |
|
|
75
|
+
|---|---|
|
|
76
|
+
| A role list is fail-closed | a `visibleTo` role list admits only the roles it names, so a caller carrying no role matches none of them |
|
|
77
|
+
| A predicate audience sees the caller and nothing else | it is handed `McpCaller` — never the call arguments, so two calls with different inputs cannot answer differently. Must return the literal `true`; if it throws, the tool is hidden |
|
|
78
|
+
| `tools/list` is answered per caller | filtered on every call against the caller the transport resolved — one per HTTP request, one per stdio connection — never a static catalog |
|
|
79
|
+
| Gate order | visibility → scope → arguments → policy; the scope gate never waits on a policy run against attacker-supplied input |
|
|
80
|
+
| Every outcome is audited | one line per `tools/call`; hidden/scope/policy at `warn`, ok at `info` — see `audit.ts` |
|
|
81
|
+
| Audit lines carry no payload | tool, outcome, actor, code. Never arguments, never rows |
|
|
82
|
+
| No trusted-tool mode | there is no flag that skips policy evaluation |
|
|
83
|
+
|
|
84
|
+
Executable contract: `security.test.ts`. Rationale: [`docs/architecture/11-ai-surface.md`](../../docs/architecture/11-ai-surface.md).
|
|
85
|
+
|
|
86
|
+
## Their apps are AI-first too
|
|
87
|
+
|
|
88
|
+
A generated app exposes its own MCP surface with one call, so the user's agents can drive the
|
|
89
|
+
user's app:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// apps/admin/src/mcp.ts
|
|
93
|
+
import { defineAppMcp, t } from '@ultimat3/mcp';
|
|
94
|
+
|
|
95
|
+
export const mcp = defineAppMcp({
|
|
96
|
+
name: 'acme-admin',
|
|
97
|
+
include: 'exposed', // every action/query with mcp: { expose: true }
|
|
98
|
+
resources: [orgExport],
|
|
99
|
+
prompts: ['apps/web/app/posts/prompts/summarize.v3.md'],
|
|
100
|
+
tools: {
|
|
101
|
+
seatReport: { // the key IS the tool name
|
|
102
|
+
description: 'Seats used, remaining and the plan limit. Read-only.',
|
|
103
|
+
input: t.object({}), // any Standard Schema
|
|
104
|
+
policy: 'org:administer', // an existing permission, never a new rule
|
|
105
|
+
destructive: false,
|
|
106
|
+
async handle({ ctx }) {
|
|
107
|
+
return seats(await ctx.orgs.byId(ctx.actor.orgId));
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
scopes: { 'admin:seats': ['seatReport'] }, // scope name → tool NAMES, by string
|
|
112
|
+
resolveToken: (token) => sessions.resolveAgentToken(token),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// app.config.ts
|
|
116
|
+
routes: [mcp.route] // POST /mcp, rate-limited per method class
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`include: 'exposed'` reads the action and query registries instead of asking for
|
|
120
|
+
`actions: [...]` / `queries: [...]` — the registries already know who opted in, and a
|
|
121
|
+
second hand-maintained list is a thing that goes stale silently. The explicit arrays still
|
|
122
|
+
work and win over the registry's copy of the same name.
|
|
123
|
+
|
|
124
|
+
The two lists are read differently, on purpose. `include` **sweeps**: it holds every primitive
|
|
125
|
+
the app registered, so one that never opted in is passed over. `actions:`/`queries:` are
|
|
126
|
+
**written out**: naming a primitive there is the request to expose it, so one that never declared
|
|
127
|
+
`mcp: { expose: true }` is `X_MCP_TOOL_UNDECLARED` at boot — a listed tool is never silently
|
|
128
|
+
missing from the catalog, and exposure stays declared next to the policy. Two primitives reaching
|
|
129
|
+
one tool name is `X_MCP_TOOL_DUPLICATE`, also at boot.
|
|
130
|
+
|
|
131
|
+
Both lists take the primitives themselves, exactly as the app declared them:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { publishPost } from '../api/posts';
|
|
135
|
+
|
|
136
|
+
defineAppMcp({ name: 'postly', actions: [publishPost] });
|
|
137
|
+
// X_MCP_TOOL_UNDECLARED unless publishPost declared mcp: { expose: true }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
One adapter serves both routes, so a written-out primitive runs through the same `invoke` (or
|
|
141
|
+
`sourceFor`) the swept one does — the list changes which tools are NAMED, never how one runs.
|
|
142
|
+
An action that was never handed to `defineApi` has no export name, and is
|
|
143
|
+
`X_ACTION_UNREGISTERED` rather than a tool called `''` that nothing could call.
|
|
144
|
+
|
|
145
|
+
A hand-written tool's `policy` is a permission, evaluated through the same `guard()` an
|
|
146
|
+
HTTP request goes through, so a tool cannot acquire a second authz path. A tool without one
|
|
147
|
+
is `X_MCP_TOOL_UNSAFE` at boot, and an unmarked tool is metered as a write.
|
|
148
|
+
|
|
149
|
+
`scopes:` (type `McpScopes`, applied through the exported `withScopes`) is outcome 2's
|
|
150
|
+
declaration surface: a scope name → the TOOL NAMES it covers, however each one reached the
|
|
151
|
+
catalog — a projected action, a projected query, or a key in `tools`. It lives here, not
|
|
152
|
+
beside the action, because a scope is a capability of the CONNECTION's token — what
|
|
153
|
+
`x token grant <scope>` names — not a fact about the operation; the policy beside the action
|
|
154
|
+
stays the only rule that reads the input. A name this server does not project is
|
|
155
|
+
`X_MCP_SCOPE_UNKNOWN` at boot; one tool claimed by two scopes is `X_MCP_SCOPE_CONFLICT`.
|
|
156
|
+
|
|
157
|
+
## Transports
|
|
158
|
+
|
|
159
|
+
| Transport | Entry | Auth |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| HTTP | `mcpHttpRoute({ server, resolveToken })` → `POST /mcp` | `Authorization: Bearer <token>` → `Actor { kind: 'agent' }` |
|
|
162
|
+
| stdio | `serveStdio({ server, caller })` | none — the peer already owns the shell |
|
|
163
|
+
|
|
164
|
+
The HTTP transport exports a route *descriptor*, not a mounted handler: `@ultimat3/http`
|
|
165
|
+
owns the lifecycle, and the descriptor stays drivable from a bare `Request` in a test. It
|
|
166
|
+
carries `rateLimitClass(body)` because all MCP traffic is one URL — a per-route bucket would
|
|
167
|
+
charge `initialize` to the write bucket and throttle an agent on its handshake.
|
|
168
|
+
|
|
169
|
+
Reads: 120/min. Writes: 20/min. Unresolvable calls bill the write bucket (fail-closed).
|
|
170
|
+
|
|
171
|
+
## Resources
|
|
172
|
+
|
|
173
|
+
| URI | Contents |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `ultimate://manifest` | `x.manifest.json` — the generated facts |
|
|
176
|
+
| `ultimate://openapi.json` | OpenAPI 3.1 projected from actions and queries |
|
|
177
|
+
| `ultimate://routes` | route table |
|
|
178
|
+
| `ultimate://schema` | entities, columns, invariants |
|
|
179
|
+
|
|
180
|
+
Providers are injected thunks: `@ultimat3/manifest` and `@ultimat3/render` sit in this same
|
|
181
|
+
tier, so the CLI wires them and this package owns only the shape and the URIs.
|
|
182
|
+
|
|
183
|
+
## Argument validation
|
|
184
|
+
|
|
185
|
+
`tools/list` hands the agent a JSON Schema, so that document is the thing enforced — there is
|
|
186
|
+
no second private validator a tool could be judged against instead. `validate-args.ts`
|
|
187
|
+
implements the emitted subset (objects, arrays, enums, `required`, `additionalProperties`,
|
|
188
|
+
bounds, `default`) and applies declared defaults. Actions still re-parse authoritatively
|
|
189
|
+
inside their own handler.
|
|
190
|
+
|
|
191
|
+
## Errors
|
|
192
|
+
|
|
193
|
+
| Code | Meaning |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `X_MCP_TOOL_UNKNOWN` | no visible tool by that name — absent and role-hidden are one answer |
|
|
196
|
+
| `X_MCP_SCOPE_DENIED` | visible, but the connection's token lacks the scope |
|
|
197
|
+
| `X_MCP_SCOPE_UNKNOWN` | `defineAppMcp`'s `scopes:` names a tool this server does not project |
|
|
198
|
+
| `X_MCP_SCOPE_CONFLICT` | two scopes in `defineAppMcp`'s `scopes:` claim one tool |
|
|
199
|
+
| `X_MCP_ARGS_INVALID` | arguments failed the declared schema |
|
|
200
|
+
| `X_MCP_PROTOCOL` | malformed envelope, unknown method, bad auth header |
|
|
201
|
+
| `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
|
|
202
|
+
| `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server, dev tools, and the action-to-tool projection — one authz system, two surfaces",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/mcp"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/action": "1.0.0",
|
|
34
|
+
"@ultimat3/core": "1.0.0",
|
|
35
|
+
"@ultimat3/entity": "1.0.0",
|
|
36
|
+
"@ultimat3/jobs": "1.0.0",
|
|
37
|
+
"@ultimat3/policy": "1.0.0",
|
|
38
|
+
"@ultimat3/query": "1.0.0",
|
|
39
|
+
"@ultimat3/schema": "1.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/app-tool.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The AUTHORING shape of a hand-written app tool, and its normalization into a runtime one.
|
|
2
|
+
//
|
|
3
|
+
// An app declares tools as a NAMED RECORD — the key is the tool name, so the name is written
|
|
4
|
+
// once — and gives a Standard Schema plus a `resource:verb` permission. None of that is what the
|
|
5
|
+
// registry runs, so this file is the mapping to `ProjectablePrimitive`, which `toolsFrom` then
|
|
6
|
+
// projects exactly as it projects an action. One projection, not two.
|
|
7
|
+
//
|
|
8
|
+
// The load-bearing line is `guard(...)` in `run` below: it is the SAME function `invoke` calls
|
|
9
|
+
// for an HTTP request, reached through the same `@ultimat3/action` policy gate. A hand-written
|
|
10
|
+
// tool therefore has no authz of its own — it borrows the action tier's, so a rule change cannot
|
|
11
|
+
// apply to routes and miss tools.
|
|
12
|
+
|
|
13
|
+
import { actorOf, guard } from '@ultimat3/action';
|
|
14
|
+
import type { Ctx } from '@ultimat3/core';
|
|
15
|
+
import { useContext, withChildContext } from '@ultimat3/core';
|
|
16
|
+
import type { KnownPermission } from '@ultimat3/policy';
|
|
17
|
+
import { can } from '@ultimat3/policy';
|
|
18
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
|
+
import { McpToolUnsafeError } from './errors';
|
|
20
|
+
import type { ProjectablePrimitive } from './from-action';
|
|
21
|
+
import { toWireSchema } from './input-schema';
|
|
22
|
+
import type { McpRole } from './registry';
|
|
23
|
+
|
|
24
|
+
export interface AppToolArgs<TInput extends StandardSchemaV1> {
|
|
25
|
+
readonly input: InferOutput<TInput>;
|
|
26
|
+
/** The ambient request context, with `actor` set to the MCP caller. */
|
|
27
|
+
readonly ctx: Ctx;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AppToolDefinition<TInput extends StandardSchemaV1 = StandardSchemaV1> {
|
|
31
|
+
/** Says what it does AND whether it costs money or sends mail. Agents read only this. */
|
|
32
|
+
readonly description: string;
|
|
33
|
+
/** A Standard Schema. The tool's only argument contract, published by `tools/list`. */
|
|
34
|
+
readonly input: TInput;
|
|
35
|
+
/** An existing `resource:verb` permission. Never a new rule invented for MCP. */
|
|
36
|
+
readonly policy: KnownPermission;
|
|
37
|
+
/** Catalog visibility, not authz — the policy still decides. */
|
|
38
|
+
readonly visibleTo?: readonly McpRole[];
|
|
39
|
+
/**
|
|
40
|
+
* Defaults to `true`. Fail-closed on purpose: an unmarked tool is metered in the strict
|
|
41
|
+
* rate-limit bucket, so forgetting the flag costs throughput rather than safety.
|
|
42
|
+
*/
|
|
43
|
+
readonly destructive?: boolean;
|
|
44
|
+
// Method syntax (not a property) so a tool declared with narrower args stays assignable.
|
|
45
|
+
handle(args: AppToolArgs<TInput>): unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Schema-erased view — what the normalizer walks once the per-tool types have done their job. */
|
|
49
|
+
export interface AnyAppToolDefinition {
|
|
50
|
+
readonly description: string;
|
|
51
|
+
readonly input: StandardSchemaV1;
|
|
52
|
+
readonly policy: KnownPermission;
|
|
53
|
+
readonly visibleTo?: readonly McpRole[];
|
|
54
|
+
readonly destructive?: boolean;
|
|
55
|
+
handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The authored record, keyed by tool name. Written as a mapped type over the schemas so each
|
|
60
|
+
* tool's `handle` infers `input` from that tool's own schema instead of every tool sharing one.
|
|
61
|
+
*/
|
|
62
|
+
export type AppTools<TSchemas extends Readonly<Record<string, StandardSchemaV1>>> = {
|
|
63
|
+
readonly [K in keyof TSchemas]: AppToolDefinition<TSchemas[K]>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Normalize the authored record into primitives, in the record's own key order. */
|
|
67
|
+
export function appToolPrimitives(
|
|
68
|
+
tools: Readonly<Record<string, AnyAppToolDefinition>>,
|
|
69
|
+
): readonly ProjectablePrimitive[] {
|
|
70
|
+
return Object.entries(tools).map(([name, def]) => appToolPrimitive(name, def));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function appToolPrimitive(name: string, def: AnyAppToolDefinition): ProjectablePrimitive {
|
|
74
|
+
// Boot-time, not call-time: an unguarded tool must never reach a client, and a server that
|
|
75
|
+
// starts and then refuses every call is indistinguishable from one that is merely broken.
|
|
76
|
+
if (typeof def.policy !== 'string' || def.policy.length === 0) {
|
|
77
|
+
throw new McpToolUnsafeError({ name });
|
|
78
|
+
}
|
|
79
|
+
const policy = can(def.policy);
|
|
80
|
+
const visibleTo = def.visibleTo;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
description: def.description,
|
|
85
|
+
// Authoring one implies exposing it — a hand-written tool exists only to be a tool.
|
|
86
|
+
mcp: {
|
|
87
|
+
expose: true,
|
|
88
|
+
description: def.description,
|
|
89
|
+
...(visibleTo !== undefined ? { visibleTo } : {}),
|
|
90
|
+
},
|
|
91
|
+
inputJsonSchema: toWireSchema(def.input),
|
|
92
|
+
mutates: def.destructive ?? true,
|
|
93
|
+
run: ({ input, actor }) =>
|
|
94
|
+
// The caller is the actor for the WHOLE call. A child context is how the framework
|
|
95
|
+
// impersonates, so the policy subject and whatever `handle` reads off `ctx.actor` are
|
|
96
|
+
// the same identity by construction rather than by two call sites agreeing.
|
|
97
|
+
withChildContext({ actor }, async () => {
|
|
98
|
+
const ctx = useContext();
|
|
99
|
+
guard(policy, { actor: actorOf(ctx), input, ctx, action: name }, 'mcp');
|
|
100
|
+
return def.handle({ input, ctx });
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/app-tools.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// `defineAppMcp` — one call that makes a GENERATED app's own dashboard AI-first.
|
|
2
|
+
//
|
|
3
|
+
// The framework's dev server drives the framework. This is the other half: a user's app
|
|
4
|
+
// exposes its own actions and queries as MCP tools so the user's agents can drive the
|
|
5
|
+
// user's app. Same projection, same policies, same registry — an app gets a real MCP server
|
|
6
|
+
// for the price of `mcp: { expose: true }` on the primitives it already wrote.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately one function: an app author should never have to know that `ToolRegistry`,
|
|
9
|
+
// `frameworkResources` and `mcpHttpRoute` exist.
|
|
10
|
+
|
|
11
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
12
|
+
import type { AnyAppToolDefinition, AppTools } from './app-tool';
|
|
13
|
+
import { appToolPrimitives } from './app-tool';
|
|
14
|
+
import { McpToolDuplicateError } from './errors';
|
|
15
|
+
import { exposedPrimitives } from './exposed';
|
|
16
|
+
import { toolsFrom, toolsListed } from './from-action';
|
|
17
|
+
import type { ListedPrimitive } from './projectable';
|
|
18
|
+
import { asProjectable } from './projectable';
|
|
19
|
+
import type { AnyMcpTool } from './registry';
|
|
20
|
+
import type { McpPrompt, McpResource } from './resources';
|
|
21
|
+
import { toPrompts } from './resources';
|
|
22
|
+
import type { McpScopes } from './scopes';
|
|
23
|
+
import { withScopes } from './scopes';
|
|
24
|
+
import type { CreateMcpServerInput } from './server';
|
|
25
|
+
import { createMcpServer, type McpServer } from './server';
|
|
26
|
+
import type { McpRouteDescriptor, ResolvedToken } from './transport-http';
|
|
27
|
+
import { mcpHttpRoute } from './transport-http';
|
|
28
|
+
|
|
29
|
+
/** Schema map behind the authored `tools` record; inferred per tool, never written by hand. */
|
|
30
|
+
export type AppToolSchemas = Readonly<Record<string, StandardSchemaV1>>;
|
|
31
|
+
|
|
32
|
+
export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSchemas> {
|
|
33
|
+
/** Server identity the client shows the user. Defaults to the package name at boot. */
|
|
34
|
+
readonly name?: string;
|
|
35
|
+
readonly version?: string;
|
|
36
|
+
/**
|
|
37
|
+
* `'exposed'` projects every registered action and query that declared
|
|
38
|
+
* `mcp: { expose: true }`, and quietly passes over the rest — that list is every primitive the
|
|
39
|
+
* app registered, not one anyone wrote out. Additive: `actions`/`queries` below still work,
|
|
40
|
+
* and an explicitly listed primitive wins over the registry's copy of the same name.
|
|
41
|
+
*/
|
|
42
|
+
readonly include?: 'exposed';
|
|
43
|
+
/**
|
|
44
|
+
* Actions to project, as the app declared them: `actions: [publishPost]`. Naming one here IS
|
|
45
|
+
* the request to expose it, so a listed action that never declared `mcp: { expose: true }` is
|
|
46
|
+
* `X_MCP_TOOL_UNDECLARED` at boot rather than a tool missing from the catalog — exposure stays
|
|
47
|
+
* declared next to the policy, never in this list.
|
|
48
|
+
*/
|
|
49
|
+
readonly actions?: readonly ListedPrimitive[];
|
|
50
|
+
/** Queries to project. Same rule, same error. */
|
|
51
|
+
readonly queries?: readonly ListedPrimitive[];
|
|
52
|
+
/** App-specific readable documents (a catalog export, a report). */
|
|
53
|
+
readonly resources?: readonly McpResource[];
|
|
54
|
+
/** Prompts the app ships: a path to a versioned artifact, or the full descriptor. */
|
|
55
|
+
readonly prompts?: readonly (string | McpPrompt)[];
|
|
56
|
+
/**
|
|
57
|
+
* Hand-written tools for things no primitive covers. Rare — prefer an action. Authored as a
|
|
58
|
+
* record whose KEY is the tool name; the array of ready `McpTool`s stays accepted for
|
|
59
|
+
* surfaces that build their catalog programmatically (`@ultimat3/admin` does).
|
|
60
|
+
*/
|
|
61
|
+
readonly tools?: readonly AnyMcpTool[] | AppTools<TSchemas>;
|
|
62
|
+
/**
|
|
63
|
+
* Scope name → the tools that capability covers, by tool name. The connection gate, and the
|
|
64
|
+
* second of the three outcomes: a caller that may SEE a tool but whose token does not carry
|
|
65
|
+
* its scope is refused `X_MCP_SCOPE_DENIED` naming the scope, BEFORE the policy runs.
|
|
66
|
+
*
|
|
67
|
+
* Declared here rather than on the primitive because a scope is a property of the TOKEN, not
|
|
68
|
+
* of the operation — `x token grant orders:write` and this map name the same thing, and the
|
|
69
|
+
* policy beside the action stays the only rule that reads the input.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* scopes: { 'orders:write': ['refundOrder'], 'catalog:admin': ['reindexCatalog'] },
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
readonly scopes?: McpScopes;
|
|
76
|
+
/** Bearer-token resolution. Omit to expose no HTTP route (stdio/embedded only). */
|
|
77
|
+
resolveToken?(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
78
|
+
/** Mount path. Defaults to `/mcp`. */
|
|
79
|
+
readonly path?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AppMcp {
|
|
83
|
+
readonly server: McpServer;
|
|
84
|
+
/** The projected catalog, so a test can assert exactly what the app exposes. */
|
|
85
|
+
readonly tools: readonly AnyMcpTool[];
|
|
86
|
+
/** `undefined` when no `resolveToken` was given. */
|
|
87
|
+
readonly route: McpRouteDescriptor | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Project an app's primitives into a ready MCP server.
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* // apps/admin/src/mcp.ts
|
|
95
|
+
* export const mcp = defineAppMcp({
|
|
96
|
+
* name: 'acme-admin',
|
|
97
|
+
* include: 'exposed',
|
|
98
|
+
* prompts: ['apps/web/app/posts/prompts/summarize.v3.md'],
|
|
99
|
+
* tools: {
|
|
100
|
+
* seatReport: {
|
|
101
|
+
* description: 'Seats used, remaining and the plan limit. Read-only.',
|
|
102
|
+
* input: t.object({}),
|
|
103
|
+
* policy: 'org:administer',
|
|
104
|
+
* async handle({ ctx }) {
|
|
105
|
+
* return seats(await ctx.orgs.byId(ctx.actor.orgId));
|
|
106
|
+
* },
|
|
107
|
+
* },
|
|
108
|
+
* },
|
|
109
|
+
* resolveToken: (token) => sessions.resolveAgentToken(token),
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export function defineAppMcp<TSchemas extends AppToolSchemas>(
|
|
114
|
+
input: DefineAppMcpInput<TSchemas>,
|
|
115
|
+
): AppMcp {
|
|
116
|
+
// `toolsListed`, not `toolsFrom`: these two arrays are what the author wrote out, so an
|
|
117
|
+
// undeclared entry is a mistake to report, not a primitive to pass over. ONE call over both
|
|
118
|
+
// arrays, because `toolsListed` collects every offender before throwing — calling it twice
|
|
119
|
+
// would throw on the first undeclared action and never look at the queries, so the author
|
|
120
|
+
// fixes one list, re-boots, and meets a second `X_MCP_TOOL_UNDECLARED`.
|
|
121
|
+
const listed = toolsListed(
|
|
122
|
+
[...(input.actions ?? []), ...(input.queries ?? [])].map(asProjectable),
|
|
123
|
+
);
|
|
124
|
+
// An explicitly listed primitive is a refinement of the registry's entry, not a rival to it,
|
|
125
|
+
// so `include` fills the gaps rather than colliding with what the caller already spelled out.
|
|
126
|
+
const included =
|
|
127
|
+
input.include === 'exposed' ? notNamed(toolsFrom(exposedPrimitives()), listed) : [];
|
|
128
|
+
const named = [...listed, ...included, ...handWritten(input.tools)];
|
|
129
|
+
// Unique names FIRST: the scope map addresses tools by name, so a duplicate would make
|
|
130
|
+
// "which tool did this scope gate?" unanswerable before the question is worth asking.
|
|
131
|
+
assertUniqueNames(named);
|
|
132
|
+
const projected = withScopes(named, input.scopes);
|
|
133
|
+
|
|
134
|
+
const config: CreateMcpServerInput = {
|
|
135
|
+
tools: projected,
|
|
136
|
+
resources: input.resources ?? [],
|
|
137
|
+
prompts: toPrompts(input.prompts ?? []),
|
|
138
|
+
serverInfo: { name: input.name ?? 'ultimate-app', version: input.version ?? '0.0.0' },
|
|
139
|
+
};
|
|
140
|
+
const server = createMcpServer(config);
|
|
141
|
+
|
|
142
|
+
const resolveToken = input.resolveToken;
|
|
143
|
+
const route =
|
|
144
|
+
resolveToken === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: mcpHttpRoute({
|
|
147
|
+
server,
|
|
148
|
+
resolveToken,
|
|
149
|
+
...(input.path !== undefined ? { path: input.path } : {}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { server, tools: projected, route };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The record form is normalized into primitives and then handed to the SAME `toolsFrom` that
|
|
157
|
+
* projects an action — which is what makes "one projection, one authz path" true rather than
|
|
158
|
+
* asserted. The array form is already a projected catalog and passes through untouched, so a
|
|
159
|
+
* surface that builds its tools programmatically (`@ultimat3/admin`) keeps working verbatim.
|
|
160
|
+
*/
|
|
161
|
+
function handWritten(
|
|
162
|
+
tools: readonly AnyMcpTool[] | AppTools<AppToolSchemas> | undefined,
|
|
163
|
+
): readonly AnyMcpTool[] {
|
|
164
|
+
if (tools === undefined) return [];
|
|
165
|
+
if (Array.isArray(tools)) return tools as readonly AnyMcpTool[];
|
|
166
|
+
return toolsFrom(appToolPrimitives(tools as Readonly<Record<string, AnyAppToolDefinition>>));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function notNamed(
|
|
170
|
+
tools: readonly AnyMcpTool[],
|
|
171
|
+
exclude: readonly AnyMcpTool[],
|
|
172
|
+
): readonly AnyMcpTool[] {
|
|
173
|
+
const taken = new Set(exclude.map((tool) => tool.name));
|
|
174
|
+
return tools.filter((tool) => !taken.has(tool.name));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A duplicate tool name is caught here rather than at first call: two primitives projecting
|
|
179
|
+
* to one name means the agent silently reaches the wrong one, which is the worst failure
|
|
180
|
+
* mode available. Boot-time, loud, and named.
|
|
181
|
+
*/
|
|
182
|
+
function assertUniqueNames(tools: readonly AnyMcpTool[]): void {
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
for (const tool of tools) {
|
|
185
|
+
if (seen.has(tool.name)) {
|
|
186
|
+
throw new McpToolDuplicateError({ name: tool.name });
|
|
187
|
+
}
|
|
188
|
+
seen.add(tool.name);
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// One structured line per `tools/call`, whatever the outcome — including the outcomes that
|
|
2
|
+
// deliberately tell the caller nothing.
|
|
3
|
+
//
|
|
4
|
+
// The three-outcome model works by giving a prober no signal. That is a property of the
|
|
5
|
+
// ANSWER, not of the system: enumeration is a pattern across many requests, so the refusal
|
|
6
|
+
// that reveals nothing to the caller still has to be visible to whoever reads the logs.
|
|
7
|
+
// Hence ToolNotFound is audited at `warn` — a run of them is the detectable shape of a name
|
|
8
|
+
// walk. Fields carry the decision, never the data it was made about.
|
|
9
|
+
|
|
10
|
+
import type { LogFields, Logger } from '@ultimat3/core';
|
|
11
|
+
import { logger } from '@ultimat3/core';
|
|
12
|
+
import type { McpCaller } from './registry';
|
|
13
|
+
|
|
14
|
+
/** What happened to one `tools/call`. The three security outcomes, plus the two that are not. */
|
|
15
|
+
export type McpOutcome =
|
|
16
|
+
/** Ran, and the tool answered. */
|
|
17
|
+
| 'ok'
|
|
18
|
+
/** OUTCOME 1: absent, or hidden from this caller's role. Answered ToolNotFound. */
|
|
19
|
+
| 'hidden'
|
|
20
|
+
/** OUTCOME 2: visible, but the connection's token lacks the tool's scope. */
|
|
21
|
+
| 'scope-denied'
|
|
22
|
+
/** OUTCOME 3: ran, and the tool's own policy refused this input. */
|
|
23
|
+
| 'policy-denied'
|
|
24
|
+
/** Arguments failed the schema the agent was handed. Not an authz outcome. */
|
|
25
|
+
| 'invalid-args'
|
|
26
|
+
/**
|
|
27
|
+
* The handler threw something that is not an authz denial: a non-authz framework refusal
|
|
28
|
+
* (`db.migrate` aimed at prod), or a bug. Both want a human — a projected action rejecting
|
|
29
|
+
* input MCP already validated means the JSON Schema and the real schema have drifted.
|
|
30
|
+
*/
|
|
31
|
+
| 'failed';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Codes that mean authz said no, wherever in the stack it decided. `X_POLICY_DENIED` used to sit
|
|
35
|
+
* here and matched nothing: `@ultimat3/policy` owns the denial and throws `X_FORBIDDEN`, so the
|
|
36
|
+
* entry classified a code no build can produce — and every real denial that reached it still had
|
|
37
|
+
* to be recognised by one of the other two.
|
|
38
|
+
*/
|
|
39
|
+
const DENIAL_CODES: ReadonlySet<string> = new Set(['X_FORBIDDEN', 'X_UNAUTHENTICATED']);
|
|
40
|
+
|
|
41
|
+
/** Classify a code a tool threw. Denials are outcome 3; everything else wants a human. */
|
|
42
|
+
export function outcomeForCode(code: string): McpOutcome {
|
|
43
|
+
return DENIAL_CODES.has(code) ? 'policy-denied' : 'failed';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface McpAuditEntry {
|
|
47
|
+
readonly tool: string;
|
|
48
|
+
readonly outcome: McpOutcome;
|
|
49
|
+
readonly caller: McpCaller;
|
|
50
|
+
/** The scope the call needed, on a `scope-denied` outcome. */
|
|
51
|
+
readonly scope?: string;
|
|
52
|
+
/** The `X_*` code the outcome carried, when it carried one. */
|
|
53
|
+
readonly code?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Severity per outcome. Every refusal a prober can drive is `warn` so one alert rule covers
|
|
58
|
+
* the whole enumeration surface; `invalid-args` is a well-behaved client misreading a schema,
|
|
59
|
+
* and an unexpected throw is the only `error` because it is the only one that is a bug.
|
|
60
|
+
*/
|
|
61
|
+
const LEVEL: Readonly<Record<McpOutcome, 'info' | 'warn' | 'error'>> = Object.freeze({
|
|
62
|
+
ok: 'info',
|
|
63
|
+
hidden: 'warn',
|
|
64
|
+
'scope-denied': 'warn',
|
|
65
|
+
'policy-denied': 'warn',
|
|
66
|
+
'invalid-args': 'info',
|
|
67
|
+
failed: 'error',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Audit one call. `log` is a parameter so a test can read the line it produced; production
|
|
72
|
+
* callers pass nothing and get the process logger, which is the only sink — there is no
|
|
73
|
+
* switch that turns auditing off.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately NOT logged: the call's arguments and anything the tool loaded. A denial reason
|
|
76
|
+
* that names `post p_42 in org o_9` is a row leak wearing an audit line's clothes.
|
|
77
|
+
*/
|
|
78
|
+
export function auditToolCall(entry: McpAuditEntry, log: Logger = logger): void {
|
|
79
|
+
const fields: LogFields = {
|
|
80
|
+
surface: 'mcp',
|
|
81
|
+
tool: entry.tool,
|
|
82
|
+
outcome: entry.outcome,
|
|
83
|
+
actor: entry.caller.actor.id,
|
|
84
|
+
actorKind: entry.caller.actor.kind,
|
|
85
|
+
...(entry.caller.role === undefined ? {} : { role: entry.caller.role }),
|
|
86
|
+
...(entry.scope === undefined ? {} : { scope: entry.scope }),
|
|
87
|
+
...(entry.code === undefined ? {} : { code: entry.code }),
|
|
88
|
+
};
|
|
89
|
+
// `<subsystem>.<event>.<outcome>`, matching every other structured line in the framework,
|
|
90
|
+
// so `x logs --json | grep mcp.tool-call.hidden` is the whole enumeration alert.
|
|
91
|
+
log[LEVEL[entry.outcome]](`mcp.tool-call.${entry.outcome}`, fields);
|
|
92
|
+
}
|