@protobase/server 0.41.0 → 0.41.1

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.
@@ -59,6 +59,9 @@ export type AdminOptions = {
59
59
  * `writes: true` offers `create_record` and `update_record`, through the REST API's access rules, `validate` and write
60
60
  * hooks; the person approves each write in their client, and a client that cannot ask is refused.
61
61
  * `writes: { resources: ['tasks'] }` offers them on the listed resources only, and refuses a write to any other.
62
+ * `writes: { rolesWithoutApproval: ['service'] }` lets a user holding one of those roles, such as a service account
63
+ * with no person present, write without the approval; any other user is still asked. Each must be a role the app's
64
+ * users can hold.
62
65
  * `false`: the host serves its own MCP endpoint at `auth.mcp.path`, in front of this app. This app then answers 404
63
66
  * there and needs no `forAudience`, while the OAuth provider's discovery stays.
64
67
  */
@@ -23,7 +23,7 @@ import { realtimePath, restPath, storagePath, supabaseFunctionsPath, supabasePat
23
23
  import { goTruePath } from "./gotrue/keys.js";
24
24
  import { authBasePath } from "./better-auth/auth-base-path.js";
25
25
  import { mcpDiscovery } from "./better-auth/mcp/discovery-routes.js";
26
- import { checkMcpWrites } from "./mcp/mcp-writes.js";
26
+ import { checkMcpWrites, rolesWithoutApproval } from "./mcp/mcp-writes.js";
27
27
  import { mcpAuthenticator } from "./mcp/mcp-authenticator.js";
28
28
  import { mcpToolContext } from "./mcp/mcp-context.js";
29
29
  import { mcpHandler } from "./mcp/mcp-handler.js";
@@ -128,7 +128,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
128
128
  checkRoleLimits(registry, options.roles?.limits ?? []);
129
129
  checkOwnerFields(registry, authenticate.userIdType, auth);
130
130
  const mcpAuthenticate = mcpAuthenticator(authenticate, auth?.mcp, options.mcp);
131
- if (options.mcp) checkMcpWrites(registry, options.mcp.writes);
131
+ if (options.mcp) checkMcpWrites(registry, declaredRoles(auth, options.roles), options.mcp.writes);
132
132
  checkPositionAfter(registry);
133
133
  checkParents(registry);
134
134
  checkPages(pageModels, {
@@ -299,10 +299,11 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
299
299
  const report = options.onUnhandledError ?? ((error) => console.error(error));
300
300
  if (auth?.mcp && options.mcp === false) closedMcpRoutes(app, auth.mcp.path);
301
301
  else if (auth?.mcp && mcpAuthenticate) {
302
+ const writes = options.mcp !== false && options.mcp?.writes || false;
302
303
  const tools = mcpTools({
303
304
  db,
304
305
  sql: options.mcp !== false && options.mcp?.sql === true,
305
- writes: options.mcp !== false && options.mcp?.writes || false
306
+ writes
306
307
  });
307
308
  const server = (session) => mcpServer({
308
309
  info: {
@@ -311,7 +312,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
311
312
  },
312
313
  tools: tools(session),
313
314
  context: mcpToolContext(deps, meta, session),
314
- report
315
+ report,
316
+ rolesWithoutApproval: rolesWithoutApproval(writes)
315
317
  });
316
318
  mcpRoutes(app, {
317
319
  path: auth.mcp.path,
@@ -18,6 +18,13 @@ const mcpApproval = (inputResponses) => (request) => {
18
18
  if (answer.kind === "elicit" && answer.action === "accept" && answer.content?.approve === true) return Promise.resolve("approved");
19
19
  return Promise.resolve("rejected");
20
20
  };
21
+ /**
22
+ * Whether `roles`, the roles the signed-in user holds, include one of `exempt`, whose MCP writes need no approval: an
23
+ * exact match, and none for anything but a list.
24
+ */
25
+ const writesUnasked = (exempt, roles) => Array.isArray(roles) && roles.some((role) => exempt.includes(role));
26
+ /** `ToolContext.approve` over MCP for a user whose writes need no approval: approved, without asking the client. */
27
+ const approvedUnasked = () => Promise.resolve("approved");
21
28
  const asksForms = (capabilities) => {
22
29
  const elicitation = capabilities?.elicitation;
23
30
  return elicitation !== void 0 && (elicitation.form !== void 0 || elicitation.url === void 0);
@@ -54,4 +61,4 @@ const approvalAsked = (request, capabilities) => {
54
61
  }) } });
55
62
  };
56
63
  //#endregion
57
- export { ApprovalNeeded, approvalAsked, mcpApproval };
64
+ export { ApprovalNeeded, approvalAsked, approvedUnasked, mcpApproval, writesUnasked };
@@ -5,11 +5,11 @@ const isMcpOptions = (options) => options === void 0 || options === false || typ
5
5
  /**
6
6
  * The authenticator of the MCP endpoint's tokens, when MCP is on (`createAuth({ mcp })`); none when the host serves its
7
7
  * own (`options.mcp: false`). Stops the server at startup when `options.mcp` is neither `false` nor
8
- * `{ sql?: boolean; writes?: boolean | { resources: string[] } }`, when it is set without MCP, or when `authenticate` cannot verify tokens for
8
+ * `{ sql?: boolean; writes?: boolean | { resources?: string[]; rolesWithoutApproval?: string[] } }`, when it is set without MCP, or when `authenticate` cannot verify tokens for
9
9
  * another audience.
10
10
  */
11
11
  const mcpAuthenticator = (authenticate, mcp, options) => {
12
- if (!isMcpOptions(options)) throw new Error("options.mcp takes false or { sql?: boolean, writes?: boolean | { resources: [<resource>, ...] } }");
12
+ if (!isMcpOptions(options)) throw new Error("options.mcp takes false or { sql?: boolean, writes?: boolean | { resources?: [<resource>, ...], rolesWithoutApproval?: [<role>, ...] } }");
13
13
  if (options === false) return void 0;
14
14
  if (options && !mcp) throw new Error("options.mcp needs createAuth({ mcp }) passed as auth");
15
15
  if (!mcp) return void 0;
@@ -13,10 +13,13 @@ export type McpServerInput = {
13
13
  context: Omit<ToolContext, 'approve'>;
14
14
  /** Gets the errors a tool throws, which the client only sees as a failure. */
15
15
  report: (error: unknown) => void;
16
+ /** The roles whose calls are approved without asking the client: a user holding one of them is never asked. None by default. */
17
+ rolesWithoutApproval?: readonly string[];
16
18
  };
17
19
  /**
18
20
  * An MCP server offering `tools` as MCP tools, each with its JSON Schema, its hints, and the text it returns. A tool
19
- * that asks for approval makes the call ask the client's user, and runs again from the start with their answer.
21
+ * that asks for approval makes the call ask the client's user, and runs again from the start with their answer; for a
22
+ * user holding one of `rolesWithoutApproval` it is approved at once, and the client is never asked.
20
23
  */
21
- export declare const mcpServer: ({ info, tools, context, report }: McpServerInput) => McpServer;
24
+ export declare const mcpServer: ({ info, tools, context, report, rolesWithoutApproval }: McpServerInput) => McpServer;
22
25
  //#endregion
@@ -1,5 +1,5 @@
1
1
  import { checkedTools } from "../assistant/checked-tools.js";
2
- import { ApprovalNeeded, approvalAsked, mcpApproval } from "./mcp-approval.js";
2
+ import { ApprovalNeeded, approvalAsked, approvedUnasked, mcpApproval, writesUnasked } from "./mcp-approval.js";
3
3
  import { CLIENT_CAPABILITIES_META_KEY, McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
4
4
  //#region src/mcp/mcp-server.ts
5
5
  const capabilitiesOf = (envelope) => envelope?.[CLIENT_CAPABILITIES_META_KEY];
@@ -12,10 +12,12 @@ const failed = {
12
12
  };
13
13
  /**
14
14
  * An MCP server offering `tools` as MCP tools, each with its JSON Schema, its hints, and the text it returns. A tool
15
- * that asks for approval makes the call ask the client's user, and runs again from the start with their answer.
15
+ * that asks for approval makes the call ask the client's user, and runs again from the start with their answer; for a
16
+ * user holding one of `rolesWithoutApproval` it is approved at once, and the client is never asked.
16
17
  */
17
- const mcpServer = ({ info, tools, context, report }) => {
18
+ const mcpServer = ({ info, tools, context, report, rolesWithoutApproval = [] }) => {
18
19
  const server = new McpServer(info);
20
+ const unasked = writesUnasked(rolesWithoutApproval, context.session.user.roles);
19
21
  for (const tool of checkedTools(tools)) {
20
22
  const config = {
21
23
  description: tool.description,
@@ -28,7 +30,7 @@ const mcpServer = ({ info, tools, context, report }) => {
28
30
  type: "text",
29
31
  text: await tool.run(args, {
30
32
  ...context,
31
- approve: mcpApproval(ctx.mcpReq.inputResponses)
33
+ approve: unasked ? approvedUnasked : mcpApproval(ctx.mcpReq.inputResponses)
32
34
  })
33
35
  }] };
34
36
  } catch (error) {
@@ -9,7 +9,7 @@ export type McpToolsInput = {
9
9
  sql: boolean;
10
10
  /**
11
11
  * Offers `create_record` and `update_record`, on the listed resources only when given `{ resources }`; each write still
12
- * needs the caller's role and their approval in the client.
12
+ * needs the caller's role, and their approval in the client unless they hold one of `rolesWithoutApproval`.
13
13
  */
14
14
  writes: McpWrites;
15
15
  };
@@ -9,7 +9,7 @@ import { writeRecordTools } from "../assistant/write-record-tools.js";
9
9
  */
10
10
  const mcpTools = ({ db, sql, writes }) => (session) => [
11
11
  ...readRecordTools(),
12
- ...writes === false ? [] : writeRecordTools(writes === true ? {} : { resources: writes.resources }),
12
+ ...writes === false ? [] : writeRecordTools(writes === true || !writes.resources ? {} : { resources: writes.resources }),
13
13
  ...sql && seesAssistant(session.user.roles) ? [readOnlyQueryTool(db)] : []
14
14
  ];
15
15
  //#endregion
@@ -1,7 +1,11 @@
1
1
  import "../registry.js";
2
2
  //#region src/mcp/mcp-writes.d.ts
3
- /** Which resources the MCP write tools may change: none, every one the caller may write, or the ones named. */
3
+ /**
4
+ * Which resources the MCP write tools may change: none, every one the caller may write, or the ones named; and the
5
+ * roles whose writes run without the person's approval in the client.
6
+ */
4
7
  export type McpWrites = boolean | {
5
- resources: string[];
8
+ resources?: string[];
9
+ rolesWithoutApproval?: string[];
6
10
  };
7
11
  //#endregion
@@ -1,16 +1,25 @@
1
+ import { anonymousRole } from "@protobase/schema";
1
2
  //#region src/mcp/mcp-writes.ts
2
- const isResourceList = (value) => Array.isArray(value) && value.length > 0 && value.every((name) => typeof name === "string" && name !== "");
3
+ const isNameList = (value) => Array.isArray(value) && value.every((name) => typeof name === "string" && name !== "");
4
+ const isWritesEntry = ([name, value]) => name === "resources" && isNameList(value) && value.length > 0 || name === "rolesWithoutApproval" && isNameList(value);
3
5
  const isMcpWrites = (value) => {
4
6
  if (typeof value === "boolean") return true;
5
7
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
6
8
  const entries = Object.entries(value);
7
- return entries.length === 1 && entries[0][0] === "resources" && isResourceList(entries[0][1]);
9
+ return entries.length > 0 && entries.every(isWritesEntry);
8
10
  };
9
- /** Stops startup when `options.mcp.writes` names a resource the app does not have. */
10
- const checkMcpWrites = (registry, writes) => {
11
+ /** The roles `writes` lets write without approval; none unless named. */
12
+ const rolesWithoutApproval = (writes) => typeof writes === "object" ? writes.rolesWithoutApproval ?? [] : [];
13
+ /**
14
+ * Stops startup when `options.mcp.writes` names a resource the app does not have, or lets a role write without
15
+ * approval that is not one of `roles`, the roles its users can hold, or is the anonymous role.
16
+ */
17
+ const checkMcpWrites = (registry, roles, writes) => {
11
18
  if (typeof writes !== "object") return;
12
- const unknown = writes.resources.filter((name) => !registry.find(name));
19
+ const unknown = (writes.resources ?? []).filter((name) => !registry.find(name));
13
20
  if (unknown.length > 0) throw new Error(`options.mcp.writes names resources the app does not have: ${unknown.join(", ")}`);
21
+ const unheld = rolesWithoutApproval(writes).filter((role) => role === anonymousRole || !(roles ?? []).includes(role));
22
+ if (unheld.length > 0) throw new Error(`options.mcp.writes.rolesWithoutApproval names roles the app's users cannot hold: ${unheld.join(", ")}`);
14
23
  };
15
24
  //#endregion
16
- export { checkMcpWrites, isMcpWrites };
25
+ export { checkMcpWrites, isMcpWrites, rolesWithoutApproval };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.41.0";
2
+ var version = "0.41.1";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -36,9 +36,9 @@
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
38
  "@modelcontextprotocol/server": "^2.3.1",
39
- "@protobase/layout": "0.41.0",
40
- "@protobase/query": "0.41.0",
41
- "@protobase/schema": "0.41.0",
39
+ "@protobase/layout": "0.41.1",
40
+ "@protobase/query": "0.41.1",
41
+ "@protobase/schema": "0.41.1",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",