@adminforth/mcp 1.1.2 → 1.3.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 (60) hide show
  1. package/README.md +2 -2
  2. package/dist/adminPanelConfirmation.d.ts +47 -0
  3. package/dist/adminPanelConfirmation.js +180 -0
  4. package/dist/apiTools.d.ts +10 -12
  5. package/dist/apiTools.js +58 -49
  6. package/dist/authSecretStore.d.ts +10 -12
  7. package/dist/authSecretStore.js +42 -115
  8. package/dist/clientInfo.js +1 -1
  9. package/dist/custom/McpConfirm.vue +97 -0
  10. package/dist/custom/McpSettings.vue +3 -13
  11. package/dist/custom/instructions.md +1 -1
  12. package/dist/custom/skills/mutate_data/SKILL.md +1 -1
  13. package/dist/endpoints/confirmation.d.ts +3 -0
  14. package/dist/endpoints/confirmation.js +38 -0
  15. package/dist/endpoints/mcp.d.ts +2 -0
  16. package/dist/endpoints/mcp.js +24 -0
  17. package/dist/endpoints/oAuth.d.ts +3 -0
  18. package/dist/{oauthEndpoints.js → endpoints/oAuth.js} +16 -35
  19. package/dist/endpoints/settings.d.ts +4 -0
  20. package/dist/endpoints/settings.js +44 -0
  21. package/dist/endpoints/tools.d.ts +2 -0
  22. package/dist/endpoints/tools.js +19 -0
  23. package/dist/index.d.ts +3 -4
  24. package/dist/index.js +54 -175
  25. package/dist/mcpProtocol.d.ts +9 -22
  26. package/dist/mcpProtocol.js +23 -118
  27. package/dist/{oauth.d.ts → oauth/authorizationServer.d.ts} +8 -16
  28. package/dist/{oauth.js → oauth/authorizationServer.js} +10 -22
  29. package/dist/{oauthClientMetadata.d.ts → oauth/clientMetadata.d.ts} +1 -5
  30. package/dist/{oauthClientMetadata.js → oauth/clientMetadata.js} +8 -28
  31. package/dist/oauth/redirectUris.d.ts +4 -0
  32. package/dist/oauth/redirectUris.js +18 -0
  33. package/dist/oauth/setup.d.ts +7 -0
  34. package/dist/oauth/setup.js +25 -0
  35. package/dist/repositories/authSecret.d.ts +31 -0
  36. package/dist/repositories/authSecret.js +86 -0
  37. package/dist/requestExtra.d.ts +2 -0
  38. package/dist/requestExtra.js +10 -0
  39. package/dist/schemas/confirmation.d.ts +18 -0
  40. package/dist/schemas/confirmation.js +9 -0
  41. package/dist/schemas/oAuth.d.ts +13 -0
  42. package/dist/schemas/oAuth.js +12 -0
  43. package/dist/schemas/settings.d.ts +10 -0
  44. package/dist/schemas/settings.js +8 -0
  45. package/dist/schemas/tools.d.ts +7 -0
  46. package/dist/schemas/tools.js +7 -0
  47. package/dist/serverReadOnly.d.ts +4 -0
  48. package/dist/serverReadOnly.js +3 -0
  49. package/dist/skills.d.ts +2 -2
  50. package/dist/twoFactorContext.d.ts +6 -0
  51. package/dist/twoFactorContext.js +5 -0
  52. package/dist/types.d.ts +9 -13
  53. package/dist/urls.d.ts +10 -5
  54. package/dist/urls.js +10 -1
  55. package/package.json +9 -4
  56. package/dist/oauthEndpoints.d.ts +0 -3
  57. /package/dist/{inputSchema.d.ts → compactInputSchema.d.ts} +0 -0
  58. /package/dist/{inputSchema.js → compactInputSchema.js} +0 -0
  59. /package/dist/{oauthError.d.ts → oauth/errors.d.ts} +0 -0
  60. /package/dist/{oauthError.js → oauth/errors.js} +0 -0
package/dist/index.d.ts CHANGED
@@ -4,16 +4,16 @@ 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;
9
+ private adminPanelConfirmation?;
10
10
  private skills;
11
- /** Null without adminPanelOrigin; then the settings page shows the MCP URL of the address it was opened at. */
12
11
  private urls;
