@adminforth/mcp 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +2 -2
  2. package/dist/apiTools.d.ts +6 -11
  3. package/dist/apiTools.js +38 -40
  4. package/dist/authSecretStore.d.ts +10 -12
  5. package/dist/authSecretStore.js +42 -115
  6. package/dist/clientInfo.js +1 -1
  7. package/dist/custom/McpSettings.vue +3 -13
  8. package/dist/endpoints/mcp.d.ts +2 -0
  9. package/dist/endpoints/mcp.js +24 -0
  10. package/dist/endpoints/oAuth.d.ts +3 -0
  11. package/dist/{oauthEndpoints.js → endpoints/oAuth.js} +15 -34
  12. package/dist/endpoints/settings.d.ts +3 -0
  13. package/dist/endpoints/settings.js +44 -0
  14. package/dist/endpoints/tools.d.ts +2 -0
  15. package/dist/endpoints/tools.js +19 -0
  16. package/dist/index.d.ts +1 -4
  17. package/dist/index.js +25 -172
  18. package/dist/mcpProtocol.d.ts +9 -22
  19. package/dist/mcpProtocol.js +23 -118
  20. package/dist/{oauth.d.ts → oauth/authorizationServer.d.ts} +3 -13
  21. package/dist/{oauth.js → oauth/authorizationServer.js} +4 -17
  22. package/dist/{oauthClientMetadata.d.ts → oauth/clientMetadata.d.ts} +1 -5
  23. package/dist/{oauthClientMetadata.js → oauth/clientMetadata.js} +8 -28
  24. package/dist/oauth/redirectUris.d.ts +4 -0
  25. package/dist/oauth/redirectUris.js +18 -0
  26. package/dist/oauth/setup.d.ts +6 -0
  27. package/dist/oauth/setup.js +25 -0
  28. package/dist/repositories/authSecret.d.ts +31 -0
  29. package/dist/repositories/authSecret.js +86 -0
  30. package/dist/requestExtra.d.ts +2 -0
  31. package/dist/requestExtra.js +10 -0
  32. package/dist/schemas/oAuth.d.ts +13 -0
  33. package/dist/schemas/oAuth.js +12 -0
  34. package/dist/schemas/settings.d.ts +10 -0
  35. package/dist/schemas/settings.js +8 -0
  36. package/dist/schemas/tools.d.ts +7 -0
  37. package/dist/schemas/tools.js +7 -0
  38. package/dist/skills.d.ts +2 -2
  39. package/dist/types.d.ts +3 -10
  40. package/dist/urls.d.ts +9 -5
  41. package/dist/urls.js +9 -1
  42. package/package.json +9 -4
  43. package/dist/oauthEndpoints.d.ts +0 -3
  44. /package/dist/{inputSchema.d.ts → compactInputSchema.d.ts} +0 -0
  45. /package/dist/{inputSchema.js → compactInputSchema.js} +0 -0
  46. /package/dist/{oauthError.d.ts → oauth/errors.d.ts} +0 -0
  47. /package/dist/{oauthError.js → oauth/errors.js} +0 -0
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # AdminForth MCP
2
2
 
3
- `@adminforth/mcp` exposes the AdminForth API tool surface as an authenticated remote MCP server. Each MCP auth secret belongs to one AdminForth user, so resource permissions and hooks are preserved.
3
+ `@adminforth/mcp` exposes the AdminForth API tool surface as an authenticated remote MCP server. Agents connect through OAuth sign-in or with an auth secret, and act as the AdminForth user who connected them, so resource permissions and hooks are preserved.
4
4
 
5
- The server identifies itself to agents using the configured AdminForth `brandName`. An optional `adminPanelOrigin` can distinguish deployments further; the plugin combines it with the AdminForth `baseUrl`.
5
+ The required `adminPanelOrigin` is the public origin agents connect to; the plugin combines it with the AdminForth `baseUrl` to build the MCP URL and the OAuth issuer.
6
6
 
7
7
  > **Security:** an MCP auth secret carries the full permissions of the user who created it. An agent
8
8
  > holding it can do everything that user can do in the admin panel, including destructive actions and
@@ -1,24 +1,19 @@
1
+ import type { Tool } from '@modelcontextprotocol/server';
1
2
  import type { McpPageSize } from './types.js';
2
3
  import type { AdminUser, IAdminForth } from 'adminforth';
