@adminforth/mcp 1.1.0 → 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 +6 -1
- package/dist/apiTools.js +195 -13
- package/dist/authSecretStore.d.ts +31 -0
- package/dist/authSecretStore.js +80 -11
- package/dist/custom/McpAuthorize.vue +147 -0
- package/dist/custom/McpSettings.vue +130 -56
- 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 -1
- package/dist/index.js +95 -24
- 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 +39 -1
- package/dist/urls.d.ts +13 -0
- package/dist/urls.js +13 -0
- package/package.json +4 -1
|
@@ -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
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -9,11 +9,49 @@ 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;
|
|
17
|
+
}
|
|
18
|
+
/** An MCP client allowed to connect through OAuth, as its Client ID Metadata Document describes it. */
|
|
19
|
+
export interface McpOAuthClient {
|
|
20
|
+
clientId: string;
|
|
21
|
+
clientName: string;
|
|
22
|
+
redirectUris: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface McpPageSize {
|
|
25
|
+
/** Rows get_resource_data returns when the client does not pass limit. */
|
|
26
|
+
default: number;
|
|
27
|
+
/** Most rows get_resource_data returns per call; larger limits are capped. */
|
|
28
|
+
max: number;
|
|
12
29
|
}
|
|
13
30
|
export interface PluginOptions extends PluginsCommonOptions {
|
|
14
31
|
authSecretResource: McpAuthSecretResourceOptions;
|
|
15
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Public origin of this AdminForth installation, without the AdminForth baseUrl path, e.g. https://example.com.
|
|
34
|
+
* 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.
|
|
37
|
+
*/
|
|
16
38
|
adminPanelOrigin?: string;
|
|
39
|
+
/**
|
|
40
|
+
* MCP clients resolved from this list instead of fetching their Client ID Metadata Document, so OAuth can be
|
|
41
|
+
* tried locally without a public https URL. Ignored when NODE_ENV is "production": there every client_id is
|
|
42
|
+
* fetched as a document with SSRF checks. Unlisted client_ids are fetched in development too.
|
|
43
|
+
*/
|
|
44
|
+
devOAuthClients?: McpOAuthClient[];
|
|
45
|
+
/** Page size limits for get_resource_data, also quoted in the MCP instructions and skills. Defaults to 10 and 100. */
|
|
46
|
+
pageSize?: Partial<McpPageSize>;
|
|
47
|
+
/** How long a tool handler may run before the call fails with a timeout error. Defaults to 15000 ms. */
|
|
48
|
+
toolTimeoutMs?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Tool calls an agent may make for one user request before it must ask the user to continue. Defaults to 10.
|
|
51
|
+
* MCP clients do not tell the server where a user request starts, so this is a rule in the MCP instructions,
|
|
52
|
+
* not a server-side limit.
|
|
53
|
+
*/
|
|
54
|
+
toolCallsPerRequest?: number;
|
|
17
55
|
/**
|
|
18
56
|
* Expose only endpoints marked with `agent: { onlyReadsData: true }` to every auth secret.
|
|
19
57
|
* Without it, read-only mode is chosen per auth secret.
|
package/dist/urls.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const MCP_PATH = "/mcp";
|
|
2
|
+
/** Path prefix of AdminForth API endpoints, e.g. `/admin/adminapi/v1`. */
|
|
3
|
+
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
|
+
export interface McpUrls {
|
|
9
|
+
apiUrl: string;
|
|
10
|
+
mcpUrl: string;
|
|
11
|
+
adminPanelUrl: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function createMcpUrls(adminPanelOrigin: string, baseUrl: string): McpUrls;
|
package/dist/urls.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const MCP_PATH = '/mcp';
|
|
2
|
+
/** Path prefix of AdminForth API endpoints, e.g. `/admin/adminapi/v1`. */
|
|
3
|
+
export function adminApiPrefix(baseUrl) {
|
|
4
|
+
return `${baseUrl}/adminapi/v1`;
|
|
5
|
+
}
|
|
6
|
+
export function createMcpUrls(adminPanelOrigin, baseUrl) {
|
|
7
|
+
const apiUrl = new URL(adminApiPrefix(baseUrl), adminPanelOrigin).href;
|
|
8
|
+
return {
|
|
9
|
+
apiUrl,
|
|
10
|
+
mcpUrl: `${apiUrl}${MCP_PATH}`,
|
|
11
|
+
adminPanelUrl: new URL(baseUrl || '/', adminPanelOrigin).href,
|
|
12
|
+
};
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adminforth/mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Expose AdminForth APIs as an authenticated MCP server",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -73,5 +73,8 @@
|
|
|
73
73
|
"prerelease": true
|
|
74
74
|
}
|
|
75
75
|
]
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"yaml": "^2.9.1"
|
|
76
79
|
}
|
|
77
80
|
}
|