@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/src/resources.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// MCP resources and prompts: the read-only documents an agent pulls once and keeps.
|
|
2
|
+
//
|
|
3
|
+
// Resources are what stops an agent guessing. Rather than teaching it the framework in a
|
|
4
|
+
// prompt, hand it the generated facts at a stable URI and let it read them. Providers are
|
|
5
|
+
// injected as thunks because `@ultimat3/manifest` and `@ultimat3/render` sit in this same
|
|
6
|
+
// tier — the CLI wires them, this package only defines the shape and the URIs.
|
|
7
|
+
|
|
8
|
+
import type { JsonSchema } from './wire';
|
|
9
|
+
|
|
10
|
+
/** Stable URIs. These are quoted in AGENTS.md files, so treat them as public API. */
|
|
11
|
+
export const RESOURCE_URIS = {
|
|
12
|
+
manifest: 'ultimate://manifest',
|
|
13
|
+
openapi: 'ultimate://openapi.json',
|
|
14
|
+
routes: 'ultimate://routes',
|
|
15
|
+
schema: 'ultimate://schema',
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export interface McpResource {
|
|
19
|
+
readonly uri: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly description: string;
|
|
22
|
+
readonly mimeType: string;
|
|
23
|
+
/** Read on demand — a resource is never eagerly materialised at boot. */
|
|
24
|
+
read(): Promise<string> | string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `resources/list` row (the MCP shape, minus the body). */
|
|
28
|
+
export interface ResourceListEntry {
|
|
29
|
+
readonly uri: string;
|
|
30
|
+
readonly name: string;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly mimeType: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `resources/read` contents entry. */
|
|
36
|
+
export interface ResourceContents {
|
|
37
|
+
readonly uri: string;
|
|
38
|
+
readonly mimeType: string;
|
|
39
|
+
readonly text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A versioned prompt the server offers to clients (`prompts/list`). */
|
|
43
|
+
export interface McpPrompt {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly description: string;
|
|
46
|
+
readonly arguments?: readonly McpPromptArgument[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface McpPromptArgument {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly description: string;
|
|
52
|
+
readonly required?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A prompt may be authored as a path to a versioned prompt artifact
|
|
57
|
+
* (`apps/web/app/posts/prompts/summarize.v3.md`). The file IS the contract, so restating its
|
|
58
|
+
* name and description in a second place is exactly the duplication that goes stale — the name
|
|
59
|
+
* comes from the filename, version suffix included, because `summarize.v2` and `summarize.v3`
|
|
60
|
+
* are two different prompts and an agent must be able to say which one it read.
|
|
61
|
+
*/
|
|
62
|
+
export function promptFromPath(path: string): McpPrompt {
|
|
63
|
+
const file = path.split('/').pop() ?? path;
|
|
64
|
+
const name = file.replace(/\.(md|markdown|txt|prompt)$/i, '');
|
|
65
|
+
return { name, description: `Versioned prompt artifact: ${path}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Accepts either authoring form; an object is already the wire shape and passes through. */
|
|
69
|
+
export function toPrompts(prompts: readonly (string | McpPrompt)[]): readonly McpPrompt[] {
|
|
70
|
+
return prompts.map((prompt) => (typeof prompt === 'string' ? promptFromPath(prompt) : prompt));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The four documents every Ultimate app publishes. Any provider may be omitted. */
|
|
74
|
+
export interface FrameworkResourceProviders {
|
|
75
|
+
/** `x.manifest.json` contents — the generated facts. */
|
|
76
|
+
readonly manifest?: () => Promise<string> | string;
|
|
77
|
+
/** The OpenAPI document projected from actions and queries. */
|
|
78
|
+
readonly openapi?: () => Promise<string> | string;
|
|
79
|
+
/** The route table: URL, render mode, offline strategy, budget. */
|
|
80
|
+
readonly routes?: () => Promise<string> | string;
|
|
81
|
+
/** Entity/column/invariant description — the DB shape as JSON. */
|
|
82
|
+
readonly schema?: () => Promise<string> | string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function frameworkResources(providers: FrameworkResourceProviders): readonly McpResource[] {
|
|
86
|
+
const out: McpResource[] = [];
|
|
87
|
+
if (providers.manifest !== undefined) {
|
|
88
|
+
out.push({
|
|
89
|
+
uri: RESOURCE_URIS.manifest,
|
|
90
|
+
name: 'x.manifest.json',
|
|
91
|
+
description: 'Generated facts: routes, entities, actions, queries, jobs, policies.',
|
|
92
|
+
mimeType: 'application/json',
|
|
93
|
+
read: providers.manifest,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (providers.openapi !== undefined) {
|
|
97
|
+
out.push({
|
|
98
|
+
uri: RESOURCE_URIS.openapi,
|
|
99
|
+
name: 'openapi.json',
|
|
100
|
+
description: 'OpenAPI 3.1 document projected from every action and query.',
|
|
101
|
+
mimeType: 'application/json',
|
|
102
|
+
read: providers.openapi,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (providers.routes !== undefined) {
|
|
106
|
+
out.push({
|
|
107
|
+
uri: RESOURCE_URIS.routes,
|
|
108
|
+
name: 'routes',
|
|
109
|
+
description: 'Route table: url, render mode, offline strategy, hydrate, budget.',
|
|
110
|
+
mimeType: 'application/json',
|
|
111
|
+
read: providers.routes,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (providers.schema !== undefined) {
|
|
115
|
+
out.push({
|
|
116
|
+
uri: RESOURCE_URIS.schema,
|
|
117
|
+
name: 'schema',
|
|
118
|
+
description: 'Entities with columns, types and invariants.',
|
|
119
|
+
mimeType: 'application/json',
|
|
120
|
+
read: providers.schema,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class ResourceRegistry {
|
|
127
|
+
private readonly resources = new Map<string, McpResource>();
|
|
128
|
+
|
|
129
|
+
register(resource: McpResource): this {
|
|
130
|
+
this.resources.set(resource.uri, resource);
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
registerAll(resources: readonly McpResource[]): this {
|
|
135
|
+
for (const r of resources) this.register(r);
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Sorted by URI: a stable list is diffable between two boots. */
|
|
140
|
+
list(): readonly ResourceListEntry[] {
|
|
141
|
+
return [...this.resources.values()]
|
|
142
|
+
.map((r) => ({
|
|
143
|
+
uri: r.uri,
|
|
144
|
+
name: r.name,
|
|
145
|
+
description: r.description,
|
|
146
|
+
mimeType: r.mimeType,
|
|
147
|
+
}))
|
|
148
|
+
.sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async read(uri: string): Promise<ResourceContents | undefined> {
|
|
152
|
+
const resource = this.resources.get(uri);
|
|
153
|
+
if (resource === undefined) return undefined;
|
|
154
|
+
return { uri, mimeType: resource.mimeType, text: await resource.read() };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** JSON Schema for a tool that takes one resource URI — reused by app surfaces. */
|
|
159
|
+
export const URI_ARG_SCHEMA: JsonSchema = {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
uri: { type: 'string', description: `Resource URI, e.g. ${RESOURCE_URIS.manifest}` },
|
|
163
|
+
},
|
|
164
|
+
required: ['uri'],
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
};
|
package/src/scopes.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `defineAppMcp`'s `scopes:` map — OUTCOME 2's declaration surface.
|
|
2
|
+
//
|
|
3
|
+
// Visibility and the policy are declared ON the primitive: one is the tool's audience, the
|
|
4
|
+
// other is its authz rule, and both belong beside the code they guard. A SCOPE is neither.
|
|
5
|
+
// It is a capability of the CONNECTION — what the token was issued to do — so it is grouped
|
|
6
|
+
// here, once per scope, naming the tools that capability covers. That is also why
|
|
7
|
+
// `toolFromAction` never invents one: a projection cannot know what a token means.
|
|
8
|
+
//
|
|
9
|
+
// Without this map the second outcome is unreachable for a generated app. `ToolRegistry`
|
|
10
|
+
// enforces `scope`, the framework's own dev tools declare one, and until 2026-08 nothing an
|
|
11
|
+
// app could write ever set the field — an enforced gate no app declaration could engage.
|
|
12
|
+
|
|
13
|
+
import { McpScopeConflictError, McpScopeUnknownError } from './errors';
|
|
14
|
+
import type { AnyMcpTool } from './registry';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scope name → the tools it covers, BY TOOL NAME. A name, not an object reference: it is the
|
|
18
|
+
* one identifier every tool in the catalog has, whichever way it got there — a projected
|
|
19
|
+
* action or query, a key in the `tools` record, a ready `McpTool` from a programmatic surface.
|
|
20
|
+
* It is also what the wire and a token grant both talk about, so `x token grant orders:write`
|
|
21
|
+
* and this map name the same thing. A typo cannot survive boot — see `withScopes`.
|
|
22
|
+
*/
|
|
23
|
+
export type McpScopes = Readonly<Record<string, readonly string[]>>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Attach each declared scope to the tool it covers, refusing anything ambiguous at BOOT.
|
|
27
|
+
*
|
|
28
|
+
* Two refusals, because both silently ship an ungated tool otherwise:
|
|
29
|
+
*
|
|
30
|
+
* - a name no tool in the catalog answers to (`X_MCP_SCOPE_UNKNOWN`) — a typo, or a primitive
|
|
31
|
+
* that was renamed or never listed. Skipping it leaves the tool reachable with no scope at
|
|
32
|
+
* all, which is the opposite of what the author wrote;
|
|
33
|
+
* - one tool claimed by two scopes (`X_MCP_SCOPE_CONFLICT`) — a tool carries ONE scope, so
|
|
34
|
+
* the second claim would either overwrite the first or be dropped, and which one wins would
|
|
35
|
+
* be decided by object key order.
|
|
36
|
+
*
|
|
37
|
+
* Runs AFTER duplicate names are refused, so a name resolves to exactly one tool.
|
|
38
|
+
*/
|
|
39
|
+
export function withScopes(
|
|
40
|
+
tools: readonly AnyMcpTool[],
|
|
41
|
+
scopes: McpScopes | undefined,
|
|
42
|
+
): readonly AnyMcpTool[] {
|
|
43
|
+
if (scopes === undefined) return tools;
|
|
44
|
+
|
|
45
|
+
const catalog = new Map(tools.map((tool) => [tool.name, tool]));
|
|
46
|
+
const required = new Map<string, string>();
|
|
47
|
+
|
|
48
|
+
for (const [scope, names] of Object.entries(scopes)) {
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
if (!catalog.has(name)) {
|
|
51
|
+
throw new McpScopeUnknownError({ scope, name, projected: [...catalog.keys()].sort() });
|
|
52
|
+
}
|
|
53
|
+
const claimed = required.get(name);
|
|
54
|
+
if (claimed !== undefined && claimed !== scope) {
|
|
55
|
+
throw new McpScopeConflictError({ name, scopes: [claimed, scope] });
|
|
56
|
+
}
|
|
57
|
+
// A tool that arrived with its own `scope` (a ready `McpTool` from a programmatic
|
|
58
|
+
// surface) is claimed too: two sources for one gate is the same ambiguity.
|
|
59
|
+
const declared = catalog.get(name)?.scope;
|
|
60
|
+
if (declared !== undefined && declared !== scope) {
|
|
61
|
+
throw new McpScopeConflictError({ name, scopes: [declared, scope] });
|
|
62
|
+
}
|
|
63
|
+
required.set(name, scope);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return tools.map((tool) => {
|
|
68
|
+
const scope = required.get(tool.name);
|
|
69
|
+
return scope === undefined ? tool : { ...tool, scope };
|
|
70
|
+
});
|
|
71
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// The MCP server: JSON-RPC dispatch over a tool registry, a resource registry and a
|
|
2
|
+
// prompt list. Transport-independent — `handle(body, caller)` takes an already-parsed body
|
|
3
|
+
// and an already-resolved caller, and returns a response or `null` for a notification.
|
|
4
|
+
// Both transports (http, stdio) and every test drive this one function.
|
|
5
|
+
|
|
6
|
+
import { formatIssues } from '@ultimat3/schema';
|
|
7
|
+
import { auditToolCall, outcomeForCode } from './audit';
|
|
8
|
+
import { McpScopeDeniedError } from './errors';
|
|
9
|
+
import type { AnyMcpTool, McpCaller, McpToolResult, McpVerbClass, ToolListEntry } from './registry';
|
|
10
|
+
import { ToolRegistry } from './registry';
|
|
11
|
+
import type { McpPrompt, McpResource } from './resources';
|
|
12
|
+
import { ResourceRegistry } from './resources';
|
|
13
|
+
import type { JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_SERVER_INFO,
|
|
16
|
+
errorResponse,
|
|
17
|
+
INTERNAL_ERROR,
|
|
18
|
+
INVALID_PARAMS,
|
|
19
|
+
INVALID_REQUEST,
|
|
20
|
+
isJsonRpcRequest,
|
|
21
|
+
isNotification,
|
|
22
|
+
MCP_PROTOCOL_VERSION,
|
|
23
|
+
METHOD_NOT_FOUND,
|
|
24
|
+
paramsOf,
|
|
25
|
+
resultResponse,
|
|
26
|
+
} from './wire';
|
|
27
|
+
|
|
28
|
+
export interface CreateMcpServerInput {
|
|
29
|
+
readonly tools?: readonly AnyMcpTool[];
|
|
30
|
+
readonly resources?: readonly McpResource[];
|
|
31
|
+
readonly prompts?: readonly McpPrompt[];
|
|
32
|
+
readonly serverInfo?: ServerInfo;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
|
|
36
|
+
const METHODS = [
|
|
37
|
+
'initialize',
|
|
38
|
+
'tools/list',
|
|
39
|
+
'tools/call',
|
|
40
|
+
'resources/list',
|
|
41
|
+
'resources/read',
|
|
42
|
+
'prompts/list',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
|
|
46
|
+
const tools = new ToolRegistry().registerAll(input.tools ?? []);
|
|
47
|
+
const resources = new ResourceRegistry().registerAll(input.resources ?? []);
|
|
48
|
+
return new McpServer(
|
|
49
|
+
tools,
|
|
50
|
+
resources,
|
|
51
|
+
input.prompts ?? [],
|
|
52
|
+
input.serverInfo ?? DEFAULT_SERVER_INFO,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class McpServer {
|
|
57
|
+
readonly tools: ToolRegistry;
|
|
58
|
+
readonly resources: ResourceRegistry;
|
|
59
|
+
private readonly prompts: readonly McpPrompt[];
|
|
60
|
+
private readonly serverInfo: ServerInfo;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
tools: ToolRegistry,
|
|
64
|
+
resources: ResourceRegistry,
|
|
65
|
+
prompts: readonly McpPrompt[],
|
|
66
|
+
serverInfo: ServerInfo,
|
|
67
|
+
) {
|
|
68
|
+
this.tools = tools;
|
|
69
|
+
this.resources = resources;
|
|
70
|
+
this.prompts = prompts;
|
|
71
|
+
this.serverInfo = serverInfo;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async handle(body: unknown, caller: McpCaller): Promise<JsonRpcResponse | null> {
|
|
75
|
+
if (!isJsonRpcRequest(body)) {
|
|
76
|
+
return errorResponse(null, INVALID_REQUEST, 'not a JSON-RPC 2.0 request envelope');
|
|
77
|
+
}
|
|
78
|
+
// Notifications get no answer at all; the transport replies 202 with an empty body.
|
|
79
|
+
if (isNotification(body)) return null;
|
|
80
|
+
const id = body.id ?? null;
|
|
81
|
+
|
|
82
|
+
switch (body.method) {
|
|
83
|
+
case 'initialize':
|
|
84
|
+
return resultResponse(id, {
|
|
85
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
86
|
+
capabilities: {
|
|
87
|
+
tools: { listChanged: false },
|
|
88
|
+
resources: { subscribe: false, listChanged: false },
|
|
89
|
+
prompts: { listChanged: false },
|
|
90
|
+
},
|
|
91
|
+
serverInfo: this.serverInfo,
|
|
92
|
+
});
|
|
93
|
+
case 'tools/list':
|
|
94
|
+
return resultResponse(id, { tools: this.list(caller) });
|
|
95
|
+
case 'tools/call':
|
|
96
|
+
return this.toolsCall(body, caller);
|
|
97
|
+
case 'resources/list':
|
|
98
|
+
return resultResponse(id, { resources: this.resources.list() });
|
|
99
|
+
case 'resources/read':
|
|
100
|
+
return this.resourcesRead(body);
|
|
101
|
+
case 'prompts/list':
|
|
102
|
+
return resultResponse(id, { prompts: this.prompts });
|
|
103
|
+
default:
|
|
104
|
+
return errorResponse(id, METHOD_NOT_FOUND, `method not found: ${body.method}`, {
|
|
105
|
+
supported: METHODS,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Role-filtered catalog. Exposed so a transport can answer a cheap capability probe. */
|
|
111
|
+
list(caller: McpCaller): readonly ToolListEntry[] {
|
|
112
|
+
return this.tools.list(caller);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Rate-limit class of a body WITHOUT executing it, and WITHOUT a caller — bucket
|
|
117
|
+
* selection is metering, not authorization. All MCP traffic is one `POST /mcp`, so a
|
|
118
|
+
* coarse per-route rule would charge `initialize` and every read to the write bucket and
|
|
119
|
+
* throttle an agent on its handshake.
|
|
120
|
+
*
|
|
121
|
+
* KEEP IN SYNC with `handle`: only `tools/call` can reach a tool, so every other method
|
|
122
|
+
* is protocol chatter that structurally cannot mutate.
|
|
123
|
+
*/
|
|
124
|
+
classify(body: unknown): McpVerbClass {
|
|
125
|
+
if (!isJsonRpcRequest(body) || body.method !== 'tools/call') return 'read';
|
|
126
|
+
const name = paramsOf(body)?.['name'];
|
|
127
|
+
// An unresolvable call is refused before it runs, so charging it the strict bucket
|
|
128
|
+
// only costs a broken client — it never hands an unproven verb the cheap one.
|
|
129
|
+
if (typeof name !== 'string') return 'write';
|
|
130
|
+
return this.tools.verbClass(name);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async toolsCall(req: JsonRpcRequest, caller: McpCaller): Promise<JsonRpcResponse> {
|
|
134
|
+
const id = req.id ?? null;
|
|
135
|
+
const params = paramsOf(req);
|
|
136
|
+
if (params === null) return errorResponse(id, INVALID_PARAMS, 'tools/call requires params');
|
|
137
|
+
const name = params['name'];
|
|
138
|
+
if (typeof name !== 'string') {
|
|
139
|
+
return errorResponse(id, INVALID_PARAMS, 'tools/call params.name must be a string');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Three outcomes, deliberately different — and every one of them audited, including the
|
|
143
|
+
// one that tells the caller nothing. See `audit.ts`.
|
|
144
|
+
const resolved = this.tools.resolve(name, params['arguments'] ?? {}, caller);
|
|
145
|
+
switch (resolved.kind) {
|
|
146
|
+
// OUTCOME 1. Absent AND role-hidden collapse to the same answer, with no `data` at
|
|
147
|
+
// all: any extra field would be the difference a prober is looking for.
|
|
148
|
+
case 'not-found':
|
|
149
|
+
auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
|
|
150
|
+
return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name}`);
|
|
151
|
+
// OUTCOME 2. The caller can already see this tool, so naming the missing scope leaks
|
|
152
|
+
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
153
|
+
case 'scope-denied': {
|
|
154
|
+
const denial = new McpScopeDeniedError({ name, scope: resolved.scope });
|
|
155
|
+
auditToolCall({
|
|
156
|
+
tool: name,
|
|
157
|
+
outcome: 'scope-denied',
|
|
158
|
+
caller,
|
|
159
|
+
scope: resolved.scope,
|
|
160
|
+
code: denial.code,
|
|
161
|
+
});
|
|
162
|
+
return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
|
|
163
|
+
code: denial.code,
|
|
164
|
+
scope: resolved.scope,
|
|
165
|
+
fix: denial.fix,
|
|
166
|
+
docs: denial.docs,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
case 'invalid-args':
|
|
170
|
+
auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: 'X_MCP_ARGS_INVALID' });
|
|
171
|
+
return errorResponse(id, INVALID_PARAMS, `invalid arguments for ${name}`, {
|
|
172
|
+
code: 'X_MCP_ARGS_INVALID',
|
|
173
|
+
issues: formatIssues(resolved.issues),
|
|
174
|
+
});
|
|
175
|
+
case 'ok':
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let result: McpToolResult;
|
|
180
|
+
try {
|
|
181
|
+
result = await resolved.tool.handle(resolved.args, caller);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
// OUTCOME 3 arrives here: the tool ran its policy through `guard()` and the policy
|
|
184
|
+
// said no. An UltimateError is an EXPECTED outcome the model can act on, so it comes
|
|
185
|
+
// back as an `isError` result carrying code/cause/fix — the same three lines an HTTP
|
|
186
|
+
// caller gets for the same call — rather than an opaque transport failure.
|
|
187
|
+
const framework = asFrameworkError(error);
|
|
188
|
+
if (framework !== undefined) {
|
|
189
|
+
auditToolCall({
|
|
190
|
+
tool: name,
|
|
191
|
+
outcome: outcomeForCode(framework.code),
|
|
192
|
+
caller,
|
|
193
|
+
code: framework.code,
|
|
194
|
+
});
|
|
195
|
+
return resultResponse(id, {
|
|
196
|
+
content: [{ type: 'text', text: renderFrameworkError(framework) }],
|
|
197
|
+
isError: true,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
auditToolCall({ tool: name, outcome: 'failed', caller });
|
|
201
|
+
return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A tool may answer `isError` itself (admin renders its own denial): still outcome 3.
|
|
205
|
+
auditToolCall({
|
|
206
|
+
tool: name,
|
|
207
|
+
outcome: result.isError === true ? 'policy-denied' : 'ok',
|
|
208
|
+
caller,
|
|
209
|
+
});
|
|
210
|
+
const payload: Record<string, unknown> = { content: result.content };
|
|
211
|
+
if (result.isError === true) payload['isError'] = true;
|
|
212
|
+
return resultResponse(id, payload);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async resourcesRead(req: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
216
|
+
const id = req.id ?? null;
|
|
217
|
+
const uri = paramsOf(req)?.['uri'];
|
|
218
|
+
if (typeof uri !== 'string') {
|
|
219
|
+
return errorResponse(id, INVALID_PARAMS, 'resources/read params.uri must be a string');
|
|
220
|
+
}
|
|
221
|
+
const contents = await this.resources.read(uri);
|
|
222
|
+
if (contents === undefined) {
|
|
223
|
+
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`, {
|
|
224
|
+
available: this.resources.list().map((r) => r.uri),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return resultResponse(id, { contents: [contents] });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface FrameworkError {
|
|
232
|
+
readonly code: string;
|
|
233
|
+
/** `''` for a foreign thrown object that carries no title. See `renderFrameworkError`. */
|
|
234
|
+
readonly title: string;
|
|
235
|
+
readonly cause: string;
|
|
236
|
+
readonly fix: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read a thrown framework error, or `undefined` when it is not one (a genuine bug, which
|
|
241
|
+
* becomes `-32603` with no internals leaked). Structural rather than `instanceof`: the
|
|
242
|
+
* transport must stay independent of which package threw.
|
|
243
|
+
*/
|
|
244
|
+
function asFrameworkError(error: unknown): FrameworkError | undefined {
|
|
245
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
246
|
+
const e = error as { code?: unknown; title?: unknown; cause?: unknown; fix?: unknown };
|
|
247
|
+
if (typeof e.code !== 'string' || !e.code.startsWith('X_')) return undefined;
|
|
248
|
+
return {
|
|
249
|
+
code: e.code,
|
|
250
|
+
title: typeof e.title === 'string' ? e.title : '',
|
|
251
|
+
cause: typeof e.cause === 'string' ? e.cause : 'unknown',
|
|
252
|
+
fix: typeof e.fix === 'string' ? e.fix : 'see docs',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The agent-readable form, BYTE-IDENTICAL to `UltimateError.format()` — one denial reads the
|
|
258
|
+
* same over MCP as it does in the terminal, so an agent that learned the shape from `x` does
|
|
259
|
+
* not have to learn a second one here. Dropping the title would be a second rendering of the
|
|
260
|
+
* same contract, and the two would drift.
|
|
261
|
+
*
|
|
262
|
+
* The bare-`code` head is the fallback for a foreign thrown object that carries `code`/`cause`
|
|
263
|
+
* but no title; a real `UltimateError` always has one.
|
|
264
|
+
*/
|
|
265
|
+
function renderFrameworkError(error: FrameworkError): string {
|
|
266
|
+
const head = error.title === '' ? error.code : `${error.code}: ${error.title}`;
|
|
267
|
+
return `${head}\n cause: ${error.cause}\n fix: ${error.fix}`;
|
|
268
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// `POST /mcp` — the HTTP transport.
|
|
2
|
+
//
|
|
3
|
+
// Exported as a route DESCRIPTOR rather than a mounted handler: `@ultimat3/http` owns the
|
|
4
|
+
// lifecycle (ALS context, tracing, rate limiting) and mounts this, while the descriptor
|
|
5
|
+
// stays drivable from a bare `Request` in a test. Two things travel with it that a generic
|
|
6
|
+
// route table cannot infer:
|
|
7
|
+
//
|
|
8
|
+
// 1. `rateLimitClass(body)` — all MCP traffic is one URL, so a per-route bucket would
|
|
9
|
+
// charge `initialize` and every read to the write bucket and throttle an agent on its
|
|
10
|
+
// handshake. The server classifies each body instead.
|
|
11
|
+
// 2. `authenticate` — a bearer token resolves to an Actor of kind 'agent'. An agent is
|
|
12
|
+
// never silently upgraded to the user behind the token; policies see 'agent' and can
|
|
13
|
+
// refuse what a human would be allowed.
|
|
14
|
+
|
|
15
|
+
import type { Actor } from '@ultimat3/core';
|
|
16
|
+
import type { McpCaller, McpRole, McpVerbClass } from './registry';
|
|
17
|
+
import type { McpServer } from './server';
|
|
18
|
+
import type { JsonRpcResponse } from './wire';
|
|
19
|
+
import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
|
|
20
|
+
|
|
21
|
+
/** Requests per minute per token, by class. Reads are cheap; a write may run migrations. */
|
|
22
|
+
export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
|
|
23
|
+
read: 120,
|
|
24
|
+
write: 20,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What a token resolves to. `null` = unauthenticated, answered 401 with no catalog. */
|
|
28
|
+
export interface ResolvedToken {
|
|
29
|
+
readonly actor: Actor;
|
|
30
|
+
readonly scopes: ReadonlySet<string>;
|
|
31
|
+
readonly role?: McpRole;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface McpHttpTransportInput {
|
|
35
|
+
readonly server: McpServer;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve an OAuth bearer / personal token to a caller. The framework supplies the token
|
|
38
|
+
* string only — credential storage belongs to `@ultimat3/policy` and the app, never here.
|
|
39
|
+
*/
|
|
40
|
+
resolveToken(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
|
|
41
|
+
/** Route path. Overridable so an app can mount a second, app-scoped surface. */
|
|
42
|
+
readonly path?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface McpRouteDescriptor {
|
|
46
|
+
readonly method: 'POST';
|
|
47
|
+
readonly path: string;
|
|
48
|
+
/** Bucket for one already-parsed body. Metering only — never an authz decision. */
|
|
49
|
+
rateLimitClass(body: unknown): McpVerbClass;
|
|
50
|
+
readonly limits: Readonly<Record<McpVerbClass, number>>;
|
|
51
|
+
handle(request: Request): Promise<Response>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const JSON_HEADERS = { 'content-type': 'application/json' } as const;
|
|
55
|
+
|
|
56
|
+
export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
57
|
+
const { server } = input;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
path: input.path ?? '/mcp',
|
|
62
|
+
limits: MCP_RATE_LIMITS,
|
|
63
|
+
rateLimitClass: (body) => server.classify(body),
|
|
64
|
+
|
|
65
|
+
async handle(request: Request): Promise<Response> {
|
|
66
|
+
const token = bearerToken(request);
|
|
67
|
+
if (token === null) {
|
|
68
|
+
// 401 before parsing: an unauthenticated caller learns nothing about the catalog,
|
|
69
|
+
// not even whether its JSON was well formed.
|
|
70
|
+
return unauthorized();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let body: unknown;
|
|
74
|
+
try {
|
|
75
|
+
body = await request.json();
|
|
76
|
+
} catch {
|
|
77
|
+
return json(errorResponse(null, PARSE_ERROR, 'request body is not valid JSON'), 400);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolved = await input.resolveToken(token);
|
|
81
|
+
if (resolved === null) return unauthorized();
|
|
82
|
+
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
83
|
+
|
|
84
|
+
const caller: McpCaller = {
|
|
85
|
+
actor: resolved.actor,
|
|
86
|
+
scopes: resolved.scopes,
|
|
87
|
+
...(resolved.role !== undefined ? { role: resolved.role } : {}),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const response = await server.handle(body, caller);
|
|
91
|
+
// A notification has no response. 202 with an empty body is the MCP-correct answer.
|
|
92
|
+
if (response === null) return new Response(null, { status: 202 });
|
|
93
|
+
// JSON-RPC errors are 200s: the transport succeeded, the call did not. Only a
|
|
94
|
+
// malformed envelope (below) is an HTTP-level failure.
|
|
95
|
+
const status = response.error?.code === INVALID_REQUEST && response.id === null ? 400 : 200;
|
|
96
|
+
return json(response, status);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `Authorization: Bearer <token>`, the only accepted form. No query-string tokens. */
|
|
102
|
+
export function bearerToken(request: Request): string | null {
|
|
103
|
+
const header = request.headers.get('authorization');
|
|
104
|
+
if (header === null) return null;
|
|
105
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
106
|
+
return match?.[1] ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A token-authenticated MCP caller is ALWAYS `kind: 'agent'`, never the human the token
|
|
111
|
+
* belongs to. Enforced here rather than trusted from `resolveToken`, so a policy that says
|
|
112
|
+
* "agents may not do this" cannot be bypassed by an app handing back a user actor.
|
|
113
|
+
*/
|
|
114
|
+
export function isAgentActor(actor: Actor): boolean {
|
|
115
|
+
return (actor as { kind?: unknown }).kind === 'agent';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function notAnAgent(): Response {
|
|
119
|
+
return new Response(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
code: 'X_MCP_PROTOCOL',
|
|
122
|
+
cause: 'resolveToken returned an actor whose kind is not "agent"',
|
|
123
|
+
fix: 'return { kind: "agent", ... } from resolveToken; MCP callers are agents, not users',
|
|
124
|
+
}),
|
|
125
|
+
{ status: 403, headers: JSON_HEADERS },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function unauthorized(): Response {
|
|
130
|
+
return new Response(
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
code: 'X_MCP_PROTOCOL',
|
|
133
|
+
cause: 'missing or unrecognised bearer token',
|
|
134
|
+
fix: 'x token create --scopes dev:read, then send Authorization: Bearer <token>',
|
|
135
|
+
}),
|
|
136
|
+
{
|
|
137
|
+
status: 401,
|
|
138
|
+
headers: { ...JSON_HEADERS, 'www-authenticate': 'Bearer realm="ultimate-mcp"' },
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function json(response: JsonRpcResponse, status: number): Response {
|
|
144
|
+
return new Response(JSON.stringify(response), { status, headers: JSON_HEADERS });
|
|
145
|
+
}
|