@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
@@ -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) {
@@ -53,9 +40,10 @@ export class McpOAuth {
53
40
  fetchedClients = new Map();
54
41
  /**
55
42
  * `configuredClients` are trusted as is, without fetching their client metadata documents. `serverReadOnly` is
56
- * the plugin readOnly option: it makes every connection read-only, whatever the user chooses on the consent page.
43
+ * the plugin readOnly option for a user: it makes the connections of that user read-only, whatever they choose on
44
+ * the consent page.
57
45
  */
58
- constructor(adminforth, store, urls, configuredClients = [], serverReadOnly = false) {
46
+ constructor(adminforth, store, urls, configuredClients = [], serverReadOnly) {
59
47
  this.adminforth = adminforth;
60
48
  this.store = store;
61
49
  this.serverReadOnly = serverReadOnly;
@@ -124,7 +112,7 @@ export class McpOAuth {
124
112
  return `${this.consentPageUrl}?request=${encodeURIComponent(signedRequest)}`;
125
113
  }
126
114
  /** What the consent page shows about a pending authorization request. */
127
- async describeAuthorization(signedRequest) {
115
+ async describeAuthorization(signedRequest, adminUser) {
128
116
  const { request, client, redirectUrl } = await this.verifyAuthorizationRequest(signedRequest);
129
117
  return {
130
118
  clientName: client.clientName,
@@ -132,7 +120,7 @@ export class McpOAuth {
132
120
  clientHost: URL.parse(request.clientId)?.host ?? null,
133
121
  redirectHost: redirectUrl.host,
134
122
  loopbackRedirect: isLoopbackUrl(redirectUrl),
135
- serverReadOnly: this.serverReadOnly,
123
+ serverReadOnly: await this.serverReadOnly(adminUser),
136
124
  };
137
125
  }
138
126
  /**
@@ -150,7 +138,7 @@ export class McpOAuth {
150
138
  clientName: client.clientName,
151
139
  redirectUri: request.redirectUri,
152
140
  codeChallenge: request.codeChallenge,
153
- readOnly: this.serverReadOnly || readOnly,
141
+ readOnly: readOnly || await this.serverReadOnly(adminUser),
154
142
  };
155
143
  url.searchParams.set('code', this.adminforth.auth.issueJWT(code, CODE_JWT_TYPE, CODE_TTL));
156
144
  }
@@ -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.
@@ -2,12 +2,11 @@ import dns from 'node:dns';
2
2
  import https from 'node:https';
3
3
  import net from 'node:net';
4
4
  import { logger } from 'adminforth';
5
- import { OAuthError } from './oauthError.js';
5
+ import { clientMetadataDocumentSchema } from '../schemas/oAuth.js';
6
+ import { OAuthError } from './errors.js';
6
7
  const MAX_DOCUMENT_BYTES = 5 * 1024;
7
8
  const FETCH_TIMEOUT_MS = 5000;
8
- // client_name is shown on the consent page and stored as the connection name.
9
9
  const MAX_CLIENT_NAME_LENGTH = 100;
10
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
11
10
  const specialPurposeAddresses = new net.BlockList();
12
11
  [
13
12
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
@@ -57,23 +56,6 @@ export function isClientIdUrl(clientId) {
57
56
  && !net.isIP(url.hostname)
58
57
  && !url.hostname.startsWith('[');
59
58
  }
60
- export function isLoopbackUrl(url) {
61
- return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname);
62
- }
63
- /** Native clients listen on a random loopback port, so RFC 8252 lets the port differ from the registered one. */
64
- export function redirectUriMatches(requested, registered) {
65
- const registeredUrl = new URL(registered);
66
- if (!isLoopbackUrl(registeredUrl))
67
- return requested.href === registeredUrl.href;
68
- return requested.protocol === registeredUrl.protocol
69
- && requested.hostname === registeredUrl.hostname
70
- && requested.pathname === registeredUrl.pathname
71
- && requested.search === registeredUrl.search
72
- && requested.hash === registeredUrl.hash;
73
- }
74
- export function isAllowedRedirectUri(redirectUri) {
75
- return redirectUri.protocol === 'https:' || isLoopbackUrl(redirectUri);
76
- }
77
59
  function fetchJson(url) {
78
60
  return new Promise((resolve, reject) => {
79
61
  const request = https.get(url, {
@@ -116,9 +98,9 @@ function fetchJson(url) {
116
98
  * that passed isClientIdUrl.
117
99
  */
118
100
  export async function fetchClientMetadata(clientId) {
119
- let document;
101
+ let fetched;
120
102
  try {
121
- document = await fetchJson(clientId);
103
+ fetched = await fetchJson(clientId);
122
104
  }
123
105
  catch (error) {
124
106
  // The reason stays in the log: resolved addresses, HTTP statuses and parse errors quoting the response
@@ -126,13 +108,11 @@ export async function fetchClientMetadata(clientId) {
126
108
  logger.warn(`AdminForthMcpPlugin: failed to fetch client metadata document ${clientId}: ${error.message}`);
127
109
  throw new OAuthError('invalid_client', 'Could not fetch the client metadata document');
128
110
  }
129
- if (typeof document?.client_name !== 'string'
130
- || !Array.isArray(document.redirect_uris)
131
- || !document.redirect_uris.length
132
- || !document.redirect_uris.every((uri) => typeof uri === 'string' && URL.canParse(uri))
133
- || document.token_endpoint_auth_method !== 'none') {
134
- throw new OAuthError('invalid_client', 'Client metadata document must have client_name, redirect_uris and token_endpoint_auth_method "none"');
111
+ const parsed = clientMetadataDocumentSchema.safeParse(fetched);
112
+ if (!parsed.success) {
113
+ throw new OAuthError('invalid_client', 'Client metadata document must have client_id, client_name, redirect_uris and token_endpoint_auth_method "none"');
135
114
  }
115
+ const document = parsed.data;
136
116
  if (document.client_id !== clientId) {
137
117
  throw new OAuthError('invalid_client', 'client_id in the client metadata document does not match its URL');
138
118
  }
@@ -0,0 +1,4 @@
1
+ export declare function isLoopbackUrl(url: URL): boolean;
2
+ /** Native clients listen on a random loopback port, so RFC 8252 lets the port differ from the registered one. */
3
+ export declare function redirectUriMatches(requested: URL, registered: string): boolean;
4
+ export declare function isAllowedRedirectUri(redirectUri: URL): boolean;
@@ -0,0 +1,18 @@
1
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
2
+ export function isLoopbackUrl(url) {
3
+ return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname);
4
+ }
5
+ /** Native clients listen on a random loopback port, so RFC 8252 lets the port differ from the registered one. */
6
+ export function redirectUriMatches(requested, registered) {
7
+ const registeredUrl = new URL(registered);
8
+ if (!isLoopbackUrl(registeredUrl))
9
+ return requested.href === registeredUrl.href;
10
+ return requested.protocol === registeredUrl.protocol
11
+ && requested.hostname === registeredUrl.hostname
12
+ && requested.pathname === registeredUrl.pathname
13
+ && requested.search === registeredUrl.search
14
+ && requested.hash === registeredUrl.hash;
15
+ }
16
+ export function isAllowedRedirectUri(redirectUri) {
17
+ return redirectUri.protocol === 'https:' || isLoopbackUrl(redirectUri);
18
+ }
@@ -0,0 +1,7 @@
1
+ import { type IAdminForth } from 'adminforth';
2
+ import type { McpAuthSecretStore } from '../authSecretStore.js';
3
+ import type { ServerReadOnly } from '../serverReadOnly.js';
4
+ import type { PluginOptions } from '../types.js';
5
+ import { type McpUrls } from '../urls.js';
6
+ import { McpOAuth } from './authorizationServer.js';
7
+ export declare function setupOAuth(adminforth: IAdminForth, authSecretStore: McpAuthSecretStore, urls: McpUrls, options: PluginOptions, consentPageFile: string, serverReadOnly: ServerReadOnly): McpOAuth;
@@ -0,0 +1,25 @@
1
+ import { logger } from 'adminforth';
2
+ import { CONSENT_PAGE_PATH } from '../urls.js';
3
+ import { McpOAuth } from './authorizationServer.js';
4
+ import { isLoopbackUrl } from './redirectUris.js';
5
+ export function setupOAuth(adminforth, authSecretStore, urls, options, consentPageFile, serverReadOnly) {
6
+ // OAuth sends codes and tokens through these endpoints, so they must be served over TLS (RFC 6749 §3.1);
7
+ // plain http is left for a local admin panel.
8
+ const origin = new URL(urls.adminPanelUrl);
9
+ if (origin.protocol !== 'https:' && !isLoopbackUrl(origin)) {
10
+ throw new Error(`AdminForthMcpPlugin: adminPanelOrigin must use https with OAuth sign-in, got "${origin.origin}"; `
11
+ + 'http is allowed only for localhost and 127.0.0.1');
12
+ }
13
+ adminforth.config.customization.customPages.push({
14
+ path: CONSENT_PAGE_PATH,
15
+ component: {
16
+ file: consentPageFile,
17
+ meta: { sidebarAndHeader: 'none' },
18
+ },
19
+ });
20
+ const isProduction = process.env.NODE_ENV === 'production';
21
+ if (isProduction && options.devOAuthClients?.length) {
22
+ logger.warn('AdminForthMcpPlugin: devOAuthClients are ignored because NODE_ENV is "production"');
23
+ }
24
+ return new McpOAuth(adminforth, authSecretStore, urls, isProduction ? [] : options.devOAuthClients, serverReadOnly);
25
+ }
@@ -0,0 +1,31 @@
1
+ import { type AdminUser, type CreateResourceRecordResult, type DeleteResourceRecordResult, type HttpExtra, type IAdminForth } from 'adminforth';
2
+ import type { McpAuthSecretResourceOptions, McpClientInfo } from '../types.js';
3
+ export interface AuthSecret {
4
+ id: string;
5
+ name: string;
6
+ secretHash: string;
7
+ userId: string;
8
+ createdAt: string;
9
+ lastUsedAt: string | null;
10
+ lastUsedByAgent: McpClientInfo | null;
11
+ readOnly: boolean;
12
+ oauthClientId: string | null;
13
+ }
14
+ export type NewAuthSecret = Pick<AuthSecret, 'id' | 'name' | 'secretHash' | 'userId' | 'readOnly' | 'oauthClientId'>;
15
+ export declare class AuthSecretRepository {
16
+ private readonly adminforth;
17
+ private readonly fields;
18
+ private readonly resource;
19
+ constructor(adminforth: IAdminForth, fields: McpAuthSecretResourceOptions);
20
+ listByUser(userId: string): Promise<AuthSecret[]>;
21
+ findById(id: string): Promise<AuthSecret | null>;
22
+ findBySecretHash(secretHash: string): Promise<AuthSecret | null>;
23
+ create(authSecret: NewAuthSecret, adminUser: AdminUser, extra: HttpExtra): Promise<CreateResourceRecordResult>;
24
+ deleteByIdAndUser(id: string, adminUser: AdminUser, extra: HttpExtra): Promise<DeleteResourceRecordResult | null>;
25
+ deleteById(id: string): Promise<void>;
26
+ updateSecretHash(id: string, secretHash: string): Promise<void>;
27
+ updateUsage(id: string, client?: McpClientInfo): Promise<void>;
28
+ private records;
29
+ private findOne;
30
+ private toAuthSecret;
31
+ }
@@ -0,0 +1,86 @@
1
+ import { Filters, Sorts, } from 'adminforth';
2
+ export class AuthSecretRepository {
3
+ adminforth;
4
+ fields;
5
+ resource;
6
+ constructor(adminforth, fields) {
7
+ this.adminforth = adminforth;
8
+ this.fields = fields;
9
+ this.resource = adminforth.config.resources.find((resource) => resource.resourceId === fields.resourceId);
10
+ }
11
+ async listByUser(userId) {
12
+ const records = await this.records().list(Filters.EQ(this.fields.userIdField, userId), undefined, undefined, Sorts.DESC(this.fields.createdAtField));
13
+ return records.map((record) => this.toAuthSecret(record));
14
+ }
15
+ findById(id) {
16
+ return this.findOne(Filters.EQ(this.fields.idField, id));
17
+ }
18
+ findBySecretHash(secretHash) {
19
+ return this.findOne(Filters.EQ(this.fields.secretHashField, secretHash));
20
+ }
21
+ create(authSecret, adminUser, extra) {
22
+ const fields = this.fields;
23
+ return this.adminforth.createResourceRecord({
24
+ resource: this.resource,
25
+ adminUser,
26
+ extra,
27
+ record: {
28
+ [fields.idField]: authSecret.id,
29
+ [fields.nameField]: authSecret.name,
30
+ [fields.secretHashField]: authSecret.secretHash,
31
+ [fields.userIdField]: authSecret.userId,
32
+ [fields.createdAtField]: new Date().toISOString(),
33
+ [fields.lastUsedAtField]: null,
34
+ [fields.lastUsedByAgentField]: null,
35
+ [fields.readOnlyField]: authSecret.readOnly,
36
+ [fields.oauthClientIdField]: authSecret.oauthClientId,
37
+ },
38
+ });
39
+ }
40
+ async deleteByIdAndUser(id, adminUser, extra) {
41
+ const record = await this.records().get(Filters.AND(Filters.EQ(this.fields.idField, id), Filters.EQ(this.fields.userIdField, adminUser.pk)));
42
+ if (!record)
43
+ return null;
44
+ return this.adminforth.deleteResourceRecord({
45
+ resource: this.resource,
46
+ record,
47
+ recordId: id,
48
+ adminUser,
49
+ extra,
50
+ });
51
+ }
52
+ async deleteById(id) {
53
+ await this.records().delete(id);
54
+ }
55
+ async updateSecretHash(id, secretHash) {
56
+ await this.records().update(id, { [this.fields.secretHashField]: secretHash });
57
+ }
58
+ async updateUsage(id, client) {
59
+ await this.records().update(id, {
60
+ [this.fields.lastUsedAtField]: new Date().toISOString(),
61
+ ...(client && { [this.fields.lastUsedByAgentField]: JSON.stringify(client) }),
62
+ });
63
+ }
64
+ records() {
65
+ return this.adminforth.resource(this.fields.resourceId);
66
+ }
67
+ async findOne(filter) {
68
+ const record = await this.records().get(filter);
69
+ return record ? this.toAuthSecret(record) : null;
70
+ }
71
+ toAuthSecret(record) {
72
+ const fields = this.fields;
73
+ const lastUsedByAgent = record[fields.lastUsedByAgentField];
74
+ return {
75
+ id: record[fields.idField],
76
+ name: record[fields.nameField],
77
+ secretHash: record[fields.secretHashField],
78
+ userId: record[fields.userIdField],
79
+ createdAt: record[fields.createdAtField],
80
+ lastUsedAt: record[fields.lastUsedAtField],
81
+ lastUsedByAgent: lastUsedByAgent && JSON.parse(lastUsedByAgent),
82
+ readOnly: record[fields.readOnlyField],
83
+ oauthClientId: record[fields.oauthClientIdField],
84
+ };
85
+ }
86
+ }
@@ -0,0 +1,2 @@
1
+ import type { HttpExtra, IAdminForthEndpointHandlerInput } from 'adminforth';
2
+ export declare function requestExtra(input: IAdminForthEndpointHandlerInput): HttpExtra;
@@ -0,0 +1,10 @@
1
+ export function requestExtra(input) {
2
+ return {
3
+ body: input.body,
4
+ query: input.query,
5
+ headers: input.headers,
6
+ cookies: input.cookies,
7
+ requestUrl: input.requestUrl,
8
+ response: input.response,
9
+ };
10
+ }
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ declare const runConfirmationBody: z.ZodObject<{
3
+ id: z.ZodString;
4
+ verificationResult: z.ZodRecord<z.ZodString, z.ZodUnknown>;
5
+ }, z.core.$strip>;
6
+ declare const declineConfirmationBody: z.ZodObject<{
7
+ id: z.ZodString;
8
+ }, z.core.$strip>;
9
+ export type RunConfirmationBody = z.infer<typeof runConfirmationBody>;
10
+ export type DeclineConfirmationBody = z.infer<typeof declineConfirmationBody>;
11
+ export declare const runConfirmationBodySchema: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
12
+ id: z.ZodString;
13
+ verificationResult: z.ZodRecord<z.ZodString, z.ZodUnknown>;
14
+ }, z.core.$strip>>;
15
+ export declare const declineConfirmationBodySchema: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
16
+ id: z.ZodString;
17
+ }, z.core.$strip>>;
18
+ export {};
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ const runConfirmationBody = z.object({
3
+ id: z.string(),
4
+ verificationResult: z.record(z.string(), z.unknown()),
5
+ });
6
+ const declineConfirmationBody = z.object({ id: z.string() });
7
+ // Convert with the same Zod version that created the schema. AdminForth may use another version.
8
+ export const runConfirmationBodySchema = z.toJSONSchema(runConfirmationBody, { target: 'draft-07' });
9
+ export const declineConfirmationBodySchema = z.toJSONSchema(declineConfirmationBody, { target: 'draft-07' });
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ export declare const resolveAuthorizationBodySchema: z.ZodObject<{
3
+ request: z.ZodString;
4
+ approved: z.ZodBoolean;
5
+ readOnly: z.ZodBoolean;
6
+ }, z.core.$strip>;
7
+ export declare const clientMetadataDocumentSchema: z.ZodObject<{
8
+ client_id: z.ZodString;
9
+ client_name: z.ZodString;
10
+ redirect_uris: z.ZodArray<z.ZodString>;
11
+ token_endpoint_auth_method: z.ZodLiteral<"none">;
12
+ }, z.core.$strip>;
13
+ export type ResolveAuthorizationBody = z.infer<typeof resolveAuthorizationBodySchema>;
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ export const resolveAuthorizationBodySchema = z.object({
3
+ request: z.string(),
4
+ approved: z.boolean(),
5
+ readOnly: z.boolean(),
6
+ });
7
+ export const clientMetadataDocumentSchema = z.object({
8
+ client_id: z.string(),
9
+ client_name: z.string(),
10
+ redirect_uris: z.array(z.string().refine((uri) => URL.canParse(uri))).min(1),
11
+ token_endpoint_auth_method: z.literal('none'),
12
+ });
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const createAuthSecretBodySchema: z.ZodObject<{
3
+ name: z.ZodString;
4
+ readOnly: z.ZodBoolean;
5
+ }, z.core.$strip>;
6
+ export declare const revokeAuthSecretBodySchema: z.ZodObject<{
7
+ id: z.ZodString;
8
+ }, z.core.$strip>;
9
+ export type CreateAuthSecretBody = z.infer<typeof createAuthSecretBodySchema>;
10
+ export type RevokeAuthSecretBody = z.infer<typeof revokeAuthSecretBodySchema>;
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export const createAuthSecretBodySchema = z.object({
3
+ name: z.string().min(1),
4
+ readOnly: z.boolean(),
5
+ });
6
+ export const revokeAuthSecretBodySchema = z.object({
7
+ id: z.string(),
8
+ });
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ export declare const resourcesListResponseSchema: z.ZodObject<{
3
+ resources: z.ZodArray<z.ZodObject<{
4
+ resourceId: z.ZodString;
5
+ label: z.ZodString;
6
+ }, z.core.$strip>>;
7
+ }, z.core.$strip>;
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ export const resourcesListResponseSchema = z.object({
3
+ resources: z.array(z.object({
4
+ resourceId: z.string().describe('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.'),
5
+ label: z.string().describe('Human readable resource name, translated for the current user.'),
6
+ })).describe('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.'),
7
+ });
@@ -0,0 +1,4 @@
1
+ import type { AdminUser } from 'adminforth';
2
+ import type { PluginOptions } from './types.js';
3
+ export type ServerReadOnly = (adminUser: AdminUser) => Promise<boolean>;
4
+ export declare function createServerReadOnly(readOnly?: PluginOptions['readOnly']): ServerReadOnly;
@@ -0,0 +1,3 @@
1
+ export function createServerReadOnly(readOnly = false) {
2
+ return async (adminUser) => (typeof readOnly === 'function' ? readOnly({ adminUser }) : readOnly);
3
+ }
package/dist/skills.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { McpToolDefinition } from './apiTools.js';
1
+ import type { Tool } from '@modelcontextprotocol/server';
2
2
  export declare const FETCH_SKILL_TOOL_NAME = "fetch_skill";
3
3
  type TemplateValues = Record<string, string | number>;
4
4
  /**
@@ -10,7 +10,7 @@ export declare class McpSkills {
10
10
  private readonly skills;
11
11
  constructor(customFolderPath: string, values: TemplateValues);
12
12
  serverInstructions(): string;
13
- toolDefinition(): McpToolDefinition;
13
+ toolDefinition(): Tool;
14
14
  call(args: Record<string, unknown> | undefined): {
15
15
  output: unknown;
16
16
  isError: boolean;
@@ -0,0 +1,6 @@
1
+ import type { HttpExtra, IAdminForth } from 'adminforth';
2
+ export interface ContextualTwoFactorsAuth {
3
+ withVerificationContext<T>(verificationResult: Record<string, unknown> | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<unknown>;
4
+ }
5
+ /** Only newer 2FA plugins can verify verifyAuto calls within an external request. */
6
+ export declare function findContextualTwoFactorsAuth(adminforth: IAdminForth): ContextualTwoFactorsAuth | undefined;
@@ -0,0 +1,5 @@
1
+ /** Only newer 2FA plugins can verify verifyAuto calls within an external request. */
2
+ export function findContextualTwoFactorsAuth(adminforth) {
3
+ const plugin = adminforth.getPluginsByClassName('TwoFactorsAuthPlugin')[0];
4
+ return plugin?.withVerificationContext ? plugin : undefined;
5
+ }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginsCommonOptions } from 'adminforth';
1
+ import type { AdminUser, PluginsCommonOptions } from 'adminforth';
2
2
  export interface McpAuthSecretResourceOptions {
3
3
  resourceId: string;
4
4
  idField: string;
@@ -9,11 +9,7 @@ export interface McpAuthSecretResourceOptions {
9
9
  lastUsedAtField: string;
10
10
  lastUsedByAgentField: string;
11
11
  readOnlyField: string;
12
- /**
13
- * Client ID Metadata Document URL of the MCP client connected through OAuth; empty for personal auth secrets.
14
- * Setting it enables OAuth sign-in, which also needs adminPanelOrigin. Without it only auth secrets work.
15
- */
16
- oauthClientIdField?: string;
12
+ oauthClientIdField: string;
17
13
  }
18
14
  /** An MCP client allowed to connect through OAuth, as its Client ID Metadata Document describes it. */
19
15
  export interface McpOAuthClient {
@@ -22,9 +18,7 @@ export interface McpOAuthClient {
22
18
  redirectUris: string[];
23
19
  }
24
20
  export interface McpPageSize {
25
- /** Rows get_resource_data returns when the client does not pass limit. */
26
21
  default: number;
27
- /** Most rows get_resource_data returns per call; larger limits are capped. */
28
22
  max: number;
29
23
  }
30
24
  export interface PluginOptions extends PluginsCommonOptions {
@@ -32,10 +26,9 @@ export interface PluginOptions extends PluginsCommonOptions {
32
26
  /**
33
27
  * Public origin of this AdminForth installation, without the AdminForth baseUrl path, e.g. https://example.com.
34
28
  * The MCP server URL shown on the settings page, the OAuth issuer and resource URLs are built from it, so it must
35
- * be the origin MCP clients connect to. Required with OAuth sign-in; without it the settings page shows the MCP URL
36
- * of the address it was opened at.
29
+ * be the origin MCP clients connect to.
37
30
  */
38
- adminPanelOrigin?: string;
31
+ adminPanelOrigin: string;
39
32
  /**
40
33
  * MCP clients resolved from this list instead of fetching their Client ID Metadata Document, so OAuth can be
41
34
  * tried locally without a public https URL. Ignored when NODE_ENV is "production": there every client_id is
@@ -53,10 +46,13 @@ export interface PluginOptions extends PluginsCommonOptions {
53
46
  */
54
47
  toolCallsPerRequest?: number;
55
48
  /**
56
- * Expose only endpoints marked with `agent: { onlyReadsData: true }` to every auth secret.
49
+ * Expose only endpoints marked with `agent: { onlyReadsData: true }`: with true to every auth secret, with a function
50
+ * to the auth secrets of users it returns true for, checked on every MCP request.
57
51
  * Without it, read-only mode is chosen per auth secret.
58
52
  */
59
- readOnly?: boolean;
53
+ readOnly?: boolean | ((input: {
54
+ adminUser: AdminUser;
55
+ }) => boolean | Promise<boolean>);
60
56
  }
61
57
  export interface McpClientInfo {
62
58
  client: string;
package/dist/urls.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  export declare const MCP_PATH = "/mcp";
2
- /** Path prefix of AdminForth API endpoints, e.g. `/admin/adminapi/v1`. */
2
+ export declare const CONFIRMATION_PAGE_PATH = "/mcp-confirm";
3
+ export declare const CONSENT_PAGE_PATH = "/mcp-authorize";
4
+ export declare const OAUTH_PATHS: {
5
+ protectedResourceMetadata: string;
6
+ authorizationServerMetadata: string;
7
+ jwks: string;
8
+ authorize: string;
9
+ token: string;
10
+ authorization: string;
11
+ };
3
12
  export declare function adminApiPrefix(baseUrl: string): string;
4
- /**
5
- * Public URLs of the plugin, built once from adminPanelOrigin: the settings page, OAuth metadata and the MCP server
6
- * info must all name the same URL, because OAuth accepts tokens only for the exact MCP resource URL.
7
- */
8
13
  export interface McpUrls {
9
14
  apiUrl: string;
10
15
  mcpUrl: string;
package/dist/urls.js CHANGED
@@ -1,5 +1,14 @@
1
1
  export const MCP_PATH = '/mcp';
2
- /** Path prefix of AdminForth API endpoints, e.g. `/admin/adminapi/v1`. */
2
+ export const CONFIRMATION_PAGE_PATH = '/mcp-confirm';
3
+ export const CONSENT_PAGE_PATH = '/mcp-authorize';
4
+ export const OAUTH_PATHS = {
5
+ protectedResourceMetadata: `${MCP_PATH}/oauth-protected-resource`,
6
+ authorizationServerMetadata: `${MCP_PATH}/.well-known/openid-configuration`,
7
+ jwks: `${MCP_PATH}/oauth/jwks`,
8
+ authorize: `${MCP_PATH}/oauth/authorize`,
9
+ token: `${MCP_PATH}/oauth/token`,
10
+ authorization: `${MCP_PATH}/oauth/authorization`,
11
+ };
3
12
  export function adminApiPrefix(baseUrl) {
4
13
  return `${baseUrl}/adminapi/v1`;
5
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/mcp",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "Expose AdminForth APIs as an authenticated MCP server",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "scripts": {
28
28
  "build": "tsc && rsync -av --exclude 'node_modules' custom dist/",
29
- "test": "pnpm build && node --test tests/*.test.mjs"
29
+ "test": "rm -rf dist && pnpm build && node --test tests/*.test.mjs"
30
30
  },
31
31
  "keywords": [
32
32
  "adminforth",
@@ -40,14 +40,17 @@
40
40
  "author": "DevForth (https://devforth.io)",
41
41
  "license": "MIT",
42
42
  "devDependencies": {
43
+ "@types/express": "^5.0.6",
43
44
  "@types/node": "latest",
44
45
  "adminforth": "^3.32.0",
46
+ "express": "^5.2.1",
45
47
  "semantic-release": "^24.2.1",
46
48
  "semantic-release-slack-bot": "^4.0.2",
47
49
  "typescript": "^5.7.3"
48
50
  },
49
51
  "peerDependencies": {
50
- "adminforth": "^3.32.0"
52
+ "adminforth": "^3.32.0",
53
+ "express": "^4.21.0 || ^5.0.0"
51
54
  },
52
55
  "release": {
53
56
  "plugins": [
@@ -75,6 +78,8 @@
75
78
  ]
76
79
  },
77
80
  "dependencies": {
78
- "yaml": "^2.9.1"
81
+ "@modelcontextprotocol/server": "^2.3.1",
82
+ "yaml": "^2.9.1",
83
+ "zod": "^4.6.5"
79
84
  }
80
85
  }
@@ -1,3 +0,0 @@
1
- import { type IHttpServer } from 'adminforth';
2
- import { type McpOAuth } from './oauth.js';
3
- export declare function setupOAuthEndpoints(server: IHttpServer, oauth: McpOAuth, apiPrefix: string): void;
File without changes
File without changes