@actuarial-ts/agents 0.2.0 → 0.3.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/src/mcp.ts ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * MCP tenant seam + boot self-test for the governed workspace.
3
+ *
4
+ * When the workspace is exposed over the Model Context Protocol, external AI
5
+ * clients read everything and mutate nothing directly. The one hard guarantee
6
+ * is that EVERY exposed tool resolves its tenant (project id) from the MCP
7
+ * request's auth info, never from the model — the same secureToolWrapper rule
8
+ * the ActNG server proved in production, restated for the MCP transport.
9
+ *
10
+ * @mastra/mcp gives no built-in per-tool authorization outside its EE FGA path,
11
+ * so a missed wire-up FAILS OPEN: a tool that forgets to read authInfo would
12
+ * happily serve an unauthenticated caller. These two helpers close that caveat:
13
+ *
14
+ * 1. requireMcpTenant — the read side. A tool calls it instead of trusting the
15
+ * model; absent auth info it THROWS AgentsError("NO_TENANT_CONTEXT"), which
16
+ * the defineActuarialTool wrapper turns into a { success:false } envelope.
17
+ * A tool built on it fails CLOSED.
18
+ *
19
+ * 2. assertFailClosed — the proof. At server startup, drive a probe read tool
20
+ * through the server WITHOUT auth and assert it fails closed. If the probe
21
+ * SUCCEEDS, the seam is not wired up: this throws MCP_SELF_TEST_FAILED and
22
+ * startup MUST abort.
23
+ *
24
+ * ---------------------------------------------------------------------------
25
+ * VERIFIED against the installed @mastra/mcp 1.14.0 (house rule — types and
26
+ * compiled source, not memory):
27
+ *
28
+ * - MCPServer.executeTool(toolId, args, executionContext?: { messages?,
29
+ * toolCallId?, requestContext? }): Promise<any> returns the tool's execute
30
+ * result VERBATIM (dist/index.js: `const result = await tool.execute(...);
31
+ * return result;`). For a defineActuarialTool with no outputSchema the core
32
+ * tool builder passes the value through untouched, so an executeTool caller
33
+ * sees exactly the { success:false, error:{ code } } envelope the wrapper
34
+ * produced. Argument validation runs FIRST and, on failure, short-circuits
35
+ * to a { error:true, message } object before the tool ever executes — hence
36
+ * assertFailClosed passes minimal VALID probe args.
37
+ *
38
+ * - Auth-context access path. On the real streamable-HTTP call path the SDK
39
+ * copies the transport `extra` (with `authInfo` from `req.auth`) into the
40
+ * tool context TWO ways: directly at `context.mcp.extra`, and — via
41
+ * createProxiedRequestContext — as INDIVIDUAL keys set on a fresh
42
+ * RequestContext (`context.requestContext.get("authInfo")`). NOTE the
43
+ * surprise: the installed 1.14.0 sets each extra key on the RequestContext
44
+ * verbatim, so the tenant lives at `requestContext.get("authInfo")`, NOT
45
+ * under a single `"mcp.extra"` key as the research draft documented. This
46
+ * helper tries all three shapes so it is correct against both the installed
47
+ * package and the documented pattern.
48
+ */
49
+
50
+ import type { ActuarialToolContext, ToolEnvelopeFailure } from "./tools.js";
51
+ import { resolveTenant } from "./tools.js";
52
+ import { AgentsError, type AgentsErrorCode } from "./errors.js";
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Tenant seam (read side)
56
+
57
+ /**
58
+ * The MCP auth-info bag: whatever the host's bearer-token middleware set on
59
+ * `req.auth` before delegating to startHTTP (e.g. `{ projectId }`). Values are
60
+ * unknown — requireMcpTenant proves the tenant is a non-empty string.
61
+ */
62
+ export interface McpAuthInfo {
63
+ [key: string]: unknown;
64
+ }
65
+
66
+ /**
67
+ * The transport `extra` (MCP SDK RequestHandlerExtra) surfaced to a tool at
68
+ * `context.mcp.extra`. Only `authInfo` is load-bearing here; the rest
69
+ * (sessionId, requestInfo, signal, ...) is carried opaquely.
70
+ */
71
+ export interface McpRequestExtra {
72
+ authInfo?: McpAuthInfo;
73
+ [key: string]: unknown;
74
+ }
75
+
76
+ /**
77
+ * The structural slice of a Mastra tool-execution context this seam needs on
78
+ * the MCP path: the tenant-seam `requestContext` (inherited from
79
+ * ActuarialToolContext) plus the MCP-specific `mcp.extra`. Typed structurally
80
+ * so tests exercise the helper with a plain object and no live MCP server.
81
+ */
82
+ export interface McpToolContext extends ActuarialToolContext {
83
+ mcp?: { extra?: McpRequestExtra };
84
+ }
85
+
86
+
87
+
88
+ /**
89
+ * Reads the tenant id (default key "projectId") from the MCP execution
90
+ * context's auth info — the server-set identity, never the model. THROWS a
91
+ * typed AgentsError("NO_TENANT_CONTEXT") when the auth info or the key is
92
+ * absent, non-string, or empty. Inside a defineActuarialTool execute the
93
+ * wrapper converts that throw into a { success:false } envelope, so a tool
94
+ * built on requireMcpTenant fails CLOSED for any unauthenticated MCP caller.
95
+ */
96
+ export function requireMcpTenant(context: McpToolContext | undefined, key = "projectId"): string {
97
+ // One seam, one reader: this is resolveTenant with the MCP source pinned.
98
+ // Prefer declaring `tenant: "required", tenantSource: "mcp-auth"` on the
99
+ // tool itself, which makes the wrapper do this before the body runs.
100
+ return resolveTenant(context, { source: "mcp-auth", key });
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Boot self-test (proof side)
105
+
106
+ /**
107
+ * The structural slice of @mastra/mcp's MCPServer that assertFailClosed drives.
108
+ * A real MCPServer satisfies it; so does a bare `{ executeTool }` stub, so the
109
+ * self-test is unit-testable without booting a transport. Verified against the
110
+ * installed executeTool signature (see the file header).
111
+ */
112
+ export interface McpToolServer {
113
+ executeTool(
114
+ toolId: string,
115
+ args: Record<string, unknown>,
116
+ executionContext?: { requestContext?: unknown; messages?: unknown[]; toolCallId?: string },
117
+ ): Promise<unknown>;
118
+ /**
119
+ * Tool enumeration, present on the installed @mastra/mcp MCPServer. The
120
+ * DECLARATION (server.d.ts) types entries by `name` and allows a Promise
121
+ * return; the runtime also carries `id`. Typed from the declaration — a
122
+ * runtime probe is how the first draft of this signature broke assignability.
123
+ * Optional so a bare `{ executeTool }` stub still satisfies the type; the
124
+ * self-test's probe-everything form requires it and says so when absent.
125
+ */
126
+ getToolListInfo?():
127
+ | { tools: Array<{ name: string; id?: string }> }
128
+ | Promise<{ tools: Array<{ name: string; id?: string }> }>;
129
+ }
130
+
131
+ export interface AssertFailClosedOptions {
132
+ /** The MCP server whose exposed tools are being wired up (typically the workspace MCPServer). */
133
+ server: McpToolServer;
134
+ /**
135
+ * Probe ONE tool instead of every tool. Prefer omitting this: the
136
+ * single-tool form proved exactly one wire-up, and a sibling tool that
137
+ * skipped the tenant seam sailed through boot while serving every tenant's
138
+ * data to unauthenticated callers.
139
+ */
140
+ probeToolId?: string;
141
+ /**
142
+ * Minimal VALID args for the single-tool form. Defaults to `{}`
143
+ * (correct for read tools whose schema is `z.object({})` or all-optional).
144
+ * Args that fail schema validation would short-circuit before the tenant
145
+ * check runs, so pass real minimal args for probes that require input.
146
+ */
147
+ probeArgs?: Record<string, unknown>;
148
+ /**
149
+ * Per-tool minimal valid args for the probe-everything form, keyed by tool
150
+ * id. Any tool not listed is driven with `{}`.
151
+ */
152
+ argsByTool?: Record<string, Record<string, unknown>>;
153
+ /**
154
+ * Tools that are tenant-free BY DESIGN and therefore expected to succeed
155
+ * without auth. The same contract as `tenant: "none"` on the definition:
156
+ * greppable, deliberate, reviewable. A name listed here that is not on the
157
+ * server fails the self-test — a stale exemption is how the next leak hides.
158
+ */
159
+ exempt?: string[];
160
+ /** The failure code every probed tool must fail closed with. Defaults to NO_TENANT_CONTEXT. */
161
+ expectedErrorCode?: AgentsErrorCode;
162
+ }
163
+
164
+ /** Structural check for the { success:false, error:{ code } } tool-failure envelope. */
165
+ function isFailureEnvelope(result: unknown): result is ToolEnvelopeFailure {
166
+ if (typeof result !== "object" || result === null) return false;
167
+ if ((result as { success?: unknown }).success !== false) return false;
168
+ const error = (result as { error?: { code?: unknown } }).error;
169
+ return typeof error?.code === "string";
170
+ }
171
+
172
+ /** A short, log-safe description of an unexpected probe result for the abort message. */
173
+ function describeResult(result: unknown): string {
174
+ if (isFailureEnvelope(result)) {
175
+ return `a { success: false } envelope with code "${result.error.code}"`;
176
+ }
177
+ if (typeof result === "object" && result !== null) {
178
+ if ((result as { error?: unknown }).error === true) {
179
+ return "an argument-validation error (the probe never reached the tenant check — pass valid probeArgs)";
180
+ }
181
+ if ((result as { success?: unknown }).success === true) {
182
+ return "a { success: true } result (the probe SUCCEEDED without tenant context — the seam is not wired up)";
183
+ }
184
+ }
185
+ return "a non-envelope result (the probe did not fail closed on tenant context)";
186
+ }
187
+
188
+ /**
189
+ * The boot self-test. Drives EVERY tool on the server WITHOUT any auth info
190
+ * and asserts each fails closed with the tenant error code, reporting every
191
+ * hole at once. THROWS AgentsError("MCP_SELF_TEST_FAILED") loudly for any
192
+ * other outcome — most importantly if a probe SUCCEEDED, which means that tool
193
+ * did not require tenant context and the MCP tenant seam is a fail-open hole.
194
+ * Tenant-free-by-design tools are excused only via the greppable `exempt`
195
+ * list; a single-tool form remains for targeted re-checks.
196
+ *
197
+ * Call this at server startup whenever MCP is enabled, and ABORT startup if it
198
+ * throws: a governed workspace must never accept unauthenticated MCP clients.
199
+ */
200
+ export async function assertFailClosed(options: AssertFailClosedOptions): Promise<void> {
201
+ const { server, probeToolId, probeArgs = {}, expectedErrorCode = "NO_TENANT_CONTEXT" } = options;
202
+
203
+ // Single-tool form: kept for hosts that need a targeted re-check, but the
204
+ // probe-everything form below is the one to call at boot.
205
+ if (probeToolId !== undefined) {
206
+ await probeOne(server, probeToolId, probeArgs, expectedErrorCode);
207
+ return;
208
+ }
209
+
210
+ if (typeof server.getToolListInfo !== "function") {
211
+ throw new AgentsError(
212
+ "MCP_SELF_TEST_FAILED",
213
+ "MCP boot self-test cannot enumerate this server's tools (no getToolListInfo); " +
214
+ "pass probeToolId per tool, or upgrade @mastra/mcp. Refusing to certify a server " +
215
+ "whose tool list cannot be inspected.",
216
+ );
217
+ }
218
+
219
+ const listing = await server.getToolListInfo();
220
+ const toolIds = listing.tools.map((tool) => tool.id ?? tool.name);
221
+ const exempt = new Set(options.exempt ?? []);
222
+ const failures: string[] = [];
223
+
224
+ // A stale exemption is a future hole: the tool it excused is gone, and the
225
+ // next tool to take that name inherits a free pass nobody remembers granting.
226
+ for (const name of exempt) {
227
+ if (!toolIds.includes(name)) {
228
+ failures.push(`"${name}" is exempted but not on the server; remove the stale exemption`);
229
+ }
230
+ }
231
+
232
+ for (const toolId of toolIds) {
233
+ if (exempt.has(toolId)) continue;
234
+ try {
235
+ await probeOne(server, toolId, options.argsByTool?.[toolId] ?? {}, expectedErrorCode);
236
+ } catch (err) {
237
+ // Collect EVERY hole before reporting: a boot test that stops at the
238
+ // first one hides the second.
239
+ failures.push(err instanceof AgentsError ? err.message : String(err));
240
+ }
241
+ }
242
+
243
+ if (failures.length > 0) {
244
+ throw new AgentsError(
245
+ "MCP_SELF_TEST_FAILED",
246
+ `MCP boot self-test failed on ${failures.length} of ${toolIds.length} tool(s):\n` +
247
+ failures.map((message) => ` - ${message}`).join("\n") +
248
+ "\nAbort startup: a governed workspace must never accept unauthenticated MCP clients.",
249
+ );
250
+ }
251
+ }
252
+
253
+ /** Drives one tool with no auth info; it must fail closed with the expected code. */
254
+ async function probeOne(
255
+ server: McpToolServer,
256
+ toolId: string,
257
+ args: Record<string, unknown>,
258
+ expectedErrorCode: AgentsErrorCode,
259
+ ): Promise<void> {
260
+ // No requestContext, no mcp.extra -> the probe sees no auth info at all.
261
+ const result = await server.executeTool(toolId, args, {});
262
+
263
+ if (isFailureEnvelope(result) && result.error.code === expectedErrorCode) {
264
+ return;
265
+ }
266
+
267
+ throw new AgentsError(
268
+ "MCP_SELF_TEST_FAILED",
269
+ `probe tool "${toolId}" did not fail closed without auth. ` +
270
+ `Expected a { success: false, error.code: "${expectedErrorCode}" } envelope but got ${describeResult(result)}. ` +
271
+ `An MCP-exposed tool must either enforce the tenant seam (tenant: "required", tenantSource: "mcp-auth") ` +
272
+ "or be a declared exemption; this outcome means it is neither.",
273
+ );
274
+ }