3
- export interface McpToolDefinition {
4
- name: string;
5
- description?: string;
6
- inputSchema: Record<string, unknown>;
7
- annotations?: {
8
- readOnlyHint?: boolean;
9
- destructiveHint?: boolean;
10
- };
11
- }
12
4
  export declare class AdminForthApiTools {
13
5
  private readonly adminforth;
14
6
  private readonly hiddenResourceIds;
15
7
  private readonly toolTimeoutMs;
16
8
  constructor(adminforth: IAdminForth, hiddenResourceIds: ReadonlySet<string>, pageSize: McpPageSize, toolTimeoutMs: number);
17
9
  private readonly toolOverrides;
18
- private schemas;
10
+ private fullCatalog?;
11
+ private readOnlyCatalog?;
12
+ private catalog;
13
+ private buildCatalog;
19
14
  private description;
20
15
  private withTimeout;
21
- list(readOnly: boolean): McpToolDefinition[];
16
+ list(readOnly: boolean): Tool[];
22
17
  call(params: {
23
18
  name: string;
24
19
  arguments?: Record<string, unknown>;
package/dist/apiTools.js CHANGED
@@ -1,7 +1,9 @@
1
- import { compactInputSchema } from './inputSchema.js';
1
+ import { compactInputSchema } from './compactInputSchema.js';
2
2
  import { adminApiPrefix } from './urls.js';
3
3
  import { AdminForthDataTypes } from 'adminforth';
4
4
  const METHODS_WITHOUT_REQUEST_BODY = new Set(['GET', 'HEAD']);
5
+ const NON_TOOL_NAME_CHARACTER_RE = /[^a-zA-Z0-9_]+/g;
6
+ const EDGE_UNDERSCORE_RE = /^_+|_+$/g;
5
7
  // Milliseconds cost tokens on every datetime value and are never needed to answer the user.
6
8
  const ISO_MILLISECONDS_RE = /\.\d+(?=Z$)/;
7
9
  const TOOL_TIMEOUT = Symbol('TOOL_TIMEOUT');
@@ -44,15 +46,7 @@ function isRegisteredToolSchema(schema) {
44
46
  return typeof schema.handler === 'function' && !schema.agent?.hiddenFromAgents;
45
47
  }
46
48
  function endpointPathToToolName(path) {
47
- return path
48
- .replace(/^\/+/, '')
49
- .replace(/[^a-zA-Z0-9_]+/g, '_')
50
- .replace(/^_+|_+$/g, '');
51
- }
52
- function stripAdminApiPrefix(path, adminforth) {
53
- const apiPrefix = adminApiPrefix(adminforth.config.baseUrl);
54
- const strippedPath = path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) : path;
55
- return strippedPath.startsWith('/') ? strippedPath : `/${strippedPath}`;
49
+ return path.replace(NON_TOOL_NAME_CHARACTER_RE, '_').replace(EDGE_UNDERSCORE_RE, '');
56
50
  }
57
51
  // Drops frontend-only parts of the get_resource response that are useless for MCP clients and only waste their context.
58
52
  // Copies instead of deleting: the response shares nested objects with the AdminForth config.
@@ -174,16 +168,37 @@ export class AdminForthApiTools {
174
168
  this.toolOverrides = createToolOverrides(pageSize, adminforth);
175
169
  }
176
170
  toolOverrides;
177
- schemas(readOnly) {
178
- const schemas = new Map();
179
- for (const schema of this.adminforth.openApi.registeredSchemas) {
180
- if (!isRegisteredToolSchema(schema))
181
- continue;
182
- if (readOnly && !schema.agent?.onlyReadsData)
183
- continue;
184
- schemas.set(endpointPathToToolName(stripAdminApiPrefix(schema.path, this.adminforth)), schema);
185
- }
186
- return schemas;
171
+ fullCatalog;
172
+ readOnlyCatalog;
173
+ catalog(readOnly) {
174
+ return readOnly
175
+ ? (this.readOnlyCatalog ??= this.buildCatalog(true))
176
+ : (this.fullCatalog ??= this.buildCatalog(false));
177
+ }
178
+ buildCatalog(readOnly) {
179
+ const apiPrefix = adminApiPrefix(this.adminforth.config.baseUrl);
180
+ const schemas = new Map(this.adminforth.openApi.registeredSchemas
181
+ .filter(isRegisteredToolSchema)
182
+ .filter((schema) => !readOnly || schema.agent?.onlyReadsData)
183
+ .map((schema) => [endpointPathToToolName(schema.path.slice(apiPrefix.length)), schema]));
184
+ const tools = Array.from(schemas.entries())
185
+ .sort(([left], [right]) => left.localeCompare(right))
186
+ .map(([name, schema]) => ({
187
+ name,
188
+ ...this.description(name, schema),
189
+ inputSchema: compactInputSchema(overrideInputSchema(schema.request_schema ?? {
190
+ type: 'object',
191
+ properties: {},
192
+ additionalProperties: true,
193
+ }, this.toolOverrides[name])),
194
+ ...(schema.agent?.onlyReadsData && {
195
+ annotations: { readOnlyHint: true },
196
+ }),
197
+ ...(schema.agent?.requiresHumanApproval && {
198
+ annotations: { destructiveHint: true },
199
+ }),
200
+ }));
201
+ return { schemas, tools };
187
202
  }
188
203
  description(name, schema) {
189
204
  const description = [
@@ -215,26 +230,10 @@ export class AdminForthApiTools {
215
230
  }
216
231
  }
217
232
  list(readOnly) {
218
- return Array.from(this.schemas(readOnly).entries())
219
- .sort(([left], [right]) => left.localeCompare(right))
220
- .map(([name, schema]) => ({
221
- name,
222
- ...this.description(name, schema),
223
- inputSchema: compactInputSchema(overrideInputSchema(schema.request_schema ?? {
224
- type: 'object',
225
- properties: {},
226
- additionalProperties: true,
227
- }, this.toolOverrides[name])),
228
- ...(schema.agent?.onlyReadsData && {
229
- annotations: { readOnlyHint: true },
230
- }),
231
- ...(schema.agent?.requiresHumanApproval && {
232
- annotations: { destructiveHint: true },
233
- }),
234
- }));
233
+ return this.catalog(readOnly).tools;
235
234
  }
236
235
  async call(params) {
237
- const schema = this.schemas(params.readOnly).get(params.name);
236
+ const schema = this.catalog(params.readOnly).schemas.get(params.name);
238
237
  if (!schema) {
239
238
  return { output: { error: `Unknown tool: ${params.name}` }, isError: true };
240
239
  }
@@ -261,7 +260,6 @@ export class AdminForthApiTools {
261
260
  };
262
261
  }
263
262
  const response = createDirectResponse();
264
- const language = String(params.headers['accept-language'] ?? 'en');
265
263
  const output = await this.withTimeout(params.abortSignal, (abortSignal) => schema.handler({
266
264
  body,
267
265
  query,
@@ -273,7 +271,7 @@ export class AdminForthApiTools {
273
271
  abortSignal,
274
272
  _raw_express_req: undefined,
275
273
  _raw_express_res: undefined,
276
- tr: (message, category, translationParams, pluralizationNumber) => this.adminforth.tr(message, category, language, translationParams, pluralizationNumber),
274
+ tr: (message, category, translationParams, pluralizationNumber) => this.adminforth.tr(message, category, params.headers['accept-language'], translationParams, pluralizationNumber),
277
275
  }));
278
276
  if (output === TOOL_TIMEOUT) {
279
277
  return {
@@ -1,5 +1,6 @@
1
1
  import { type AdminUser, type HttpExtra, type IAdminForth } from 'adminforth';
2
- import type { McpAuthSecretResourceOptions, McpClientInfo } from './types.js';
2
+ import type { AuthSecretRepository } from './repositories/authSecret.js';
3
+ import type { McpClientInfo } from './types.js';
3
4
  export declare const SECRET_PREFIX = "afmcp_";
4
5
  type AdminUserWithExecutor = AdminUser & {
5
6
  executedBy?: string;
@@ -20,18 +21,17 @@ export interface AuthenticatedMcpSecret {
20
21
  }
21
22
  export declare class McpAuthSecretStore {
22
23
  private readonly adminforth;
23
- private readonly options;
24
- private readonly resource;
24
+ private readonly repository;
25
25
  private readonly refreshLock;
26
- constructor(adminforth: IAdminForth, options: McpAuthSecretResourceOptions);
26
+ constructor(adminforth: IAdminForth, repository: AuthSecretRepository);
27
27
  list(adminUser: AdminUser): Promise<{
28
- id: any;
29
- name: any;
30
- createdAt: any;
31
- lastUsedAt: any;
28
+ id: string;
29
+ name: string;
30
+ createdAt: string;
31
+ lastUsedAt: string | null;
32
32
  lastUsedByAgent: McpClientInfo | null;
33
- readOnly: any;
34
- oauthClientId: any;
33
+ readOnly: boolean;
34
+ oauthClientId: string | null;
35
35
  }[]>;
36
36
  create(name: string, readOnly: boolean, adminUser: AdminUser, extra: HttpExtra): Promise<{
37
37
  error: string;
@@ -59,8 +59,6 @@ export declare class McpAuthSecretStore {
59
59
  rotateRefreshToken(grantId: string, refreshToken: string, clientId: string, newRefreshToken: string): Promise<string | null>;
60
60
  /** Access tokens are checked against their grant on every request, so revoking the grant applies at once. */
61
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
62
  private loadAdminUser;
65
63
  private toAuthenticated;
66
64
  touch(recordId: string, client: McpClientInfo): void;
@@ -1,120 +1,61 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
- import { Filters, Sorts, logger, } from 'adminforth';
2
+ import { Filters, logger, } from 'adminforth';
3
3
  import { UNKNOWN_CLIENT } from './clientInfo.js';
4
4
  import { KeyedLock } from './keyedLock.js';
5
5
  export const SECRET_PREFIX = 'afmcp_';
6
6
  function hashSecret(secret) {
7
7
  return createHash('sha256').update(secret).digest('hex');
8
8
  }
9
- /**
10
- * Stored agent info originates from the MCP client, so it is never trusted to be valid JSON:
11
- * a database column which truncated an oversized value would otherwise break every next
12
- * request made with the auth secret, including the page used to revoke it.
13
- */
14
- function parseStoredClient(value) {
15
- if (typeof value !== 'string' || !value)
16
- return null;
17
- let parsed;
18
- try {
19
- parsed = JSON.parse(value);
20
- }
21
- catch {
22
- return null;
23
- }
24
- if (!parsed || typeof parsed !== 'object' || typeof parsed.client !== 'string')
25
- return null;
26
- return { client: parsed.client, ver: typeof parsed.ver === 'string' ? parsed.ver : null };
27
- }
28
9
  export class McpAuthSecretStore {
29
10
  adminforth;
30
- options;
31
- resource;
11
+ repository;
32
12
  refreshLock = new KeyedLock();
33
- constructor(adminforth, options) {
13
+ constructor(adminforth, repository) {
34
14
  this.adminforth = adminforth;
35
- this.options = options;
36
- this.resource = adminforth.config.resources.find((resource) => resource.resourceId === options.resourceId);
15
+ this.repository = repository;
37
16
  }
38
17
  async list(adminUser) {
39
- const fields = this.options;
40
- const records = await this.adminforth.resource(fields.resourceId).list(Filters.EQ(fields.userIdField, adminUser.pk), undefined, undefined, Sorts.DESC(fields.createdAtField));
41
- return records.map((record) => ({
42
- id: record[fields.idField],
43
- name: record[fields.nameField],
44
- createdAt: record[fields.createdAtField],
45
- lastUsedAt: record[fields.lastUsedAtField] ?? null,
46
- lastUsedByAgent: parseStoredClient(record[fields.lastUsedByAgentField]),
47
- readOnly: record[fields.readOnlyField],
48
- oauthClientId: fields.oauthClientIdField ? record[fields.oauthClientIdField] ?? null : null,
49
- }));
18
+ const authSecrets = await this.repository.listByUser(adminUser.pk);
19
+ return authSecrets.map(({ secretHash, userId, ...authSecret }) => authSecret);
50
20
  }
51
21
  async create(name, readOnly, adminUser, extra) {
52
- const fields = this.options;
53
22
  const secret = `${SECRET_PREFIX}${randomBytes(32).toString('base64url')}`;
54
- const result = await this.adminforth.createResourceRecord({
55
- resource: this.resource,
56
- adminUser,
57
- extra,
58
- record: {
59
- [fields.idField]: randomUUID(),
60
- [fields.nameField]: name,
61
- [fields.secretHashField]: hashSecret(secret),
62
- [fields.userIdField]: adminUser.pk,
63
- [fields.createdAtField]: new Date().toISOString(),
64
- [fields.lastUsedAtField]: null,
65
- [fields.lastUsedByAgentField]: null,
66
- [fields.readOnlyField]: readOnly,
67
- ...(fields.oauthClientIdField && { [fields.oauthClientIdField]: null }),
68
- },
69
- });
23
+ const result = await this.repository.create({
24
+ id: randomUUID(),
25
+ name,
26
+ secretHash: hashSecret(secret),
27
+ userId: adminUser.pk,
28
+ readOnly,
29
+ oauthClientId: null,
30
+ }, adminUser, extra);
70
31
  return result.error ? { error: result.error } : { secret };
71
32
  }
72
33
  async revoke(id, adminUser, extra) {
73
- const fields = this.options;
74
- const record = await this.adminforth.resource(fields.resourceId).get(Filters.AND(Filters.EQ(fields.idField, id), Filters.EQ(fields.userIdField, adminUser.pk)));
75
- if (!record)
76
- return { error: 'MCP auth secret not found' };
77
- return this.adminforth.deleteResourceRecord({
78
- resource: this.resource,
79
- record,
80
- recordId: id,
81
- adminUser,
82
- extra,
83
- });
34
+ return await this.repository.deleteByIdAndUser(id, adminUser, extra) ?? { error: 'MCP auth secret not found' };
84
35
  }
85
36
  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;
37
+ const authSecret = await this.repository.findBySecretHash(hashSecret(secret));
38
+ return authSecret ? this.toAuthenticated(authSecret) : null;
88
39
  }
89
40
  /**
90
41
  * Stores the connection an MCP client got through OAuth as an auth secret record, so it is listed and revoked
91
42
  * like personal secrets. The record id is the authorization code id, which makes every code redeemable once.
92
43
  */
93
44
  async createOAuthGrant(grant, refreshToken) {
94
- const fields = this.options;
95
- const resource = this.adminforth.resource(fields.resourceId);
96
- if (await resource.get(Filters.EQ(fields.idField, grant.id))) {
45
+ if (await this.repository.findById(grant.id)) {
97
46
  return { error: 'Authorization code was already used' };
98
47
  }
99
48
  const adminUser = await this.loadAdminUser(grant.userId);
100
49
  if (!adminUser)
101
50
  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
- });
51
+ const result = await this.repository.create({
52
+ id: grant.id,
53
+ name: grant.clientName,
54
+ secretHash: hashSecret(refreshToken),
55
+ userId: grant.userId,
56
+ readOnly: grant.readOnly,
57
+ oauthClientId: grant.clientId,
58
+ }, adminUser, {});
118
59
  return result.error ? { error: result.error } : {};
119
60
  }
120
61
  /**
@@ -125,30 +66,23 @@ export class McpAuthSecretStore {
125
66
  * from both passing, within this process.
126
67
  */
127
68
  async rotateRefreshToken(grantId, refreshToken, clientId, newRefreshToken) {
128
- const fields = this.options;
129
- const resource = this.adminforth.resource(fields.resourceId);
130
69
  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)
70
+ const grant = await this.repository.findById(grantId);
71
+ if (grant?.oauthClientId !== clientId)
133
72
  return null;
134
- if (record[fields.secretHashField] !== hashSecret(refreshToken)) {
73
+ if (grant.secretHash !== hashSecret(refreshToken)) {
135
74
  logger.warn(`AdminForthMcpPlugin: a rotated refresh token of OAuth grant ${grantId} was used again, revoking the grant`);
136
- await resource.delete(grantId);
75
+ await this.repository.deleteById(grantId);
137
76
  return null;
138
77
  }
139
- await resource.update(grantId, { [fields.secretHashField]: hashSecret(newRefreshToken) });
140
- return record[fields.userIdField];
78
+ await this.repository.updateSecretHash(grantId, hashSecret(newRefreshToken));
79
+ return grant.userId;
141
80
  });
142
81
  }
143
82
  /** Access tokens are checked against their grant on every request, so revoking the grant applies at once. */
144
83
  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;
84
+ const grant = await this.repository.findById(grantId);
85
+ return grant?.userId === userId && grant.oauthClientId ? this.toAuthenticated(grant) : null;
152
86
  }
153
87
  async loadAdminUser(pk) {
154
88
  const auth = this.adminforth.config.auth;
@@ -157,28 +91,21 @@ export class McpAuthSecretStore {
157
91
  const dbUser = await this.adminforth.resource(usersResource.resourceId).get(Filters.EQ(userPrimaryKeyField, pk));
158
92
  return dbUser ? { pk, username: dbUser[auth.usernameField], dbUser } : null;
159
93
  }
160
- async toAuthenticated(record) {
161
- const fields = this.options;
162
- const adminUser = await this.loadAdminUser(record[fields.userIdField]);
94
+ async toAuthenticated(authSecret) {
95
+ const adminUser = await this.loadAdminUser(authSecret.userId);
163
96
  if (!adminUser)
164
97
  return null;
165
98
  return {
166
99
  adminUser,
167
- client: parseStoredClient(record[fields.lastUsedByAgentField]),
168
- name: record[fields.nameField],
169
- readOnly: record[fields.readOnlyField],
170
- recordId: record[fields.idField],
100
+ client: authSecret.lastUsedByAgent,
101
+ name: authSecret.name,
102
+ readOnly: authSecret.readOnly,
103
+ recordId: authSecret.id,
171
104
  };
172
105
  }
173
106
  touch(recordId, client) {
174
- const fields = this.options;
175
- const updates = {
176
- [fields.lastUsedAtField]: new Date().toISOString(),
177
- };
178
- if (client.client !== UNKNOWN_CLIENT) {
179
- updates[fields.lastUsedByAgentField] = JSON.stringify(client);
180
- }
181
- void this.adminforth.resource(fields.resourceId).update(recordId, updates).catch((error) => {
107
+ const knownClient = client.client === UNKNOWN_CLIENT ? undefined : client;
108
+ void this.repository.updateUsage(recordId, knownClient).catch((error) => {
182
109
  logger.error(`AdminForthMcpPlugin: failed to update MCP auth secret usage: ${String(error)}`);
183
110
  });
184
111
  }
@@ -35,7 +35,7 @@ export function readMcpClient(body, headers) {
35
35
  if (client) {
36
36
  return { client, ver: normalizeClientVersion(clientInfo.version) };
37
37
  }
38
- return parseUserAgent(String(headers['user-agent'] ?? '')) ?? {
38
+ return parseUserAgent(headers['user-agent'] ?? '') ?? {
39
39
  client: UNKNOWN_CLIENT,
40
40
  ver: null,
41
41
  };
@@ -68,8 +68,7 @@
68
68
  :header="$t('Connect an AI agent')"
69
69
  :buttons="dialogButtons"
70
70
  >
71
- <!-- mounted once the list is loaded: ButtonGroup reads its buttons only on mount -->
72
- <ButtonGroup v-if="oauthEnabled" v-model="activeClient">
71
+ <ButtonGroup v-model="activeClient">
73
72
  <template v-for="client in CLIENTS" :key="client" #[`button:${client}`]>
74
73
  <span class="px-4 py-2">{{ client }}</span>
75
74
  </template>
@@ -148,10 +147,7 @@ const { t } = useI18n();
148
147
  const coreStore = useCoreStore();
149
148
  const brandName = computed<string>(() => coreStore.config?.brandName);
150
149
  const activeClient = ref<Client>('Claude Code');
151
- // Without OAuth sign-in the agents connect only with auth secrets, so the dialog is the Other tab alone.
152
- const oauthEnabled = ref(false);
153
- // Built by the plugin from adminPanelOrigin: OAuth accepts tokens only for this exact URL. Without adminPanelOrigin
154
- // there is no OAuth and auth secrets do not check the URL, so the address this page was opened at is shown.
150
+ // Built by the plugin from adminPanelOrigin: OAuth accepts tokens only for this exact URL.
155
151
  const mcpUrl = ref('');
156
152
 
157
153
  const authSecrets = ref<AuthSecret[]>([]);
@@ -208,10 +204,6 @@ const dialogButtons = computed(() => {
208
204
 
209
205
  const createdSecretReadOnly = computed(() => serverReadOnly.value || secretReadOnly.value);
210
206
 
211
- function fallbackMcpUrl() {
212
- const baseUrl = (import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || '').replace(/\/$/, '');
213
- return `${window.location.origin}${baseUrl}/adminapi/v1/mcp`;
214
- }
215
207
  // Name the MCP server is registered under in the agent, e.g. `claude mcp add ... my-admin <url>`.
216
208
  // A brand name of non-Latin letters only leaves an empty slug, hence the default.
217
209
  const serverName = computed(() => brandName.value
@@ -261,9 +253,7 @@ async function loadAuthSecrets() {
261
253
  const response = await callAdminForthApi({ method: 'GET', path: '/mcp/auth-secrets' });
262
254
  if (response) {
263
255
  authSecrets.value = response.authSecrets;
264
- if (!response.oauthEnabled) activeClient.value = 'Other';
265
- oauthEnabled.value = response.oauthEnabled;
266
- mcpUrl.value = response.mcpUrl ?? fallbackMcpUrl();
256
+ mcpUrl.value = response.mcpUrl;
267
257
  serverReadOnly.value = response.serverReadOnly;
268
258
  }
269
259
  } finally {
@@ -0,0 +1,2 @@
1
+ import type { IAdminForthEndpointHandlerInput, IHttpServer } from 'adminforth';
2
+ export declare function registerMcpEndpoints(server: IHttpServer, handleMcpRequest: (input: IAdminForthEndpointHandlerInput) => Promise<unknown>): void;
@@ -0,0 +1,24 @@
1
+ import { MCP_PATH } from '../urls.js';
2
+ export function registerMcpEndpoints(server, handleMcpRequest) {
3
+ server.endpoint({
4
+ method: 'POST',
5
+ path: MCP_PATH,
6
+ agent: {
7
+ hiddenFromAgents: true,
8
+ },
9
+ noAuth: true,
10
+ handler: handleMcpRequest,
11
+ });
12
+ server.endpoint({
13
+ method: 'GET',
14
+ path: MCP_PATH,
15
+ agent: {
16
+ hiddenFromAgents: true,
17
+ },
18
+ noAuth: true,
19
+ handler: async ({ response }) => {
20
+ response.setHeader('Allow', 'POST');
21
+ response.setStatus(405, 'Method Not Allowed');
22
+ },
23
+ });
24
+ }
@@ -0,0 +1,3 @@
1
+ import { type IHttpServer } from 'adminforth';
2
+ import type { McpOAuth } from '../oauth/authorizationServer.js';
3
+ export declare function registerOAuthEndpoints(server: IHttpServer, oauth: McpOAuth, apiPrefix: string): void;
@@ -1,31 +1,12 @@
1
1
  import { logger } from 'adminforth';
2
- import { OAUTH_PATHS } from './oauth.js';
3
- import { OAuthError } from './oauthError.js';
4
- import { MCP_PATH } from './urls.js';
5
- const MAX_FORM_BODY_BYTES = 16 * 1024;
6
- /**
7
- * RFC 6749 token requests are application/x-www-form-urlencoded. The host app may already have parsed the body
8
- * with its own urlencoded middleware; otherwise the stream is still unread.
9
- */
10
- async function readFormBody(req) {
11
- if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) {
12
- return req.body;
13
- }
14
- const chunks = [];
15
- let size = 0;
16
- for await (const chunk of req) {
17
- size += chunk.length;
18
- if (size > MAX_FORM_BODY_BYTES) {
19
- throw new OAuthError('invalid_request', `Request body is larger than ${MAX_FORM_BODY_BYTES} bytes`);
20
- }
21
- chunks.push(chunk);
22
- }
23
- return Object.fromEntries(new URLSearchParams(Buffer.concat(chunks).toString('utf8')));
24
- }
2
+ import express from 'express';
3
+ import { OAuthError } from '../oauth/errors.js';
4
+ import { resolveAuthorizationBodySchema } from '../schemas/oAuth.js';
5
+ import { MCP_PATH, OAUTH_PATHS } from '../urls.js';
6
+ const FORM_BODY_LIMIT = '16kb';
25
7
  async function oauthResponse(input, handler) {
26
8
  try {
27
9
  const result = await handler();
28
- // A handler that answered through the raw response returns null; headers can no longer be set then.
29
10
  if (result !== null)
30
11
  input.response.setHeader('Cache-Control', 'no-store');
31
12
  return result;
@@ -38,7 +19,7 @@ async function oauthResponse(input, handler) {
38
19
  return error.toResponseObject();
39
20
  }
40
21
  }
41
- export function setupOAuthEndpoints(server, oauth, apiPrefix) {
22
+ export function registerOAuthEndpoints(server, oauth, apiPrefix) {
42
23
  server.endpoint({
43
24
  method: 'GET',
44
25
  path: OAUTH_PATHS.protectedResourceMetadata,
@@ -93,15 +74,15 @@ export function setupOAuthEndpoints(server, oauth, apiPrefix) {
93
74
  agent: {
94
75
  hiddenFromAgents: true,
95
76
  },
96
- handler: async (input) => oauthResponse(input, async () => ({
97
- redirectUrl: await oauth.resolveAuthorization(String(input.body.request), input.body.approved === true, input.adminUser, input.body.readOnly === true),
98
- })),
77
+ request_schema: resolveAuthorizationBodySchema,
78
+ handler: async (input) => {
79
+ const body = input.body;
80
+ return oauthResponse(input, async () => ({
81
+ redirectUrl: await oauth.resolveAuthorization(body.request, body.approved, input.adminUser, body.readOnly),
82
+ }));
83
+ },
99
84
  });
100
85
  const { expressApp } = server;
101
- // MCP clients try the host-root well-known URLs first and stop at the first 200 response. The AdminForth SPA
102
- // answers every unknown path with 200 HTML, so unless these are served the client fails before it reaches
103
- // `<issuer>/.well-known/openid-configuration`. Behind a proxy that forwards only the AdminForth path they 404,
104
- // and the client goes on to that URL.
105
86
  const mcpPath = `${apiPrefix}${MCP_PATH}`;
106
87
  expressApp.get(`/.well-known/oauth-protected-resource${mcpPath}`, async (_req, res) => {
107
88
  res.json(oauth.protectedResourceMetadata());
@@ -109,10 +90,10 @@ export function setupOAuthEndpoints(server, oauth, apiPrefix) {
109
90
  expressApp.get(`/.well-known/oauth-authorization-server${mcpPath}`, async (_req, res) => {
110
91
  res.json(oauth.authorizationServerMetadata());
111
92
  });
112
- expressApp.post(`${apiPrefix}${OAUTH_PATHS.token}`, async (req, res) => {
93
+ expressApp.post(`${apiPrefix}${OAUTH_PATHS.token}`, express.urlencoded({ extended: false, limit: FORM_BODY_LIMIT }), async (req, res) => {
113
94
  res.set('Cache-Control', 'no-store');
114
95
  try {
115
- res.json(await oauth.exchangeToken(await readFormBody(req)));
96
+ res.json(await oauth.exchangeToken(req.body ?? {}));
116
97
  }
117
98
  catch (error) {
118
99
  if (error instanceof OAuthError) {
@@ -0,0 +1,3 @@
1
+ import type { IHttpServer } from 'adminforth';
2
+ import type { McpAuthSecretStore } from '../authSecretStore.js';
3
+ export declare function registerSettingsEndpoints(server: IHttpServer, authSecretStore: McpAuthSecretStore, mcpUrl: string, serverReadOnly: boolean): void;
@@ -0,0 +1,44 @@
1
+ import { requestExtra } from '../requestExtra.js';
2
+ import { createAuthSecretBodySchema, revokeAuthSecretBodySchema, } from '../schemas/settings.js';
3
+ import { MCP_PATH } from '../urls.js';
4
+ export function registerSettingsEndpoints(server, authSecretStore, mcpUrl, serverReadOnly) {
5
+ server.endpoint({
6
+ method: 'GET',
7
+ path: `${MCP_PATH}/auth-secrets`,
8
+ agent: {
9
+ hiddenFromAgents: true,
10
+ },
11
+ handler: async ({ adminUser }) => ({
12
+ authSecrets: await authSecretStore.list(adminUser),
13
+ mcpUrl,
14
+ serverReadOnly,
15
+ }),
16
+ });
17
+ server.endpoint({
18
+ method: 'POST',
19
+ path: `${MCP_PATH}/auth-secrets`,
20
+ agent: {
21
+ hiddenFromAgents: true,
22
+ },
23
+ request_schema: createAuthSecretBodySchema,
24
+ handler: async (input) => {
25
+ const body = input.body;
26
+ return authSecretStore.create(body.name, serverReadOnly || body.readOnly, input.adminUser, requestExtra(input));
27
+ },
28
+ });
29
+ server.endpoint({
30
+ method: 'DELETE',
31
+ path: `${MCP_PATH}/auth-secrets`,
32
+ agent: {
33
+ hiddenFromAgents: true,
34
+ },
35
+ request_schema: revokeAuthSecretBodySchema,
36
+ handler: async (input) => {
37
+ const body = input.body;
38
+ const result = await authSecretStore.revoke(body.id, input.adminUser, requestExtra(input));
39
+ if (result.error)
40
+ input.response.setStatus(404);
41
+ return result;
42
+ },
43
+ });
44
+ }