@protobase/server 0.40.5 → 0.40.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.
@@ -55,11 +55,14 @@ export type AdminOptions = {
55
55
  /**
56
56
  * The MCP endpoint, on with `createAuth({ mcp })`. `sql: true` also offers admins and the `ai` role
57
57
  * `run_read_only_query`, which reads the whole database as the app's database role, outside the access rules.
58
+ * `writes: true` offers `create_record` and `update_record`, through the REST API's access rules, `validate` and write
59
+ * hooks; the person approves each write in their client, and a client that cannot ask is refused.
58
60
  * `false`: the host serves its own MCP endpoint at `auth.mcp.path`, in front of this app. This app then answers 404
59
61
  * there and needs no `forAudience`, while the OAuth provider's discovery stays.
60
62
  */
61
63
  mcp?: {
62
64
  sql?: boolean;
65
+ writes?: boolean;
63
66
  } | false;
64
67
  /**
65
68
  * The websites whose browser code may call the API (`/api/*` but `/api/auth/*`) besides the app's own origins (see
@@ -0,0 +1,33 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ //#region src/assistant/record-tool-args.ts
3
+ /** A refusal the model can act on (no such resource, not allowed, a bad filter) is its to read; anything else fails the call. */
4
+ const refused = (error) => {
5
+ if (error instanceof HttpProblem && error.status < 500) return JSON.stringify({
6
+ error: error.slug,
7
+ message: error.detail
8
+ });
9
+ throw error;
10
+ };
11
+ const resourceOf = (args) => typeof args.resource === "string" && args.resource ? args.resource : void 0;
12
+ const keyOf = (value) => {
13
+ if (typeof value === "string" || typeof value === "number") return value;
14
+ if (Array.isArray(value) && value.length > 0 && value.every((part) => typeof part === "string" || typeof part === "number")) return value;
15
+ };
16
+ const resourceParameter = {
17
+ type: "string",
18
+ description: "The resource's name, as list_resources gives it."
19
+ };
20
+ const keyParameter = {
21
+ description: "The record's key: its value, or the values of its key fields in order when the key has several.",
22
+ anyOf: [
23
+ { type: "string" },
24
+ { type: "number" },
25
+ {
26
+ type: "array",
27
+ items: { type: ["string", "number"] },
28
+ minItems: 1
29
+ }
30
+ ]
31
+ };
32
+ //#endregion
33
+ export { keyOf, keyParameter, refused, resourceOf, resourceParameter };
@@ -1,5 +1,5 @@
1
- import { HttpProblem } from "../problem.js";
2
1
  import { defineTool } from "./tools.js";
2
+ import { keyOf, keyParameter, refused, resourceOf, resourceParameter } from "./record-tool-args.js";
3
3
  //#region src/assistant/record-tools.ts
4
4
  const readOnly = {
5
5
  readOnlyHint: true,
@@ -7,13 +7,6 @@ const readOnly = {
7
7
  };
8
8
  const defaultPageSize = 20;
9
9
  const maxPageSize = 100;
10
- const refused = (error) => {
11
- if (error instanceof HttpProblem && error.status < 500) return JSON.stringify({
12
- error: error.slug,
13
- message: error.detail
14
- });
15
- throw error;
16
- };
17
10
  const fieldEntry = (field, view) => {
18
11
  const hint = view?.fields[field.name];
19
12
  return {
@@ -37,15 +30,6 @@ const resourceEntry = (model, view) => ({
37
30
  fields: Object.values(model.fields).map((field) => fieldEntry(field, view))
38
31
  });
39
32
  const described = ({ models, views }) => models.map((model) => resourceEntry(model, views.find((view) => view.resource === model.name)));
40
- const resourceOf = (args) => typeof args.resource === "string" && args.resource ? args.resource : void 0;
41
- const keyOf = (value) => {
42
- if (typeof value === "string" || typeof value === "number") return value;
43
- if (Array.isArray(value) && value.length > 0 && value.every((part) => typeof part === "string" || typeof part === "number")) return value;
44
- };
45
- const resourceParameter = {
46
- type: "string",
47
- description: "The resource's name, as list_resources gives it."
48
- };
49
33
  /** Lists the resources the caller may read, with their fields, labels and which fields filters and sorts take. */
50
34
  const listResourcesTool = () => defineTool({
51
35
  name: "list_resources",
@@ -107,18 +91,7 @@ const getRecordTool = () => defineTool({
107
91
  type: "object",
108
92
  properties: {
109
93
  resource: resourceParameter,
110
- key: {
111
- description: "The record's key: its value, or the values of its key fields in order when the key has several.",
112
- anyOf: [
113
- { type: "string" },
114
- { type: "number" },
115
- {
116
- type: "array",
117
- items: { type: ["string", "number"] },
118
- minItems: 1
119
- }
120
- ]
121
- }
94
+ key: keyParameter
122
95
  },
123
96
  required: ["resource", "key"]
124
97
  },
@@ -0,0 +1,9 @@
1
+ import { AssistantTool } from "./tools.js";
2
+ //#region src/assistant/write-record-tools.d.ts
3
+ /** Creates a record of a resource once the user approves it, under the caller's access rules. */
4
+ export declare const createRecordTool: () => AssistantTool;
5
+ /** Updates one record by its key once the user approves it, under the caller's access rules. */
6
+ export declare const updateRecordTool: () => AssistantTool;
7
+ /** `create_record` and `update_record`. */
8
+ export declare const writeRecordTools: () => AssistantTool[];
9
+ //#endregion
@@ -0,0 +1,85 @@
1
+ import { defineTool } from "./tools.js";
2
+ import { keyOf, keyParameter, refused, resourceOf, resourceParameter } from "./record-tool-args.js";
3
+ //#region src/assistant/write-record-tools.ts
4
+ const writes = {
5
+ readOnlyHint: false,
6
+ destructiveHint: false,
7
+ openWorldHint: false
8
+ };
9
+ const dataParameter = {
10
+ type: "object",
11
+ description: "Field names to values, as list_resources gives the fields."
12
+ };
13
+ const dataOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
14
+ const notApproved = (answer) => `The user did not approve this change (${answer}); nothing was written.`;
15
+ /** Creates a record of a resource once the user approves it, under the caller's access rules. */
16
+ const createRecordTool = () => defineTool({
17
+ name: "create_record",
18
+ description: "Creates a record of a resource and returns it as JSON ({ record, etag }). The user approves each write in their client first; nothing is written without it.",
19
+ parameters: {
20
+ type: "object",
21
+ properties: {
22
+ resource: resourceParameter,
23
+ data: dataParameter
24
+ },
25
+ required: ["resource", "data"]
26
+ },
27
+ annotations: writes,
28
+ run: async (args, context) => {
29
+ const resource = resourceOf(args);
30
+ const data = dataOf(args.data);
31
+ if (!resource || !data) return "Pass the resource and data as { \"resource\": \"<name>\", \"data\": { \"<field>\": <value> } }.";
32
+ const answer = await context.approve({
33
+ title: `Create a record in ${resource}?`,
34
+ code: JSON.stringify(data, null, 2),
35
+ approveLabel: "Create"
36
+ });
37
+ if (answer !== "approved") return notApproved(answer);
38
+ return context.records.create(resource, data).then((record) => JSON.stringify(record), refused);
39
+ }
40
+ });
41
+ /** Updates one record by its key once the user approves it, under the caller's access rules. */
42
+ const updateRecordTool = () => defineTool({
43
+ name: "update_record",
44
+ description: "Updates fields of one record of a resource by its key and returns it as JSON ({ record, etag }). Pass the etag get_record gave to fail when someone changed the record since. The user approves each write in their client first; nothing is written without it.",
45
+ parameters: {
46
+ type: "object",
47
+ properties: {
48
+ resource: resourceParameter,
49
+ key: keyParameter,
50
+ data: {
51
+ ...dataParameter,
52
+ description: "The fields to change, names to new values, as list_resources gives the fields."
53
+ },
54
+ etag: {
55
+ type: "string",
56
+ description: "The etag get_record gave, to fail when the record changed since."
57
+ }
58
+ },
59
+ required: [
60
+ "resource",
61
+ "key",
62
+ "data"
63
+ ]
64
+ },
65
+ annotations: writes,
66
+ run: async (args, context) => {
67
+ const resource = resourceOf(args);
68
+ const key = keyOf(args.key);
69
+ const data = dataOf(args.data);
70
+ if (!resource || key === void 0 || !data) return "Pass the resource, key and data as { \"resource\": \"<name>\", \"key\": \"<key>\", \"data\": { \"<field>\": <value> } }.";
71
+ const shownKey = Array.isArray(key) ? key.join(",") : String(key);
72
+ const answer = await context.approve({
73
+ title: `Update ${resource} ${shownKey}?`,
74
+ code: JSON.stringify(data, null, 2),
75
+ approveLabel: "Update"
76
+ });
77
+ if (answer !== "approved") return notApproved(answer);
78
+ const etag = typeof args.etag === "string" && args.etag ? args.etag : void 0;
79
+ return context.records.update(resource, key, data, { etag }).then((record) => JSON.stringify(record), refused);
80
+ }
81
+ });
82
+ /** `create_record` and `update_record`. */
83
+ const writeRecordTools = () => [createRecordTool(), updateRecordTool()];
84
+ //#endregion
85
+ export { createRecordTool, updateRecordTool, writeRecordTools };
@@ -31,7 +31,9 @@ export type BetterAuthAuthenticator = Authenticator & {
31
31
  * An authenticator for the OAuth access tokens issued for `audience` alone: the app's keys, a `typ` of `at+jwt`, and
32
32
  * proof of the key for a DPoP-bound token, checked against `audience` as the URL it guards (the endpoint must answer
33
33
  * at exactly that URL). The app's own tokens and trusted apps' are refused; the session keeps the token's `client`.
34
- * With `jwksUrl` instead of `auth`, a DPoP proof's single use holds in this process only.
34
+ * With `auth`, every request also reads the store to check that the person still allows the client
35
+ * (`checkMcpGrant`), so a disconnected client or a secured account ends its tokens at once. With `jwksUrl` instead of
36
+ * `auth`, a token works until it expires, and a DPoP proof's single use holds in this process only.
35
37
  */
36
38
  forAudience: (audience: string) => Authenticator;
37
39
  };
@@ -2,6 +2,7 @@ import { jwtSessions } from "../auth/jwt.js";
2
2
  import { noGlobalRole } from "./organizations/role-definitions.js";
3
3
  import { cachedKeys } from "./jwks-cache.js";
4
4
  import { withTrustedApps } from "./app-tokens.js";
5
+ import { checkMcpGrant } from "./mcp/grant-standing.js";
5
6
  import { checkTokenStanding, tokenClaims } from "./token-standing.js";
6
7
  import { createDpopReplayStore, createInMemoryDpopReplayStore } from "better-auth/oauth2";
7
8
  //#region src/better-auth/authenticator.ts
@@ -43,7 +44,7 @@ const betterAuthAuthenticator = (options) => {
43
44
  });
44
45
  const verify = async (request) => {
45
46
  const { payload, session } = await sessions(request);
46
- if (auth && standing) await checkTokenStanding(await auth.$context, tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0), organizations);
47
+ if (auth) await standing(await auth.$context, payload, session);
47
48
  return session;
48
49
  };
49
50
  if (!organizations) return verify;
@@ -62,7 +63,13 @@ const betterAuthAuthenticator = (options) => {
62
63
  };
63
64
  let replayStore;
64
65
  const dpopReplayStore = () => replayStore ??= options.auth ? options.auth.$context.then(({ internalAdapter }) => createDpopReplayStore(internalAdapter)) : Promise.resolve(createInMemoryDpopReplayStore());
65
- const own = sessionOf({ audience: options.audience ?? issuer }, true);
66
+ const ownStanding = (context, payload, session) => checkTokenStanding(context, tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0), organizations);
67
+ const own = sessionOf({ audience: options.audience ?? issuer }, ownStanding);
68
+ const grantStanding = (context, payload, session) => checkMcpGrant(context.adapter, {
69
+ userId: payload.sub,
70
+ clientId: session.client?.id,
71
+ issuedAt: payload.iat
72
+ });
66
73
  return Object.assign(withTrustedApps(own, issuer, options.apps ?? []), { forAudience: (audience) => sessionOf({
67
74
  audience,
68
75
  typ: "at+jwt",
@@ -71,7 +78,7 @@ const betterAuthAuthenticator = (options) => {
71
78
  replayStore: dpopReplayStore
72
79
  },
73
80
  oauthClient: true
74
- }, false) });
81
+ }, grantStanding) });
75
82
  };
