@raybase/core 0.3.5 → 0.3.7

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 (62) 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 +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +4 -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 +182 -45
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/operator-mcp.d.ts.map +1 -1
  37. package/dist/operator-mcp.js +5 -4
  38. package/dist/operator-mcp.js.map +1 -1
  39. package/dist/request-context.d.ts +5 -0
  40. package/dist/request-context.d.ts.map +1 -0
  41. package/dist/request-context.js +22 -0
  42. package/dist/request-context.js.map +1 -0
  43. package/dist/same-origin-path.d.ts +37 -0
  44. package/dist/same-origin-path.d.ts.map +1 -0
  45. package/dist/same-origin-path.js +29 -0
  46. package/dist/same-origin-path.js.map +1 -0
  47. package/dist/storage-client.d.ts.map +1 -1
  48. package/dist/storage-client.js +44 -28
  49. package/dist/storage-client.js.map +1 -1
  50. package/dist/version.d.ts +3 -0
  51. package/dist/version.d.ts.map +1 -0
  52. package/dist/version.js +6 -0
  53. package/dist/version.js.map +1 -0
  54. package/dist/vite.d.ts +76 -0
  55. package/dist/vite.d.ts.map +1 -1
  56. package/dist/vite.js +121 -0
  57. package/dist/vite.js.map +1 -1
  58. package/dist/worker.d.ts +2 -0
  59. package/dist/worker.d.ts.map +1 -1
  60. package/dist/worker.js +76 -43
  61. package/dist/worker.js.map +1 -1
  62. package/package.json +2 -2
package/dist/mcp.js CHANGED
@@ -1,17 +1,22 @@
1
1
  import { z } from "zod";
2
+ import { RAYBASE_CORE_VERSION } from "./version.js";
2
3
  import { withHostUserRoleCeiling } from "./host-user.js";
3
4
  import { redactSecretColumnKeys, secretColumnKeys } from "./data-secrets.js";
5
+ import { actorAuditRef, recordAudit } from "./audit.js";
4
6
  import { audit, base64url, normalizeRoles } from "./auth.js";
7
+ import { servicePrincipal } from "./principal.js";
5
8
  import { hasAdminAccess, normalizeStoredAdminRole } from "./admin-role.js";
6
9
  import { inputSchemaForTable } from "./data-client.js";
7
10
  import { createRaybaseDb, dataStub } from "./db.js";
8
11
  import { sha256Hex } from "./internal.js";
9
- import { jsonSchemaFor } from "./json-schema.js";
12
+ import { actionInputIssues } from "./actions.js";
13
+ import { jsonSchemaFor, parseWithSchema } from "./json-schema.js";
10
14
  import { searchIndex, updateSearchForDataChange } from "./search.js";
11
15
  import { id, longText, raybaseSystemTable, text } from "./schema.js";
12
16
  import { randomId } from "./sql.js";
13
17
  import { wallNowIso as nowIso, wallNowMs } from "./clock.js";
14
18
  import { ensureManifestTables } from "./system-tables.js";
19
+ import { sameOriginPath } from "./same-origin-path.js";
15
20
  export const mcpAppsExtensionId = "io.modelcontextprotocol/ui";
16
21
  export const mcpAppResourceMimeType = "text/html;profile=mcp-app";
17
22
  const jsonHeaders = { "content-type": "application/json; charset=utf-8" };
@@ -595,7 +600,29 @@ export function wwwAuthenticate(request, config, surface = "app") {
595
600
  const scope = surface === "operator" ? "operator:read" : "mcp:read mcp:write";
596
601
  return `Bearer resource_metadata="${serverOrigin(request)}${metadataPath}", resource="${resource}", scope="${scope}"`;
597
602
  }
