@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The free-tool projection: an `action` (or `query`) with `mcp.expose` becomes an MCP tool
|
|
2
|
+
// at zero authorization cost.
|
|
3
|
+
//
|
|
4
|
+
// The whole claim rests on one line in `handle` below: the tool calls `action.run(...)` —
|
|
5
|
+
// the SAME entry point the HTTP route calls. Policy evaluation lives inside `run`, so
|
|
6
|
+
// there is nothing here to keep in sync and no second authz system to drift. This
|
|
7
|
+
// projection therefore INVENTS no `scope`: a scope is a capability of the connection's
|
|
8
|
+
// token, which a projection cannot know anything about. An app that wants one names the
|
|
9
|
+
// tool in `defineAppMcp`'s `scopes:` map (see `scopes.ts`) — declared once, next to the
|
|
10
|
+
// other tools that same token capability covers, never guessed from the action.
|
|
11
|
+
//
|
|
12
|
+
// `toMcpTool` in @ultimat3/action owns the schema half of the projection (input schema →
|
|
13
|
+
// JSON Schema); this file owns the execution half.
|
|
14
|
+
|
|
15
|
+
import type { Actor } from '@ultimat3/core';
|
|
16
|
+
import { McpToolUndeclaredError } from './errors';
|
|
17
|
+
import type { AnyMcpTool, McpCaller, McpRole, McpToolResult, ToolArgs } from './registry';
|
|
18
|
+
import { jsonResult } from './registry';
|
|
19
|
+
import type { JsonSchema } from './wire';
|
|
20
|
+
import { NO_ARGS } from './wire';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* MCP exposure as declared on an action/query (`mcp: { expose: true, description }`).
|
|
24
|
+
* `visibleTo` restricts which roles may enumerate the tool; it is a catalog concern, not
|
|
25
|
+
* an authz one — the policy still decides.
|
|
26
|
+
*/
|
|
27
|
+
export interface McpExposure {
|
|
28
|
+
readonly expose?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Contract text, not UI text: the same string is the OpenAPI operation `summary`, and
|
|
31
|
+
* `buildOpenApi`'s bytes are what `x verify` diffs. Routing it through the ambient,
|
|
32
|
+
* request-scoped `t()` would make that artifact locale-dependent — see
|
|
33
|
+
* `ActionMcp.description` in @ultimat3/action.
|
|
34
|
+
*/
|
|
35
|
+
readonly description?: string;
|
|
36
|
+
readonly visibleTo?: readonly McpRole[];
|
|
37
|
+
/** Override the projected tool name. Defaults to the primitive's own name. */
|
|
38
|
+
readonly name?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The surface this projection needs from a primitive. Structurally satisfied by `Action`
|
|
43
|
+
* and by `query`'s runnable handle from @ultimat3/action / @ultimat3/query — restated here
|
|
44
|
+
* (rather than imported as a generic) so the projection is testable with a fake and does
|
|
45
|
+
* not bind to either package's type parameters.
|
|
46
|
+
*/
|
|
47
|
+
export interface ProjectablePrimitive {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly description?: string;
|
|
50
|
+
readonly mcp?: McpExposure;
|
|
51
|
+
/** JSON Schema of the input, as produced by `toMcpTool`. */
|
|
52
|
+
readonly inputJsonSchema?: JsonSchema;
|
|
53
|
+
/** True for a mutation. Drives the rate-limit bucket; queries set it false. */
|
|
54
|
+
readonly mutates?: boolean;
|
|
55
|
+
/** The one server-authoritative entry point. Runs policy, then the handler. */
|
|
56
|
+
run(args: { input: unknown; actor: Actor }): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing. */
|
|
60
|
+
export function isExposed(primitive: ProjectablePrimitive): boolean {
|
|
61
|
+
return primitive.mcp?.expose === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Project one primitive. Throws nothing, and does not re-check exposure: the two list
|
|
66
|
+
* projections below decide what an un-exposed primitive means — skip it, or refuse it.
|
|
67
|
+
*/
|
|
68
|
+
export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
|
|
69
|
+
const name = primitive.mcp?.name ?? primitive.name;
|
|
70
|
+
const description =
|
|
71
|
+
primitive.mcp?.description ?? primitive.description ?? `Run the "${primitive.name}" action.`;
|
|
72
|
+
const mutates = primitive.mutates ?? true;
|
|
73
|
+
const visibleTo = primitive.mcp?.visibleTo;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
description,
|
|
78
|
+
inputSchema: primitive.inputJsonSchema ?? NO_ARGS,
|
|
79
|
+
destructive: mutates,
|
|
80
|
+
...(visibleTo !== undefined ? { visibleTo } : {}),
|
|
81
|
+
// No `scope` from here: see the header. `defineAppMcp`'s `scopes:` map may add one.
|
|
82
|
+
async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
|
|
83
|
+
// ONE authz system, TWO surfaces. HTTP does exactly this call with an actor of
|
|
84
|
+
// kind 'user'; MCP does it with kind 'agent'. Same policy, same decision.
|
|
85
|
+
const output = await primitive.run({ input: args, actor: caller.actor });
|
|
86
|
+
return jsonResult(output);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Alias that reads correctly at a query call site — same projection, same guarantees. */
|
|
92
|
+
export const toolFromQuery = toolFromAction;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Project every exposed primitive, SKIPPING the rest. Stable name order.
|
|
96
|
+
*
|
|
97
|
+
* For a swept list — every primitive the registries hold — where the un-exposed ones are the
|
|
98
|
+
* normal case and dropping them is the whole job. A list the author wrote out by hand goes
|
|
99
|
+
* through `toolsListed`, which refuses instead.
|
|
100
|
+
*/
|
|
101
|
+
export function toolsFrom(primitives: readonly ProjectablePrimitive[]): readonly AnyMcpTool[] {
|
|
102
|
+
return primitives
|
|
103
|
+
.filter(isExposed)
|
|
104
|
+
.map(toolFromAction)
|
|
105
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Project a list the AUTHOR wrote out, REFUSING any primitive that never opted in.
|
|
110
|
+
*
|
|
111
|
+
* Naming a primitive in `defineAppMcp` is the request to expose it, so silence there is a
|
|
112
|
+
* contradiction rather than an opt-out — and a silently dropped entry ships a server whose
|
|
113
|
+
* catalog is missing a tool its author believes is in it. Every offender is collected before
|
|
114
|
+
* throwing so one edit closes all of them.
|
|
115
|
+
*/
|
|
116
|
+
export function toolsListed(primitives: readonly ProjectablePrimitive[]): readonly AnyMcpTool[] {
|
|
117
|
+
const undeclared = primitives.filter((primitive) => !isExposed(primitive));
|
|
118
|
+
if (undeclared.length > 0) {
|
|
119
|
+
// The primitive's own name, not `mcp.name`: it is what the author greps for.
|
|
120
|
+
throw new McpToolUndeclaredError({ names: undeclared.map((primitive) => primitive.name) });
|
|
121
|
+
}
|
|
122
|
+
return toolsFrom(primitives);
|
|
123
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Public API of @ultimat3/mcp. Explicit — nothing is re-exported by wildcard, so the
|
|
2
|
+
// surface an app or an agent can reach is exactly this list.
|
|
3
|
+
|
|
4
|
+
/** Re-exported so a `defineAppMcp` file needs one import, not two. Same object as schema's. */
|
|
5
|
+
export type { Infer } from '@ultimat3/schema';
|
|
6
|
+
export { formatIssues, t } from '@ultimat3/schema';
|
|
7
|
+
export type {
|
|
8
|
+
AnyAppToolDefinition,
|
|
9
|
+
AppToolArgs,
|
|
10
|
+
AppToolDefinition,
|
|
11
|
+
AppTools,
|
|
12
|
+
} from './app-tool';
|
|
13
|
+
export { appToolPrimitive, appToolPrimitives } from './app-tool';
|
|
14
|
+
export type { AppMcp, AppToolSchemas, DefineAppMcpInput } from './app-tools';
|
|
15
|
+
export { defineAppMcp } from './app-tools';
|
|
16
|
+
export type { McpAuditEntry, McpOutcome } from './audit';
|
|
17
|
+
export { auditToolCall, outcomeForCode } from './audit';
|
|
18
|
+
export type { CreateDevServerInput } from './dev-host';
|
|
19
|
+
export { createDevServer, devHost, frameworkIntrospection } from './dev-host';
|
|
20
|
+
export type {
|
|
21
|
+
DevCapabilities,
|
|
22
|
+
DevHost,
|
|
23
|
+
DevIntrospection,
|
|
24
|
+
ErrorExplanation,
|
|
25
|
+
MigrateResult,
|
|
26
|
+
QueueDepth,
|
|
27
|
+
TestRun,
|
|
28
|
+
VerifyResult,
|
|
29
|
+
VerifyStep,
|
|
30
|
+
} from './dev-server';
|
|
31
|
+
export { DEV_SCOPES, devTools } from './dev-server';
|
|
32
|
+
export type { McpErrorCode } from './errors';
|
|
33
|
+
export {
|
|
34
|
+
MCP_ERROR_CODES,
|
|
35
|
+
MCP_ERROR_TITLES,
|
|
36
|
+
McpArgsInvalidError,
|
|
37
|
+
McpNotBranchDbError,
|
|
38
|
+
McpProtocolError,
|
|
39
|
+
McpQueryRejectedError,
|
|
40
|
+
McpScopeConflictError,
|
|
41
|
+
McpScopeDeniedError,
|
|
42
|
+
McpScopeUnknownError,
|
|
43
|
+
McpToolDuplicateError,
|
|
44
|
+
McpToolUndeclaredError,
|
|
45
|
+
McpToolUnknownError,
|
|
46
|
+
McpToolUnsafeError,
|
|
47
|
+
} from './errors';
|
|
48
|
+
export { exposedPrimitives } from './exposed';
|
|
49
|
+
export type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
50
|
+
export {
|
|
51
|
+
isExposed,
|
|
52
|
+
toolFromAction,
|
|
53
|
+
toolFromQuery,
|
|
54
|
+
toolsFrom,
|
|
55
|
+
toolsListed,
|
|
56
|
+
} from './from-action';
|
|
57
|
+
export { toWireSchema } from './input-schema';
|
|
58
|
+
export type { ListedPrimitive } from './projectable';
|
|
59
|
+
export { asProjectable } from './projectable';
|
|
60
|
+
export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
|
|
61
|
+
export {
|
|
62
|
+
capQueryRows,
|
|
63
|
+
DEFAULT_QUERY_ROWS,
|
|
64
|
+
QUERY_LIMITS,
|
|
65
|
+
resolveQueryLimits,
|
|
66
|
+
} from './query-limits';
|
|
67
|
+
export type { DatabaseTarget } from './readonly-sql';
|
|
68
|
+
export { assertBranchDatabase, assertReadOnlyQuery, PARSE_GUARD } from './readonly-sql';
|
|
69
|
+
export type {
|
|
70
|
+
AnyMcpTool,
|
|
71
|
+
ContentBlock,
|
|
72
|
+
McpCaller,
|
|
73
|
+
McpRole,
|
|
74
|
+
McpTool,
|
|
75
|
+
McpToolResult,
|
|
76
|
+
McpVerbClass,
|
|
77
|
+
McpVisibility,
|
|
78
|
+
ToolArgs,
|
|
79
|
+
ToolListEntry,
|
|
80
|
+
ToolResolution,
|
|
81
|
+
} from './registry';
|
|
82
|
+
export { jsonResult, ToolRegistry, textResult, visibleToCaller } from './registry';
|
|
83
|
+
export type {
|
|
84
|
+
FrameworkResourceProviders,
|
|
85
|
+
McpPrompt,
|
|
86
|
+
McpPromptArgument,
|
|
87
|
+
McpResource,
|
|
88
|
+
ResourceContents,
|
|
89
|
+
ResourceListEntry,
|
|
90
|
+
} from './resources';
|
|
91
|
+
export {
|
|
92
|
+
frameworkResources,
|
|
93
|
+
promptFromPath,
|
|
94
|
+
RESOURCE_URIS,
|
|
95
|
+
ResourceRegistry,
|
|
96
|
+
toPrompts,
|
|
97
|
+
URI_ARG_SCHEMA,
|
|
98
|
+
} from './resources';
|
|
99
|
+
export type { McpScopes } from './scopes';
|
|
100
|
+
export { withScopes } from './scopes';
|
|
101
|
+
export type { CreateMcpServerInput } from './server';
|
|
102
|
+
export { createMcpServer, McpServer } from './server';
|
|
103
|
+
export type {
|
|
104
|
+
McpHttpTransportInput,
|
|
105
|
+
McpRouteDescriptor,
|
|
106
|
+
ResolvedToken,
|
|
107
|
+
} from './transport-http';
|
|
108
|
+
export { bearerToken, isAgentActor, MCP_RATE_LIMITS, mcpHttpRoute } from './transport-http';
|
|
109
|
+
export type { StdioTransportInput } from './transport-stdio';
|
|
110
|
+
export { serveStdio } from './transport-stdio';
|
|
111
|
+
export type { ArgIssue, ArgValidation } from './validate-args';
|
|
112
|
+
export { validateArgs } from './validate-args';
|
|
113
|
+
export type {
|
|
114
|
+
JsonRpcError,
|
|
115
|
+
JsonRpcId,
|
|
116
|
+
JsonRpcRequest,
|
|
117
|
+
JsonRpcResponse,
|
|
118
|
+
JsonSchema,
|
|
119
|
+
ServerInfo,
|
|
120
|
+
} from './wire';
|
|
121
|
+
export {
|
|
122
|
+
DEFAULT_SERVER_INFO,
|
|
123
|
+
errorResponse,
|
|
124
|
+
INTERNAL_ERROR,
|
|
125
|
+
INVALID_PARAMS,
|
|
126
|
+
INVALID_REQUEST,
|
|
127
|
+
isJsonRpcRequest,
|
|
128
|
+
MCP_PROTOCOL_VERSION,
|
|
129
|
+
METHOD_NOT_FOUND,
|
|
130
|
+
NO_ARGS,
|
|
131
|
+
PARSE_ERROR,
|
|
132
|
+
resultResponse,
|
|
133
|
+
} from './wire';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// A Standard Schema (`t.object({...})`, Zod, Valibot) -> the JSON Schema subset the wire speaks.
|
|
2
|
+
//
|
|
3
|
+
// Two `JsonSchema` types exist on purpose. `@ultimat3/schema` emits the full draft-07 vocabulary;
|
|
4
|
+
// `wire.ts` declares the narrow subset `validate-args.ts` can actually ENFORCE. Handing an agent a
|
|
5
|
+
// keyword the server ignores is worse than omitting it — the agent obeys a rule nothing checks and
|
|
6
|
+
// gets a silent pass. So this is a real projection, not a cast: a keyword outside the subset is
|
|
7
|
+
// dropped here, and `tools/list` publishes only what the resolver will hold a call to.
|
|
8
|
+
|
|
9
|
+
import type { JsonSchema as RichJsonSchema } from '@ultimat3/schema';
|
|
10
|
+
import { toMcpInputSchema } from '@ultimat3/schema';
|
|
11
|
+
import type { JsonSchema } from './wire';
|
|
12
|
+
|
|
13
|
+
/** Introspect any Standard Schema and narrow the result to the wire subset. */
|
|
14
|
+
export function toWireSchema(schema: unknown): JsonSchema {
|
|
15
|
+
return narrow(toMcpInputSchema(schema));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function narrow(source: RichJsonSchema): JsonSchema {
|
|
19
|
+
// exactOptionalPropertyTypes: every field is attached only when it is present, never as
|
|
20
|
+
// an explicit `undefined` — `tools/list` serialises this object verbatim.
|
|
21
|
+
return {
|
|
22
|
+
...(source.type === undefined ? {} : { type: source.type }),
|
|
23
|
+
...(source.title === undefined ? {} : { title: source.title }),
|
|
24
|
+
...(source.description === undefined ? {} : { description: source.description }),
|
|
25
|
+
...(source.properties === undefined ? {} : { properties: narrowProperties(source.properties) }),
|
|
26
|
+
...(source.required === undefined ? {} : { required: source.required }),
|
|
27
|
+
...narrowAdditional(source.additionalProperties),
|
|
28
|
+
...(source.items === undefined ? {} : { items: narrow(source.items) }),
|
|
29
|
+
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
30
|
+
...(source.const === undefined ? {} : { const: source.const }),
|
|
31
|
+
...(source.default === undefined ? {} : { default: source.default }),
|
|
32
|
+
...(source.format === undefined ? {} : { format: source.format }),
|
|
33
|
+
...(source.minimum === undefined ? {} : { minimum: source.minimum }),
|
|
34
|
+
...(source.maximum === undefined ? {} : { maximum: source.maximum }),
|
|
35
|
+
...(source.minLength === undefined ? {} : { minLength: source.minLength }),
|
|
36
|
+
...(source.maxLength === undefined ? {} : { maxLength: source.maxLength }),
|
|
37
|
+
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(narrow) }),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function narrowProperties(
|
|
42
|
+
properties: Readonly<Record<string, RichJsonSchema>>,
|
|
43
|
+
): Readonly<Record<string, JsonSchema>> {
|
|
44
|
+
const out: Record<string, JsonSchema> = {};
|
|
45
|
+
for (const [key, child] of Object.entries(properties)) out[key] = narrow(child);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A record schema says "extra keys, shaped like this". The wire subset can only say yes or no,
|
|
51
|
+
* and `validate-args.ts` reads `false` as "reject unknown keys" — so an unrepresentable value
|
|
52
|
+
* schema becomes `true` (permit) rather than a rejection the agent was never warned about.
|
|
53
|
+
*/
|
|
54
|
+
function narrowAdditional(value: boolean | RichJsonSchema | undefined): {
|
|
55
|
+
readonly additionalProperties?: boolean;
|
|
56
|
+
} {
|
|
57
|
+
if (value === undefined) return {};
|
|
58
|
+
return { additionalProperties: typeof value === 'boolean' ? value : true };
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// A real `action` or `query` → the `ProjectablePrimitive` this package projects.
|
|
2
|
+
//
|
|
3
|
+
// ONE adapter, two callers — the registry sweep and the written-out list — so writing a
|
|
4
|
+
// primitive out is a different way to NAME a tool, never a second way to run one.
|
|
5
|
+
|
|
6
|
+
import type { AnyAction } from '@ultimat3/action';
|
|
7
|
+
import { actionName, invoke, isAction } from '@ultimat3/action';
|
|
8
|
+
import { withChildContext } from '@ultimat3/core';
|
|
9
|
+
import type { AnyQuery } from '@ultimat3/query';
|
|
10
|
+
import { isQuery, queryName, sourceFor } from '@ultimat3/query';
|
|
11
|
+
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
12
|
+
import { toWireSchema } from './input-schema';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What `defineAppMcp`'s `actions:`/`queries:` accept.
|
|
16
|
+
*
|
|
17
|
+
* The real primitives come first because they are what an app writes: `actions: [publishPost]`.
|
|
18
|
+
* Until 2026-08 this list took `ProjectablePrimitive` alone, which no `action()` or `query()`
|
|
19
|
+
* structurally satisfies — they carry `as`/`tool`, never `run` — so the only value that could
|
|
20
|
+
* reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake, and the gate refused nothing an app could
|
|
21
|
+
* actually declare. `ProjectablePrimitive` stays in the union for surfaces that build their
|
|
22
|
+
* catalog programmatically (`@ultimat3/admin`) and for tests that project a stand-in.
|
|
23
|
+
*/
|
|
24
|
+
export type ListedPrimitive = AnyAction | AnyQuery | ProjectablePrimitive;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adapt whatever the author listed.
|
|
28
|
+
*
|
|
29
|
+
* `isAction`/`isQuery` are structural against each package's PRIVATE declaration store, so a
|
|
30
|
+
* look-alike carrying `kind: 'action'` cannot take either branch — it falls through as the
|
|
31
|
+
* already-projectable object it claims to be, and is projected verbatim.
|
|
32
|
+
*/
|
|
33
|
+
export function asProjectable(listed: ListedPrimitive): ProjectablePrimitive {
|
|
34
|
+
if (isAction(listed)) return primitiveFromAction(listed);
|
|
35
|
+
if (isQuery(listed)) return primitiveFromQuery(listed);
|
|
36
|
+
return listed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
40
|
+
const exposure = exposureOf(target.mcp);
|
|
41
|
+
return {
|
|
42
|
+
// Throws `X_ACTION_UNREGISTERED` on an unnamed action rather than projecting a tool called
|
|
43
|
+
// `''`: a nameless tool is unaddressable by the scope map, by `tools/call`, and by the author.
|
|
44
|
+
name: actionName(target),
|
|
45
|
+
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
46
|
+
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
47
|
+
inputJsonSchema: toWireSchema(target.input),
|
|
48
|
+
mutates: true,
|
|
49
|
+
// The actor rides in on the options: `invoke` swaps it inside the one execution path.
|
|
50
|
+
run: ({ input, actor }) => invoke(target, input, { surface: 'mcp', actor }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
55
|
+
const exposure = exposureOf(target.mcp);
|
|
56
|
+
return {
|
|
57
|
+
name: queryName(target),
|
|
58
|
+
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
59
|
+
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
60
|
+
inputJsonSchema: toWireSchema(target.input),
|
|
61
|
+
mutates: false,
|
|
62
|
+
run: ({ input, actor }) =>
|
|
63
|
+
withChildContext({ actor }, async () => {
|
|
64
|
+
// `sourceFor` is the authorized front half of `runQuery` — validate, guard, build —
|
|
65
|
+
// and is what `live`, `paginate` and `explain` build on too. Executed without the
|
|
66
|
+
// cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
|
|
67
|
+
// not a TTL.
|
|
68
|
+
const source = await sourceFor(target, input);
|
|
69
|
+
return source.execute();
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An action and a query declare MCP exposure with the same fields, so one typed path reads
|
|
76
|
+
* both. Narrow on purpose: only a literal `expose: true` counts, so nothing is exposed by
|
|
77
|
+
* accident — an undeclared `mcp` block yields no exposure at all.
|
|
78
|
+
*
|
|
79
|
+
* `visibleTo` travels with it, and must: it is OUTCOME 1's only declaration surface for a
|
|
80
|
+
* projected primitive. Dropping it here — which this function did until 2026-08 — left
|
|
81
|
+
* `ToolRegistry`'s role gate enforcing a field no action or query could ever set, so every
|
|
82
|
+
* projected tool was visible to every caller and the first outcome existed only for the
|
|
83
|
+
* hand-written tools that build their own `McpTool`.
|
|
84
|
+
*/
|
|
85
|
+
function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined {
|
|
86
|
+
if (declared === undefined) return undefined;
|
|
87
|
+
return {
|
|
88
|
+
expose: declared.expose === true,
|
|
89
|
+
...(declared.description === undefined ? {} : { description: declared.description }),
|
|
90
|
+
...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `ActionMcp` and `QueryMcp` are the same shape; restating it binds to neither. */
|
|
95
|
+
interface DeclaredMcp {
|
|
96
|
+
readonly expose: boolean;
|
|
97
|
+
readonly description?: string;
|
|
98
|
+
readonly visibleTo?: readonly string[];
|
|
99
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// LAYER 4 of `db.query`'s four defences: what one agent-authored read may cost, and how much of
|
|
2
|
+
// it may come back. A cap the caller cannot raise, applied where the tool cannot forget it —
|
|
3
|
+
// `limit` is an argument, and an argument is a request, never a permission.
|
|
4
|
+
|
|
5
|
+
/** The ceilings. Frozen because a cap a caller can widen is a default, not a cap. */
|
|
6
|
+
export interface QueryLimits {
|
|
7
|
+
/** Hard row ceiling. A larger `limit` argument is clamped, not honoured. */
|
|
8
|
+
readonly maxRows: number;
|
|
9
|
+
/** Ceiling on the serialised rows. Rows are cheap; a row of 2 MB of JSONB is not. */
|
|
10
|
+
readonly maxBytes: number;
|
|
11
|
+
/** `statement_timeout` for the read. 0 disables. */
|
|
12
|
+
readonly timeoutMs: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const QUERY_LIMITS: QueryLimits = Object.freeze({
|
|
16
|
+
maxRows: 1000,
|
|
17
|
+
// 256 KiB: the result lands in a model's context, and a tool that can fill it has denied the
|
|
18
|
+
// agent the rest of its turn as surely as any error would.
|
|
19
|
+
maxBytes: 256 * 1024,
|
|
20
|
+
timeoutMs: 5_000,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** What `limit` means when the caller does not send one. Well under the hard ceiling. */
|
|
24
|
+
export const DEFAULT_QUERY_ROWS = 100;
|
|
25
|
+
|
|
26
|
+
/** What the host hands back: rows plus the database-side defences that engaged. */
|
|
27
|
+
export interface QueryRows {
|
|
28
|
+
readonly columns: readonly string[];
|
|
29
|
+
/** One array per row, in `columns` order. Fetch `maxRows + 1` — see `capQueryRows`. */
|
|
30
|
+
readonly rows: readonly (readonly unknown[])[];
|
|
31
|
+
/** Guards from layers 1–2. The tool appends its own; nothing is inferred. */
|
|
32
|
+
readonly guards: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What `db.query` answers. Every cap that bit says so; none of them is silent. */
|
|
36
|
+
export interface QueryResult extends QueryRows {
|
|
37
|
+
readonly rowCount: number;
|
|
38
|
+
readonly truncated: boolean;
|
|
39
|
+
/** Which ceiling cut the result short, or `null` when nothing did. */
|
|
40
|
+
readonly truncatedBy: 'rows' | 'bytes' | null;
|
|
41
|
+
/** Bytes of JSON actually returned, so the next `limit` can be chosen rather than guessed. */
|
|
42
|
+
readonly bytes: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The ceilings for one call. A `limit` argument may only ever narrow them: below 1 is a typo,
|
|
47
|
+
* above `maxRows` is a caller who read the schema's `maximum` as a suggestion.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveQueryLimits(requested: unknown): QueryLimits {
|
|
50
|
+
const asked =
|
|
51
|
+
typeof requested === 'number' && Number.isFinite(requested)
|
|
52
|
+
? Math.trunc(requested)
|
|
53
|
+
: DEFAULT_QUERY_ROWS;
|
|
54
|
+
return { ...QUERY_LIMITS, maxRows: Math.max(1, Math.min(QUERY_LIMITS.maxRows, asked)) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const encoder = new TextEncoder();
|
|
58
|
+
|
|
59
|
+
/** Serialised size of one row, plus the separator it costs inside the JSON array. */
|
|
60
|
+
function rowBytes(row: readonly unknown[]): number {
|
|
61
|
+
return encoder.encode(JSON.stringify(row)).length + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply the row and byte ceilings and say which one bit.
|
|
66
|
+
*
|
|
67
|
+
* The host fetches `maxRows + 1` rows: one row past the ceiling is how `truncated` is known
|
|
68
|
+
* without a second count query, and it is dropped here.
|
|
69
|
+
*
|
|
70
|
+
* A single row larger than `maxBytes` yields zero rows and `truncatedBy: 'bytes'`. Returning it
|
|
71
|
+
* anyway would make the byte cap a suggestion, and the honest answer tells the agent exactly
|
|
72
|
+
* what to do next: select fewer columns.
|
|
73
|
+
*/
|
|
74
|
+
export function capQueryRows(source: QueryRows, limits: QueryLimits): QueryResult {
|
|
75
|
+
const overRows = source.rows.length > limits.maxRows;
|
|
76
|
+
const kept: (readonly unknown[])[] = [];
|
|
77
|
+
let bytes = 2; // the enclosing `[]`
|
|
78
|
+
let overBytes = false;
|
|
79
|
+
|
|
80
|
+
for (const row of source.rows.slice(0, limits.maxRows)) {
|
|
81
|
+
const size = rowBytes(row);
|
|
82
|
+
if (bytes + size > limits.maxBytes) {
|
|
83
|
+
overBytes = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
kept.push(row);
|
|
87
|
+
bytes += size;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
columns: source.columns,
|
|
92
|
+
rows: kept,
|
|
93
|
+
rowCount: kept.length,
|
|
94
|
+
truncated: overRows || overBytes,
|
|
95
|
+
// Bytes first: it is the ceiling that cut this particular answer short, even when the row
|
|
96
|
+
// ceiling would also have applied further down.
|
|
97
|
+
truncatedBy: overBytes ? 'bytes' : overRows ? 'rows' : null,
|
|
98
|
+
bytes,
|
|
99
|
+
guards: [...source.guards, `cap:${limits.maxRows} rows`, `cap:${limits.maxBytes} bytes`],
|
|
100
|
+
};
|
|
101
|
+
}
|