@adminforth/mcp 1.0.4 → 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.
- package/dist/apiTools.d.ts +10 -3
- package/dist/apiTools.js +206 -22
- package/dist/authSecretStore.d.ts +34 -1
- package/dist/authSecretStore.js +84 -12
- package/dist/custom/McpAuthorize.vue +147 -0
- package/dist/custom/McpSettings.vue +153 -54
- package/dist/custom/instructions.md +6 -0
- package/dist/custom/skills/analyze_data/SKILL.md +19 -0
- package/dist/custom/skills/fetch_data/SKILL.md +31 -0
- package/dist/custom/skills/mutate_data/SKILL.md +48 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +163 -25
- package/dist/inputSchema.d.ts +3 -0
- package/dist/inputSchema.js +63 -0
- package/dist/keyedLock.d.ts +8 -0
- package/dist/keyedLock.js +20 -0
- package/dist/mcpProtocol.d.ts +1 -1
- package/dist/mcpProtocol.js +8 -6
- package/dist/oauth.d.ts +96 -0
- package/dist/oauth.js +263 -0
- package/dist/oauthClientMetadata.d.ts +13 -0
- package/dist/oauthClientMetadata.js +140 -0
- package/dist/oauthEndpoints.d.ts +3 -0
- package/dist/oauthEndpoints.js +126 -0
- package/dist/oauthError.d.ts +9 -0
- package/dist/oauthError.js +11 -0
- package/dist/skills.d.ts +19 -0
- package/dist/skills.js +79 -0
- package/dist/types.d.ts +45 -1
- package/dist/urls.d.ts +13 -0
- package/dist/urls.js +13 -0
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -1,11 +1,43 @@
|
|
|
1
|
-
import { AdminForthPlugin, } from 'adminforth';
|
|
1
|
+
import { AdminForthPlugin, logger, } from 'adminforth';
|
|
2
2
|
import { AdminForthApiTools } from './apiTools.js';
|
|
3
3
|
import { formatMcpExecutedBy, readMcpClient, UNKNOWN_CLIENT } from './clientInfo.js';
|
|
4
|
-
import { createMcpServerPresentation, handleMcpProtocol
|
|
5
|
-
import { McpAuthSecretStore } from './authSecretStore.js';
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { createMcpServerPresentation, handleMcpProtocol } from './mcpProtocol.js';
|
|
5
|
+
import { McpAuthSecretStore, SECRET_PREFIX } from './authSecretStore.js';
|
|
6
|
+
import { CONSENT_PAGE_PATH, McpOAuth } from './oauth.js';
|
|
7
|
+
import { isLoopbackUrl } from './oauthClientMetadata.js';
|
|
8
|
+
import { setupOAuthEndpoints } from './oauthEndpoints.js';
|
|
9
|
+
import { FETCH_SKILL_TOOL_NAME, McpSkills } from './skills.js';
|
|
10
|
+
import { adminApiPrefix, createMcpUrls, MCP_PATH } from './urls.js';
|
|
11
|
+
const BEARER_TOKEN_RE = /^Bearer (\S+)$/i;
|
|
8
12
|
const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']);
|
|
13
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
14
|
+
const MAX_PAGE_SIZE = 100;
|
|
15
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 15_000;
|
|
16
|
+
const DEFAULT_TOOL_CALLS_PER_REQUEST = 10;
|
|
17
|
+
const RESOURCES_LIST_RESPONSE_SCHEMA = {
|
|
18
|
+
type: 'object',
|
|
19
|
+
required: ['resources'],
|
|
20
|
+
properties: {
|
|
21
|
+
resources: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
description: 'Every resource of the admin panel. Access is checked when a resource is used, so the user may still be forbidden to list, show, create, edit or delete records of a listed resource.',
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
required: ['resourceId', 'label'],
|
|
27
|
+
properties: {
|
|
28
|
+
resourceId: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Resource identifier. Pass it as resourceId to get_resource, get_resource_data, aggregate, create_record, update_record, delete_record and other resource tools. Call get_resource with it to get the columns and allowed actions of the resource.',
|
|
31
|
+
},
|
|
32
|
+
label: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Human readable resource name, translated for the current user.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
9
41
|
function requestExtra(input) {
|
|
10
42
|
return {
|
|
11
43
|
body: input.body,
|
|
@@ -27,15 +59,19 @@ function jsonRpcAuthError(id = null) {
|
|
|
27
59
|
return {
|
|
28
60
|
jsonrpc: '2.0',
|
|
29
61
|
id,
|
|
30
|
-
error: { code: -32001, message: '
|
|
62
|
+
error: { code: -32001, message: 'Authentication required: the MCP auth secret or OAuth access token is missing, invalid or revoked.' },
|
|
31
63
|
};
|
|
32
64
|
}
|
|
33
65
|
export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
34
66
|
options;
|
|
35
67
|
pluginsScope = 'global';
|
|
36
68
|
authSecretStore;
|
|
69
|
+
/** Null when OAuth sign-in is not configured; then only auth secrets authenticate. */
|
|
70
|
+
oauth;
|
|
37
71
|
apiTools;
|
|
38
|
-
|
|
72
|
+
skills;
|
|
73
|
+
/** Null without adminPanelOrigin; then the settings page shows the MCP URL of the address it was opened at. */
|
|
74
|
+
urls;
|
|
39
75
|
constructor(options) {
|
|
40
76
|
super(options, import.meta.url);
|
|
41
77
|
this.options = options;
|
|
@@ -71,9 +107,46 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
71
107
|
isVisible: () => true,
|
|
72
108
|
});
|
|
73
109
|
}
|
|
110
|
+
this.urls = this.options.adminPanelOrigin
|
|
111
|
+
? createMcpUrls(this.options.adminPanelOrigin, adminforth.config.baseUrl)
|
|
112
|
+
: null;
|
|
74
113
|
this.authSecretStore = new McpAuthSecretStore(adminforth, this.options.authSecretResource);
|
|
75
|
-
this.
|
|
76
|
-
|
|
114
|
+
this.oauth = this.options.authSecretResource.oauthClientIdField ? this.createOAuth(adminforth) : null;
|
|
115
|
+
const pageSize = {
|
|
116
|
+
default: this.options.pageSize?.default ?? DEFAULT_PAGE_SIZE,
|
|
117
|
+
max: this.options.pageSize?.max ?? MAX_PAGE_SIZE,
|
|
118
|
+
};
|
|
119
|
+
this.apiTools = new AdminForthApiTools(adminforth, new Set([this.options.authSecretResource.resourceId]), pageSize, this.options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
|
|
120
|
+
this.skills = new McpSkills(this.customFolderPath, {
|
|
121
|
+
'pageSize.default': pageSize.default,
|
|
122
|
+
'pageSize.max': pageSize.max,
|
|
123
|
+
toolCallsPerRequest: this.options.toolCallsPerRequest ?? DEFAULT_TOOL_CALLS_PER_REQUEST,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
createOAuth(adminforth) {
|
|
127
|
+
if (!this.urls) {
|
|
128
|
+
throw new Error('AdminForthMcpPlugin: adminPanelOrigin is required with authSecretResource.oauthClientIdField, '
|
|
129
|
+
+ 'OAuth issuer and MCP resource URLs are built from it');
|
|
130
|
+
}
|
|
131
|
+
// OAuth sends codes and tokens through these endpoints, so they must be served over TLS (RFC 6749 §3.1);
|
|
132
|
+
// plain http is left for a local admin panel.
|
|
133
|
+
const origin = new URL(this.urls.adminPanelUrl);
|
|
134
|
+
if (origin.protocol !== 'https:' && !isLoopbackUrl(origin)) {
|
|
135
|
+
throw new Error(`AdminForthMcpPlugin: adminPanelOrigin must use https with OAuth sign-in, got "${origin.origin}"; `
|
|
136
|
+
+ 'http is allowed only for localhost and 127.0.0.1');
|
|
137
|
+
}
|
|
138
|
+
adminforth.config.customization.customPages.push({
|
|
139
|
+
path: CONSENT_PAGE_PATH,
|
|
140
|
+
component: {
|
|
141
|
+
file: this.componentPath('McpAuthorize.vue'),
|
|
142
|
+
meta: { sidebarAndHeader: 'none' },
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
146
|
+
if (isProduction && this.options.devOAuthClients?.length) {
|
|
147
|
+
logger.warn('AdminForthMcpPlugin: devOAuthClients are ignored because NODE_ENV is "production"');
|
|
148
|
+
}
|
|
149
|
+
return new McpOAuth(adminforth, this.authSecretStore, this.urls, isProduction ? [] : this.options.devOAuthClients, this.options.readOnly);
|
|
77
150
|
}
|
|
78
151
|
validateAuthSecretResource(resource) {
|
|
79
152
|
const fields = this.options.authSecretResource;
|
|
@@ -85,6 +158,8 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
85
158
|
fields.createdAtField,
|
|
86
159
|
fields.lastUsedAtField,
|
|
87
160
|
fields.lastUsedByAgentField,
|
|
161
|
+
fields.readOnlyField,
|
|
162
|
+
...(fields.oauthClientIdField ? [fields.oauthClientIdField] : []),
|
|
88
163
|
]) {
|
|
89
164
|
if (!resource.columns.some((column) => column.name === fieldName)) {
|
|
90
165
|
throw new Error(`AdminForthMcpPlugin: column "${fieldName}" not found in auth secret resource "${resource.resourceId}"`);
|
|
@@ -95,28 +170,56 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
95
170
|
}
|
|
96
171
|
}
|
|
97
172
|
setupEndpoints(server) {
|
|
173
|
+
server.endpoint({
|
|
174
|
+
method: 'GET',
|
|
175
|
+
path: '/get_resources_list',
|
|
176
|
+
description: 'Lists the resourceId and label of every resource (data table), including resources the user cannot access: access is checked when a resource is used. Call this first to discover valid resourceId values before using get_resource, get_resource_data, aggregate, create_record, update_record, delete_record or other resource tools.',
|
|
177
|
+
agent: {
|
|
178
|
+
onlyReadsData: true,
|
|
179
|
+
},
|
|
180
|
+
response_schema: RESOURCES_LIST_RESPONSE_SCHEMA,
|
|
181
|
+
handler: async ({ tr }) => {
|
|
182
|
+
const resources = await Promise.all(this.adminforth.config.resources.map(async (resource) => ({
|
|
183
|
+
resourceId: resource.resourceId,
|
|
184
|
+
label: await tr(resource.label, `resource.${resource.resourceId}`),
|
|
185
|
+
})));
|
|
186
|
+
return { resources };
|
|
187
|
+
},
|
|
188
|
+
});
|
|
98
189
|
server.endpoint({
|
|
99
190
|
method: 'GET',
|
|
100
191
|
path: `${MCP_PATH}/auth-secrets`,
|
|
192
|
+
agent: {
|
|
193
|
+
hiddenFromAgents: true,
|
|
194
|
+
},
|
|
101
195
|
handler: async ({ adminUser }) => ({
|
|
102
196
|
authSecrets: await this.authSecretStore.list(adminUser),
|
|
197
|
+
oauthEnabled: Boolean(this.oauth),
|
|
198
|
+
mcpUrl: this.urls?.mcpUrl ?? null,
|
|
199
|
+
serverReadOnly: this.options.readOnly ?? false,
|
|
103
200
|
}),
|
|
104
201
|
});
|
|
105
202
|
server.endpoint({
|
|
106
203
|
method: 'POST',
|
|
107
204
|
path: `${MCP_PATH}/auth-secrets`,
|
|
205
|
+
agent: {
|
|
206
|
+
hiddenFromAgents: true,
|
|
207
|
+
},
|
|
108
208
|
handler: async (input) => {
|
|
109
209
|
const name = String(input.body.name ?? '').trim();
|
|
110
210
|
if (!name) {
|
|
111
211
|
input.response.setStatus(400);
|
|
112
212
|
return { error: 'Auth secret name is required' };
|
|
113
213
|
}
|
|
114
|
-
return this.authSecretStore.create(name, input.adminUser, requestExtra(input));
|
|
214
|
+
return this.authSecretStore.create(name, this.options.readOnly || input.body.readOnly === true, input.adminUser, requestExtra(input));
|
|
115
215
|
},
|
|
116
216
|
});
|
|
117
217
|
server.endpoint({
|
|
118
218
|
method: 'DELETE',
|
|
119
219
|
path: `${MCP_PATH}/auth-secrets`,
|
|
220
|
+
agent: {
|
|
221
|
+
hiddenFromAgents: true,
|
|
222
|
+
},
|
|
120
223
|
handler: async (input) => {
|
|
121
224
|
const result = await this.authSecretStore.revoke(String(input.body.id), input.adminUser, requestExtra(input));
|
|
122
225
|
if (result.error)
|
|
@@ -124,12 +227,40 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
124
227
|
return result;
|
|
125
228
|
},
|
|
126
229
|
});
|
|
230
|
+
if (this.oauth) {
|
|
231
|
+
setupOAuthEndpoints(server, this.oauth, adminApiPrefix(this.adminforth.config.baseUrl));
|
|
232
|
+
}
|
|
127
233
|
server.endpoint({
|
|
128
234
|
method: 'POST',
|
|
129
235
|
path: MCP_PATH,
|
|
236
|
+
agent: {
|
|
237
|
+
hiddenFromAgents: true,
|
|
238
|
+
},
|
|
130
239
|
noAuth: true,
|
|
131
240
|
handler: async (input) => this.handleMcpRequest(input),
|
|
132
241
|
});
|
|
242
|
+
// Streamable HTTP clients open a GET stream for server-initiated messages; 405 tells them this server has none.
|
|
243
|
+
server.endpoint({
|
|
244
|
+
method: 'GET',
|
|
245
|
+
path: MCP_PATH,
|
|
246
|
+
agent: {
|
|
247
|
+
hiddenFromAgents: true,
|
|
248
|
+
},
|
|
249
|
+
noAuth: true,
|
|
250
|
+
handler: async ({ response }) => {
|
|
251
|
+
response.setHeader('Allow', 'POST');
|
|
252
|
+
response.setStatus(405, 'Method Not Allowed');
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** Accepts personal auth secrets and OAuth access tokens; access tokens are JWTs, secrets have their prefix. */
|
|
257
|
+
async authenticate(token) {
|
|
258
|
+
if (token.startsWith(SECRET_PREFIX))
|
|
259
|
+
return this.authSecretStore.authenticate(token);
|
|
260
|
+
if (!this.oauth)
|
|
261
|
+
return null;
|
|
262
|
+
const accessToken = await this.oauth.verifyAccessToken(token);
|
|
263
|
+
return accessToken && this.authSecretStore.authenticateOAuthGrant(accessToken.grantId, accessToken.pk);
|
|
133
264
|
}
|
|
134
265
|
async handleMcpRequest(input) {
|
|
135
266
|
if (input.headers.origin) {
|
|
@@ -140,12 +271,13 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
140
271
|
error: { code: -32003, message: 'Browser-originated MCP requests are not allowed.' },
|
|
141
272
|
};
|
|
142
273
|
}
|
|
143
|
-
const
|
|
144
|
-
const authenticated =
|
|
145
|
-
? await this.authSecretStore.authenticate(bearerMatch[1])
|
|
146
|
-
: null;
|
|
274
|
+
const token = String(input.headers.authorization ?? '').match(BEARER_TOKEN_RE)?.[1];
|
|
275
|
+
const authenticated = token ? await this.authenticate(token) : null;
|
|
147
276
|
if (!authenticated) {
|
|
148
|
-
|
|
277
|
+
// resource_metadata leads OAuth clients to the authorization server; invalid_token makes them refresh.
|
|
278
|
+
input.response.setHeader('WWW-Authenticate', this.oauth
|
|
279
|
+
? `Bearer ${token ? 'error="invalid_token", ' : ''}resource_metadata="${this.oauth.resourceMetadataUrl}"`
|
|
280
|
+
: 'Bearer');
|
|
149
281
|
input.response.setStatus(401);
|
|
150
282
|
return jsonRpcAuthError(input.body.id ?? null);
|
|
151
283
|
}
|
|
@@ -164,19 +296,25 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
164
296
|
...authenticated.adminUser,
|
|
165
297
|
executedBy: formatMcpExecutedBy(client, authenticated.name),
|
|
166
298
|
};
|
|
299
|
+
const readOnly = this.options.readOnly || authenticated.readOnly;
|
|
167
300
|
const protocolResponse = await handleMcpProtocol({
|
|
168
|
-
...this.
|
|
301
|
+
...createMcpServerPresentation(this.adminforth.config.customization.brandName, this.urls?.adminPanelUrl, this.skills.serverInstructions(), readOnly),
|
|
169
302
|
body: input.body,
|
|
170
303
|
headers: toolHeaders,
|
|
171
|
-
listTools: () => this.apiTools.list(),
|
|
172
|
-
callTool: (name, arguments_) =>
|
|
173
|
-
name
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
304
|
+
listTools: () => [...this.apiTools.list(readOnly), this.skills.toolDefinition()],
|
|
305
|
+
callTool: async (name, arguments_) => {
|
|
306
|
+
if (name === FETCH_SKILL_TOOL_NAME)
|
|
307
|
+
return this.skills.call(arguments_);
|
|
308
|
+
return this.apiTools.call({
|
|
309
|
+
name,
|
|
310
|
+
arguments: arguments_,
|
|
311
|
+
adminUser,
|
|
312
|
+
headers: toolHeaders,
|
|
313
|
+
requestUrl: input.requestUrl,
|
|
314
|
+
abortSignal: input.abortSignal,
|
|
315
|
+
readOnly,
|
|
316
|
+
});
|
|
317
|
+
},
|
|
180
318
|
});
|
|
181
319
|
input.response.setStatus(protocolResponse.status);
|
|
182
320
|
if (!protocolResponse.body) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Endpoint request schemas are written for OpenAPI docs. MCP clients keep tool schemas in the model context,
|
|
2
|
+
// so annotations that do not help to build a call and definitions the tool never references are dropped.
|
|
3
|
+
const DOCUMENTATION_ONLY_KEYWORDS = new Set(['title', 'examples']);
|
|
4
|
+
// Keywords whose value maps names to subschemas: their keys are names, not keywords.
|
|
5
|
+
const SUBSCHEMA_MAP_KEYWORDS = new Set(['properties', 'patternProperties', '$defs']);
|
|
6
|
+
// Keywords whose value is data, not a schema.
|
|
7
|
+
const DATA_KEYWORDS = new Set(['enum', 'const', 'default']);
|
|
8
|
+
const DEFS_REF_PREFIX = '#/$defs/';
|
|
9
|
+
function isObject(value) {
|
|
10
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function stripDocumentationKeywords(node) {
|
|
13
|
+
if (Array.isArray(node))
|
|
14
|
+
return node.map(stripDocumentationKeywords);
|
|
15
|
+
if (!isObject(node))
|
|
16
|
+
return node;
|
|
17
|
+
return Object.fromEntries(Object.entries(node)
|
|
18
|
+
.filter(([keyword]) => !DOCUMENTATION_ONLY_KEYWORDS.has(keyword))
|
|
19
|
+
.map(([keyword, value]) => {
|
|
20
|
+
if (DATA_KEYWORDS.has(keyword))
|
|
21
|
+
return [keyword, value];
|
|
22
|
+
if (SUBSCHEMA_MAP_KEYWORDS.has(keyword) && isObject(value)) {
|
|
23
|
+
return [keyword, Object.fromEntries(Object.entries(value).map(([name, subschema]) => [name, stripDocumentationKeywords(subschema)]))];
|
|
24
|
+
}
|
|
25
|
+
return [keyword, stripDocumentationKeywords(value)];
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
function collectDefRefs(node, refs) {
|
|
29
|
+
if (Array.isArray(node)) {
|
|
30
|
+
node.forEach((item) => collectDefRefs(item, refs));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(node))
|
|
34
|
+
return;
|
|
35
|
+
for (const [keyword, value] of Object.entries(node)) {
|
|
36
|
+
if (keyword === '$ref' && typeof value === 'string' && value.startsWith(DEFS_REF_PREFIX)) {
|
|
37
|
+
refs.add(value.slice(DEFS_REF_PREFIX.length));
|
|
38
|
+
}
|
|
39
|
+
else if (!DATA_KEYWORDS.has(keyword)) {
|
|
40
|
+
collectDefRefs(value, refs);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Shared $defs are attached to every endpoint schema, even to endpoints that use only some of them.
|
|
45
|
+
function pruneUnusedDefs(schema) {
|
|
46
|
+
const { $defs, ...rest } = schema;
|
|
47
|
+
if (!isObject($defs))
|
|
48
|
+
return schema;
|
|
49
|
+
const used = new Set();
|
|
50
|
+
const pending = new Set();
|
|
51
|
+
collectDefRefs(rest, pending);
|
|
52
|
+
for (const name of pending) {
|
|
53
|
+
if (used.has(name))
|
|
54
|
+
continue;
|
|
55
|
+
used.add(name);
|
|
56
|
+
collectDefRefs($defs[name], pending);
|
|
57
|
+
}
|
|
58
|
+
const usedDefs = Object.fromEntries(Object.entries($defs).filter(([name]) => used.has(name)));
|
|
59
|
+
return Object.keys(usedDefs).length > 0 ? { ...rest, $defs: usedDefs } : rest;
|
|
60
|
+
}
|
|
61
|
+
export function compactInputSchema(schema) {
|
|
62
|
+
return pruneUnusedDefs(stripDocumentationKeywords(schema));
|
|
63
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs tasks with the same key one at a time, within this process. A key is kept only while a task with it runs
|
|
3
|
+
* or waits, so keys taken by many different callers do not pile up in memory.
|
|
4
|
+
*/
|
|
5
|
+
export declare class KeyedLock {
|
|
6
|
+
private readonly tails;
|
|
7
|
+
run<T>(key: string, task: () => Promise<T>): Promise<T>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs tasks with the same key one at a time, within this process. A key is kept only while a task with it runs
|
|
3
|
+
* or waits, so keys taken by many different callers do not pile up in memory.
|
|
4
|
+
*/
|
|
5
|
+
export class KeyedLock {
|
|
6
|
+
tails = new Map();
|
|
7
|
+
async run(key, task) {
|
|
8
|
+
const result = (this.tails.get(key) ?? Promise.resolve()).then(task);
|
|
9
|
+
// The next task with this key waits for this one even when it fails.
|
|
10
|
+
const tail = result.catch(() => { });
|
|
11
|
+
this.tails.set(key, tail);
|
|
12
|
+
try {
|
|
13
|
+
return await result;
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
if (this.tails.get(key) === tail)
|
|
17
|
+
this.tails.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/mcpProtocol.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface McpServerPresentation {
|
|
|
12
12
|
serverInfo: McpServerInfo;
|
|
13
13
|
instructions: string;
|
|
14
14
|
}
|
|
15
|
-
export declare function createMcpServerPresentation(brandName: string,
|
|
15
|
+
export declare function createMcpServerPresentation(brandName: string, adminPanelUrl?: string, guidance?: string, readOnly?: boolean): McpServerPresentation;
|
|
16
16
|
export interface McpProtocolContext {
|
|
17
17
|
body: Record<string, any>;
|
|
18
18
|
headers: Record<string, any>;
|
package/dist/mcpProtocol.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
1
2
|
export const MODERN_PROTOCOL_VERSION = '2026-07-28';
|
|
2
3
|
export const LEGACY_PROTOCOL_VERSION = '2025-11-25';
|
|
3
4
|
const LEGACY_PROTOCOL_VERSIONS = new Set([
|
|
@@ -8,10 +9,7 @@ const LEGACY_PROTOCOL_VERSIONS = new Set([
|
|
|
8
9
|
const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';
|
|
9
10
|
const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities';
|
|
10
11
|
const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo';
|
|
11
|
-
export function createMcpServerPresentation(brandName,
|
|
12
|
-
const adminPanelUrl = adminPanelOrigin
|
|
13
|
-
? new URL(baseUrl || '/', adminPanelOrigin).toString()
|
|
14
|
-
: undefined;
|
|
12
|
+
export function createMcpServerPresentation(brandName, adminPanelUrl, guidance = '', readOnly = false) {
|
|
15
13
|
const adminPanel = `AdminForth admin panel for "${brandName}"${adminPanelUrl ? ` at ${adminPanelUrl}` : ''}`;
|
|
16
14
|
return {
|
|
17
15
|
serverInfo: {
|
|
@@ -21,7 +19,10 @@ export function createMcpServerPresentation(brandName, adminPanelOrigin, baseUrl
|
|
|
21
19
|
description: `${adminPanel}.`,
|
|
22
20
|
...(adminPanelUrl && { websiteUrl: adminPanelUrl }),
|
|
23
21
|
},
|
|
24
|
-
instructions:
|
|
22
|
+
instructions: [
|
|
23
|
+
`This is the ${adminPanel}. Use its tools to ${readOnly ? 'read' : 'read and update'} data allowed for the authenticated user.`,
|
|
24
|
+
guidance,
|
|
25
|
+
].filter(Boolean).join('\n\n'),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
function errorResponse(id, code, message, status = 400) {
|
|
@@ -67,8 +68,9 @@ function validateModernHeaders(body, headers) {
|
|
|
67
68
|
}
|
|
68
69
|
return null;
|
|
69
70
|
}
|
|
71
|
+
// The JSON round trip drops functions and undefined values that handler responses may carry, which YAML cannot serialize.
|
|
70
72
|
function serializeToolOutput(output) {
|
|
71
|
-
return typeof output === 'string' ? output : JSON.stringify(output
|
|
73
|
+
return typeof output === 'string' ? output : YAML.stringify(JSON.parse(JSON.stringify(output ?? null)));
|
|
72
74
|
}
|
|
73
75
|
export async function handleMcpProtocol(context) {
|
|
74
76
|
const { body, headers, serverInfo, instructions } = context;
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AdminUser, IAdminForth } from 'adminforth';
|
|
2
|
+
import type { McpAuthSecretStore } from './authSecretStore.js';
|
|
3
|
+
import type { McpOAuthClient } from './types.js';
|
|
4
|
+
import { type McpUrls } from './urls.js';
|
|
5
|
+
/** Endpoint paths relative to the AdminForth API prefix (`<baseUrl>/adminapi/v1`). */
|
|
6
|
+
export declare const OAUTH_PATHS: {
|
|
7
|
+
protectedResourceMetadata: string;
|
|
8
|
+
authorizationServerMetadata: string;
|
|
9
|
+
jwks: string;
|
|
10
|
+
authorize: string;
|
|
11
|
+
token: string;
|
|
12
|
+
authorization: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const CONSENT_PAGE_PATH = "/mcp-authorize";
|
|
15
|
+
interface AccessToken {
|
|
16
|
+
pk: string;
|
|
17
|
+
grantId: string;
|
|
18
|
+
aud: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* OAuth 2.1 authorization server for the MCP endpoint: authorization code flow with PKCE for public clients
|
|
22
|
+
* identified by Client ID Metadata Documents. Authorization requests and codes are signed JWTs, so only the
|
|
23
|
+
* resulting grants are stored, as records of the auth secret resource.
|
|
24
|
+
*/
|
|
25
|
+
export declare class McpOAuth {
|
|
26
|
+
private readonly adminforth;
|
|
27
|
+
private readonly store;
|
|
28
|
+
private readonly serverReadOnly;
|
|
29
|
+
readonly mcpUrl: string;
|
|
30
|
+
readonly resourceMetadataUrl: string;
|
|
31
|
+
private readonly issuer;
|
|
32
|
+
private readonly apiUrl;
|
|
33
|
+
private readonly consentPageUrl;
|
|
34
|
+
private readonly configuredClients;
|
|
35
|
+
private readonly fetchedClients;
|
|
36
|
+
/**
|
|
37
|
+
* `configuredClients` are trusted as is, without fetching their client metadata documents. `serverReadOnly` is
|
|
38
|
+
* the plugin readOnly option: it makes every connection read-only, whatever the user chooses on the consent page.
|
|
39
|
+
*/
|
|
40
|
+
constructor(adminforth: IAdminForth, store: McpAuthSecretStore, urls: McpUrls, configuredClients?: McpOAuthClient[], serverReadOnly?: boolean);
|
|
41
|
+
protectedResourceMetadata(): {
|
|
42
|
+
resource: string;
|
|
43
|
+
authorization_servers: string[];
|
|
44
|
+
bearer_methods_supported: string[];
|
|
45
|
+
};
|
|
46
|
+
authorizationServerMetadata(): {
|
|
47
|
+
issuer: string;
|
|
48
|
+
authorization_endpoint: string;
|
|
49
|
+
token_endpoint: string;
|
|
50
|
+
response_types_supported: string[];
|
|
51
|
+
grant_types_supported: string[];
|
|
52
|
+
code_challenge_methods_supported: string[];
|
|
53
|
+
token_endpoint_auth_methods_supported: string[];
|
|
54
|
+
client_id_metadata_document_supported: boolean;
|
|
55
|
+
authorization_response_iss_parameter_supported: boolean;
|
|
56
|
+
jwks_uri: string;
|
|
57
|
+
subject_types_supported: string[];
|
|
58
|
+
id_token_signing_alg_values_supported: string[];
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Validates the authorization request and returns the consent page URL to redirect the browser to.
|
|
62
|
+
* This endpoint needs no sign-in, so the client metadata document is fetched only once the user opens the
|
|
63
|
+
* consent page: otherwise anyone could make this server send requests to any URL passed as client_id.
|
|
64
|
+
*/
|
|
65
|
+
authorize(query: Record<string, unknown>): Promise<string>;
|
|
66
|
+
/** What the consent page shows about a pending authorization request. */
|
|
67
|
+
describeAuthorization(signedRequest: string): Promise<{
|
|
68
|
+
clientName: string;
|
|
69
|
+
clientHost: string | null;
|
|
70
|
+
redirectHost: string;
|
|
71
|
+
loopbackRedirect: boolean;
|
|
72
|
+
serverReadOnly: boolean;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Approves or denies a pending authorization request and returns where to send the browser. `readOnly` is the
|
|
76
|
+
* choice of the user on the consent page; the connection is read-only with it or with the plugin readOnly option.
|
|
77
|
+
*/
|
|
78
|
+
resolveAuthorization(signedRequest: string, approved: boolean, adminUser: AdminUser, readOnly?: boolean): Promise<string>;
|
|
79
|
+
exchangeToken(body: Record<string, unknown>): Promise<{
|
|
80
|
+
access_token: string;
|
|
81
|
+
token_type: string;
|
|
82
|
+
expires_in: number;
|
|
83
|
+
refresh_token: string;
|
|
84
|
+
}>;
|
|
85
|
+
/** Returns the grant an OAuth access token was issued for, or null for an invalid, expired or foreign token. */
|
|
86
|
+
verifyAccessToken(token: string): Promise<AccessToken | null>;
|
|
87
|
+
private checkResource;
|
|
88
|
+
private verifyAuthorizationRequest;
|
|
89
|
+
/** The consent page loads the client to describe it and again to resolve the request, hence the cache. */
|
|
90
|
+
private lookUpClient;
|
|
91
|
+
private redeemAuthorizationCode;
|
|
92
|
+
private refreshAccessToken;
|
|
93
|
+
private issueRefreshToken;
|
|
94
|
+
private tokenResponse;
|
|
95
|
+
}
|
|
96
|
+
export {};
|