598
- 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
+ }
599
626
  return jsonResponse({ error }, {
600
627
  status: 401,
601
628
  headers: { "www-authenticate": wwwAuthenticate(request, config, surface), ...Object.fromEntries(corsHeaders(request, config)) }
@@ -875,6 +902,12 @@ async function registerConfidentialClient(request, config, db, body, user, optio
875
902
  return jsonResponse({ error: "invalid_client_metadata", error_description: "token_endpoint_auth_method must be client_secret_basic or client_secret_post" }, { status: 400 });
876
903
  }
877
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
+ }
878
911
  let created;
879
912
  try {
880
913
  created = await createMcpMachineCredential(db, config, {
@@ -944,6 +977,15 @@ export async function createMcpMachineCredential(db, config, input, options = {}
944
977
  const scopeError = authorizeMcpScopes(input.user, scopes, surface, options);
945
978
  if (scopeError)
946
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
+ }
947
989
  const authMethod = input.tokenEndpointAuthMethod ?? "client_secret_basic";
948
990
  const user = input.user;
949
991
  const clientId = `raybase_cc_${randomId("client")}`;
@@ -1115,8 +1157,12 @@ function consentStylesheetHref(config) {
1115
1157
  const href = config.mcp?.consent?.stylesheet?.trim();
1116
1158
  if (!href)
1117
1159
  return null;
1118
- if (href.startsWith("/") && !href.startsWith("//"))
1119
- 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
+ }
1120
1166
  try {
1121
1167
  if (new URL(href).protocol === "https:")
1122
1168
  return href;
@@ -1257,6 +1303,18 @@ export async function mcpAuthorizationOrganizationHint(request) {
1257
1303
  const value = asString(input.org).trim();
1258
1304
  return value || null;
1259
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
+ }
1260
1318
  /** The identity a host-account grant acts as: a plain app user, never an operator. */
1261
1319
  function hostAccountUser(hostAccount, subject) {
1262
1320
  return {
@@ -1331,13 +1389,18 @@ async function createGrantAndCode(db, config, request, user, values, clientName,
1331
1389
  addSeconds(300),
1332
1390
  now
1333
1391
  ]);
1334
- await audit(db, host ? null : user.id, surface.id === "operator" ? "operator_mcp.grant.create" : "mcp.grant.create", "_mcp_grants", grantId, {
1335
- clientId: values.client_id,
1336
- scopes,
1337
- resource,
1338
- surface: surface.id,
1339
- hostAccount,
1340
- 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
+ }
1341
1404
  });
1342
1405
  const redirect = new URL(values.redirect_uri);
1343
1406
  redirect.searchParams.set("code", code);
@@ -1382,10 +1445,10 @@ function authorizeMcpScopes(user, scopes, surface, options = {}) {
1382
1445
  return null;
1383
1446
  }
1384
1447
  function mcpSignInPath(config) {
1385
- const raw = config.mcp?.oauth?.signInPath?.trim() || "/login";
1386
- if (!raw.startsWith("/") || raw.startsWith("//"))
1387
- throw new Error("mcp.oauth.signInPath must be a root-relative path");
1388
- 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;
1389
1452
  }
1390
1453
  /** The authorization request as a GET address on this origin, path and query only. */
1391
1454
  function authorizationRequestPath(values, choice, input) {
@@ -1423,10 +1486,11 @@ export async function authorizeMcpOAuthRequest(request, config, db, user, option
1423
1486
  return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "access_denied");
1424
1487
  return createGrantAndCode(db, config, request, user, validated.values, validated.clientName, options, binding);
1425
1488
  }
1426
- const ownPage = config.mcp?.consent?.path?.trim();
1427
- if (ownPage) {
1428
- if (!ownPage.startsWith("/") || ownPage.startsWith("//"))
1429
- 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");
1430
1494
  const target = new URL(ownPage, request.url);
1431
1495
  target.search = new URL(request.url).search;
1432
1496
  return new Response(null, { status: 302, headers: { location: target.toString(), "cache-control": "no-store" } });
@@ -1518,8 +1582,9 @@ export async function handleMcpHostConnect(request, config, options) {
1518
1582
  /**
1519
1583
  * The authorization endpoint when host approval is on: records a pending
1520
1584
  * authorization for the requested host account and sends the person to the
1521
- * host's admin to approve it. With no host account, sends them to the connect
1522
- * 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.
1523
1588
  */
1524
1589
  export async function beginMcpHostAuthorization(request, config, db, env, options) {
1525
1590
  const limited = checkMcpRateLimit(request, "host-oauth-authorize", 120);
@@ -1527,7 +1592,10 @@ export async function beginMcpHostAuthorization(request, config, db, env, option
1527
1592
  return limited;
1528
1593
  const param = hostAccountParam(options);
1529
1594
  const input = await authorizationInput(request);
1530
- 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)) {
1531
1599
  const connect = new URL(mcpHostApprovalPath(options, "connectPath"), request.url);
1532
1600
  for (const [key, value] of Object.entries(input))
1533
1601
  connect.searchParams.set(key, value);
@@ -1538,8 +1606,8 @@ export async function beginMcpHostAuthorization(request, config, db, env, option
1538
1606
  return validated;
1539
1607
  if (validated.surface.id !== "app")
1540
1608
  return jsonResponse({ error: "Host-account MCP grants are only supported for the app MCP resource" }, { status: 400 });
1541
- const resource = validated.values.resource || canonicalMcpResource(request, config);
1542
- if (resource !== canonicalMcpResource(request, config))
1609
+ const resource = validated.values.resource || canonical;
1610
+ if (resource !== canonical)
1543
1611
  return redirectWithError(validated.values.redirect_uri, validated.values.state || null, "invalid_target");
1544
1612
  const hostAccount = options.normalizeHostAccount(input[param]);
1545
1613
  const pendingId = randomId("mcp_pending");
@@ -1827,40 +1895,40 @@ export async function validateMcpBearerToken(request, config, db, expectedSurfac
1827
1895
  const header = request.headers.get("authorization") ?? "";
1828
1896
  const match = /^Bearer\s+(.+)$/i.exec(header);
1829
1897
  if (!match) {
1830
- return unauthorizedMcpResponse(request, config, "Unauthorized", expectedSurface);
1898
+ return unauthorizedMcpResponse(request, config, "Unauthorized", expectedSurface, options);
1831
1899
  }
1832
1900
  const tokenHash = await sha256Hex(match[1]);
1833
1901
  const row = await db.first(`SELECT * FROM "_oauth_access_tokens" WHERE "tokenHash" = ?`, [tokenHash]);
1834
1902
  if (!row || row.revokedAt || Date.parse(row.expiresAt) <= wallNowMs()) {
1835
- return unauthorizedMcpResponse(request, config, "Invalid or expired token", expectedSurface);
1903
+ return unauthorizedMcpResponse(request, config, "Invalid or expired token", expectedSurface, options);
1836
1904
  }
1837
1905
  const expectedResource = resourceOverride ?? canonicalMcpResourceForSurface(request, config, expectedSurface);
1838
1906
  if (row.resource !== expectedResource)
1839
- 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);
1840
1908
  const grant = await loadGrant(db, row.grantId);
1841
1909
  if (!grant || grant.revokedAt || grant.clientId !== row.clientId || grant.resource !== expectedResource) {
1842
- return unauthorizedMcpResponse(request, config, "MCP grant is no longer active", expectedSurface);
1910
+ return unauthorizedMcpResponse(request, config, "MCP grant is no longer active", expectedSurface, options);
1843
1911
  }
1844
1912
  if (grant.expiresAt && Date.parse(grant.expiresAt) <= wallNowMs())
1845
- return unauthorizedMcpResponse(request, config, "MCP grant has expired", expectedSurface);
1913
+ return unauthorizedMcpResponse(request, config, "MCP grant has expired", expectedSurface, options);
1846
1914
  const user = await grantUser(db, grant, row.userId, options);
1847
1915
  if (!user)
1848
- return unauthorizedMcpResponse(request, config, "MCP subject is no longer valid", expectedSurface);
1916
+ return unauthorizedMcpResponse(request, config, "MCP subject is no longer valid", expectedSurface, options);
1849
1917
  const resourceRequest = resourceOverride ? new Request(expectedResource) : request;
1850
1918
  const surface = mcpSurfaceForResource(resourceRequest, config, expectedResource, options);
1851
1919
  if (!surface)
1852
- return unauthorizedMcpResponse(request, config, "Unknown MCP resource", expectedSurface);
1920
+ return unauthorizedMcpResponse(request, config, "Unknown MCP resource", expectedSurface, options);
1853
1921
  if (surface.id === "operator") {
1854
1922
  if (grant.subjectType !== "raybase-operator" || grant.subjectId !== user.id) {
1855
- 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);
1856
1924
  }
1857
1925
  const grantScopes = splitScopes(parseJson(grant.scopes));
1858
1926
  if (authorizeMcpScopes(user, grantScopes, surface, options)) {
1859
- 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);
1860
1928
  }
1861
1929
  }
1862
1930
  else if (grant.subjectType === "raybase-operator" || row.scope.includes("operator:")) {
1863
- 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);
1864
1932
  }
1865
1933
  const tokenHostAccount = grant.hostAccount || undefined;
1866
1934
  if (options.hostApproval) {
@@ -1920,10 +1988,44 @@ function mcpToolDescriptor(tool) {
1920
1988
  _meta: mcpToolMeta(tool)
1921
1989
  };
1922
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
+ */
1923
1996
  function parseToolArgs(tool, raw) {
1924
- if (typeof tool.inputSchema.parse === "function")
1925
- return tool.inputSchema.parse(raw);
1926
- 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
+ };
1927
2029
  }
1928
2030
  function rpcResult(id, result) {
1929
2031
  return { jsonrpc: "2.0", id: id ?? null, result };
@@ -2120,14 +2222,32 @@ async function event(db, grantId, clientId, action, toolName, metadata) {
2120
2222
  await db.run(`INSERT INTO "_mcp_grant_events" ("id", "grantId", "clientId", "action", "toolName", "metadata", "createdAt")
2121
2223
  VALUES (?, ?, ?, ?, ?, ?, ?)`, [randomId("mcp_event"), grantId, clientId, action, toolName ?? null, metadata === undefined ? null : JSON.stringify(metadata), nowIso()]);
2122
2224
  }
2123
- export function createMcpHandler(options) {
2124
- const surface = options.surface ?? "app";
2125
- const secretColumns = secretColumnKeys(options.schema, options.systemTables);
2126
- const allTools = [
2225
+ function connectorTools(options) {
2226
+ return [
2127
2227
  ...generatedMcpTools(options.schema, options.generatedTools),
2128
2228
  ...generatedSearchMcpTools(options),
2129
2229
  ...(options.tools ?? [])
2130
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);
2131
2251
  const resources = options.resources ?? [];
2132
2252
  for (const resource of resources)
2133
2253
  validateMcpAppResource(resource);
@@ -2215,7 +2335,12 @@ export function createMcpHandler(options) {
2215
2335
  const client = db.for({
2216
2336
  user: token.user,
2217
2337
  source: { surface: "mcp", requestId: request.headers.get("cf-ray") ?? undefined },
2218
- 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
+ }),
2219
2344
  notify: realtimeEnabled
2220
2345
  ? async (change) => {
2221
2346
  if (!("seq" in change))
@@ -2342,7 +2467,7 @@ async function handleRpc(rpc, tools, resources, ctx, control, surface, mcpAppUiE
2342
2467
  return rpcResult(rpc.id, {
2343
2468
  protocolVersion: "2025-06-18",
2344
2469
  capabilities,
2345
- serverInfo: { name: "raybase", title: ctx.config ? mcpConnectorName(ctx.config) : undefined, version: "0.1.0" },
2470
+ serverInfo: { name: "raybase", title: ctx.config ? mcpConnectorName(ctx.config) : undefined, version: RAYBASE_CORE_VERSION },
2346
2471
  ...(surface === "app" && ctx.config && mcpInstructions(ctx.config) ? { instructions: mcpInstructions(ctx.config) } : {})
2347
2472
  });
2348
2473
  case "ping":
@@ -2366,9 +2491,21 @@ async function handleRpc(rpc, tools, resources, ctx, control, surface, mcpAppUiE
2366
2491
  }
2367
2492
  return rpcError(rpc.id, -32001, `Token scope does not allow tool: ${name}`);
2368
2493
  }
2369
- 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;
2370
2508
  try {
2371
- args = parseToolArgs(tool, params.arguments ?? {});
2372
2509
  if (recordEvent) {
2373
2510
  await recordEvent("tool.call", name, { arguments: auditArguments(secretColumns, args) });
2374
2511
  }