12
+ private readonly protocol;
13
+ private readonly serverReadOnly;
13
14
  constructor(options: PluginOptions);
14
15
  instanceUniqueRepresentation(): string;
15
16
  modifyGlobalConfig(adminforth: IAdminForth): void;
16
- private createOAuth;
17
17
  private validateAuthSecretResource;
18
18
  setupEndpoints(server: IHttpServer): void;
19
19
  /** Accepts personal auth secrets and OAuth access tokens; access tokens are JWTs, secrets have their prefix. */
@@ -22,4 +22,3 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
22
22
  }
23
23
  export type { McpAuthSecretResourceOptions, McpOAuthClient, PluginOptions } from './types.js';
24
24
  export { canonicalAgentName, formatMcpExecutedBy, readMcpClient } from './clientInfo.js';
25
- export { handleMcpProtocol } from './mcpProtocol.js';
package/dist/index.js CHANGED
@@ -1,53 +1,27 @@
1
- import { AdminForthPlugin, logger, } from 'adminforth';
1
+ import { AdminForthPlugin, } from 'adminforth';
2
+ import { McpAdminPanelConfirmation, WAIT_FOR_CONFIRMATION_TOOL_NAME } from './adminPanelConfirmation.js';
3
+ import { registerConfirmationEndpoints } from './endpoints/confirmation.js';
2
4
  import { AdminForthApiTools } from './apiTools.js';
3
5
  import { formatMcpExecutedBy, readMcpClient, UNKNOWN_CLIENT } from './clientInfo.js';
4
- import { createMcpServerPresentation, handleMcpProtocol } from './mcpProtocol.js';
6
+ import { createMcpServerPresentation, McpProtocol } from './mcpProtocol.js';
5
7
  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';
8
+ import { registerMcpEndpoints } from './endpoints/mcp.js';
9
+ import { registerOAuthEndpoints } from './endpoints/oAuth.js';
10
+ import { registerSettingsEndpoints } from './endpoints/settings.js';
11
+ import { registerToolEndpoints } from './endpoints/tools.js';
12
+ import { AuthSecretRepository } from './repositories/authSecret.js';
13
+ import { setupOAuth } from './oauth/setup.js';
14
+ import { requestExtra } from './requestExtra.js';
15
+ import { createServerReadOnly } from './serverReadOnly.js';
9
16
  import { FETCH_SKILL_TOOL_NAME, McpSkills } from './skills.js';
10
- import { adminApiPrefix, createMcpUrls, MCP_PATH } from './urls.js';
17
+ import { findContextualTwoFactorsAuth } from './twoFactorContext.js';
18
+ import { adminApiPrefix, CONFIRMATION_PAGE_PATH, createMcpUrls } from './urls.js';
11
19
  const BEARER_TOKEN_RE = /^Bearer (\S+)$/i;
12
20
  const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']);
13
21
  const DEFAULT_PAGE_SIZE = 10;
14
22
  const MAX_PAGE_SIZE = 100;
15
23
  const DEFAULT_TOOL_TIMEOUT_MS = 15_000;
16
24
  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
25
  /**
52
26
  * The MCP auth secret is a long-lived credential, so it is dropped as soon as it is verified and
53
27
  * never reaches tool handlers, which may log, persist or forward the headers they are given.
@@ -66,15 +40,17 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
66
40
  options;
67
41
  pluginsScope = 'global';
68
42
  authSecretStore;
69
- /** Null when OAuth sign-in is not configured; then only auth secrets authenticate. */
70
43
  oauth;
71
44
  apiTools;
45
+ adminPanelConfirmation;
72
46
  skills;
73
- /** Null without adminPanelOrigin; then the settings page shows the MCP URL of the address it was opened at. */
74
47
  urls;
48
+ protocol = new McpProtocol();
49
+ serverReadOnly;
75
50
  constructor(options) {
76
51
  super(options, import.meta.url);
77
52
  this.options = options;
53
+ this.serverReadOnly = createServerReadOnly(options.readOnly);
78
54
  this.shouldHaveSingleInstancePerWholeApp = () => true;
79
55
  }
