@raybase/core 0.3.6 → 0.3.8

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.
Files changed (54) hide show
  1. package/dist/agent-ai-chat.js +2 -2
  2. package/dist/agent-ai-chat.js.map +1 -1
  3. package/dist/audited-write.d.ts +19 -0
  4. package/dist/audited-write.d.ts.map +1 -0
  5. package/dist/audited-write.js +42 -0
  6. package/dist/audited-write.js.map +1 -0
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +21 -8
  9. package/dist/auth.js.map +1 -1
  10. package/dist/data-client.d.ts +8 -1
  11. package/dist/data-client.d.ts.map +1 -1
  12. package/dist/data-client.js +29 -22
  13. package/dist/data-client.js.map +1 -1
  14. package/dist/db.d.ts +13 -0
  15. package/dist/db.d.ts.map +1 -1
  16. package/dist/db.js +3 -2
  17. package/dist/db.js.map +1 -1
  18. package/dist/do.d.ts +5 -0
  19. package/dist/do.d.ts.map +1 -1
  20. package/dist/do.js +5 -0
  21. package/dist/do.js.map +1 -1
  22. package/dist/email.d.ts.map +1 -1
  23. package/dist/email.js +13 -4
  24. package/dist/email.js.map +1 -1
  25. package/dist/html.d.ts.map +1 -1
  26. package/dist/html.js +11 -13
  27. package/dist/html.js.map +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp.d.ts +23 -5
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +180 -44
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/operator-mcp.js +1 -1
  37. package/dist/operator-mcp.js.map +1 -1
  38. package/dist/same-origin-path.d.ts +37 -0
  39. package/dist/same-origin-path.d.ts.map +1 -0
  40. package/dist/same-origin-path.js +29 -0
  41. package/dist/same-origin-path.js.map +1 -0
  42. package/dist/storage-client.d.ts.map +1 -1
  43. package/dist/storage-client.js +44 -28
  44. package/dist/storage-client.js.map +1 -1
  45. package/dist/version.d.ts +2 -2
  46. package/dist/version.js +2 -2
  47. package/dist/vite.d.ts +76 -0
  48. package/dist/vite.d.ts.map +1 -1
  49. package/dist/vite.js +121 -0
  50. package/dist/vite.js.map +1 -1
  51. package/dist/worker.d.ts.map +1 -1
  52. package/dist/worker.js +71 -41
  53. package/dist/worker.js.map +1 -1
  54. package/package.json +2 -2
package/dist/mcp.js CHANGED
@@ -2,17 +2,21 @@ import { z } from "zod";
2
2
  import { RAYBASE_CORE_VERSION } from "./version.js";
3
3
  import { withHostUserRoleCeiling } from "./host-user.js";
4
4
  import { redactSecretColumnKeys, secretColumnKeys } from "./data-secrets.js";
5
+ import { actorAuditRef, recordAudit } from "./audit.js";
5
6
  import { audit, base64url, normalizeRoles } from "./auth.js";
7
+ import { servicePrincipal } from "./principal.js";
6
8
  import { hasAdminAccess, normalizeStoredAdminRole } from "./admin-role.js";
7
9
  import { inputSchemaForTable } from "./data-client.js";
8
10
  import { createRaybaseDb, dataStub } from "./db.js";
9
11
  import { sha256Hex } from "./internal.js";
10
- import { jsonSchemaFor } from "./json-schema.js";
12
+ import { actionInputIssues } from "./actions.js";
13
+ import { jsonSchemaFor, parseWithSchema } from "./json-schema.js";
11
14
  import { searchIndex, updateSearchForDataChange } from "./search.js";
12
15
  import { id, longText, raybaseSystemTable, text } from "./schema.js";
13
16
  import { randomId } from "./sql.js";
14
17
  import { wallNowIso as nowIso, wallNowMs } from "./clock.js";
15
18
  import { ensureManifestTables } from "./system-tables.js";