76
83
  //#endregion
77
84
  export { betterAuthAuthenticator, betterAuthTokens };
@@ -8446,6 +8446,24 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
8446
8446
  handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
8447
8447
  }[];
8448
8448
  };
8449
+ } | {
8450
+ id: "protobase-mcp-refresh-reuse";
8451
+ hooks: {
8452
+ before: {
8453
+ matcher: (context: {
8454
+ path?: string;
8455
+ body?: unknown;
8456
+ }) => boolean;
8457
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
8458
+ }[];
8459
+ after: {
8460
+ matcher: (context: {
8461
+ path?: string;
8462
+ body?: unknown;
8463
+ }) => boolean;
8464
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
8465
+ }[];
8466
+ };
8449
8467
  } | {
8450
8468
  id: "protobase-mcp-connected-apps";
8451
8469
  endpoints: {
@@ -0,0 +1,26 @@
1
+ import { unauthorized } from "../../problem.js";
2
+ import "./consent-check.js";
3
+ //#region src/better-auth/mcp/grant-standing.ts
4
+ /**
5
+ * Whether an MCP access token's grant still stands, read from the store on every request: the person still allows its
6
+ * client with a consent made no later than the token. Disconnecting the client, or securing the account, deletes the
7
+ * consents (`revokeMcpGrants`), so its access tokens stop at once instead of working out their 15 minutes, and stay
8
+ * stopped when the person allows the client again, whose new consent is newer than them. Not the browser session that
9
+ * allowed the client: the grant outlives it. A lookup that fails is not caught.
10
+ */
11
+ const checkMcpGrant = async (adapter, { userId, clientId, issuedAt }) => {
12
+ if (clientId === void 0) throw unauthorized("The access token names no client");
13
+ if (issuedAt === void 0) throw unauthorized("The access token has no issue time");
14
+ if (!(await adapter.findMany({
15
+ model: "oauthConsent",
16
+ where: [{
17
+ field: "userId",
18
+ value: userId
19
+ }, {
20
+ field: "clientId",
21
+ value: clientId
22
+ }]
23
+ })).some((consent) => new Date(consent.createdAt).getTime() <= issuedAt * 1e3)) throw unauthorized("The client was disconnected: connect it again");
24
+ };
25
+ //#endregion
26
+ export { checkMcpGrant };
@@ -4,6 +4,7 @@ import { pluginNames } from "../snake-case-names.js";
4
4
  import { mcpConnectedApps } from "./connected-apps.js";
5
5
  import { consentReference } from "./consent-reference.js";
6
6
  import { mcpTokenClaims } from "./mcp-token-claims.js";
7
+ import { mcpRefreshReuse } from "./refresh-reuse.js";
7
8
  import { mcpRequestDefaults } from "./request-defaults.js";
8
9
  import { mcpSetupGate } from "./setup-gate.js";
9
10
  import { mcpSignedQueryGuard } from "./signed-query-guard.js";
@@ -56,7 +57,8 @@ const mcpUnusedPaths = [
56
57
  /**
57
58
  * The Better Auth plugins of an OAuth provider for MCP clients, after the `jwt` plugin, in this order: the guard on
58
59
  * signed queries, the request defaults, the setup gate, the provider (`mcp()`), and client metadata documents
59
- * (`cimd()`), the revocation when the account is secured (`mcpGrantRevocation`), and the person's list of connected
60
+ * (`cimd()`), the revocation when the account is secured (`mcpGrantRevocation`) or a refresh token is reused
61
+ * (`mcpRefreshReuse`), and the person's list of connected
60
62
  * apps, where they disconnect one (`mcpConnectedApps`). A client registers itself, the person signs in on the app's own pages and allows it on the connect
61
63
  * page, and the client gets 15-minute access tokens for the MCP resource only, with refresh tokens, carrying the role
62
64
  * and organization the person worked in when they allowed it.
@@ -101,6 +103,7 @@ const mcpAuthPlugins = ({ mcp: resolved, organizations, mail, defaultPolicy, def
101
103
  metadataProfile: "mcp-2026-07-28"
102
104
  }),
103
105
  mcpGrantRevocation(),
106
+ mcpRefreshReuse(),
104
107
  mcpConnectedApps({ definitions })
105
108
  ];
106
109
  //#endregion
@@ -0,0 +1,66 @@
1
+ import { revokeMcpGrants } from "./revoke-grants.js";
2
+ import { base64url } from "jose";
3
+ import { createAuthMiddleware } from "better-auth/api";
4
+ //#region src/better-auth/mcp/refresh-reuse.ts
5
+ const refreshModel = "oauthRefreshToken";
6
+ /** The refresh token a request to the token or revocation endpoint presents, or `undefined`. */
7
+ const presentedRefreshToken = (path, body) => {
8
+ if (typeof body !== "object" || body === null) return void 0;
9
+ const params = body;
10
+ if (path === "/oauth2/token" && params.grant_type === "refresh_token" && typeof params.refresh_token === "string") return params.refresh_token;
11
+ if (path === "/oauth2/revoke" && params.token_type_hint !== "access_token" && typeof params.token === "string") return params.token;
12
+ };
13
+ const storedToken = async (token) => base64url.encode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))));
14
+ /** The stored row of a refresh token, before the provider handles it. */
15
+ const findRefreshToken = async (adapter, token) => await adapter.findOne({
16
+ model: refreshModel,
17
+ where: [{
18
+ field: "token",
19
+ value: await storedToken(token)
20
+ }]
21
+ }) ?? void 0;
22
+ /**
23
+ * Ends the grant after the provider took a refresh token for stolen: when a rotated one is presented again, it deletes
24
+ * every refresh token of that client and person (`invalidateRefreshFamily`) but leaves the consent, so the access
25
+ * tokens would still be taken. A row the request found before and that is gone after was deleted that way, so the
26
+ * grant is revoked (`revokeMcpGrants`), and the client's access tokens stop at once (`checkMcpGrant`).
27
+ */
28
+ const endGrantIfFamilyInvalidated = async (adapter, before) => {
29
+ if (await adapter.findOne({
30
+ model: refreshModel,
31
+ where: [{
32
+ field: "id",
33
+ value: before.id
34
+ }]
35
+ })) return;
36
+ await revokeMcpGrants(adapter, before.userId, before.clientId);
37
+ };
38
+ /**
39
+ * Hooks around the token and revocation endpoints that disconnect a client whose refresh token was reused
40
+ * (`endGrantIfFamilyInvalidated`). A failure to read or delete is not caught: the request answers 500.
41
+ */
42
+ const mcpRefreshReuse = () => {
43
+ const found = /* @__PURE__ */ new WeakMap();
44
+ const matcher = (context) => presentedRefreshToken(context.path, context.body) !== void 0;
45
+ return {
46
+ id: "protobase-mcp-refresh-reuse",
47
+ hooks: {
48
+ before: [{
49
+ matcher,
50
+ handler: createAuthMiddleware(async (ctx) => {
51
+ const row = await findRefreshToken(ctx.context.adapter, presentedRefreshToken(ctx.path, ctx.body));
52
+ if (row) found.set(ctx.context, row);
53
+ })
54
+ }],
55
+ after: [{
56
+ matcher,
57
+ handler: createAuthMiddleware(async (ctx) => {
58
+ const row = found.get(ctx.context);
59
+ if (row) await endGrantIfFamilyInvalidated(ctx.context.adapter, row);
60
+ })
61
+ }]
62
+ }
63
+ };
64
+ };
65
+ //#endregion
66
+ export { endGrantIfFamilyInvalidated, findRefreshToken, mcpRefreshReuse, presentedRefreshToken };
@@ -3,9 +3,9 @@ import { createAuthMiddleware } from "better-auth/api";
3
3
  //#region src/better-auth/mcp/revoke-grants.ts