80
56
  instanceUniqueRepresentation() {
@@ -107,47 +83,29 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
107
83
  isVisible: () => true,
108
84
  });
109
85
  }
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;
86
+ this.urls = createMcpUrls(this.options.adminPanelOrigin, adminforth.config.baseUrl);
87
+ this.authSecretStore = new McpAuthSecretStore(adminforth, new AuthSecretRepository(adminforth, this.options.authSecretResource));
88
+ this.oauth = setupOAuth(adminforth, this.authSecretStore, this.urls, this.options, this.componentPath('McpAuthorize.vue'), this.serverReadOnly);
89
+ const toolTimeoutMs = this.options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
90
+ const twoFactorsAuth = findContextualTwoFactorsAuth(adminforth);
91
+ if (twoFactorsAuth) {
92
+ adminforth.config.customization.customPages.push({
93
+ path: CONFIRMATION_PAGE_PATH,
94
+ component: { file: this.componentPath('McpConfirm.vue'), meta: { sidebarAndHeader: 'none' } },
95
+ });
96
+ this.adminPanelConfirmation = new McpAdminPanelConfirmation(adminforth, this.urls, twoFactorsAuth, toolTimeoutMs);
97
+ }
115
98
  const pageSize = {
116
99
  default: this.options.pageSize?.default ?? DEFAULT_PAGE_SIZE,
117
100
  max: this.options.pageSize?.max ?? MAX_PAGE_SIZE,
118
101
  };
119
- this.apiTools = new AdminForthApiTools(adminforth, new Set([this.options.authSecretResource.resourceId]), pageSize, this.options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS);
102
+ this.apiTools = new AdminForthApiTools(adminforth, new Set([this.options.authSecretResource.resourceId]), pageSize, toolTimeoutMs, this.adminPanelConfirmation);
120
103
  this.skills = new McpSkills(this.customFolderPath, {
121
104
  'pageSize.default': pageSize.default,
122
105
  'pageSize.max': pageSize.max,
123
106
  toolCallsPerRequest: this.options.toolCallsPerRequest ?? DEFAULT_TOOL_CALLS_PER_REQUEST,
124
107
  });
125
108
  }
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
109
  validateAuthSecretResource(resource) {
152
110
  const fields = this.options.authSecretResource;
153
111
  for (const fieldName of [
@@ -159,7 +117,7 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
159
117
  fields.lastUsedAtField,
160
118
  fields.lastUsedByAgentField,
161
119
  fields.readOnlyField,
162
- ...(fields.oauthClientIdField ? [fields.oauthClientIdField] : []),
120
+ fields.oauthClientIdField,
163
121
  ]) {
164
122
  if (!resource.columns.some((column) => column.name === fieldName)) {
165
123
  throw new Error(`AdminForthMcpPlugin: column "${fieldName}" not found in auth secret resource "${resource.resourceId}"`);
@@ -170,95 +128,17 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
170
128
  }
171
129
  }
172
130
  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
- });
131
+ registerToolEndpoints(server, this.adminforth);
132
+ registerSettingsEndpoints(server, this.authSecretStore, this.urls.mcpUrl, this.serverReadOnly);
133
+ registerOAuthEndpoints(server, this.oauth, adminApiPrefix(this.adminforth.config.baseUrl));
134
+ if (this.adminPanelConfirmation)
135
+ registerConfirmationEndpoints(server, this.adminPanelConfirmation);
136
+ registerMcpEndpoints(server, (input) => this.handleMcpRequest(input));
255
137
  }
256
138
  /** Accepts personal auth secrets and OAuth access tokens; access tokens are JWTs, secrets have their prefix. */
257
139
  async authenticate(token) {
258
140
  if (token.startsWith(SECRET_PREFIX))
259
141
  return this.authSecretStore.authenticate(token);
260
- if (!this.oauth)
261
- return null;
262
142
  const accessToken = await this.oauth.verifyAccessToken(token);
263
143
  return accessToken && this.authSecretStore.authenticateOAuthGrant(accessToken.grantId, accessToken.pk);
264
144
  }
@@ -271,13 +151,11 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
271
151
  error: { code: -32003, message: 'Browser-originated MCP requests are not allowed.' },
272
152
  };
273
153
  }
