@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.
- package/README.md +2 -2
- package/dist/apiTools.d.ts +6 -11
- package/dist/apiTools.js +38 -40
- package/dist/authSecretStore.d.ts +10 -12
- package/dist/authSecretStore.js +42 -115
- package/dist/clientInfo.js +1 -1
- package/dist/custom/McpSettings.vue +3 -13
- package/dist/endpoints/mcp.d.ts +2 -0
- package/dist/endpoints/mcp.js +24 -0
- package/dist/endpoints/oAuth.d.ts +3 -0
- package/dist/{oauthEndpoints.js → endpoints/oAuth.js} +15 -34
- package/dist/endpoints/settings.d.ts +3 -0
- package/dist/endpoints/settings.js +44 -0
- package/dist/endpoints/tools.d.ts +2 -0
- package/dist/endpoints/tools.js +19 -0
- package/dist/index.d.ts +1 -4
- package/dist/index.js +25 -172
- package/dist/mcpProtocol.d.ts +9 -22
- package/dist/mcpProtocol.js +23 -118
- package/dist/{oauth.d.ts → oauth/authorizationServer.d.ts} +3 -13
- package/dist/{oauth.js → oauth/authorizationServer.js} +4 -17
- package/dist/{oauthClientMetadata.d.ts → oauth/clientMetadata.d.ts} +1 -5
- package/dist/{oauthClientMetadata.js → oauth/clientMetadata.js} +8 -28
- package/dist/oauth/redirectUris.d.ts +4 -0
- package/dist/oauth/redirectUris.js +18 -0
- package/dist/oauth/setup.d.ts +6 -0
- package/dist/oauth/setup.js +25 -0
- package/dist/repositories/authSecret.d.ts +31 -0
- package/dist/repositories/authSecret.js +86 -0
- package/dist/requestExtra.d.ts +2 -0
- package/dist/requestExtra.js +10 -0
- package/dist/schemas/oAuth.d.ts +13 -0
- package/dist/schemas/oAuth.js +12 -0
- package/dist/schemas/settings.d.ts +10 -0
- package/dist/schemas/settings.js +8 -0
- package/dist/schemas/tools.d.ts +7 -0
- package/dist/schemas/tools.js +7 -0
- package/dist/skills.d.ts +2 -2
- package/dist/types.d.ts +3 -10
- package/dist/urls.d.ts +9 -5
- package/dist/urls.js +9 -1
- package/package.json +9 -4
- package/dist/oauthEndpoints.d.ts +0 -3
- /package/dist/{inputSchema.d.ts → compactInputSchema.d.ts} +0 -0
- /package/dist/{inputSchema.js → compactInputSchema.js} +0 -0
- /package/dist/{oauthError.d.ts → oauth/errors.d.ts} +0 -0
- /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.
|
|
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
|
|
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
|
package/dist/apiTools.d.ts
CHANGED
|
@@ -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
|
|
10
|
+
private fullCatalog?;
|
|
11
|
+
private readOnlyCatalog?;
|
|
12
|
+
private catalog;
|
|
13
|
+
private buildCatalog;
|
|
19
14
|
private description;
|
|
20
15
|
private withTimeout;
|
|
21
|
-
list(readOnly: boolean):
|
|
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 './
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
|
24
|
-
private readonly resource;
|
|
24
|
+
private readonly repository;
|
|
25
25
|
private readonly refreshLock;
|
|
26
|
-
constructor(adminforth: IAdminForth,
|
|
26
|
+
constructor(adminforth: IAdminForth, repository: AuthSecretRepository);
|
|
27
27
|
list(adminUser: AdminUser): Promise<{
|
|
28
|
-
id:
|
|
29
|
-
name:
|
|
30
|
-
createdAt:
|
|
31
|
-
lastUsedAt:
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
lastUsedAt: string | null;
|
|
32
32
|
lastUsedByAgent: McpClientInfo | null;
|
|
33
|
-
readOnly:
|
|
34
|
-
oauthClientId:
|
|
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;
|
package/dist/authSecretStore.js
CHANGED
|
@@ -1,120 +1,61 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { Filters,
|
|
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
|
-
|
|
31
|
-
resource;
|
|
11
|
+
repository;
|
|
32
12
|
refreshLock = new KeyedLock();
|
|
33
|
-
constructor(adminforth,
|
|
13
|
+
constructor(adminforth, repository) {
|
|
34
14
|
this.adminforth = adminforth;
|
|
35
|
-
this.
|
|
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
|
|
40
|
-
|
|
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.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
return
|
|
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
|
-
|
|
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.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
132
|
-
if (
|
|
70
|
+
const grant = await this.repository.findById(grantId);
|
|
71
|
+
if (grant?.oauthClientId !== clientId)
|
|
133
72
|
return null;
|
|
134
|
-
if (
|
|
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
|
|
75
|
+
await this.repository.deleteById(grantId);
|
|
137
76
|
return null;
|
|
138
77
|
}
|
|
139
|
-
await
|
|
140
|
-
return
|
|
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
|
|
146
|
-
|
|
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(
|
|
161
|
-
const
|
|
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:
|
|
168
|
-
name:
|
|
169
|
-
readOnly:
|
|
170
|
-
recordId:
|
|
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
|
|
175
|
-
|
|
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
|
}
|
package/dist/clientInfo.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,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
|
+
}
|
|
@@ -1,31 +1,12 @@
|
|
|
1
1
|
import { logger } from 'adminforth';
|
|
2
|
-
import
|
|
3
|
-
import { OAuthError } from '
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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(
|
|
96
|
+
res.json(await oauth.exchangeToken(req.body ?? {}));
|
|
116
97
|
}
|
|
117
98
|
catch (error) {
|
|
118
99
|
if (error instanceof OAuthError) {
|
|
@@ -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
|
+
}
|