@adminforth/mcp 1.0.4 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiTools.d.ts +10 -3
- package/dist/apiTools.js +206 -22
- package/dist/authSecretStore.d.ts +34 -1
- package/dist/authSecretStore.js +84 -12
- package/dist/custom/McpAuthorize.vue +147 -0
- package/dist/custom/McpSettings.vue +153 -54
- package/dist/custom/instructions.md +6 -0
- package/dist/custom/skills/analyze_data/SKILL.md +19 -0
- package/dist/custom/skills/fetch_data/SKILL.md +31 -0
- package/dist/custom/skills/mutate_data/SKILL.md +48 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.js +163 -25
- package/dist/inputSchema.d.ts +3 -0
- package/dist/inputSchema.js +63 -0
- package/dist/keyedLock.d.ts +8 -0
- package/dist/keyedLock.js +20 -0
- package/dist/mcpProtocol.d.ts +1 -1
- package/dist/mcpProtocol.js +8 -6
- package/dist/oauth.d.ts +96 -0
- package/dist/oauth.js +263 -0
- package/dist/oauthClientMetadata.d.ts +13 -0
- package/dist/oauthClientMetadata.js +140 -0
- package/dist/oauthEndpoints.d.ts +3 -0
- package/dist/oauthEndpoints.js +126 -0
- package/dist/oauthError.d.ts +9 -0
- package/dist/oauthError.js +11 -0
- package/dist/skills.d.ts +19 -0
- package/dist/skills.js +79 -0
- package/dist/types.d.ts +45 -1
- package/dist/urls.d.ts +13 -0
- package/dist/urls.js +13 -0
- package/package.json +6 -3
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
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';
|
|
5
|
+
const REQUEST_JWT_TYPE = 'mcp-oauth-request';
|
|
6
|
+
const CODE_JWT_TYPE = 'mcp-oauth-code';
|
|
7
|
+
const ACCESS_TOKEN_JWT_TYPE = 'mcp-oauth-access';
|
|
8
|
+
const REFRESH_TOKEN_JWT_TYPE = 'mcp-oauth-refresh';
|
|
9
|
+
const REQUEST_TTL = '10m';
|
|
10
|
+
const CODE_TTL = '5m';
|
|
11
|
+
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
|
+
const REFRESH_TOKEN_TTL = '7d';
|
|
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
|
+
function requireParams(params, names) {
|
|
29
|
+
const missing = names.filter((name) => typeof params[name] !== 'string' || !params[name]);
|
|
30
|
+
if (missing.length) {
|
|
31
|
+
throw new OAuthError('invalid_request', `Missing or invalid parameters: ${missing.join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
return params;
|
|
34
|
+
}
|
|
35
|
+
function pkceChallenge(codeVerifier) {
|
|
36
|
+
return createHash('sha256').update(codeVerifier).digest('base64url');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* OAuth 2.1 authorization server for the MCP endpoint: authorization code flow with PKCE for public clients
|
|
40
|
+
* identified by Client ID Metadata Documents. Authorization requests and codes are signed JWTs, so only the
|
|
41
|
+
* resulting grants are stored, as records of the auth secret resource.
|
|
42
|
+
*/
|
|
43
|
+
export class McpOAuth {
|
|
44
|
+
adminforth;
|
|
45
|
+
store;
|
|
46
|
+
serverReadOnly;
|
|
47
|
+
mcpUrl;
|
|
48
|
+
resourceMetadataUrl;
|
|
49
|
+
issuer;
|
|
50
|
+
apiUrl;
|
|
51
|
+
consentPageUrl;
|
|
52
|
+
configuredClients;
|
|
53
|
+
fetchedClients = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* `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.
|
|
57
|
+
*/
|
|
58
|
+
constructor(adminforth, store, urls, configuredClients = [], serverReadOnly = false) {
|
|
59
|
+
this.adminforth = adminforth;
|
|
60
|
+
this.store = store;
|
|
61
|
+
this.serverReadOnly = serverReadOnly;
|
|
62
|
+
this.configuredClients = new Map(configuredClients.map((client) => [client.clientId, client]));
|
|
63
|
+
this.apiUrl = urls.apiUrl;
|
|
64
|
+
this.mcpUrl = urls.mcpUrl;
|
|
65
|
+
this.issuer = this.mcpUrl;
|
|
66
|
+
this.resourceMetadataUrl = `${this.apiUrl}${OAUTH_PATHS.protectedResourceMetadata}`;
|
|
67
|
+
this.consentPageUrl = new URL(`${adminforth.config.baseUrl}${CONSENT_PAGE_PATH}`, this.apiUrl).href;
|
|
68
|
+
}
|
|
69
|
+
protectedResourceMetadata() {
|
|
70
|
+
return {
|
|
71
|
+
resource: this.mcpUrl,
|
|
72
|
+
authorization_servers: [this.issuer],
|
|
73
|
+
bearer_methods_supported: ['header'],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
authorizationServerMetadata() {
|
|
77
|
+
return {
|
|
78
|
+
issuer: this.issuer,
|
|
79
|
+
authorization_endpoint: `${this.apiUrl}${OAUTH_PATHS.authorize}`,
|
|
80
|
+
token_endpoint: `${this.apiUrl}${OAUTH_PATHS.token}`,
|
|
81
|
+
response_types_supported: ['code'],
|
|
82
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
83
|
+
code_challenge_methods_supported: ['S256'],
|
|
84
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
85
|
+
client_id_metadata_document_supported: true,
|
|
86
|
+
authorization_response_iss_parameter_supported: true,
|
|
87
|
+
// Required by OpenID Connect Discovery, which clients validate this document against. No ID tokens are
|
|
88
|
+
// issued, so the key set is empty.
|
|
89
|
+
jwks_uri: `${this.apiUrl}${OAUTH_PATHS.jwks}`,
|
|
90
|
+
subject_types_supported: ['public'],
|
|
91
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Validates the authorization request and returns the consent page URL to redirect the browser to.
|
|
96
|
+
* This endpoint needs no sign-in, so the client metadata document is fetched only once the user opens the
|
|
97
|
+
* consent page: otherwise anyone could make this server send requests to any URL passed as client_id.
|
|
98
|
+
*/
|
|
99
|
+
async authorize(query) {
|
|
100
|
+
const params = requireParams(query, [
|
|
101
|
+
'response_type', 'client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method',
|
|
102
|
+
]);
|
|
103
|
+
if (params.response_type !== 'code') {
|
|
104
|
+
throw new OAuthError('unsupported_response_type', 'Only response_type=code is supported');
|
|
105
|
+
}
|
|
106
|
+
if (params.code_challenge_method !== 'S256') {
|
|
107
|
+
throw new OAuthError('invalid_request', 'Only code_challenge_method=S256 is supported');
|
|
108
|
+
}
|
|
109
|
+
this.checkResource(query.resource);
|
|
110
|
+
const redirectUrl = URL.parse(params.redirect_uri);
|
|
111
|
+
if (!redirectUrl || !isAllowedRedirectUri(redirectUrl)) {
|
|
112
|
+
throw new OAuthError('invalid_request', 'redirect_uri must use https unless it points to localhost');
|
|
113
|
+
}
|
|
114
|
+
if (!this.configuredClients.has(params.client_id) && !isClientIdUrl(params.client_id)) {
|
|
115
|
+
throw new OAuthError('invalid_client', 'client_id must be the https URL of a client metadata document');
|
|
116
|
+
}
|
|
117
|
+
const request = {
|
|
118
|
+
clientId: params.client_id,
|
|
119
|
+
redirectUri: params.redirect_uri,
|
|
120
|
+
codeChallenge: params.code_challenge,
|
|
121
|
+
state: typeof query.state === 'string' ? query.state : undefined,
|
|
122
|
+
};
|
|
123
|
+
const signedRequest = this.adminforth.auth.issueJWT(request, REQUEST_JWT_TYPE, REQUEST_TTL);
|
|
124
|
+
return `${this.consentPageUrl}?request=${encodeURIComponent(signedRequest)}`;
|
|
125
|
+
}
|
|
126
|
+
/** What the consent page shows about a pending authorization request. */
|
|
127
|
+
async describeAuthorization(signedRequest) {
|
|
128
|
+
const { request, client, redirectUrl } = await this.verifyAuthorizationRequest(signedRequest);
|
|
129
|
+
return {
|
|
130
|
+
clientName: client.clientName,
|
|
131
|
+
// Null for a configured client whose client_id is not a metadata document URL.
|
|
132
|
+
clientHost: URL.parse(request.clientId)?.host ?? null,
|
|
133
|
+
redirectHost: redirectUrl.host,
|
|
134
|
+
loopbackRedirect: isLoopbackUrl(redirectUrl),
|
|
135
|
+
serverReadOnly: this.serverReadOnly,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Approves or denies a pending authorization request and returns where to send the browser. `readOnly` is the
|
|
140
|
+
* choice of the user on the consent page; the connection is read-only with it or with the plugin readOnly option.
|
|
141
|
+
*/
|
|
142
|
+
async resolveAuthorization(signedRequest, approved, adminUser, readOnly = false) {
|
|
143
|
+
// Denying redirects too, so the redirect_uri is checked against the client metadata either way.
|
|
144
|
+
const { request, client, redirectUrl: url } = await this.verifyAuthorizationRequest(signedRequest);
|
|
145
|
+
if (approved) {
|
|
146
|
+
const code = {
|
|
147
|
+
jti: randomUUID(),
|
|
148
|
+
pk: adminUser.pk,
|
|
149
|
+
clientId: request.clientId,
|
|
150
|
+
clientName: client.clientName,
|
|
151
|
+
redirectUri: request.redirectUri,
|
|
152
|
+
codeChallenge: request.codeChallenge,
|
|
153
|
+
readOnly: this.serverReadOnly || readOnly,
|
|
154
|
+
};
|
|
155
|
+
url.searchParams.set('code', this.adminforth.auth.issueJWT(code, CODE_JWT_TYPE, CODE_TTL));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
url.searchParams.set('error', 'access_denied');
|
|
159
|
+
}
|
|
160
|
+
if (request.state !== undefined) {
|
|
161
|
+
url.searchParams.set('state', request.state);
|
|
162
|
+
}
|
|
163
|
+
url.searchParams.set('iss', this.issuer);
|
|
164
|
+
return url.href;
|
|
165
|
+
}
|
|
166
|
+
async exchangeToken(body) {
|
|
167
|
+
if (body.grant_type === 'authorization_code')
|
|
168
|
+
return this.redeemAuthorizationCode(body);
|
|
169
|
+
if (body.grant_type === 'refresh_token')
|
|
170
|
+
return this.refreshAccessToken(body);
|
|
171
|
+
throw new OAuthError('unsupported_grant_type', 'Only authorization_code and refresh_token grants are supported');
|
|
172
|
+
}
|
|
173
|
+
/** Returns the grant an OAuth access token was issued for, or null for an invalid, expired or foreign token. */
|
|
174
|
+
async verifyAccessToken(token) {
|
|
175
|
+
const accessToken = await this.adminforth.auth.verify(token, ACCESS_TOKEN_JWT_TYPE, false);
|
|
176
|
+
return accessToken?.aud === this.mcpUrl ? accessToken : null;
|
|
177
|
+
}
|
|
178
|
+
checkResource(resource) {
|
|
179
|
+
if (resource !== undefined && resource !== this.mcpUrl) {
|
|
180
|
+
throw new OAuthError('invalid_target', `resource must be ${this.mcpUrl}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async verifyAuthorizationRequest(signedRequest) {
|
|
184
|
+
const request = await this.adminforth.auth.verify(signedRequest, REQUEST_JWT_TYPE, false);
|
|
185
|
+
if (!request) {
|
|
186
|
+
throw new OAuthError('invalid_request', 'Authorization request is invalid or expired, start connecting again');
|
|
187
|
+
}
|
|
188
|
+
const client = await this.lookUpClient(request.clientId);
|
|
189
|
+
const redirectUrl = new URL(request.redirectUri);
|
|
190
|
+
if (!client.redirectUris.some((registered) => redirectUriMatches(redirectUrl, registered))) {
|
|
191
|
+
throw new OAuthError('invalid_request', 'redirect_uri is not listed in the client metadata document');
|
|
192
|
+
}
|
|
193
|
+
return { request, client, redirectUrl };
|
|
194
|
+
}
|
|
195
|
+
/** The consent page loads the client to describe it and again to resolve the request, hence the cache. */
|
|
196
|
+
async lookUpClient(clientId) {
|
|
197
|
+
const configured = this.configuredClients.get(clientId);
|
|
198
|
+
if (configured)
|
|
199
|
+
return configured;
|
|
200
|
+
const cached = this.fetchedClients.get(clientId);
|
|
201
|
+
if (cached && cached.expiresAt > Date.now())
|
|
202
|
+
return cached.client;
|
|
203
|
+
const client = await fetchClientMetadata(clientId);
|
|
204
|
+
this.fetchedClients.set(clientId, { client, expiresAt: Date.now() + CLIENT_METADATA_TTL_MS });
|
|
205
|
+
return client;
|
|
206
|
+
}
|
|
207
|
+
async redeemAuthorizationCode(body) {
|
|
208
|
+
const params = requireParams(body, ['code', 'code_verifier', 'client_id', 'redirect_uri']);
|
|
209
|
+
this.checkResource(body.resource);
|
|
210
|
+
const code = await this.adminforth.auth.verify(params.code, CODE_JWT_TYPE, false);
|
|
211
|
+
if (!code) {
|
|
212
|
+
throw new OAuthError('invalid_grant', 'Authorization code is invalid or expired');
|
|
213
|
+
}
|
|
214
|
+
if (code.clientId !== params.client_id || code.redirectUri !== params.redirect_uri) {
|
|
215
|
+
throw new OAuthError('invalid_grant', 'Authorization code was issued for another client_id or redirect_uri');
|
|
216
|
+
}
|
|
217
|
+
if (pkceChallenge(params.code_verifier) !== code.codeChallenge) {
|
|
218
|
+
throw new OAuthError('invalid_grant', 'code_verifier does not match the code challenge');
|
|
219
|
+
}
|
|
220
|
+
const refreshToken = this.issueRefreshToken(code.jti);
|
|
221
|
+
const result = await this.store.createOAuthGrant({
|
|
222
|
+
id: code.jti,
|
|
223
|
+
userId: code.pk,
|
|
224
|
+
clientId: code.clientId,
|
|
225
|
+
clientName: code.clientName,
|
|
226
|
+
readOnly: code.readOnly,
|
|
227
|
+
}, refreshToken);
|
|
228
|
+
if (result.error) {
|
|
229
|
+
throw new OAuthError('invalid_grant', result.error);
|
|
230
|
+
}
|
|
231
|
+
return this.tokenResponse(code.pk, code.jti, refreshToken);
|
|
232
|
+
}
|
|
233
|
+
async refreshAccessToken(body) {
|
|
234
|
+
const params = requireParams(body, ['refresh_token', 'client_id']);
|
|
235
|
+
this.checkResource(body.resource);
|
|
236
|
+
// The signature tells a token this server issued from a made-up one: a made-up token knowing a grant id must
|
|
237
|
+
// not revoke that grant, and anonymous callers must not reach the store or its lock.
|
|
238
|
+
const presented = await this.adminforth.auth.verify(params.refresh_token, REFRESH_TOKEN_JWT_TYPE, false);
|
|
239
|
+
if (!presented) {
|
|
240
|
+
throw new OAuthError('invalid_grant', 'Refresh token is invalid or expired');
|
|
241
|
+
}
|
|
242
|
+
const { grantId } = presented;
|
|
243
|
+
const refreshToken = this.issueRefreshToken(grantId);
|
|
244
|
+
const userId = await this.store.rotateRefreshToken(grantId, params.refresh_token, params.client_id, refreshToken);
|
|
245
|
+
if (!userId) {
|
|
246
|
+
throw new OAuthError('invalid_grant', 'Refresh token was already used or its connection was revoked');
|
|
247
|
+
}
|
|
248
|
+
return this.tokenResponse(userId, grantId, refreshToken);
|
|
249
|
+
}
|
|
250
|
+
issueRefreshToken(grantId) {
|
|
251
|
+
const token = { grantId, nonce: randomBytes(16).toString('base64url') };
|
|
252
|
+
return this.adminforth.auth.issueJWT(token, REFRESH_TOKEN_JWT_TYPE, REFRESH_TOKEN_TTL);
|
|
253
|
+
}
|
|
254
|
+
tokenResponse(pk, grantId, refreshToken) {
|
|
255
|
+
const accessToken = { pk, grantId, aud: this.mcpUrl };
|
|
256
|
+
return {
|
|
257
|
+
access_token: this.adminforth.auth.issueJWT(accessToken, ACCESS_TOKEN_JWT_TYPE, ACCESS_TOKEN_TTL_SECONDS),
|
|
258
|
+
token_type: 'Bearer',
|
|
259
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
260
|
+
refresh_token: refreshToken,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpOAuthClient } from './types.js';
|
|
2
|
+
/** BlockList matches IPv4-mapped IPv6 addresses (::ffff:10.0.0.5) against the IPv4 ranges by itself. */
|
|
3
|
+
export declare function isSpecialPurposeAddress(address: string, family: number): boolean;
|
|
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
|
+
/**
|
|
10
|
+
* Resolves an MCP client by its Client ID Metadata Document, the client_id being the document URL
|
|
11
|
+
* that passed isClientIdUrl.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetchClientMetadata(clientId: string): Promise<McpOAuthClient>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import dns from 'node:dns';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { logger } from 'adminforth';
|
|
5
|
+
import { OAuthError } from './oauthError.js';
|
|
6
|
+
const MAX_DOCUMENT_BYTES = 5 * 1024;
|
|
7
|
+
const FETCH_TIMEOUT_MS = 5000;
|
|
8
|
+
// client_name is shown on the consent page and stored as the connection name.
|
|
9
|
+
const MAX_CLIENT_NAME_LENGTH = 100;
|
|
10
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
11
|
+
const specialPurposeAddresses = new net.BlockList();
|
|
12
|
+
[
|
|
13
|
+
['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],
|
|
14
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
|
|
15
|
+
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
16
|
+
['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
17
|
+
].forEach(([network, prefix]) => specialPurposeAddresses.addSubnet(network, prefix, 'ipv4'));
|
|
18
|
+
[
|
|
19
|
+
['::', 128], ['::1', 128], ['64:ff9b::', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],
|
|
20
|
+
['2002::', 16], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
|
21
|
+
].forEach(([network, prefix]) => specialPurposeAddresses.addSubnet(network, prefix, 'ipv6'));
|
|
22
|
+
/** BlockList matches IPv4-mapped IPv6 addresses (::ffff:10.0.0.5) against the IPv4 ranges by itself. */
|
|
23
|
+
export function isSpecialPurposeAddress(address, family) {
|
|
24
|
+
return specialPurposeAddresses.check(address, family === 6 ? 'ipv6' : 'ipv4');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* client_id is an attacker-chosen URL, so the fetch refuses hosts resolving to private, loopback and other
|
|
28
|
+
* special-purpose addresses. Checking in the socket lookup, not before the request, closes DNS rebinding.
|
|
29
|
+
*/
|
|
30
|
+
const lookupPublicAddress = (hostname, options, callback) => {
|
|
31
|
+
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
32
|
+
if (error) {
|
|
33
|
+
callback(error, []);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const specialAddress = addresses.find(({ address, family }) => isSpecialPurposeAddress(address, family));
|
|
37
|
+
if (specialAddress) {
|
|
38
|
+
callback(new Error(`${hostname} resolves to special-purpose address ${specialAddress.address}`), []);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (options.all) {
|
|
42
|
+
callback(null, addresses);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
export function isClientIdUrl(clientId) {
|
|
50
|
+
const url = URL.parse(clientId);
|
|
51
|
+
return url?.protocol === 'https:'
|
|
52
|
+
&& url.href === clientId
|
|
53
|
+
&& !url.username
|
|
54
|
+
&& !url.password
|
|
55
|
+
&& url.pathname !== '/'
|
|
56
|
+
&& !url.hash
|
|
57
|
+
&& !net.isIP(url.hostname)
|
|
58
|
+
&& !url.hostname.startsWith('[');
|
|
59
|
+
}
|
|
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
|
+
function fetchJson(url) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const request = https.get(url, {
|
|
80
|
+
lookup: lookupPublicAddress,
|
|
81
|
+
headers: { Accept: 'application/json' },
|
|
82
|
+
}, (response) => {
|
|
83
|
+
if (response.statusCode !== 200) {
|
|
84
|
+
response.resume();
|
|
85
|
+
reject(new Error(`responded with HTTP ${response.statusCode}`));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
response.on('data', (chunk) => {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > MAX_DOCUMENT_BYTES) {
|
|
93
|
+
request.destroy(new Error(`document is larger than ${MAX_DOCUMENT_BYTES} bytes`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
chunks.push(chunk);
|
|
97
|
+
});
|
|
98
|
+
response.on('end', () => {
|
|
99
|
+
try {
|
|
100
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
reject(error);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
// The `timeout` option of https.get fires only after a silent period, so a server sending a byte every few
|
|
108
|
+
// seconds would hold the request for hours. The deadline caps the whole request instead.
|
|
109
|
+
const deadline = setTimeout(() => request.destroy(new Error(`timed out after ${FETCH_TIMEOUT_MS} ms`)), FETCH_TIMEOUT_MS);
|
|
110
|
+
request.on('close', () => clearTimeout(deadline));
|
|
111
|
+
request.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolves an MCP client by its Client ID Metadata Document, the client_id being the document URL
|
|
116
|
+
* that passed isClientIdUrl.
|
|
117
|
+
*/
|
|
118
|
+
export async function fetchClientMetadata(clientId) {
|
|
119
|
+
let document;
|
|
120
|
+
try {
|
|
121
|
+
document = await fetchJson(clientId);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
// The reason stays in the log: resolved addresses, HTTP statuses and parse errors quoting the response
|
|
125
|
+
// would tell whoever chose the client_id what this server can reach.
|
|
126
|
+
logger.warn(`AdminForthMcpPlugin: failed to fetch client metadata document ${clientId}: ${error.message}`);
|
|
127
|
+
throw new OAuthError('invalid_client', 'Could not fetch the client metadata document');
|
|
128
|
+
}
|
|
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"');
|
|
135
|
+
}
|
|
136
|
+
if (document.client_id !== clientId) {
|
|
137
|
+
throw new OAuthError('invalid_client', 'client_id in the client metadata document does not match its URL');
|
|
138
|
+
}
|
|
139
|
+
return { clientId, clientName: document.client_name.slice(0, MAX_CLIENT_NAME_LENGTH), redirectUris: document.redirect_uris };
|
|
140
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { logger } from 'adminforth';
|
|
2
|
+
import { OAUTH_PATHS } from './oauth.js';
|
|
3
|
+
import { OAuthError } from './oauthError.js';
|
|
4
|
+
import { MCP_PATH } from './urls.js';
|
|
5
|
+
const MAX_FORM_BODY_BYTES = 16 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* RFC 6749 token requests are application/x-www-form-urlencoded. The host app may already have parsed the body
|
|
8
|
+
* with its own urlencoded middleware; otherwise the stream is still unread.
|
|
9
|
+
*/
|
|
10
|
+
async function readFormBody(req) {
|
|
11
|
+
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) {
|
|
12
|
+
return req.body;
|
|
13
|
+
}
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let size = 0;
|
|
16
|
+
for await (const chunk of req) {
|
|
17
|
+
size += chunk.length;
|
|
18
|
+
if (size > MAX_FORM_BODY_BYTES) {
|
|
19
|
+
throw new OAuthError('invalid_request', `Request body is larger than ${MAX_FORM_BODY_BYTES} bytes`);
|
|
20
|
+
}
|
|
21
|
+
chunks.push(chunk);
|
|
22
|
+
}
|
|
23
|
+
return Object.fromEntries(new URLSearchParams(Buffer.concat(chunks).toString('utf8')));
|
|
24
|
+
}
|
|
25
|
+
async function oauthResponse(input, handler) {
|
|
26
|
+
try {
|
|
27
|
+
const result = await handler();
|
|
28
|
+
// A handler that answered through the raw response returns null; headers can no longer be set then.
|
|
29
|
+
if (result !== null)
|
|
30
|
+
input.response.setHeader('Cache-Control', 'no-store');
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!(error instanceof OAuthError))
|
|
35
|
+
throw error;
|
|
36
|
+
input.response.setHeader('Cache-Control', 'no-store');
|
|
37
|
+
input.response.setStatus(400);
|
|
38
|
+
return error.toResponseObject();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function setupOAuthEndpoints(server, oauth, apiPrefix) {
|
|
42
|
+
server.endpoint({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
path: OAUTH_PATHS.protectedResourceMetadata,
|
|
45
|
+
agent: {
|
|
46
|
+
hiddenFromAgents: true,
|
|
47
|
+
},
|
|
48
|
+
noAuth: true,
|
|
49
|
+
handler: async () => oauth.protectedResourceMetadata(),
|
|
50
|
+
});
|
|
51
|
+
server.endpoint({
|
|
52
|
+
method: 'GET',
|
|
53
|
+
path: OAUTH_PATHS.authorizationServerMetadata,
|
|
54
|
+
agent: {
|
|
55
|
+
hiddenFromAgents: true,
|
|
56
|
+
},
|
|
57
|
+
noAuth: true,
|
|
58
|
+
handler: async () => oauth.authorizationServerMetadata(),
|
|
59
|
+
});
|
|
60
|
+
server.endpoint({
|
|
61
|
+
method: 'GET',
|
|
62
|
+
path: OAUTH_PATHS.jwks,
|
|
63
|
+
agent: {
|
|
64
|
+
hiddenFromAgents: true,
|
|
65
|
+
},
|
|
66
|
+
noAuth: true,
|
|
67
|
+
handler: async () => ({ keys: [] }),
|
|
68
|
+
});
|
|
69
|
+
server.endpoint({
|
|
70
|
+
method: 'GET',
|
|
71
|
+
path: OAUTH_PATHS.authorize,
|
|
72
|
+
agent: {
|
|
73
|
+
hiddenFromAgents: true,
|
|
74
|
+
},
|
|
75
|
+
noAuth: true,
|
|
76
|
+
handler: async (input) => oauthResponse(input, async () => {
|
|
77
|
+
const consentPageUrl = await oauth.authorize(input.query);
|
|
78
|
+
input._raw_express_res.set('Cache-Control', 'no-store').redirect(302, consentPageUrl);
|
|
79
|
+
return null;
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
server.endpoint({
|
|
83
|
+
method: 'GET',
|
|
84
|
+
path: OAUTH_PATHS.authorization,
|
|
85
|
+
agent: {
|
|
86
|
+
hiddenFromAgents: true,
|
|
87
|
+
},
|
|
88
|
+
handler: async (input) => oauthResponse(input, () => oauth.describeAuthorization(String(input.query.request))),
|
|
89
|
+
});
|
|
90
|
+
server.endpoint({
|
|
91
|
+
method: 'POST',
|
|
92
|
+
path: OAUTH_PATHS.authorization,
|
|
93
|
+
agent: {
|
|
94
|
+
hiddenFromAgents: true,
|
|
95
|
+
},
|
|
96
|
+
handler: async (input) => oauthResponse(input, async () => ({
|
|
97
|
+
redirectUrl: await oauth.resolveAuthorization(String(input.body.request), input.body.approved === true, input.adminUser, input.body.readOnly === true),
|
|
98
|
+
})),
|
|
99
|
+
});
|
|
100
|
+
const { expressApp } = server;
|
|
101
|
+
// MCP clients try the host-root well-known URLs first and stop at the first 200 response. The AdminForth SPA
|
|
102
|
+
// answers every unknown path with 200 HTML, so unless these are served the client fails before it reaches
|
|
103
|
+
// `<issuer>/.well-known/openid-configuration`. Behind a proxy that forwards only the AdminForth path they 404,
|
|
104
|
+
// and the client goes on to that URL.
|
|
105
|
+
const mcpPath = `${apiPrefix}${MCP_PATH}`;
|
|
106
|
+
expressApp.get(`/.well-known/oauth-protected-resource${mcpPath}`, async (_req, res) => {
|
|
107
|
+
res.json(oauth.protectedResourceMetadata());
|
|
108
|
+
});
|
|
109
|
+
expressApp.get(`/.well-known/oauth-authorization-server${mcpPath}`, async (_req, res) => {
|
|
110
|
+
res.json(oauth.authorizationServerMetadata());
|
|
111
|
+
});
|
|
112
|
+
expressApp.post(`${apiPrefix}${OAUTH_PATHS.token}`, async (req, res) => {
|
|
113
|
+
res.set('Cache-Control', 'no-store');
|
|
114
|
+
try {
|
|
115
|
+
res.json(await oauth.exchangeToken(await readFormBody(req)));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof OAuthError) {
|
|
119
|
+
res.status(400).json(error.toResponseObject());
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
logger.error(`AdminForthMcpPlugin: OAuth token request failed: ${error.stack ?? String(error)}`);
|
|
123
|
+
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** OAuth error answered as RFC 6749 `{ error, error_description }` with HTTP 400. */
|
|
2
|
+
export declare class OAuthError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
constructor(code: string, description: string);
|
|
5
|
+
toResponseObject(): {
|
|
6
|
+
error: string;
|
|
7
|
+
error_description: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** OAuth error answered as RFC 6749 `{ error, error_description }` with HTTP 400. */
|
|
2
|
+
export class OAuthError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, description) {
|
|
5
|
+
super(description);
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
toResponseObject() {
|
|
9
|
+
return { error: this.code, error_description: this.message };
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/skills.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { McpToolDefinition } from './apiTools.js';
|
|
2
|
+
export declare const FETCH_SKILL_TOOL_NAME = "fetch_skill";
|
|
3
|
+
type TemplateValues = Record<string, string | number>;
|
|
4
|
+
/**
|
|
5
|
+
* Guidance for MCP clients, kept as Markdown in the plugin `custom` folder: `instructions.md` holds the rules every
|
|
6
|
+
* client gets in the server instructions, `skills/<name>/SKILL.md` the detailed rules clients load with fetch_skill.
|
|
7
|
+
*/
|
|
8
|
+
export declare class McpSkills {
|
|
9
|
+
private readonly instructions;
|
|
10
|
+
private readonly skills;
|
|
11
|
+
constructor(customFolderPath: string, values: TemplateValues);
|
|
12
|
+
serverInstructions(): string;
|
|
13
|
+
toolDefinition(): McpToolDefinition;
|
|
14
|
+
call(args: Record<string, unknown> | undefined): {
|
|
15
|
+
output: unknown;
|
|
16
|
+
isError: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export {};
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
export const FETCH_SKILL_TOOL_NAME = 'fetch_skill';
|
|
5
|
+
const INSTRUCTIONS_FILE_NAME = 'instructions.md';
|
|
6
|
+
const SKILLS_DIR_NAME = 'skills';
|
|
7
|
+
const SKILL_FILE_NAME = 'SKILL.md';
|
|
8
|
+
// SKILL.md starts with YAML frontmatter between two --- lines, the rest is the skill body.
|
|
9
|
+
const SKILL_FILE_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
10
|
+
// Placeholders like {{pageSize.max}} in instructions and skills are filled from plugin options.
|
|
11
|
+
const TEMPLATE_PLACEHOLDER_RE = /\{\{\s*([\w.]+)\s*\}\}/g;
|
|
12
|
+
function renderTemplate(template, values, source) {
|
|
13
|
+
return template.replace(TEMPLATE_PLACEHOLDER_RE, (_, key) => {
|
|
14
|
+
if (!(key in values)) {
|
|
15
|
+
throw new Error(`AdminForthMcpPlugin: unknown placeholder {{${key}}} in ${source}`);
|
|
16
|
+
}
|
|
17
|
+
return String(values[key]);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function readSkill(skillsDir, directoryName, values) {
|
|
21
|
+
const file = join(skillsDir, directoryName, SKILL_FILE_NAME);
|
|
22
|
+
const [, frontmatter, body] = readFileSync(file, 'utf8').match(SKILL_FILE_RE);
|
|
23
|
+
const metadata = YAML.parse(frontmatter);
|
|
24
|
+
return {
|
|
25
|
+
name: metadata.name,
|
|
26
|
+
description: renderTemplate(metadata.description, values, file),
|
|
27
|
+
instructions: renderTemplate(body.trim(), values, file),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Guidance for MCP clients, kept as Markdown in the plugin `custom` folder: `instructions.md` holds the rules every
|
|
32
|
+
* client gets in the server instructions, `skills/<name>/SKILL.md` the detailed rules clients load with fetch_skill.
|
|
33
|
+
*/
|
|
34
|
+
export class McpSkills {
|
|
35
|
+
instructions;
|
|
36
|
+
skills;
|
|
37
|
+
constructor(customFolderPath, values) {
|
|
38
|
+
const instructionsFile = join(customFolderPath, INSTRUCTIONS_FILE_NAME);
|
|
39
|
+
this.instructions = renderTemplate(readFileSync(instructionsFile, 'utf8').trim(), values, instructionsFile);
|
|
40
|
+
const skillsDir = join(customFolderPath, SKILLS_DIR_NAME);
|
|
41
|
+
this.skills = new Map(readdirSync(skillsDir, { withFileTypes: true })
|
|
42
|
+
.filter((entry) => entry.isDirectory())
|
|
43
|
+
.map((entry) => readSkill(skillsDir, entry.name, values))
|
|
44
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
45
|
+
.map((skill) => [skill.name, skill]));
|
|
46
|
+
}
|
|
47
|
+
serverInstructions() {
|
|
48
|
+
const skillList = Array.from(this.skills.values())
|
|
49
|
+
.map((skill) => `- ${skill.name}: ${skill.description}`)
|
|
50
|
+
.join('\n');
|
|
51
|
+
return [
|
|
52
|
+
this.instructions,
|
|
53
|
+
`Before a task, load the matching skill with ${FETCH_SKILL_TOOL_NAME}; it has the detailed rules:\n${skillList}`,
|
|
54
|
+
].join('\n\n');
|
|
55
|
+
}
|
|
56
|
+
toolDefinition() {
|
|
57
|
+
return {
|
|
58
|
+
name: FETCH_SKILL_TOOL_NAME,
|
|
59
|
+
description: `Returns the detailed rules of a skill for working with this admin panel. Load the matching skill before the task. Skills: ${Array.from(this.skills.keys()).join(', ')}.`,
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
required: ['skillName'],
|
|
63
|
+
properties: {
|
|
64
|
+
skillName: { type: 'string', enum: Array.from(this.skills.keys()) },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
call(args) {
|
|
70
|
+
const skill = this.skills.get(String(args?.skillName));
|
|
71
|
+
if (!skill) {
|
|
72
|
+
return {
|
|
73
|
+
output: { error: `Unknown skill: ${args?.skillName}`, skills: Array.from(this.skills.keys()) },
|
|
74
|
+
isError: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { output: skill.instructions, isError: false };
|
|
78
|
+
}
|
|
79
|
+
}
|