4
4
  /**
5
5
  * Disconnects the user's MCP clients, or only `clientId`: deletes their consents, then their access tokens and refresh
6
- * tokens. Without the consent no code or refresh is exchanged any more (`mcpConsentCheck`), and authorize asks the
7
- * person again; access tokens already issued work out their 15 minutes. Access tokens go before refresh tokens, as the
8
- * provider's own revocation does, since an access token's row may name its refresh token.
6
+ * tokens. Without the consent no code or refresh is exchanged any more (`mcpConsentCheck`), access tokens already
7
+ * issued are refused at the next request (`checkMcpGrant`), and authorize asks the person again. Access tokens go
8
+ * before refresh tokens, as the provider's own revocation does, since an access token's row may name its refresh token.
9
9
  */
10
10
  const revokeMcpGrants = async (adapter, userId, clientId) => {
11
11
  const where = [{
@@ -47,10 +47,10 @@ const resolveOperator = (provider) => {
47
47
  sessionMinutes
48
48
  };
49
49
  };
50
- const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
50
+ const base64url$1 = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
51
51
  /** A random value for `state`, `nonce` and the PKCE verifier. */
52
- const randomValue = () => base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
53
- const challengeOf = async (verifier) => base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
52
+ const randomValue = () => base64url$1(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
53
+ const challengeOf = async (verifier) => base64url$1(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
54
54
  /**
55
55
  * An OpenID Connect client of the operator provider: the authorization code flow with PKCE, a client secret, and an
56
56
  * ID token verified against the provider's keys, issuer, this client and the nonce. The provider's settings and keys
@@ -298,7 +298,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
298
298
  else if (auth?.mcp && mcpAuthenticate) {
299
299
  const tools = mcpTools({
300
300
  db,
301
- sql: options.mcp !== false && options.mcp?.sql === true
301
+ sql: options.mcp !== false && options.mcp?.sql === true,
302
+ writes: options.mcp !== false && options.mcp?.writes === true
302
303
  });
303
304
  const server = (session) => mcpServer({
304
305
  info: {
package/dist/index.d.ts CHANGED
@@ -89,6 +89,7 @@ import { WidgetOptions, maxWidgetPropsBytes, widget } from "./assistant/widget.j
89
89
  import { QueryOptions, QueryResult, runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
90
90
  import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
91
91
  import { getRecordTool, listRecordsTool, listResourcesTool, readRecordTools } from "./assistant/record-tools.js";
92
+ import { createRecordTool, updateRecordTool, writeRecordTools } from "./assistant/write-record-tools.js";
92
93
  import { McpServerInput, mcpServer } from "./mcp/mcp-server.js";
93
94
  import { McpToolsInput, mcpTools } from "./mcp/mcp-tools.js";
94
95
  import { McpGateInput, mcpGate, sessionOf } from "./mcp/mcp-gate.js";
@@ -123,4 +124,4 @@ import { isSchedule } from "./jobs/schedule.js";
123
124
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
124
125
  import { jobsDrainApp } from "./jobs/drain-route.js";
125
126
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
126
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type MemberInput, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, queryRunnerMigration, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readWriteQueryTool, readableResources, realtimeLimits, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
127
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type AppIcons, type AppIdentity, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type MemberInput, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, queryRunnerMigration, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readWriteQueryTool, readableResources, realtimeLimits, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ import { mcpHandler } from "./mcp/mcp-handler.js";
39
39
  import { mcpRoutes } from "./mcp/mcp-routes.js";
40
40
  import { mcpServer } from "./mcp/mcp-server.js";
41
41
  import { getRecordTool, listRecordsTool, listResourcesTool, readRecordTools } from "./assistant/record-tools.js";
42
+ import { createRecordTool, updateRecordTool, writeRecordTools } from "./assistant/write-record-tools.js";
42
43
  import { mcpTools } from "./mcp/mcp-tools.js";
43
44
  import { addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
44
45
  import { createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
@@ -94,4 +95,4 @@ import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/p
94
95
  import { defineEmail } from "./mail/define-email.js";
95
96
  import { startJobs } from "./jobs/runtime.js";
96
97
  import { jobsDrainApp } from "./jobs/drain-route.js";
97
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, queryRunnerMigration, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readWriteQueryTool, readableResources, realtimeLimits, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
98
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, mapSettings, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, processor, queryRunnerMigration, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readWriteQueryTool, readableResources, realtimeLimits, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
@@ -0,0 +1,57 @@
1
+ import { inputRequired, inputResponse } from "@modelcontextprotocol/server";
2
+ //#region src/mcp/mcp-approval.ts
3
+ /** Thrown by `approve` over MCP when the request carries no answer yet: the server then asks the client for one. */
4
+ var ApprovalNeeded = class extends Error {
5
+ request;
6
+ constructor(request) {
7
+ super("The user has not answered the approval yet");
8
+ this.request = request;
9
+ }
10
+ };
11
+ /**
12
+ * `ToolContext.approve` over MCP, from the answers a retried call carries: approved only when the user accepted and
13
+ * ticked `approve`, rejected on any other answer, and `ApprovalNeeded` when there is none yet.
14
+ */
15
+ const mcpApproval = (inputResponses) => (request) => {
16
+ const answer = inputResponse(inputResponses, "approve");
17
+ if (answer.kind === "missing") return Promise.reject(new ApprovalNeeded(request));
18
+ if (answer.kind === "elicit" && answer.action === "accept" && answer.content?.approve === true) return Promise.resolve("approved");
19
+ return Promise.resolve("rejected");
20
+ };
21
+ const asksForms = (capabilities) => {
22
+ const elicitation = capabilities?.elicitation;
23
+ return elicitation !== void 0 && (elicitation.form !== void 0 || elicitation.url === void 0);
24
+ };
25
+ const messageOf = (request) => [
26
+ request.title,
27
+ request.body,
28
+ request.fields?.map(({ label, value }) => `${label}: ${value}`).join("\n"),
29
+ request.code
30
+ ].filter(Boolean).join("\n\n");
31
+ const cannotAsk = {
32
+ content: [{
33
+ type: "text",
34
+ text: "This client cannot ask you to approve a change, so nothing was written; use a client that supports MCP elicitation."
35
+ }],
36
+ isError: true
37
+ };
38
+ /**
39
+ * The answer to a call whose tool asked for approval: a form asking the user to approve `request` when the client can
40
+ * show one, else a refusal. A 2025-protocol request declares no capabilities per request, so it is always refused.
41
+ */
42
+ const approvalAsked = (request, capabilities) => {
43
+ if (!asksForms(capabilities)) return cannotAsk;
44
+ return inputRequired({ inputRequests: { approve: inputRequired.elicit({
45
+ message: messageOf(request),
46
+ requestedSchema: {
47
+ type: "object",
48
+ properties: { approve: {
49
+ type: "boolean",
50
+ title: request.approveLabel ?? "Approve"
51
+ } },
52
+ required: ["approve"]
53
+ }
54
+ }) } });
55
+ };
56
+ //#endregion
57
+ export { ApprovalNeeded, approvalAsked, mcpApproval };
@@ -1,13 +1,14 @@
1
1
  //#region src/mcp/mcp-authenticator.ts
2
2
  const hasAudiences = (authenticate) => typeof authenticate.forAudience === "function";
3
- const isMcpOptions = (options) => options === void 0 || options === false || typeof options === "object" && options !== null && !Array.isArray(options) && Object.entries(options).every(([name, value]) => name === "sql" && (value === void 0 || typeof value === "boolean"));
3
+ const isMcpOptions = (options) => options === void 0 || options === false || typeof options === "object" && options !== null && !Array.isArray(options) && Object.entries(options).every(([name, value]) => (name === "sql" || name === "writes") && (value === void 0 || typeof value === "boolean"));
4
4
  /**
5
5
  * The authenticator of the MCP endpoint's tokens, when MCP is on (`createAuth({ mcp })`); none when the host serves its
6
- * own (`options.mcp: false`). Stops the server at startup when `options.mcp` is neither `false` nor `{ sql?: boolean }`,
7
- * when it is set without MCP, or when `authenticate` cannot verify tokens for another audience.
6
+ * own (`options.mcp: false`). Stops the server at startup when `options.mcp` is neither `false` nor
7
+ * `{ sql?: boolean; writes?: boolean }`, when it is set without MCP, or when `authenticate` cannot verify tokens for
8
+ * another audience.
8
9
  */
9
10
  const mcpAuthenticator = (authenticate, mcp, options) => {
10
- if (!isMcpOptions(options)) throw new Error("options.mcp takes false or { sql?: boolean }");
11
+ if (!isMcpOptions(options)) throw new Error("options.mcp takes false or { sql?: boolean, writes?: boolean }");
11
12
  if (options === false) return void 0;
12
13
  if (options && !mcp) throw new Error("options.mcp needs createAuth({ mcp }) passed as auth");
13
14
  if (!mcp) return void 0;
@@ -4,8 +4,8 @@ import { ToolContext } from "../assistant/tools.js";
4
4
  import { Meta } from "../meta.js";
5
5
  //#region src/mcp/mcp-context.d.ts
6
6
  /**
7
- * What tools get over MCP for `session`: the records and resources it may read, under its access rules. Nothing shows
8
- * beside a tool's result, and asking for approval fails: no read tool asks, and approval over MCP comes with writes.
7
+ * What tools get over MCP for `session`: its records and the resources it may read, under its access rules. Nothing
8
+ * shows beside a tool's result, and `mcpServer` gives each call its own `approve`.
9
9
  */
10
- export declare const mcpToolContext: (deps: Deps, meta: Pick<Meta, "forCaller">, session: Session) => ToolContext;
10
+ export declare const mcpToolContext: (deps: Deps, meta: Pick<Meta, "forCaller">, session: Session) => Omit<ToolContext, "approve">;
11
11
  //#endregion
@@ -2,15 +2,14 @@ import { assistantRecords } from "../assistant/records.js";
2
2
  import { readableResources } from "../assistant/readable-resources.js";
3
3
  //#region src/mcp/mcp-context.ts
4
4
  /**
5
- * What tools get over MCP for `session`: the records and resources it may read, under its access rules. Nothing shows
6
- * beside a tool's result, and asking for approval fails: no read tool asks, and approval over MCP comes with writes.
5
+ * What tools get over MCP for `session`: its records and the resources it may read, under its access rules. Nothing
6
+ * shows beside a tool's result, and `mcpServer` gives each call its own `approve`.
7
7
  */
8
8
  const mcpToolContext = (deps, meta, session) => ({
9
9
  session,
10
10
  records: assistantRecords(deps, session),
11
11
  resources: () => readableResources(meta, session),
12
- show: () => {},
13
- approve: () => Promise.reject(/* @__PURE__ */ new Error("Not implemented: MCP tools cannot ask for approval yet"))
12
+ show: () => {}
14
13
  });
15
14
  //#endregion
16
15
  export { mcpToolContext };
@@ -9,11 +9,14 @@ export type McpServerInput = {
9
9
  title?: string;
10
10
  };
11
11
  tools: AssistantTool[];
12
- /** What each tool gets as its context, for the caller of the request. */
13
- context: ToolContext;
12
+ /** What each tool gets as its context, for the caller of the request; `approve` comes from each call's own answers. */
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
16
  };
17
- /** An MCP server offering `tools` as MCP tools, each with its JSON Schema, its hints, and the text it returns. */
17
+ /**
18
+ * 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.
20
+ */
18
21
  export declare const mcpServer: ({ info, tools, context, report }: McpServerInput) => McpServer;
19
22
  //#endregion
@@ -1,6 +1,8 @@
1
1
  import { checkedTools } from "../assistant/checked-tools.js";
2
- import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
2
+ import { ApprovalNeeded, approvalAsked, mcpApproval } from "./mcp-approval.js";
3
+ import { CLIENT_CAPABILITIES_META_KEY, McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
3
4
  //#region src/mcp/mcp-server.ts
5
+ const capabilitiesOf = (envelope) => envelope?.[CLIENT_CAPABILITIES_META_KEY];
4
6
  const failed = {
5
7
  content: [{
6
8
  type: "text",
@@ -8,7 +10,10 @@ const failed = {
8
10
  }],
9
11
  isError: true
10
12
  };
11
- /** An MCP server offering `tools` as MCP tools, each with its JSON Schema, its hints, and the text it returns. */
13
+ /**
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.
16
+ */
12
17
  const mcpServer = ({ info, tools, context, report }) => {
13
18
  const server = new McpServer(info);
14
19
  for (const tool of checkedTools(tools)) {
@@ -17,13 +22,17 @@ const mcpServer = ({ info, tools, context, report }) => {
17
22
  inputSchema: fromJsonSchema(tool.parameters),
18
23
  ...tool.annotations && { annotations: tool.annotations }
19
24
  };
20
- server.registerTool(tool.name, config, async (args) => {
25
+ server.registerTool(tool.name, config, async (args, ctx) => {
21
26
  try {
22
27
  return { content: [{
23
28
  type: "text",
24
- text: await tool.run(args, context)
29
+ text: await tool.run(args, {
30
+ ...context,
31
+ approve: mcpApproval(ctx.mcpReq.inputResponses)
32
+ })
25
33
  }] };
26
34
  } catch (error) {
35
+ if (error instanceof ApprovalNeeded) return approvalAsked(error.request, capabilitiesOf(ctx.mcpReq.envelope));
27
36
  report(error);
28
37
  return failed;
29
38
  }
@@ -6,7 +6,12 @@ export type McpToolsInput = {
6
6
  db: Db;
7
7
  /** Offers `run_read_only_query` to callers who see the assistant (`admin` or `ai`); it reads outside the access rules. */
8
8
  sql: boolean;
9
+ /** Offers `create_record` and `update_record`; each write still needs the caller's role and their approval in the client. */
10
+ writes: boolean;
9
11
  };
10
- /** The default MCP tools for a caller: the record tools, and read-only SQL when `sql` is on and the caller sees the assistant. */
11
- export declare const mcpTools: ({ db, sql }: McpToolsInput) => (session: Session) => AssistantTool[];
12
+ /**
13
+ * The default MCP tools for a caller: the record tools, the write tools when `writes` is on, and read-only SQL when `sql`
14
+ * is on and the caller sees the assistant.
15
+ */
16
+ export declare const mcpTools: ({ db, sql, writes }: McpToolsInput) => (session: Session) => AssistantTool[];
12
17
  //#endregion
@@ -1,8 +1,16 @@
1
1
  import { seesAssistant } from "../assistant/assistant-settings.js";
2
2
  import { readOnlyQueryTool } from "../assistant/query-tools.js";
3
3
  import { readRecordTools } from "../assistant/record-tools.js";
4
+ import { writeRecordTools } from "../assistant/write-record-tools.js";
4
5
  //#region src/mcp/mcp-tools.ts
5
- /** The default MCP tools for a caller: the record tools, and read-only SQL when `sql` is on and the caller sees the assistant. */
6
- const mcpTools = ({ db, sql }) => (session) => [...readRecordTools(), ...sql && seesAssistant(session.user.roles) ? [readOnlyQueryTool(db)] : []];
6
+ /**
7
+ * The default MCP tools for a caller: the record tools, the write tools when `writes` is on, and read-only SQL when `sql`
8
+ * is on and the caller sees the assistant.
9
+ */
10
+ const mcpTools = ({ db, sql, writes }) => (session) => [
11
+ ...readRecordTools(),
12
+ ...writes ? writeRecordTools() : [],
13
+ ...sql && seesAssistant(session.user.roles) ? [readOnlyQueryTool(db)] : []
14
+ ];
7
15
  //#endregion
8
16
  export { mcpTools };
package/dist/meta.d.ts CHANGED
@@ -19,6 +19,8 @@ export type ResourcePermissions = {
19
19
  export type CallerMeta = {
20
20
  /** The app's name, which ends the admin app's tab titles. */
21
21
  appName: string;
22
+ /** The Protobase version the server runs: the admin app offers a reload when it changes under an open page. */
23
+ protobaseVersion: string;
22
24
  /** The app's own icon, its favicon's path, when the project has one: the sidebar's logo draws it beside the name. */
23
25
  appIcon?: string;
24
26
  resources: ResourceModel[];
package/dist/meta.js CHANGED
@@ -5,6 +5,7 @@ import { pruneView } from "./view-access.js";
5
5
  import { activeRole } from "./active-role.js";
6
6
  import { pluginsFor } from "./plugins/plugin-settings.js";
7
7
  import { metaModel } from "./meta-model.js";
8
+ import { protobaseVersion } from "./protobase-version.js";
8
9
  import { seesRuntime } from "./runtime/runtime-settings.js";
9
10
  import { sourcesFor } from "./sources/source-settings.js";
10
11
  import { prunePage } from "@protobase/layout";
@@ -63,6 +64,7 @@ const createMeta = (deps, appName, appIcon) => {
63
64
  const sources = sourcesFor(deps.sources, session.user.roles);
64
65
  const body = {
65
66
  appName,
67
+ protobaseVersion,
66
68
  ...appIcon && { appIcon },
67
69
  resources: visible.map(metaModel),
68
70
  views,
@@ -98,8 +100,11 @@ const createMeta = (deps, appName, appIcon) => {
98
100
  etag: `"${(await sha256Hex(stableJson(body))).slice(0, 32)}"`
99
101
  };
100
102
  };
101
- /** The X-Meta-Version header: the models, and the roles the access depends on, so a role change moves it. */
102
- const version = async (roles) => (await sha256Hex(`${await base}|${[...roles].sort().join(",")}`)).slice(0, 32);
103
+ /**
104
+ * The X-Meta-Version header: the models, the Protobase version, so the admin app reads `/meta` again on the first
105
+ * answer after an upgrade, and the roles the access depends on, so a role change moves it.
106
+ */
107
+ const version = async (roles) => (await sha256Hex(`${await base}|${protobaseVersion}|${[...roles].sort().join(",")}`)).slice(0, 32);
103
108
  return {
104
109
  forCaller,
105
110
  version
@@ -0,0 +1,4 @@
1
+ //#region package.json
2
+ var version = "0.40.7";
3
+ //#endregion
4
+ export { version };
@@ -0,0 +1,6 @@
1
+ import { version } from "./package.js";
2
+ //#region src/protobase-version.ts
3
+ /** The Protobase version this server is, from @protobase/server's package.json: `/meta` names it to the admin app. */
4
+ const protobaseVersion = version;
5
+ //#endregion
6
+ export { protobaseVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.40.5",
3
+ "version": "0.40.7",
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.40.5",
40
- "@protobase/query": "0.40.5",
41
- "@protobase/schema": "0.40.5",
39
+ "@protobase/layout": "0.40.7",
40
+ "@protobase/query": "0.40.7",
41
+ "@protobase/schema": "0.40.7",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",