19
+ import { sameOriginPath } from "./same-origin-path.js";
16
20
  export const mcpAppsExtensionId = "io.modelcontextprotocol/ui";
17
21
  export const mcpAppResourceMimeType = "text/html;profile=mcp-app";
18
22
  const jsonHeaders = { "content-type": "application/json; charset=utf-8" };
@@ -596,7 +600,29 @@ export function wwwAuthenticate(request, config, surface = "app") {
596
600
  const scope = surface === "operator" ? "operator:read" : "mcp:read mcp:write";
597
601
  return `Bearer resource_metadata="${serverOrigin(request)}${metadataPath}", resource="${resource}", scope="${scope}"`;
598
602
  }
599
- function unauthorizedMcpResponse(request, config, error, surface = "app") {
603
+ /**
604
+ * The answer to an app connector request that names a host account but has
605
+ * no usable token. The connector's address is the plain one: its protected
606
+ * resource metadata names it without a query (RFC 9728 section 2 discourages
607
+ * one), and section 3.3 forbids a client to use metadata whose resource is not
608
+ * the address it was given. So an address carrying the account cannot get a
609
+ * token; a 401 would leave the client stuck without a word. This says why, and
610
+ * which address to give the agent. The account is asked for when the person
611
+ * connects, and confirmed by the host.
612
+ */
613
+ function hostAccountAddressResponse(request, config, param) {
614
+ const address = canonicalMcpResource(request, config);
615
+ return jsonResponse({
616
+ error: `This connector's address is ${address}, without "${param}". Give the agent that address; it asks which account to connect when the person signs in.`,
617
+ address
618
+ }, { status: 400, headers: Object.fromEntries(corsHeaders(request, config)) });
619
+ }
620
+ function unauthorizedMcpResponse(request, config, error, surface = "app", options = {}) {
621
+ if (surface === "app" && options.hostApproval) {
622
+ const param = hostAccountParam(options.hostApproval);
623
+ if (new URL(request.url).searchParams.has(param))
624
+ return hostAccountAddressResponse(request, config, param);
625
+ }
600
626
  return jsonResponse({ error }, {
601
627
  status: 401,
602
628
  headers: { "www-authenticate": wwwAuthenticate(request, config, surface), ...Object.fromEntries(corsHeaders(request, config)) }
@@ -876,6 +902,12 @@ async function registerConfidentialClient(request, config, db, body, user, optio
876
902
  return jsonResponse({ error: "invalid_client_metadata", error_description: "token_endpoint_auth_method must be client_secret_basic or client_secret_post" }, { status: 400 });
877
903
  }
878
904
  const resource = asString(body.resource) || canonicalMcpResource(request, config);
905
+ // The credential is for one of this app's own connectors, at the address
906
+ // this request reached: never a resource on another origin.
907
+ if (!mcpSurfaceForResource(request, config, resource, options)) {
908
+ const connectors = mcpSurfaces(request, config, options).map((surface) => surface.resource).join(" or ");
909
+ return jsonResponse({ error: "invalid_target", error_description: `resource must be this app's connector: ${connectors}` }, { status: 400 });
910
+ }
879
911
  let created;
880
912
  try {
881
913
  created = await createMcpMachineCredential(db, config, {
@@ -945,6 +977,15 @@ export async function createMcpMachineCredential(db, config, input, options = {}
945
977
  const scopeError = authorizeMcpScopes(input.user, scopes, surface, options);
946
978
  if (scopeError)
947
979
  throw new McpMachineCredentialError("invalid_scope", scopeError);
980
+ // A credential holds only scopes its connector honours: one no tool or
981
+ // resource answers to would sit on the grant, unexplained, until a later
982
+ // tool happened to take its name. (authorizeMcpScopes already holds the
983
+ // operator connector to its own list.)
984
+ const offered = new Set([...surface.scopesSupported, ...(options.appScopes ?? [])]);
985
+ const unoffered = surface.id === "app" ? scopes.filter((scope) => !offered.has(scope)) : [];
986
+ if (unoffered.length) {
987
+ throw new McpMachineCredentialError("invalid_scope", `${surface.label} does not offer ${unoffered.length === 1 ? "the scope" : "the scopes"} ${unoffered.join(", ")}`);
988
+ }
948
989
  const authMethod = input.tokenEndpointAuthMethod ?? "client_secret_basic";
949
990
  const user = input.user;
950
991
  const clientId = `raybase_cc_${randomId("client")}`;
@@ -1116,8 +1157,12 @@ function consentStylesheetHref(config) {
1116
1157
  const href = config.mcp?.consent?.stylesheet?.trim();
1117
1158
  if (!href)
1118
1159
  return null;
1119
- if (href.startsWith("/") && !href.startsWith("//"))
1120
- return href;
1160
+ if (href.startsWith("/")) {
1161
+ const path = sameOriginPath(href);
1162
+ if (path)
1163
+ return path;
1164
+ throw new Error("mcp.consent.stylesheet must be a root-relative path or an HTTPS URL");
1165
+ }
1121
1166
  try {
1122
1167
  if (new URL(href).protocol === "https:")
1123
1168
  return href;
@@ -1258,6 +1303,18 @@ export async function mcpAuthorizationOrganizationHint(request) {
1258
1303
  const value = asString(input.org).trim();
1259
1304
  return value || null;
1260
1305
  }
1306
+ /**
1307
+ * Who a grant's writes are audited as. A host-account grant acts as the
1308
+ * host's subject, which is no Raybase user, while `_audit.actorId` names
1309
+ * one; so it is audited as a service principal, the way Core audits every
1310
+ * actor that is not a user, and the account, subject and grant travel in the
1311
+ * entry's metadata. Every other grant acts as its user.
1312
+ */
1313
+ function mcpGrantAuditActor(grant) {
1314
+ if (grant.hostAccount)
1315
+ return servicePrincipal("raybase-mcp-host-account", { id: grant.subjectId });
1316
+ return actorAuditRef(grant.userId);
1317
+ }
1261
1318
  /** The identity a host-account grant acts as: a plain app user, never an operator. */
1262
1319
  function hostAccountUser(hostAccount, subject) {
1263
1320
  return {
@@ -1332,13 +1389,18 @@ async function createGrantAndCode(db, config, request, user, values, clientName,
1332
1389
  addSeconds(300),
1333
1390
  now
1334
1391
  ]);
1335
- await audit(db, host ? null : user.id, surface.id === "operator" ? "operator_mcp.grant.create" : "mcp.grant.create", "_mcp_grants", grantId, {
1336
- clientId: values.client_id,
1337
- scopes,
1338
- resource,
1339
- surface: surface.id,
1340
- hostAccount,
1341
- organizationId
1392
+ await recordAudit(db, {
1393
+ actor: mcpGrantAuditActor({ hostAccount, subjectId, userId: user.id }),
1394
+ action: surface.id === "operator" ? "operator_mcp.grant.create" : "mcp.grant.create",
1395
+ target: { type: "_mcp_grants", id: grantId },
1396
+ metadata: {
1397
+ clientId: values.client_id,
1398
+ scopes,
1399
+ resource,
1400
+ surface: surface.id,
1401
+ hostAccount,
1402
+ organizationId
1403
+ }
1342
1404
  });
1343
1405
  const redirect = new URL(values.redirect_uri);
1344
1406
  redirect.searchParams.set("code", code);
@@ -1383,10 +1445,10 @@ function authorizeMcpScopes(user, scopes, surface, options = {}) {
1383
1445
  return null;
1384
1446
  }
1385
1447
  function mcpSignInPath(config) {
1386
- const raw = config.mcp?.oauth?.signInPath?.trim() || "/login";
1387
- if (!raw.startsWith("/") || raw.startsWith("//"))
1388
- throw new Error("mcp.oauth.signInPath must be a root-relative path");
1389
- return raw;
1448
+ const path = sameOriginPath(config.mcp?.oauth?.signInPath?.trim() || "/login");
1449
+ if (!path)
1450
+ throw new Error("mcp.oauth.signInPath must be a root-relative path on this origin");
1451
+ return path;
1390
1452
  }
1391
1453
  /** The authorization request as a GET address on this origin, path and query only. */
1392
1454
  function authorizationRequestPath(values, choice, input) {
@@ -1424,10 +1486,11 @@ export async function authorizeMcpOAuthRequest(request, config, db, user, option
1424
1486
  return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "access_denied");
1425
1487
  return createGrantAndCode(db, config, request, user, validated.values, validated.clientName, options, binding);
1426
1488
  }
1427
- const ownPage = config.mcp?.consent?.path?.trim();
1428
- if (ownPage) {
1429
- if (!ownPage.startsWith("/") || ownPage.startsWith("//"))
1430
- throw new Error("mcp.consent.path must be a root-relative path");
1489
+ const ownPageSetting = config.mcp?.consent?.path?.trim();
1490
+ if (ownPageSetting) {
1491
+ const ownPage = sameOriginPath(ownPageSetting);
1492
+ if (!ownPage)
1493
+ throw new Error("mcp.consent.path must be a root-relative path on this origin");
1431
1494
  const target = new URL(ownPage, request.url);
1432
1495
  target.search = new URL(request.url).search;
1433
1496
  return new Response(null, { status: 302, headers: { location: target.toString(), "cache-control": "no-store" } });
@@ -1519,8 +1582,9 @@ export async function handleMcpHostConnect(request, config, options) {
1519
1582
  /**
1520
1583
  * The authorization endpoint when host approval is on: records a pending
1521
1584
  * authorization for the requested host account and sends the person to the
1522
- * host's admin to approve it. With no host account, sends them to the connect
1523
- * page first.
1585
+ * host's admin to approve it. With no host account, sends the person to the
1586
+ * connect page first; an authorization request for any resource other than
1587
+ * the app connector is refused.
1524
1588
  */
1525
1589
  export async function beginMcpHostAuthorization(request, config, db, env, options) {
1526
1590
  const limited = checkMcpRateLimit(request, "host-oauth-authorize", 120);
@@ -1528,7 +1592,10 @@ export async function beginMcpHostAuthorization(request, config, db, env, option
1528
1592
  return limited;
1529
1593
  const param = hostAccountParam(options);
1530
1594
  const input = await authorizationInput(request);
1531
- if (!input[param]) {
1595
+ const canonical = canonicalMcpResource(request, config);
1596
+ // Only a request for the connector itself is sent to ask for an account;
1597
+ // one for any other resource is refused below, before anyone types one.
1598
+ if (!input[param] && (!input.resource || input.resource === canonical)) {
1532
1599
  const connect = new URL(mcpHostApprovalPath(options, "connectPath"), request.url);
1533
1600
  for (const [key, value] of Object.entries(input))
1534
1601
  connect.searchParams.set(key, value);
@@ -1539,8 +1606,8 @@ export async function beginMcpHostAuthorization(request, config, db, env, option
1539
1606
  return validated;
1540
1607
  if (validated.surface.id !== "app")
1541
1608
  return jsonResponse({ error: "Host-account MCP grants are only supported for the app MCP resource" }, { status: 400 });
1542
- const resource = validated.values.resource || canonicalMcpResource(request, config);
1543
- if (resource !== canonicalMcpResource(request, config))
1609
+ const resource = validated.values.resource || canonical;
1610
+ if (resource !== canonical)
1544
1611
  return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "invalid_target");
1545
1612
  const hostAccount = options.normalizeHostAccount(input[param]);
1546
1613
  const pendingId = randomId("mcp_pending");
@@ -1828,40 +1895,40 @@ export async function validateMcpBearerToken(request, config, db, expectedSurfac
1828
1895
  const header = request.headers.get("authorization") ?? "";
1829
1896
  const match = /^Bearer\s+(.+)$/i.exec(header);
1830
1897
  if (!match) {
1831
- return unauthorizedMcpResponse(request, config, "Unauthorized", expectedSurface);
1898
+ return unauthorizedMcpResponse(request, config, "Unauthorized", expectedSurface, options);
1832
1899
  }
1833
1900
  const tokenHash = await sha256Hex(match[1]);
1834
1901
  const row = await db.first(`SELECT * FROM "_oauth_access_tokens" WHERE "tokenHash" = ?`, [tokenHash]);
1835
1902
  if (!row || row.revokedAt || Date.parse(row.expiresAt) <= wallNowMs()) {
1836
- return unauthorizedMcpResponse(request, config, "Invalid or expired token", expectedSurface);
1903
+ return unauthorizedMcpResponse(request, config, "Invalid or expired token", expectedSurface, options);
1837
1904
  }
1838
1905
  const expectedResource = resourceOverride ?? canonicalMcpResourceForSurface(request, config, expectedSurface);
1839
1906
  if (row.resource !== expectedResource)
1840
- return unauthorizedMcpResponse(request, config, "Token audience does not match this MCP resource", expectedSurface);
1907
+ return unauthorizedMcpResponse(request, config, "Token audience does not match this MCP resource", expectedSurface, options);
1841
1908
  const grant = await loadGrant(db, row.grantId);
1842
1909
  if (!grant || grant.revokedAt || grant.clientId !== row.clientId || grant.resource !== expectedResource) {
1843
- return unauthorizedMcpResponse(request, config, "MCP grant is no longer active", expectedSurface);
1910
+ return unauthorizedMcpResponse(request, config, "MCP grant is no longer active", expectedSurface, options);
1844
1911
  }
1845
1912
  if (grant.expiresAt && Date.parse(grant.expiresAt) <= wallNowMs())
1846
- return unauthorizedMcpResponse(request, config, "MCP grant has expired", expectedSurface);
1913
+ return unauthorizedMcpResponse(request, config, "MCP grant has expired", expectedSurface, options);
1847
1914
  const user = await grantUser(db, grant, row.userId, options);
1848
1915
  if (!user)
1849
- return unauthorizedMcpResponse(request, config, "MCP subject is no longer valid", expectedSurface);
1916
+ return unauthorizedMcpResponse(request, config, "MCP subject is no longer valid", expectedSurface, options);
1850
1917
  const resourceRequest = resourceOverride ? new Request(expectedResource) : request;
1851
1918
  const surface = mcpSurfaceForResource(resourceRequest, config, expectedResource, options);
1852
1919
  if (!surface)
1853
- return unauthorizedMcpResponse(request, config, "Unknown MCP resource", expectedSurface);
1920
+ return unauthorizedMcpResponse(request, config, "Unknown MCP resource", expectedSurface, options);
1854
1921
  if (surface.id === "operator") {
1855
1922
  if (grant.subjectType !== "raybase-operator" || grant.subjectId !== user.id) {
1856
- return unauthorizedMcpResponse(request, config, "MCP grant is not valid for Operator MCP", expectedSurface);
1923
+ return unauthorizedMcpResponse(request, config, "MCP grant is not valid for Operator MCP", expectedSurface, options);
1857
1924
  }
1858
1925
  const grantScopes = splitScopes(parseJson(grant.scopes));
1859
1926
  if (authorizeMcpScopes(user, grantScopes, surface, options)) {
1860
- return unauthorizedMcpResponse(request, config, "Operator MCP grant is no longer authorized", expectedSurface);
1927
+ return unauthorizedMcpResponse(request, config, "Operator MCP grant is no longer authorized", expectedSurface, options);
1861
1928
  }
1862
1929
  }
1863
1930
  else if (grant.subjectType === "raybase-operator" || row.scope.includes("operator:")) {
1864
- return unauthorizedMcpResponse(request, config, "Operator MCP tokens cannot be used on the app MCP resource", expectedSurface);
1931
+ return unauthorizedMcpResponse(request, config, "Operator MCP tokens cannot be used on the app MCP resource", expectedSurface, options);
1865
1932
  }
1866
1933
  const tokenHostAccount = grant.hostAccount || undefined;
1867
1934
  if (options.hostApproval) {
@@ -1921,10 +1988,44 @@ function mcpToolDescriptor(tool) {
1921
1988
  _meta: mcpToolMeta(tool)
1922
1989
  };
1923
1990
  }
1991
+ /**
1992
+ * Validates a call's arguments against the tool's input schema, Zod or JSON
1993
+ * Schema, the way an action validates its input. A failure comes back as the
1994
+ * issues, each naming the field and what it must be.
1995
+ */
1924
1996
  function parseToolArgs(tool, raw) {
1925
- if (typeof tool.inputSchema.parse === "function")
1926
- return tool.inputSchema.parse(raw);
1927
- return raw ?? {};
1997
+ try {
1998
+ return { ok: true, value: parseWithSchema(tool.inputSchema, raw, "arguments") };
1999
+ }
2000
+ catch (error) {
2001
+ return { ok: false, issues: actionInputIssues(error) };
2002
+ }
2003
+ }
2004
+ function invalidToolArgumentsMessage(toolName, issues) {
2005
+ const listed = issues.map((issue) => (issue.path === "(root)" ? issue.message : `${issue.path}: ${issue.message}`));
2006
+ return listed.length
2007
+ ? `The arguments for ${toolName} do not match its input schema. ${listed.join("; ")}.`
2008
+ : `The arguments for ${toolName} do not match its input schema.`;
2009
+ }
2010
+ /** The tool result for arguments its schema refuses, shaped like an action's invalid_input error. */
2011
+ function invalidToolArgumentsResult(toolName, issues) {
2012
+ const message = invalidToolArgumentsMessage(toolName, issues);
2013
+ const guidance = issues.length
2014
+ ? `Fix the fields named above and call ${toolName} again.`
2015
+ : `Call ${toolName} again with arguments that match its input schema.`;
2016
+ return {
2017
+ content: [{ type: "text", text: `${message} ${guidance}` }],
2018
+ structuredContent: {
2019
+ ok: false,
2020
+ tool: toolName,
2021
+ code: "invalid_input",
2022
+ message,
2023
+ guidance,
2024
+ retryable: true,
2025
+ ...(issues.length ? { details: { issues } } : {})
2026
+ },
2027
+ isError: true
2028
+ };
1928
2029
  }
1929
2030
  function rpcResult(id, result) {
1930
2031
  return { jsonrpc: "2.0", id: id ?? null, result };
@@ -2121,14 +2222,32 @@ async function event(db, grantId, clientId, action, toolName, metadata) {
2121
2222
  await db.run(`INSERT INTO "_mcp_grant_events" ("id", "grantId", "clientId", "action", "toolName", "metadata", "createdAt")
2122
2223
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [randomId("mcp_event"), grantId, clientId, action, toolName ?? null, metadata === undefined ? null : JSON.stringify(metadata), nowIso()]);
2123
2224
  }
2124
- export function createMcpHandler(options) {
2125
- const surface = options.surface ?? "app";
2126
- const secretColumns = secretColumnKeys(options.schema, options.systemTables);
2127
- const allTools = [
2225
+ function connectorTools(options) {
2226
+ return [
2128
2227
  ...generatedMcpTools(options.schema, options.generatedTools),
2129
2228
  ...generatedSearchMcpTools(options),
2130
2229
  ...(options.tools ?? [])
2131
2230
  ];
2231
+ }
2232
+ /**
2233
+ * Every scope the app connector's tools and resources answer to, given the
2234
+ * options its `createMcpHandler` gets: what a machine credential for it may
2235
+ * ask for (`RaybaseMcpScopeOptions.appScopes`).
2236
+ */
2237
+ export function mcpAppConnectorScopes(options) {
2238
+ const scopes = new Set();
2239
+ for (const tool of connectorTools(options))
2240
+ for (const scope of requiredScopesForTool(tool))
2241
+ scopes.add(scope);
2242
+ for (const resource of options.resources ?? [])
2243
+ for (const scope of requiredScopesForResource(resource))
2244
+ scopes.add(scope);
2245
+ return [...scopes].filter((scope) => !isOperatorScope(scope)).sort();
2246
+ }
2247
+ export function createMcpHandler(options) {
2248
+ const surface = options.surface ?? "app";
2249
+ const secretColumns = secretColumnKeys(options.schema, options.systemTables);
2250
+ const allTools = connectorTools(options);
2132
2251
  const resources = options.resources ?? [];
2133
2252
  for (const resource of resources)
2134
2253
  validateMcpAppResource(resource);
@@ -2216,7 +2335,12 @@ export function createMcpHandler(options) {
2216
2335
  const client = db.for({
2217
2336
  user: token.user,
2218
2337
  source: { surface: "mcp", requestId: request.headers.get("cf-ray") ?? undefined },
2219
- audit: (entry) => audit(db.control, token.user.id, entry.action, entry.target.type, entry.target.id ?? undefined, { ...asRecord(entry.metadata), ...mcpContext.auditMetadata }),
2338
+ audit: (entry, auditDb) => recordAudit(auditDb, {
2339
+ actor: mcpGrantAuditActor({ hostAccount: token.hostAccount, subjectId: token.subjectId, userId: token.user.id }),
2340
+ action: entry.action,
2341
+ target: entry.target,
2342
+ metadata: { ...asRecord(entry.metadata), ...mcpContext.auditMetadata }
2343
+ }),
2220
2344
  notify: realtimeEnabled
2221
2345
  ? async (change) => {
2222
2346
  if (!("seq" in change))
@@ -2367,9 +2491,21 @@ async function handleRpc(rpc, tools, resources, ctx, control, surface, mcpAppUiE
2367
2491
  }
2368
2492
  return rpcError(rpc.id, -32001, `Token scope does not allow tool: ${name}`);
2369
2493
  }
2370
- let args = params.arguments ?? {};
2494
+ const parsed = parseToolArgs(tool, params.arguments ?? {});
2495
+ if (!parsed.ok) {
2496
+ // Arguments that fail the tool's schema are a tool execution error
2497
+ // (MCP 2025-06-18, "Error Handling"): the model reads which fields
2498
+ // are wrong and calls again. A JSON-RPC error would stop it there.
2499
+ if (surface === "operator" && hasOperatorScope(tool.scopes)) {
2500
+ const metadata = operatorErrorAuditMetadata(ctx, secretColumns, name, params.arguments ?? {}, new Error(invalidToolArgumentsMessage(name, parsed.issues)));
2501
+ await (recordOperatorAudit
2502
+ ? recordOperatorAudit("operator_mcp.tool.error", "_mcp_grants", ctx.mcp.grantId, metadata)
2503
+ : audit(control, ctx.user.id, "operator_mcp.tool.error", "_mcp_grants", ctx.mcp.grantId, metadata)).catch(() => { });
2504
+ }
2505
+ return rpcResult(rpc.id, invalidToolArgumentsResult(name, parsed.issues));
2506
+ }
2507
+ const args = parsed.value;
2371
2508
  try {
2372
- args = parseToolArgs(tool, params.arguments ?? {});
2373
2509
  if (recordEvent) {
2374
2510
  await recordEvent("tool.call", name, { arguments: auditArguments(secretColumns, args) });
2375
2511
  }