@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
@@ -0,0 +1,2 @@
1
+ import type { IAdminForth, IHttpServer } from 'adminforth';
2
+ export declare function registerToolEndpoints(server: IHttpServer, adminforth: IAdminForth): void;
@@ -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, logger, } from 'adminforth';
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, handleMcpProtocol } from './mcpProtocol.js';
4
+ import { createMcpServerPresentation, McpProtocol } from './mcpProtocol.js';
5
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';
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, MCP_PATH } from './urls.js';
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
- ? createMcpUrls(this.options.adminPanelOrigin, adminforth.config.baseUrl)
112
- : null;
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
- ...(fields.oauthClientIdField ? [fields.oauthClientIdField] : []),
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.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
- });
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 = String(input.headers.authorization ?? '').match(BEARER_TOKEN_RE)?.[1];
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 handleMcpProtocol({
301
- ...createMcpServerPresentation(this.adminforth.config.customization.brandName, this.urls?.adminPanelUrl, this.skills.serverInstructions(), readOnly),
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.setStatus(protocolResponse.status);
320
- if (!protocolResponse.body) {
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';
@@ -1,31 +1,18 @@
1
- import type { McpToolDefinition } from './apiTools.js';
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: McpServerInfo;
3
+ serverInfo: Implementation;
13
4
  instructions: string;
14
5
  }
15
- export declare function createMcpServerPresentation(brandName: string, adminPanelUrl?: string, guidance?: string, readOnly?: boolean): McpServerPresentation;
16
- export interface McpProtocolContext {
17
- body: Record<string, any>;
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 interface McpProtocolResponse {
28
- status: number;
29
- body?: Record<string, unknown>;
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>;
@@ -1,23 +1,15 @@
1
+ import { createMcpHandler, Server } from '@modelcontextprotocol/server';
1
2
  import YAML from 'yaml';
2
- export const MODERN_PROTOCOL_VERSION = '2026-07-28';
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}"${adminPanelUrl ? ` at ${adminPanelUrl}` : ''}`;
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
- ...(adminPanelUrl && { websiteUrl: adminPanelUrl }),
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
- export async function handleMcpProtocol(context) {
76
- const { body, headers, serverInfo, instructions } = context;
77
- if (body.jsonrpc !== '2.0' || typeof body.method !== 'string') {
78
- return errorResponse(body.id ?? null, -32600, 'Invalid JSON-RPC request.');
79
- }
80
- const modern = isModernRequest(body);
81
- const requestedVersion = body.params?._meta?.[PROTOCOL_VERSION_META_KEY];
82
- if (requestedVersion && requestedVersion !== MODERN_PROTOCOL_VERSION) {
83
- return errorResponse(body.id ?? null, -32000, `Unsupported MCP protocol version: ${requestedVersion}`);
84
- }
85
- if (modern) {
86
- const headerError = validateModernHeaders(body, headers);
87
- if (headerError)
88
- return headerError;
89
- }
90
- if (body.id === undefined) {
91
- return { status: 202 };
92
- }
93
- if (typeof body.id !== 'string' && typeof body.id !== 'number') {
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 './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";
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 { fetchClientMetadata, isAllowedRedirectUri, isClientIdUrl, isLoopbackUrl, redirectUriMatches, } from './oauthClientMetadata.js';
3
- import { OAuthError } from './oauthError.js';
4
- import { MCP_PATH } from './urls.js';
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 './types.js';
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.