274
- const token = String(input.headers.authorization ?? '').match(BEARER_TOKEN_RE)?.[1];
154
+ const token = input.headers.authorization?.match(BEARER_TOKEN_RE)?.[1];
275
155
  const authenticated = token ? await this.authenticate(token) : null;
276
156
  if (!authenticated) {
277
157
  // 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');
158
+ input.response.setHeader('WWW-Authenticate', `Bearer ${token ? 'error="invalid_token", ' : ''}resource_metadata="${this.oauth.resourceMetadataUrl}"`);
281
159
  input.response.setStatus(401);
282
160
  return jsonRpcAuthError(input.body.id ?? null);
283
161
  }
@@ -296,15 +174,20 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
296
174
  ...authenticated.adminUser,
297
175
  executedBy: formatMcpExecutedBy(client, authenticated.name),
298
176
  };
299
- 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,
304
- listTools: () => [...this.apiTools.list(readOnly), this.skills.toolDefinition()],
177
+ const readOnly = authenticated.readOnly || await this.serverReadOnly(authenticated.adminUser);
178
+ const protocolResponse = await this.protocol.handle(new Request(this.urls.mcpUrl, { method: 'POST', headers: toolHeaders }), input.body, {
179
+ ...createMcpServerPresentation(this.adminforth.config.customization.brandName, this.urls.adminPanelUrl, this.skills.serverInstructions(), readOnly),
180
+ listTools: () => [
181
+ ...this.apiTools.list(readOnly),
182
+ this.skills.toolDefinition(),
183
+ ...(this.adminPanelConfirmation ? [this.adminPanelConfirmation.toolDefinition()] : []),
184
+ ],
305
185
  callTool: async (name, arguments_) => {
306
186
  if (name === FETCH_SKILL_TOOL_NAME)
307
187
  return this.skills.call(arguments_);
188
+ if (name === WAIT_FOR_CONFIRMATION_TOOL_NAME && this.adminPanelConfirmation) {
189
+ return this.adminPanelConfirmation.wait(String(arguments_?.confirmationId), authenticated.recordId);
190
+ }
308
191
  return this.apiTools.call({
309
192
  name,
310
193
  arguments: arguments_,
@@ -313,16 +196,12 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
313
196
  requestUrl: input.requestUrl,
314
197
  abortSignal: input.abortSignal,
315
198
  readOnly,
199
+ connection: { id: authenticated.recordId, name: authenticated.name },
316
200
  });
317
201
  },
318
202
  });
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;
203
+ protocolResponse.headers.forEach((value, name) => input.response.setHeader(name, value));
204
+ input.response.setStatus(protocolResponse.status, await protocolResponse.text());
325
205
  }
326
206
  }
327
207
  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,8 @@
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 { ServerReadOnly } from '../serverReadOnly.js';
4
+ import type { McpOAuthClient } from '../types.js';
5
+ import { type McpUrls } from '../urls.js';
15
6
  interface AccessToken {
16
7
  pk: string;
17
8
  grantId: string;
@@ -35,9 +26,10 @@ export declare class McpOAuth {
35
26
  private readonly fetchedClients;
36
27
  /**
37
28
  * `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.
29
+ * the plugin readOnly option for a user: it makes the connections of that user read-only, whatever they choose on
30
+ * the consent page.
39
31
  */
40
- constructor(adminforth: IAdminForth, store: McpAuthSecretStore, urls: McpUrls, configuredClients?: McpOAuthClient[], serverReadOnly?: boolean);
32
+ constructor(adminforth: IAdminForth, store: McpAuthSecretStore, urls: McpUrls, configuredClients: McpOAuthClient[] | undefined, serverReadOnly: ServerReadOnly);
41
33
  protectedResourceMetadata(): {
42
34
  resource: string;
43
35
  authorization_servers: string[];
@@ -64,7 +56,7 @@ export declare class McpOAuth {
64
56
  */
65
57
  authorize(query: Record<string, unknown>): Promise<string>;
66
58
  /** What the consent page shows about a pending authorization request. */
67
- describeAuthorization(signedRequest: string): Promise<{
59
+ describeAuthorization(signedRequest: string, adminUser: AdminUser): Promise<{
68
60
  clientName: string;
69
61
  clientHost: string | null;
70
62
  redirectHost: string;