@adminforth/mcp 1.1.0 → 1.1.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.
@@ -1,3 +1,4 @@
1
+ import type { McpPageSize } from './types.js';
1
2
  import type { AdminUser, IAdminForth } from 'adminforth';
2
3
  export interface McpToolDefinition {
3
4
  name: string;
@@ -11,8 +12,12 @@ export interface McpToolDefinition {
11
12
  export declare class AdminForthApiTools {
12
13
  private readonly adminforth;
13
14
  private readonly hiddenResourceIds;
14
- constructor(adminforth: IAdminForth, hiddenResourceIds: ReadonlySet<string>);
15
+ private readonly toolTimeoutMs;
16
+ constructor(adminforth: IAdminForth, hiddenResourceIds: ReadonlySet<string>, pageSize: McpPageSize, toolTimeoutMs: number);
17
+ private readonly toolOverrides;
15
18
  private schemas;
19
+ private description;
20
+ private withTimeout;
16
21
  list(readOnly: boolean): McpToolDefinition[];
17
22
  call(params: {
18
23
  name: string;
package/dist/apiTools.js CHANGED
@@ -1,4 +1,45 @@
1
+ import { compactInputSchema } from './inputSchema.js';
2
+ import { adminApiPrefix } from './urls.js';
3
+ import { AdminForthDataTypes } from 'adminforth';
1
4
  const METHODS_WITHOUT_REQUEST_BODY = new Set(['GET', 'HEAD']);
5
+ // Milliseconds cost tokens on every datetime value and are never needed to answer the user.
6
+ const ISO_MILLISECONDS_RE = /\.\d+(?=Z$)/;
7
+ const TOOL_TIMEOUT = Symbol('TOOL_TIMEOUT');
8
+ // _label repeats field values of the row, _clickUrl is a frontend navigation helper.
9
+ const ROW_HELPER_FIELDS = new Set(['_label', '_clickUrl']);
10
+ // destructiveHint is only a hint: whether a client asks for approval depends on its permission mode,
11
+ // so dangerous tools also require a confirmation in chat.
12
+ const DANGEROUS_TOOL_NOTE = 'This tool changes data. If you have not loaded the mutate_data skill yet, load it with fetch_skill first. Before calling, show the user exactly what will change and wait for their explicit confirmation in chat, even when the client does not ask for approval.';
13
+ // Column properties needed to read, filter and aggregate records; writes need the detailed response.
14
+ // Non-sortable columns also get sortable: false.
15
+ const GET_RESOURCE_ESSENTIAL_COLUMN_FIELDS = ['name', 'label', 'type', 'enum', 'foreignResource'];
16
+ // The rest of foreignResource configures the foreign record picker in the UI.
17
+ const GET_RESOURCE_ESSENTIAL_FOREIGN_RESOURCE_FIELDS = ['resourceId', 'polymorphicOn', 'polymorphicResources'];
18
+ function createToolOverrides(pageSize, adminforth) {
19
+ return {
20
+ get_resource: {
21
+ descriptionNote: 'By default only the columns needed to read, filter and aggregate records are returned. Pass detailed: true right away when you are going to create or update records or run actions; the detailed response already includes the default one.',
22
+ arguments: {
23
+ detailed: {
24
+ type: 'boolean',
25
+ description: 'Set to true before create_record, update_record, start_custom_action or start_custom_bulk_action: it returns every column property (required fields, which fields can be set on create and edit, validation rules, length and value limits, showIf conditions, editing notes) and the custom and bulk actions of the resource. By default only the column properties needed to read, filter and aggregate records are returned.',
26
+ },
27
+ },
28
+ project: (output, { detailed }) => (detailed
29
+ ? withoutFrontendOnlyFields(output)
30
+ : essentialResource(output)),
31
+ },
32
+ get_resource_data: {
33
+ descriptionNote: `Returns ${pageSize.default} rows unless limit is passed, and at most ${pageSize.max} rows per call; the response note tells when more rows exist. Row properties with null values, empty arrays or empty objects are omitted, so a column missing from a row is empty. Datetimes are UTC in ISO 8601 without milliseconds. When you pass columns, include the primary key column to get record ids (composite keys come as _primaryKeyValue in list rows).`,
34
+ defaultArguments: { source: 'list', limit: pageSize.default, offset: 0 },
35
+ prepareArguments: (handlerArguments) => ({
36
+ ...handlerArguments,
37
+ limit: Math.min(handlerArguments.limit, pageSize.max),
38
+ }),
39
+ project: (output, args, clientArgs) => compactResourceData(output, args, clientArgs, pageSize, datetimeColumnNames(adminforth, args.resourceId)),
40
+ },
41
+ };
42
+ }
2
43
  function isRegisteredToolSchema(schema) {
3
44
  return typeof schema.handler === 'function' && !schema.agent?.hiddenFromAgents;
4
45
  }
@@ -9,11 +50,106 @@ function endpointPathToToolName(path) {
9
50
  .replace(/^_+|_+$/g, '');
10
51
  }
11
52
  function stripAdminApiPrefix(path, adminforth) {
12
- const baseUrl = (adminforth.config.baseUrl ?? '').replace(/\/$/, '');
13
- const apiPrefix = `${baseUrl}/adminapi/v1`;
53
+ const apiPrefix = adminApiPrefix(adminforth.config.baseUrl);
14
54
  const strippedPath = path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) : path;
15
55
  return strippedPath.startsWith('/') ? strippedPath : `/${strippedPath}`;
16
56
  }
57
+ // Drops frontend-only parts of the get_resource response that are useless for MCP clients and only waste their context.
58
+ // Copies instead of deleting: the response shares nested objects with the AdminForth config.
59
+ function withoutFrontendOnlyFields({ resource }) {
60
+ const { pageInjections, ...options } = resource.options;
61
+ return {
62
+ resource: {
63
+ ...resource,
64
+ columns: resource.columns.map(({ filterOptions, components, ...column }) => column),
65
+ options: {
66
+ ...options,
67
+ actions: options.actions?.map(({ customComponent, ...action }) => action),
68
+ },
69
+ },
70
+ };
71
+ }
72
+ function pickFields(source, fields) {
73
+ return Object.fromEntries(fields.filter((field) => field in source).map((field) => [field, source[field]]));
74
+ }
75
+ // Virtual columns are skipped: they cannot be filtered, sorted, aggregated or selected in get_resource_data.
76
+ function essentialResource({ resource }) {
77
+ const primaryKeyNames = resource.columns.filter((column) => column.primaryKey).map((column) => column.name);
78
+ return {
79
+ resource: {
80
+ resourceId: resource.resourceId,
81
+ label: resource.label,
82
+ primaryKey: primaryKeyNames.length === 1 ? primaryKeyNames[0] : primaryKeyNames,
83
+ columns: resource.columns
84
+ .filter((column) => !column.backendOnly && !column.virtual)
85
+ .map((column) => ({
86
+ ...pickFields(column, GET_RESOURCE_ESSENTIAL_COLUMN_FIELDS),
87
+ // most columns are sortable, so only the exceptions are listed
88
+ ...(column.sortable === false && { sortable: false }),
89
+ ...(column.foreignResource && {
90
+ foreignResource: pickFields(column.foreignResource, GET_RESOURCE_ESSENTIAL_FOREIGN_RESOURCE_FIELDS),
91
+ }),
92
+ })),
93
+ },
94
+ };
95
+ }
96
+ function datetimeColumnNames(adminforth, resourceId) {
97
+ const resource = adminforth.config.resources.find((candidate) => candidate.resourceId === resourceId);
98
+ return new Set(resource.dataSourceColumns
99
+ .filter((column) => column.type === AdminForthDataTypes.DATETIME)
100
+ .map((column) => column.name));
101
+ }
102
+ // null, [] and {} carry no data, so the column is left out of the row; clients read a missing column as empty.
103
+ function isEmptyValue(value) {
104
+ if (value === null)
105
+ return true;
106
+ if (Array.isArray(value))
107
+ return value.length === 0;
108
+ return typeof value === 'object' && Object.keys(value).length === 0;
109
+ }
110
+ function compactRow(row, datetimeColumns) {
111
+ return Object.fromEntries(Object.entries(row)
112
+ .filter(([name, value]) => !isEmptyValue(value) && !ROW_HELPER_FIELDS.has(name))
113
+ .map(([name, value]) => [
114
+ name,
115
+ datetimeColumns.has(name) ? value.replace(ISO_MILLISECONDS_RE, '') : value,
116
+ ]));
117
+ }
118
+ // recordIds repeat the primary keys that list rows already carry, the frontend only needs them for paging in the show view.
119
+ // The note stops clients from silently paging through every record: they should ask the user first. It is added only
120
+ // for page requests (no limit, so the default page, or a full page); a client that asked for exactly N records,
121
+ // such as the single oldest one, gets what it asked for without the note.
122
+ function compactResourceData({ recordIds, data, ...rest }, args, clientArgs, pageSize, datetimeColumns) {
123
+ const offset = args.offset;
124
+ const nextOffset = offset + data.length;
125
+ const isPageRequest = clientArgs.limit === undefined || clientArgs.limit >= pageSize.max;
126
+ const notes = [
127
+ args.limit > pageSize.max && `limit is capped at ${pageSize.max} rows per call.`,
128
+ isPageRequest && rest.total !== undefined && rest.total > nextOffset
129
+ && `Loaded ${data.length} of ${rest.total} records (offset ${offset}). Ask the user before loading more; the next page starts at offset ${nextOffset}.`,
130
+ ].filter(Boolean);
131
+ return {
132
+ ...rest,
133
+ data: data.map((row) => compactRow(row, datetimeColumns)),
134
+ ...(notes.length > 0 && { note: notes.join(' ') }),
135
+ };
136
+ }
137
+ function overrideInputSchema(inputSchema, override) {
138
+ if (!override)
139
+ return inputSchema;
140
+ const defaultedNames = new Set(Object.keys(override.defaultArguments ?? {}));
141
+ return {
142
+ ...inputSchema,
143
+ ...(Array.isArray(inputSchema.required) && {
144
+ required: inputSchema.required.filter((name) => !defaultedNames.has(name)),
145
+ }),
146
+ properties: { ...inputSchema.properties, ...override.arguments },
147
+ };
148
+ }
149
+ function withoutPluginArguments(args, override) {
150
+ const pluginArgumentNames = new Set(Object.keys(override?.arguments ?? {}));
151
+ return Object.fromEntries(Object.entries(args).filter(([name]) => !pluginArgumentNames.has(name)));
152
+ }
17
153
  function createDirectResponse() {
18
154
  return {
19
155
  status: 200,
@@ -30,10 +166,14 @@ function createDirectResponse() {
30
166
  export class AdminForthApiTools {
31
167
  adminforth;
32
168
  hiddenResourceIds;
33
- constructor(adminforth, hiddenResourceIds) {
169
+ toolTimeoutMs;
170
+ constructor(adminforth, hiddenResourceIds, pageSize, toolTimeoutMs) {
34
171
  this.adminforth = adminforth;
35
172
  this.hiddenResourceIds = hiddenResourceIds;
173
+ this.toolTimeoutMs = toolTimeoutMs;
174
+ this.toolOverrides = createToolOverrides(pageSize, adminforth);
36
175
  }
176
+ toolOverrides;
37
177
  schemas(readOnly) {
38
178
  const schemas = new Map();
39
179
  for (const schema of this.adminforth.openApi.registeredSchemas) {
@@ -45,17 +185,46 @@ export class AdminForthApiTools {
45
185
  }
46
186
  return schemas;
47
187
  }
188
+ description(name, schema) {
189
+ const description = [
190
+ schema.description,
191
+ this.toolOverrides[name]?.descriptionNote,
192
+ schema.agent?.requiresHumanApproval && DANGEROUS_TOOL_NOTE,
193
+ ].filter(Boolean).join(' ');
194
+ return description ? { description } : {};
195
+ }
196
+ // The handler gets an abort signal that fires on timeout or when the MCP request is aborted;
197
+ // a handler that ignores it keeps running in the background, but the client is not kept waiting.
198
+ async withTimeout(requestSignal, run) {
199
+ const controller = new AbortController();
200
+ const abortHandler = () => controller.abort();
201
+ requestSignal.addEventListener('abort', abortHandler, { once: true });
202
+ let timer;
203
+ const timeout = new Promise((resolve) => {
204
+ timer = setTimeout(() => {
205
+ controller.abort();
206
+ resolve(TOOL_TIMEOUT);
207
+ }, this.toolTimeoutMs);
208
+ });
209
+ try {
210
+ return await Promise.race([run(controller.signal), timeout]);
211
+ }
212
+ finally {
213
+ clearTimeout(timer);
214
+ requestSignal.removeEventListener('abort', abortHandler);
215
+ }
216
+ }
48
217
  list(readOnly) {
49
218
  return Array.from(this.schemas(readOnly).entries())
50
219
  .sort(([left], [right]) => left.localeCompare(right))
51
220
  .map(([name, schema]) => ({
52
221
  name,
53
- ...(schema.description && { description: schema.description }),
54
- inputSchema: schema.request_schema ?? {
222
+ ...this.description(name, schema),
223
+ inputSchema: compactInputSchema(overrideInputSchema(schema.request_schema ?? {
55
224
  type: 'object',
56
225
  properties: {},
57
226
  additionalProperties: true,
58
- },
227
+ }, this.toolOverrides[name])),
59
228
  ...(schema.agent?.onlyReadsData && {
60
229
  annotations: { readOnlyHint: true },
61
230
  }),
@@ -76,10 +245,14 @@ export class AdminForthApiTools {
76
245
  isError: true,
77
246
  };
78
247
  }
248
+ const override = this.toolOverrides[params.name];
249
+ const args = { ...override?.defaultArguments, ...params.arguments };
250
+ const pluginFreeArguments = withoutPluginArguments(args, override);
251
+ const handlerArguments = override?.prepareArguments?.(pluginFreeArguments) ?? pluginFreeArguments;
79
252
  const method = schema.method.toUpperCase();
80
253
  const hasBody = !METHODS_WITHOUT_REQUEST_BODY.has(method);
81
- const body = hasBody ? (params.arguments ?? {}) : {};
82
- const query = hasBody ? {} : (params.arguments ?? {});
254
+ const body = hasBody ? handlerArguments : {};
255
+ const query = hasBody ? {} : handlerArguments;
83
256
  const requestValidation = this.adminforth.openApi.validateRequestSchema(schema, body);
84
257
  if (!requestValidation.valid) {
85
258
  return {
@@ -89,7 +262,7 @@ export class AdminForthApiTools {
89
262
  }
90
263
  const response = createDirectResponse();
91
264
  const language = String(params.headers['accept-language'] ?? 'en');
92
- const output = await schema.handler({
265
+ const output = await this.withTimeout(params.abortSignal, (abortSignal) => schema.handler({
93
266
  body,
94
267
  query,
95
268
  headers: params.headers,
@@ -97,11 +270,19 @@ export class AdminForthApiTools {
97
270
  adminUser: params.adminUser,
98
271
  response,
99
272
  requestUrl: params.requestUrl,
100
- abortSignal: params.abortSignal,
273
+ abortSignal,
101
274
  _raw_express_req: undefined,
102
275
  _raw_express_res: undefined,
103
276
  tr: (message, category, translationParams, pluralizationNumber) => this.adminforth.tr(message, category, language, translationParams, pluralizationNumber),
104
- });
277
+ }));
278
+ if (output === TOOL_TIMEOUT) {
279
+ return {
280
+ output: {
281
+ error: `Tool timed out after ${this.toolTimeoutMs / 1000} seconds. A change it started may still complete: check the result before calling it again.`,
282
+ },
283
+ isError: true,
284
+ };
285
+ }
105
286
  if (response.message) {
106
287
  return {
107
288
  output: response.message,
@@ -121,9 +302,10 @@ export class AdminForthApiTools {
121
302
  isError: true,
122
303
  };
123
304
  }
305
+ const isError = response.status >= 400 || Boolean(output && typeof output === 'object' && 'error' in output);
124
306
  return {
125
- output,
126
- isError: response.status >= 400 || Boolean(output && typeof output === 'object' && 'error' in output),
307
+ output: isError || !override?.project ? output : override.project(output, args, params.arguments ?? {}),
308
+ isError,
127
309
  };
128
310
  }
129
311
  }
@@ -1,8 +1,16 @@
1
1
  import { type AdminUser, type HttpExtra, type IAdminForth } from 'adminforth';
2
2
  import type { McpAuthSecretResourceOptions, McpClientInfo } from './types.js';
3
+ export declare const SECRET_PREFIX = "afmcp_";
3
4
  type AdminUserWithExecutor = AdminUser & {
4
5
  executedBy?: string;
5
6
  };
7
+ export interface OAuthGrant {
8
+ id: string;
9
+ userId: string;
10
+ clientId: string;
11
+ clientName: string;
12
+ readOnly: boolean;
13
+ }
6
14
  export interface AuthenticatedMcpSecret {
7
15
  adminUser: AdminUserWithExecutor;
8
16
  client: McpClientInfo | null;
@@ -14,6 +22,7 @@ export declare class McpAuthSecretStore {
14
22
  private readonly adminforth;
15
23
  private readonly options;
16
24
  private readonly resource;
25
+ private readonly refreshLock;
17
26
  constructor(adminforth: IAdminForth, options: McpAuthSecretResourceOptions);
18
27
  list(adminUser: AdminUser): Promise<{
19
28
  id: any;
@@ -22,6 +31,7 @@ export declare class McpAuthSecretStore {
22
31
  lastUsedAt: any;
23
32
  lastUsedByAgent: McpClientInfo | null;
24
33
  readOnly: any;
34
+ oauthClientId: any;
25
35
  }[]>;
26
36
  create(name: string, readOnly: boolean, adminUser: AdminUser, extra: HttpExtra): Promise<{
27
37
  error: string;
@@ -32,6 +42,27 @@ export declare class McpAuthSecretStore {
32
42
  }>;
33
43
  revoke(id: string, adminUser: AdminUser, extra: HttpExtra): Promise<import("adminforth").DeleteResourceRecordResult>;
34
44
  authenticate(secret: string): Promise<AuthenticatedMcpSecret | null>;
45
+ /**
46
+ * Stores the connection an MCP client got through OAuth as an auth secret record, so it is listed and revoked
47
+ * like personal secrets. The record id is the authorization code id, which makes every code redeemable once.
48
+ */
49
+ createOAuthGrant(grant: OAuthGrant, refreshToken: string): Promise<{
50
+ error?: string;
51
+ }>;
52
+ /**
53
+ * Swaps the refresh token of a grant for a new one and returns the grant user id. The caller has verified the
54
+ * token signature, so a token that is not the current one was issued for this grant before and is used again:
55
+ * two parties hold it, a client and whoever stole it, and which one is the attacker is unknown, so the whole
56
+ * grant is revoked (OAuth 2.1 refresh token rotation). The lock keeps two concurrent refreshes with one token
57
+ * from both passing, within this process.
58
+ */
59
+ rotateRefreshToken(grantId: string, refreshToken: string, clientId: string, newRefreshToken: string): Promise<string | null>;
60
+ /** Access tokens are checked against their grant on every request, so revoking the grant applies at once. */
61
+ authenticateOAuthGrant(grantId: string, userId: string): Promise<AuthenticatedMcpSecret | null>;
62
+ /** The OAuth grant methods are called only when OAuth is enabled, which requires this field. */
63
+ private get oauthClientIdField();
64
+ private loadAdminUser;
65
+ private toAuthenticated;
35
66
  touch(recordId: string, client: McpClientInfo): void;
36
67
  }
37
68
  export {};
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import { Filters, Sorts, logger, } from 'adminforth';
3
3
  import { UNKNOWN_CLIENT } from './clientInfo.js';
4
- const SECRET_PREFIX = 'afmcp_';
4
+ import { KeyedLock } from './keyedLock.js';
5
+ export const SECRET_PREFIX = 'afmcp_';
5
6
  function hashSecret(secret) {
6
7
  return createHash('sha256').update(secret).digest('hex');
7
8
  }
@@ -28,6 +29,7 @@ export class McpAuthSecretStore {
28
29
  adminforth;
29
30
  options;
30
31
  resource;
32
+ refreshLock = new KeyedLock();
31
33
  constructor(adminforth, options) {
32
34
  this.adminforth = adminforth;
33
35
  this.options = options;
@@ -43,6 +45,7 @@ export class McpAuthSecretStore {
43
45
  lastUsedAt: record[fields.lastUsedAtField] ?? null,
44
46
  lastUsedByAgent: parseStoredClient(record[fields.lastUsedByAgentField]),
45
47
  readOnly: record[fields.readOnlyField],
48
+ oauthClientId: fields.oauthClientIdField ? record[fields.oauthClientIdField] ?? null : null,
46
49
  }));
47
50
  }
48
51
  async create(name, readOnly, adminUser, extra) {
@@ -61,6 +64,7 @@ export class McpAuthSecretStore {
61
64
  [fields.lastUsedAtField]: null,
62
65
  [fields.lastUsedByAgentField]: null,
63
66
  [fields.readOnlyField]: readOnly,
67
+ ...(fields.oauthClientIdField && { [fields.oauthClientIdField]: null }),
64
68
  },
65
69
  });
66
70
  return result.error ? { error: result.error } : { secret };
@@ -79,22 +83,87 @@ export class McpAuthSecretStore {
79
83
  });
80
84
  }
81
85
  async authenticate(secret) {
86
+ const record = await this.adminforth.resource(this.options.resourceId).get(Filters.EQ(this.options.secretHashField, hashSecret(secret)));
87
+ return record ? this.toAuthenticated(record) : null;
88
+ }
89
+ /**
90
+ * Stores the connection an MCP client got through OAuth as an auth secret record, so it is listed and revoked
91
+ * like personal secrets. The record id is the authorization code id, which makes every code redeemable once.
92
+ */
93
+ async createOAuthGrant(grant, refreshToken) {
82
94
  const fields = this.options;
83
- const record = await this.adminforth.resource(fields.resourceId).get(Filters.EQ(fields.secretHashField, hashSecret(secret)));
84
- if (!record)
85
- return null;
95
+ const resource = this.adminforth.resource(fields.resourceId);
96
+ if (await resource.get(Filters.EQ(fields.idField, grant.id))) {
97
+ return { error: 'Authorization code was already used' };
98
+ }
99
+ const adminUser = await this.loadAdminUser(grant.userId);
100
+ if (!adminUser)
101
+ return { error: 'User of the authorization code no longer exists' };
102
+ const result = await this.adminforth.createResourceRecord({
103
+ resource: this.resource,
104
+ adminUser,
105
+ extra: {},
106
+ record: {
107
+ [fields.idField]: grant.id,
108
+ [fields.nameField]: grant.clientName,
109
+ [fields.secretHashField]: hashSecret(refreshToken),
110
+ [fields.userIdField]: grant.userId,
111
+ [fields.createdAtField]: new Date().toISOString(),
112
+ [fields.lastUsedAtField]: null,
113
+ [fields.lastUsedByAgentField]: null,
114
+ [fields.readOnlyField]: grant.readOnly,
115
+ [this.oauthClientIdField]: grant.clientId,
116
+ },
117
+ });
118
+ return result.error ? { error: result.error } : {};
119
+ }
120
+ /**
121
+ * Swaps the refresh token of a grant for a new one and returns the grant user id. The caller has verified the
122
+ * token signature, so a token that is not the current one was issued for this grant before and is used again:
123
+ * two parties hold it, a client and whoever stole it, and which one is the attacker is unknown, so the whole
124
+ * grant is revoked (OAuth 2.1 refresh token rotation). The lock keeps two concurrent refreshes with one token
125
+ * from both passing, within this process.
126
+ */
127
+ async rotateRefreshToken(grantId, refreshToken, clientId, newRefreshToken) {
128
+ const fields = this.options;
129
+ const resource = this.adminforth.resource(fields.resourceId);
130
+ return this.refreshLock.run(grantId, async () => {
131
+ const record = await resource.get(Filters.AND(Filters.EQ(fields.idField, grantId), Filters.EQ(this.oauthClientIdField, clientId)));
132
+ if (!record)
133
+ return null;
134
+ if (record[fields.secretHashField] !== hashSecret(refreshToken)) {
135
+ logger.warn(`AdminForthMcpPlugin: a rotated refresh token of OAuth grant ${grantId} was used again, revoking the grant`);
136
+ await resource.delete(grantId);
137
+ return null;
138
+ }
139
+ await resource.update(grantId, { [fields.secretHashField]: hashSecret(newRefreshToken) });
140
+ return record[fields.userIdField];
141
+ });
142
+ }
143
+ /** Access tokens are checked against their grant on every request, so revoking the grant applies at once. */
144
+ async authenticateOAuthGrant(grantId, userId) {
145
+ const fields = this.options;
146
+ const record = await this.adminforth.resource(fields.resourceId).get(Filters.AND(Filters.EQ(fields.idField, grantId), Filters.EQ(fields.userIdField, userId)));
147
+ return record?.[this.oauthClientIdField] ? this.toAuthenticated(record) : null;
148
+ }
149
+ /** The OAuth grant methods are called only when OAuth is enabled, which requires this field. */
150
+ get oauthClientIdField() {
151
+ return this.options.oauthClientIdField;
152
+ }
153
+ async loadAdminUser(pk) {
86
154
  const auth = this.adminforth.config.auth;
87
155
  const usersResource = this.adminforth.config.resources.find((resource) => resource.resourceId === auth.usersResourceId);
88
156
  const userPrimaryKeyField = usersResource.columns.find((column) => column.primaryKey).name;
89
- const dbUser = await this.adminforth.resource(usersResource.resourceId).get(Filters.EQ(userPrimaryKeyField, record[fields.userIdField]));
90
- if (!dbUser)
157
+ const dbUser = await this.adminforth.resource(usersResource.resourceId).get(Filters.EQ(userPrimaryKeyField, pk));
158
+ return dbUser ? { pk, username: dbUser[auth.usernameField], dbUser } : null;
159
+ }
160
+ async toAuthenticated(record) {
161
+ const fields = this.options;
162
+ const adminUser = await this.loadAdminUser(record[fields.userIdField]);
163
+ if (!adminUser)
91
164
  return null;
92
165
  return {
93
- adminUser: {
94
- pk: record[fields.userIdField],
95
- username: dbUser[auth.usernameField],
96
- dbUser,
97
- },
166
+ adminUser,
98
167
  client: parseStoredClient(record[fields.lastUsedByAgentField]),
99
168
  name: record[fields.nameField],
100
169
  readOnly: record[fields.readOnlyField],
@@ -0,0 +1,147 @@
1
+ <template>
2
+ <div class="af-mcp-authorize flex min-h-screen items-center justify-center bg-gray-100 p-4 dark:bg-gray-800">
3
+ <div class="w-full max-w-lg rounded-lg bg-white p-6 shadow dark:bg-gray-700">
4
+ <template v-if="framed">
5
+ <h1 class="text-xl font-medium text-gray-900 dark:text-white">{{ $t('Cannot connect MCP client') }}</h1>
6
+ <p class="mt-3 text-sm text-gray-600 dark:text-gray-300">
7
+ {{ $t('This page is embedded in another site. Open it in a separate browser tab to connect your MCP client.') }}
8
+ </p>
9
+ </template>
10
+ <template v-else-if="authorization">
11
+ <h1 class="text-xl font-medium text-gray-900 dark:text-white">
12
+ {{ $t('Connect {client}?', { client: authorization.clientName }) }}
13
+ </h1>
14
+ <p class="mt-3 text-sm text-gray-600 dark:text-gray-300">
15
+ <b>{{ authorization.clientName }}</b>
16
+ <span v-if="authorization.clientHost"> ({{ authorization.clientHost }})</span>
17
+ <template v-if="readOnly">
18
+ {{ $t('asks to act on your behalf in {brand}: it will see all data you can see, but not change it.', { brand: brandName }) }}
19
+ </template>
20
+ <template v-else>
21
+ {{ $t('asks to act on your behalf in {brand}: it will see and change everything you can.', { brand: brandName }) }}
22
+ </template>
23
+ </p>
24
+ <Checkbox v-if="!authorization.serverReadOnly" v-model="readOnlyChosen" class="mt-4">
25
+ {{ $t('Read only: the agent can only read data') }}
26
+ </Checkbox>
27
+ <p v-if="authorization.loopbackRedirect" class="mt-3 text-sm font-medium text-amber-700 dark:text-amber-300">
28
+ {{ $t('After you answer, your browser returns to an app running on this computer. Allow only if you have just started connecting {client} yourself.', { client: authorization.clientName }) }}
29
+ </p>
30
+ <p v-else class="mt-3 text-sm text-gray-600 dark:text-gray-300">
31
+ {{ $t('After you answer, your browser returns to {host}.', { host: authorization.redirectHost }) }}
32
+ </p>
33
+ <div class="mt-6 flex justify-end gap-2">
34
+ <Button variant="secondary" :disabled="resolving" @click="resolve(false)">{{ $t('Deny') }}</Button>
35
+ <Button :loader="resolving" :disabled="resolving || !armed" @click="resolve(true)">{{ $t('Allow') }}</Button>
36
+ </div>
37
+ </template>
38
+ <template v-else-if="error">
39
+ <h1 class="text-xl font-medium text-gray-900 dark:text-white">{{ $t('Cannot connect MCP client') }}</h1>
40
+ <p class="mt-3 text-sm text-gray-600 dark:text-gray-300">{{ error }}</p>
41
+ </template>
42
+ <Spinner v-else class="mx-auto h-8 w-8" />
43
+ </div>
44
+ </div>
45
+ </template>
46
+
47
+ <script setup lang="ts">
48
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
49
+ import { useRoute } from 'vue-router';
50
+ import { Button, Checkbox, Spinner } from '@/afcl';
51
+ import { useCoreStore } from '@/stores/core';
52
+ import { callAdminForthApi } from '@/utils';
53
+
54
+ // Long enough to swallow the second click of a double click, too short for a reader to notice.
55
+ const ALLOW_DELAY_MS = 500;
56
+
57
+ type Authorization = {
58
+ clientName: string;
59
+ clientHost: string | null;
60
+ redirectHost: string;
61
+ loopbackRedirect: boolean;
62
+ serverReadOnly: boolean;
63
+ };
64
+
65
+ const route = useRoute();
66
+ const coreStore = useCoreStore();
67
+ const brandName = computed(() => coreStore.config?.brandName);
68
+
69
+ // The signed authorization request /mcp/oauth/authorize redirected the browser here with
70
+ const request = String(route.query.request);
71
+ const authorization = ref<Authorization | null>(null);
72
+ const error = ref('');
73
+ const resolving = ref(false);
74
+ const readOnlyChosen = ref(false);
75
+ // With the plugin readOnly option every connection is read-only, so there is no choice to make.
76
+ const readOnly = computed(() => authorization.value?.serverReadOnly || readOnlyChosen.value);
77
+ // AdminForth sends no frame protection headers, so a site sharing the admin panel domain could load this page in
78
+ // an invisible frame and trick a click on Allow (clickjacking). The consent is never offered inside a frame.
79
+ const framed = window.top !== window.self;
80
+
81
+ // Double-clickjacking: a page asks for a double click, swaps itself for this one on the first click, and the second
82
+ // click lands on Allow before anyone can read it. Allow is enabled only once the page has been visible and focused
83
+ // for a moment, and disabled again whenever it loses either.
84
+ const armed = ref(false);
85
+ let armTimer: ReturnType<typeof setTimeout> | undefined;
86
+
87
+ function disarm() {
88
+ clearTimeout(armTimer);
89
+ armed.value = false;
90
+ }
91
+
92
+ function arm() {
93
+ disarm();
94
+ if (document.visibilityState === 'visible' && document.hasFocus()) {
95
+ armTimer = setTimeout(() => { armed.value = true; }, ALLOW_DELAY_MS);
96
+ }
97
+ }
98
+
99
+ function onVisibilityChange() {
100
+ if (document.visibilityState === 'visible') arm();
101
+ else disarm();
102
+ }
103
+
104
+ onBeforeUnmount(() => {
105
+ disarm();
106
+ window.removeEventListener('focus', arm);
107
+ window.removeEventListener('blur', disarm);
108
+ document.removeEventListener('visibilitychange', onVisibilityChange);
109
+ });
110
+
111
+ onMounted(async () => {
112
+ if (framed) return;
113
+ window.addEventListener('focus', arm);
114
+ window.addEventListener('blur', disarm);
115
+ document.addEventListener('visibilitychange', onVisibilityChange);
116
+ const response = await callAdminForthApi({
117
+ method: 'GET',
118
+ path: `/mcp/oauth/authorization?request=${encodeURIComponent(request)}`,
119
+ });
120
+ if (response?.error) {
121
+ error.value = response.error_description;
122
+ } else if (response) {
123
+ authorization.value = response;
124
+ // the delay counts from when the buttons are on the screen
125
+ await nextTick();
126
+ arm();
127
+ }
128
+ });
129
+
130
+ async function resolve(approved: boolean) {
131
+ resolving.value = true;
132
+ const response = await callAdminForthApi({
133
+ method: 'POST',
134
+ path: '/mcp/oauth/authorization',
135
+ body: { request, approved, readOnly: readOnlyChosen.value },
136
+ });
137
+ if (response?.redirectUrl) {
138
+ window.location.href = response.redirectUrl;
139
+ return;
140
+ }
141
+ resolving.value = false;
142
+ if (response?.error) {
143
+ authorization.value = null;
144
+ error.value = response.error_description;
145
+ }
146
+ }
147
+ </script>