@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { resourcesListResponseSchema } from '../schemas/tools.js';
|
|
2
|
+
export function registerToolEndpoints(server, adminforth) {
|
|
3
|
+
server.endpoint({
|
|
4
|
+
method: 'GET',
|
|
5
|
+
path: '/get_resources_list',
|
|
6
|
+
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.',
|
|
7
|
+
agent: {
|
|
8
|
+
onlyReadsData: true,
|
|
9
|
+
},
|
|
10
|
+
response_schema: resourcesListResponseSchema,
|
|
11
|
+
handler: async ({ tr }) => {
|
|
12
|
+
const resources = await Promise.all(adminforth.config.resources.map(async (resource) => ({
|
|
13
|
+
resourceId: resource.resourceId,
|
|
14
|
+
label: await tr(resource.label, `resource.${resource.resourceId}`),
|
|
15
|
+
})));
|
|
16
|
+
return { resources };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,16 +4,14 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
4
4
|
options: PluginOptions;
|
|
5
5
|
pluginsScope: 'global';
|
|
6
6
|
private authSecretStore;
|
|
7
|
-
/** Null when OAuth sign-in is not configured; then only auth secrets authenticate. */
|
|
8
7
|
private oauth;
|
|
9
8
|
private apiTools;
|
|
10
9
|
private skills;
|
|
11
|
-
/** Null without adminPanelOrigin; then the settings page shows the MCP URL of the address it was opened at. */
|
|
12
10
|
private urls;
|
|
11
|
+
private readonly protocol;
|
|
13
12
|
constructor(options: PluginOptions);
|
|
14
13
|
instanceUniqueRepresentation(): string;
|
|
15
14
|
modifyGlobalConfig(adminforth: IAdminForth): void;
|
|
16
|
-
private createOAuth;
|
|
17
15
|
private validateAuthSecretResource;
|
|
18
16
|
setupEndpoints(server: IHttpServer): void;
|
|
19
17
|
/** Accepts personal auth secrets and OAuth access tokens; access tokens are JWTs, secrets have their prefix. */
|
|
@@ -22,4 +20,3 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
22
20
|
}
|
|
23
21
|
export type { McpAuthSecretResourceOptions, McpOAuthClient, PluginOptions } from './types.js';
|
|
24
22
|
export { canonicalAgentName, formatMcpExecutedBy, readMcpClient } from './clientInfo.js';
|
|
25
|
-
export { handleMcpProtocol } from './mcpProtocol.js';
|
package/dist/index.js
CHANGED
|
@@ -1,53 +1,23 @@
|
|
|
1
|
-
import { AdminForthPlugin,
|
|
1
|
+
import { AdminForthPlugin, } from 'adminforth';
|
|
2
2
|
import { AdminForthApiTools } from './apiTools.js';
|
|
3
3
|
import { formatMcpExecutedBy, readMcpClient, UNKNOWN_CLIENT } from './clientInfo.js';
|
|
4
|
-
import { createMcpServerPresentation,
|
|
4
|
+
import { createMcpServerPresentation, McpProtocol } from './mcpProtocol.js';
|
|
5
5
|
import { McpAuthSecretStore, SECRET_PREFIX } from './authSecretStore.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { registerMcpEndpoints } from './endpoints/mcp.js';
|
|
7
|
+
import { registerOAuthEndpoints } from './endpoints/oAuth.js';
|
|
8
|
+
import { registerSettingsEndpoints } from './endpoints/settings.js';
|
|
9
|
+
import { registerToolEndpoints } from './endpoints/tools.js';
|
|
10
|
+
import { AuthSecretRepository } from './repositories/authSecret.js';
|
|
11
|
+
import { setupOAuth } from './oauth/setup.js';
|
|
12
|
+
import { requestExtra } from './requestExtra.js';
|
|
9
13
|
import { FETCH_SKILL_TOOL_NAME, McpSkills } from './skills.js';
|
|
10
|
-
import { adminApiPrefix, createMcpUrls
|
|
14
|
+
import { adminApiPrefix, createMcpUrls } from './urls.js';
|
|
11
15
|
const BEARER_TOKEN_RE = /^Bearer (\S+)$/i;
|
|
12
16
|
const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']);
|
|
13
17
|
const DEFAULT_PAGE_SIZE = 10;
|
|
14
18
|
const MAX_PAGE_SIZE = 100;
|
|
15
19
|
const DEFAULT_TOOL_TIMEOUT_MS = 15_000;
|
|
16
20
|
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
|
-
};
|
|
41
|
-
function requestExtra(input) {
|
|
42
|
-
return {
|
|
43
|
-
body: input.body,
|
|
44
|
-
query: input.query,
|
|
45
|
-
headers: input.headers,
|
|
46
|
-
cookies: input.cookies,
|
|
47
|
-
requestUrl: input.requestUrl,
|
|
48
|
-
response: input.response,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
21
|
/**
|
|
52
22
|
* The MCP auth secret is a long-lived credential, so it is dropped as soon as it is verified and
|
|
53
23
|
* never reaches tool handlers, which may log, persist or forward the headers they are given.
|
|
@@ -66,12 +36,11 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
66
36
|
options;
|
|
67
37
|
pluginsScope = 'global';
|
|
68
38
|
authSecretStore;
|
|
69
|
-
/** Null when OAuth sign-in is not configured; then only auth secrets authenticate. */
|
|
70
39
|
oauth;
|
|
71
40
|
apiTools;
|
|
72
41
|
skills;
|
|
73
|
-
/** Null without adminPanelOrigin; then the settings page shows the MCP URL of the address it was opened at. */
|
|
74
42
|
urls;
|
|
43
|
+
protocol = new McpProtocol();
|
|
75
44
|
constructor(options) {
|
|
76
45
|
super(options, import.meta.url);
|
|
77
46
|
this.options = options;
|
|
@@ -107,11 +76,9 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
107
76
|
isVisible: () => true,
|
|
108
77
|
});
|
|
109
78
|
}
|
|
110
|
-
this.urls = this.options.adminPanelOrigin
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.authSecretStore = new McpAuthSecretStore(adminforth, this.options.authSecretResource);
|
|
114
|
-
this.oauth = this.options.authSecretResource.oauthClientIdField ? this.createOAuth(adminforth) : null;
|
|
79
|
+
this.urls = createMcpUrls(this.options.adminPanelOrigin, adminforth.config.baseUrl);
|
|
80
|
+
this.authSecretStore = new McpAuthSecretStore(adminforth, new AuthSecretRepository(adminforth, this.options.authSecretResource));
|
|
81
|
+
this.oauth = setupOAuth(adminforth, this.authSecretStore, this.urls, this.options, this.componentPath('McpAuthorize.vue'));
|
|
115
82
|
const pageSize = {
|
|
116
83
|
default: this.options.pageSize?.default ?? DEFAULT_PAGE_SIZE,
|
|
117
84
|
max: this.options.pageSize?.max ?? MAX_PAGE_SIZE,
|
|
@@ -123,31 +90,6 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
123
90
|
toolCallsPerRequest: this.options.toolCallsPerRequest ?? DEFAULT_TOOL_CALLS_PER_REQUEST,
|
|
124
91
|
});
|
|
125
92
|
}
|
|
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);
|
|
150
|
-
}
|
|
151
93
|
validateAuthSecretResource(resource) {
|
|
152
94
|
const fields = this.options.authSecretResource;
|
|
153
95
|
for (const fieldName of [
|
|
@@ -159,7 +101,7 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
159
101
|
fields.lastUsedAtField,
|
|
160
102
|
fields.lastUsedByAgentField,
|
|
161
103
|
fields.readOnlyField,
|
|
162
|
-
|
|
104
|
+
fields.oauthClientIdField,
|
|
163
105
|
]) {
|
|
164
106
|
if (!resource.columns.some((column) => column.name === fieldName)) {
|
|
165
107
|
throw new Error(`AdminForthMcpPlugin: column "${fieldName}" not found in auth secret resource "${resource.resourceId}"`);
|
|
@@ -170,95 +112,15 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
170
112
|
}
|
|
171
113
|
}
|
|
172
114
|
setupEndpoints(server) {
|
|
173
|
-
server.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
});
|
|
189
|
-
server.endpoint({
|
|
190
|
-
method: 'GET',
|
|
191
|
-
path: `${MCP_PATH}/auth-secrets`,
|
|
192
|
-
agent: {
|
|
193
|
-
hiddenFromAgents: true,
|
|
194
|
-
},
|
|
195
|
-
handler: async ({ adminUser }) => ({
|
|
196
|
-
authSecrets: await this.authSecretStore.list(adminUser),
|
|
197
|
-
oauthEnabled: Boolean(this.oauth),
|
|
198
|
-
mcpUrl: this.urls?.mcpUrl ?? null,
|
|
199
|
-
serverReadOnly: this.options.readOnly ?? false,
|
|
200
|
-
}),
|
|
201
|
-
});
|
|
202
|
-
server.endpoint({
|
|
203
|
-
method: 'POST',
|
|
204
|
-
path: `${MCP_PATH}/auth-secrets`,
|
|
205
|
-
agent: {
|
|
206
|
-
hiddenFromAgents: true,
|
|
207
|
-
},
|
|
208
|
-
handler: async (input) => {
|
|
209
|
-
const name = String(input.body.name ?? '').trim();
|
|
210
|
-
if (!name) {
|
|
211
|
-
input.response.setStatus(400);
|
|
212
|
-
return { error: 'Auth secret name is required' };
|
|
213
|
-
}
|
|
214
|
-
return this.authSecretStore.create(name, this.options.readOnly || input.body.readOnly === true, input.adminUser, requestExtra(input));
|
|
215
|
-
},
|
|
216
|
-
});
|
|
217
|
-
server.endpoint({
|
|
218
|
-
method: 'DELETE',
|
|
219
|
-
path: `${MCP_PATH}/auth-secrets`,
|
|
220
|
-
agent: {
|
|
221
|
-
hiddenFromAgents: true,
|
|
222
|
-
},
|
|
223
|
-
handler: async (input) => {
|
|
224
|
-
const result = await this.authSecretStore.revoke(String(input.body.id), input.adminUser, requestExtra(input));
|
|
225
|
-
if (result.error)
|
|
226
|
-
input.response.setStatus(404);
|
|
227
|
-
return result;
|
|
228
|
-
},
|
|
229
|
-
});
|
|
230
|
-
if (this.oauth) {
|
|
231
|
-
setupOAuthEndpoints(server, this.oauth, adminApiPrefix(this.adminforth.config.baseUrl));
|
|
232
|
-
}
|
|
233
|
-
server.endpoint({
|
|
234
|
-
method: 'POST',
|
|
235
|
-
path: MCP_PATH,
|
|
236
|
-
agent: {
|
|
237
|
-
hiddenFromAgents: true,
|
|
238
|
-
},
|
|
239
|
-
noAuth: true,
|
|
240
|
-
handler: async (input) => this.handleMcpRequest(input),
|
|
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
|
-
});
|
|
115
|
+
registerToolEndpoints(server, this.adminforth);
|
|
116
|
+
registerSettingsEndpoints(server, this.authSecretStore, this.urls.mcpUrl, this.options.readOnly ?? false);
|
|
117
|
+
registerOAuthEndpoints(server, this.oauth, adminApiPrefix(this.adminforth.config.baseUrl));
|
|
118
|
+
registerMcpEndpoints(server, (input) => this.handleMcpRequest(input));
|
|
255
119
|
}
|
|
256
120
|
/** Accepts personal auth secrets and OAuth access tokens; access tokens are JWTs, secrets have their prefix. */
|
|
257
121
|
async authenticate(token) {
|
|
258
122
|
if (token.startsWith(SECRET_PREFIX))
|
|
259
123
|
return this.authSecretStore.authenticate(token);
|
|
260
|
-
if (!this.oauth)
|
|
261
|
-
return null;
|
|
262
124
|
const accessToken = await this.oauth.verifyAccessToken(token);
|
|
263
125
|
return accessToken && this.authSecretStore.authenticateOAuthGrant(accessToken.grantId, accessToken.pk);
|
|
264
126
|
}
|
|
@@ -271,13 +133,11 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
271
133
|
error: { code: -32003, message: 'Browser-originated MCP requests are not allowed.' },
|
|
272
134
|
};
|
|
273
135
|
}
|
|
274
|
-
const token =
|
|
136
|
+
const token = input.headers.authorization?.match(BEARER_TOKEN_RE)?.[1];
|
|
275
137
|
const authenticated = token ? await this.authenticate(token) : null;
|
|
276
138
|
if (!authenticated) {
|
|
277
139
|
// 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');
|
|
140
|
+
input.response.setHeader('WWW-Authenticate', `Bearer ${token ? 'error="invalid_token", ' : ''}resource_metadata="${this.oauth.resourceMetadataUrl}"`);
|
|
281
141
|
input.response.setStatus(401);
|
|
282
142
|
return jsonRpcAuthError(input.body.id ?? null);
|
|
283
143
|
}
|
|
@@ -297,10 +157,8 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
297
157
|
executedBy: formatMcpExecutedBy(client, authenticated.name),
|
|
298
158
|
};
|
|
299
159
|
const readOnly = this.options.readOnly || authenticated.readOnly;
|
|
300
|
-
const protocolResponse = await
|
|
301
|
-
...createMcpServerPresentation(this.adminforth.config.customization.brandName, this.urls
|
|
302
|
-
body: input.body,
|
|
303
|
-
headers: toolHeaders,
|
|
160
|
+
const protocolResponse = await this.protocol.handle(new Request(this.urls.mcpUrl, { method: 'POST', headers: toolHeaders }), input.body, {
|
|
161
|
+
...createMcpServerPresentation(this.adminforth.config.customization.brandName, this.urls.adminPanelUrl, this.skills.serverInstructions(), readOnly),
|
|
304
162
|
listTools: () => [...this.apiTools.list(readOnly), this.skills.toolDefinition()],
|
|
305
163
|
callTool: async (name, arguments_) => {
|
|
306
164
|
if (name === FETCH_SKILL_TOOL_NAME)
|
|
@@ -316,13 +174,8 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
316
174
|
});
|
|
317
175
|
},
|
|
318
176
|
});
|
|
319
|
-
input.response.
|
|
320
|
-
|
|
321
|
-
input._raw_express_res.status(protocolResponse.status).end();
|
|
322
|
-
return null;
|
|
323
|
-
}
|
|
324
|
-
return protocolResponse.body;
|
|
177
|
+
protocolResponse.headers.forEach((value, name) => input.response.setHeader(name, value));
|
|
178
|
+
input.response.setStatus(protocolResponse.status, await protocolResponse.text());
|
|
325
179
|
}
|
|
326
180
|
}
|
|
327
181
|
export { canonicalAgentName, formatMcpExecutedBy, readMcpClient } from './clientInfo.js';
|
|
328
|
-
export { handleMcpProtocol } from './mcpProtocol.js';
|
package/dist/mcpProtocol.d.ts
CHANGED
|
@@ -1,31 +1,18 @@
|
|
|
1
|
-
import type
|
|
2
|
-
export declare const MODERN_PROTOCOL_VERSION = "2026-07-28";
|
|
3
|
-
export declare const LEGACY_PROTOCOL_VERSION = "2025-11-25";
|
|
4
|
-
export interface McpServerInfo {
|
|
5
|
-
name: string;
|
|
6
|
-
title: string;
|
|
7
|
-
version: string;
|
|
8
|
-
description: string;
|
|
9
|
-
websiteUrl?: string;
|
|
10
|
-
}
|
|
1
|
+
import { type Implementation, type Tool } from '@modelcontextprotocol/server';
|
|
11
2
|
export interface McpServerPresentation {
|
|
12
|
-
serverInfo:
|
|
3
|
+
serverInfo: Implementation;
|
|
13
4
|
instructions: string;
|
|
14
5
|
}
|
|
15
|
-
export declare function createMcpServerPresentation(brandName: string, adminPanelUrl
|
|
16
|
-
export interface McpProtocolContext {
|
|
17
|
-
|
|
18
|
-
headers: Record<string, any>;
|
|
19
|
-
serverInfo: McpServerInfo;
|
|
20
|
-
instructions: string;
|
|
21
|
-
listTools: () => McpToolDefinition[];
|
|
6
|
+
export declare function createMcpServerPresentation(brandName: string, adminPanelUrl: string, guidance?: string, readOnly?: boolean): McpServerPresentation;
|
|
7
|
+
export interface McpProtocolContext extends McpServerPresentation {
|
|
8
|
+
listTools: () => Tool[];
|
|
22
9
|
callTool: (name: string, arguments_: Record<string, unknown> | undefined) => Promise<{
|
|
23
10
|
output: unknown;
|
|
24
11
|
isError: boolean;
|
|
25
12
|
}>;
|
|
26
13
|
}
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
export declare class McpProtocol {
|
|
15
|
+
private readonly contexts;
|
|
16
|
+
private readonly handler;
|
|
17
|
+
handle(request: Request, parsedBody: unknown, context: McpProtocolContext): Promise<Response>;
|
|
30
18
|
}
|
|
31
|
-
export declare function handleMcpProtocol(context: McpProtocolContext): Promise<McpProtocolResponse>;
|
package/dist/mcpProtocol.js
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
+
import { createMcpHandler, Server } from '@modelcontextprotocol/server';
|
|
1
2
|
import YAML from 'yaml';
|
|
2
|
-
|
|
3
|
-
export const LEGACY_PROTOCOL_VERSION = '2025-11-25';
|
|
4
|
-
const LEGACY_PROTOCOL_VERSIONS = new Set([
|
|
5
|
-
LEGACY_PROTOCOL_VERSION,
|
|
6
|
-
'2025-06-18',
|
|
7
|
-
'2025-03-26',
|
|
8
|
-
]);
|
|
9
|
-
const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';
|
|
10
|
-
const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities';
|
|
11
|
-
const SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo';
|
|
3
|
+
const UNCACHED = { ttlMs: 0, cacheScope: 'private' };
|
|
12
4
|
export function createMcpServerPresentation(brandName, adminPanelUrl, guidance = '', readOnly = false) {
|
|
13
|
-
const adminPanel = `AdminForth admin panel for "${brandName}"
|
|
5
|
+
const adminPanel = `AdminForth admin panel for "${brandName}" at ${adminPanelUrl}`;
|
|
14
6
|
return {
|
|
15
7
|
serverInfo: {
|
|
16
8
|
name: 'adminforth-mcp',
|
|
17
9
|
title: `${brandName} Admin Panel`,
|
|
18
10
|
version: '1.0.0',
|
|
19
11
|
description: `${adminPanel}.`,
|
|
20
|
-
|
|
12
|
+
websiteUrl: adminPanelUrl,
|
|
21
13
|
},
|
|
22
14
|
instructions: [
|
|
23
15
|
`This is the ${adminPanel}. Use its tools to ${readOnly ? 'read' : 'read and update'} data allowed for the authenticated user.`,
|
|
@@ -25,115 +17,28 @@ export function createMcpServerPresentation(brandName, adminPanelUrl, guidance =
|
|
|
25
17
|
].filter(Boolean).join('\n\n'),
|
|
26
18
|
};
|
|
27
19
|
}
|
|
28
|
-
function errorResponse(id, code, message, status = 400) {
|
|
29
|
-
return {
|
|
30
|
-
status,
|
|
31
|
-
body: { jsonrpc: '2.0', id, error: { code, message } },
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
function isModernRequest(body) {
|
|
35
|
-
return body.params?._meta?.[PROTOCOL_VERSION_META_KEY] === MODERN_PROTOCOL_VERSION;
|
|
36
|
-
}
|
|
37
|
-
function completeResult(result, modern, serverInfo) {
|
|
38
|
-
if (!modern)
|
|
39
|
-
return result;
|
|
40
|
-
return {
|
|
41
|
-
resultType: 'complete',
|
|
42
|
-
...result,
|
|
43
|
-
_meta: {
|
|
44
|
-
...result._meta,
|
|
45
|
-
[SERVER_INFO_META_KEY]: serverInfo,
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
function successResponse(id, result, modern, serverInfo) {
|
|
50
|
-
return {
|
|
51
|
-
status: 200,
|
|
52
|
-
body: {
|
|
53
|
-
jsonrpc: '2.0',
|
|
54
|
-
id,
|
|
55
|
-
result: completeResult(result, modern, serverInfo),
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
function validateModernHeaders(body, headers) {
|
|
60
|
-
const meta = body.params?._meta;
|
|
61
|
-
if (!meta || !(CLIENT_CAPABILITIES_META_KEY in meta)) {
|
|
62
|
-
return errorResponse(body.id ?? null, -32602, 'Missing required MCP request metadata.');
|
|
63
|
-
}
|
|
64
|
-
if (headers['mcp-protocol-version'] !== MODERN_PROTOCOL_VERSION
|
|
65
|
-
|| headers['mcp-method'] !== body.method
|
|
66
|
-
|| (body.method === 'tools/call' && headers['mcp-name'] !== body.params?.name)) {
|
|
67
|
-
return errorResponse(body.id ?? null, -32020, 'MCP request headers do not match the JSON-RPC body.');
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
20
|
// The JSON round trip drops functions and undefined values that handler responses may carry, which YAML cannot serialize.
|
|
72
21
|
function serializeToolOutput(output) {
|
|
73
22
|
return typeof output === 'string' ? output : YAML.stringify(JSON.parse(JSON.stringify(output ?? null)));
|
|
74
23
|
}
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return errorResponse(null, -32600, 'JSON-RPC id must be a string or number.');
|
|
95
|
-
}
|
|
96
|
-
switch (body.method) {
|
|
97
|
-
case 'server/discover':
|
|
98
|
-
if (!modern) {
|
|
99
|
-
return errorResponse(body.id, -32602, 'server/discover requires modern MCP request metadata.');
|
|
100
|
-
}
|
|
101
|
-
return successResponse(body.id, {
|
|
102
|
-
supportedVersions: [MODERN_PROTOCOL_VERSION],
|
|
103
|
-
capabilities: { tools: {} },
|
|
104
|
-
instructions,
|
|
105
|
-
ttlMs: 0,
|
|
106
|
-
cacheScope: 'private',
|
|
107
|
-
}, true, serverInfo);
|
|
108
|
-
case 'initialize': {
|
|
109
|
-
const requestedVersion = body.params?.protocolVersion;
|
|
110
|
-
return successResponse(body.id, {
|
|
111
|
-
protocolVersion: typeof requestedVersion === 'string' && LEGACY_PROTOCOL_VERSIONS.has(requestedVersion)
|
|
112
|
-
? requestedVersion
|
|
113
|
-
: LEGACY_PROTOCOL_VERSION,
|
|
114
|
-
capabilities: { tools: { listChanged: false } },
|
|
115
|
-
serverInfo,
|
|
116
|
-
instructions,
|
|
117
|
-
}, false, serverInfo);
|
|
118
|
-
}
|
|
119
|
-
case 'ping':
|
|
120
|
-
return successResponse(body.id, {}, modern, serverInfo);
|
|
121
|
-
case 'tools/list':
|
|
122
|
-
return successResponse(body.id, {
|
|
123
|
-
tools: context.listTools(),
|
|
124
|
-
...(modern && { ttlMs: 0, cacheScope: 'private' }),
|
|
125
|
-
}, modern, serverInfo);
|
|
126
|
-
case 'tools/call': {
|
|
127
|
-
if (typeof body.params?.name !== 'string') {
|
|
128
|
-
return errorResponse(body.id, -32602, 'Tool name is required.');
|
|
129
|
-
}
|
|
130
|
-
const result = await context.callTool(body.params.name, body.params.arguments);
|
|
131
|
-
return successResponse(body.id, {
|
|
132
|
-
content: [{ type: 'text', text: serializeToolOutput(result.output) }],
|
|
133
|
-
isError: result.isError,
|
|
134
|
-
}, modern, serverInfo);
|
|
135
|
-
}
|
|
136
|
-
default:
|
|
137
|
-
return errorResponse(body.id, -32601, `Method not found: ${body.method}`);
|
|
24
|
+
function createServer({ serverInfo, instructions, listTools, callTool }) {
|
|
25
|
+
const server = new Server(serverInfo, {
|
|
26
|
+
capabilities: { tools: { listChanged: false } },
|
|
27
|
+
instructions,
|
|
28
|
+
cacheHints: { 'tools/list': UNCACHED, 'server/discover': UNCACHED },
|
|
29
|
+
});
|
|
30
|
+
server.setRequestHandler('tools/list', () => ({ tools: listTools() }));
|
|
31
|
+
server.setRequestHandler('tools/call', async ({ params }) => {
|
|
32
|
+
const { output, isError } = await callTool(params.name, params.arguments);
|
|
33
|
+
return { content: [{ type: 'text', text: serializeToolOutput(output) }], isError };
|
|
34
|
+
});
|
|
35
|
+
return server;
|
|
36
|
+
}
|
|
37
|
+
export class McpProtocol {
|
|
38
|
+
contexts = new WeakMap();
|
|
39
|
+
handler = createMcpHandler(({ requestInfo }) => createServer(this.contexts.get(requestInfo)));
|
|
40
|
+
handle(request, parsedBody, context) {
|
|
41
|
+
this.contexts.set(request, context);
|
|
42
|
+
return this.handler.fetch(request, { parsedBody });
|
|
138
43
|
}
|
|
139
44
|
}
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import type { AdminUser, IAdminForth } from 'adminforth';
|
|
2
|
-
import type { McpAuthSecretStore } from '
|
|
3
|
-
import type { McpOAuthClient } from '
|
|
4
|
-
import { type McpUrls } from '
|
|
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";
|
|
2
|
+
import type { McpAuthSecretStore } from '../authSecretStore.js';
|
|
3
|
+
import type { McpOAuthClient } from '../types.js';
|
|
4
|
+
import { type McpUrls } from '../urls.js';
|
|
15
5
|
interface AccessToken {
|
|
16
6
|
pk: string;
|
|
17
7
|
grantId: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { CONSENT_PAGE_PATH, OAUTH_PATHS } from '../urls.js';
|
|
3
|
+
import { fetchClientMetadata, isClientIdUrl } from './clientMetadata.js';
|
|
4
|
+
import { OAuthError } from './errors.js';
|
|
5
|
+
import { isAllowedRedirectUri, isLoopbackUrl, redirectUriMatches } from './redirectUris.js';
|
|
5
6
|
const REQUEST_JWT_TYPE = 'mcp-oauth-request';
|
|
6
7
|
const CODE_JWT_TYPE = 'mcp-oauth-code';
|
|
7
8
|
const ACCESS_TOKEN_JWT_TYPE = 'mcp-oauth-access';
|
|
@@ -9,22 +10,8 @@ const REFRESH_TOKEN_JWT_TYPE = 'mcp-oauth-refresh';
|
|
|
9
10
|
const REQUEST_TTL = '10m';
|
|
10
11
|
const CODE_TTL = '5m';
|
|
11
12
|
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
12
|
-
// Every refresh issues a new token, so a connection expires only after this long without use.
|
|
13
13
|
const REFRESH_TOKEN_TTL = '7d';
|
|
14
14
|
const CLIENT_METADATA_TTL_MS = 10 * 60 * 1000;
|
|
15
|
-
/** Endpoint paths relative to the AdminForth API prefix (`<baseUrl>/adminapi/v1`). */
|
|
16
|
-
export const OAUTH_PATHS = {
|
|
17
|
-
protectedResourceMetadata: `${MCP_PATH}/oauth-protected-resource`,
|
|
18
|
-
// Clients derive authorization server metadata URL from the issuer; for an issuer with a path, the
|
|
19
|
-
// OpenID Connect Discovery form `<issuer>/.well-known/openid-configuration` is the only one not at the host root,
|
|
20
|
-
// so it works behind a proxy that forwards only the AdminForth path. The host-root forms are in oauthEndpoints.ts.
|
|
21
|
-
authorizationServerMetadata: `${MCP_PATH}/.well-known/openid-configuration`,
|
|
22
|
-
jwks: `${MCP_PATH}/oauth/jwks`,
|
|
23
|
-
authorize: `${MCP_PATH}/oauth/authorize`,
|
|
24
|
-
token: `${MCP_PATH}/oauth/token`,
|
|
25
|
-
authorization: `${MCP_PATH}/oauth/authorization`,
|
|
26
|
-
};
|
|
27
|
-
export const CONSENT_PAGE_PATH = '/mcp-authorize';
|
|
28
15
|
function requireParams(params, names) {
|
|
29
16
|
const missing = names.filter((name) => typeof params[name] !== 'string' || !params[name]);
|
|
30
17
|
if (missing.length) {
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import type { McpOAuthClient } from '
|
|
1
|
+
import type { McpOAuthClient } from '../types.js';
|
|
2
2
|
/** BlockList matches IPv4-mapped IPv6 addresses (::ffff:10.0.0.5) against the IPv4 ranges by itself. */
|
|
3
3
|
export declare function isSpecialPurposeAddress(address: string, family: number): boolean;
|
|
4
4
|
export declare function isClientIdUrl(clientId: string): boolean;
|
|
5
|
-
export declare function isLoopbackUrl(url: URL): boolean;
|
|
6
|
-
/** Native clients listen on a random loopback port, so RFC 8252 lets the port differ from the registered one. */
|
|
7
|
-
export declare function redirectUriMatches(requested: URL, registered: string): boolean;
|
|
8
|
-
export declare function isAllowedRedirectUri(redirectUri: URL): boolean;
|
|
9
5
|
/**
|
|
10
6
|
* Resolves an MCP client by its Client ID Metadata Document, the client_id being the document URL
|
|
11
7
|
* that passed isClientIdUrl.
|