@adminforth/mcp 1.3.0 → 1.4.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/adminPanelConfirmation.d.ts +15 -7
- package/dist/adminPanelConfirmation.js +103 -44
- package/dist/apiTools.d.ts +3 -2
- package/dist/apiTools.js +6 -9
- package/dist/custom/McpConfirm.vue +62 -37
- package/dist/endpoints/confirmation.js +2 -2
- package/dist/endpoints/tools.d.ts +2 -1
- package/dist/endpoints/tools.js +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -2
- package/dist/resourceVisibility.d.ts +9 -0
- package/dist/resourceVisibility.js +27 -0
- package/dist/schemas/confirmation.d.ts +6 -4
- package/dist/schemas/confirmation.js +5 -2
- package/dist/schemas/tools.js +1 -1
- package/dist/twoFactorContext.d.ts +2 -1
- package/package.json +1 -1
|
@@ -10,6 +10,9 @@ type ToolCallResult = {
|
|
|
10
10
|
type AdminUserWithExecutor = AdminUser & {
|
|
11
11
|
executedBy?: string;
|
|
12
12
|
};
|
|
13
|
+
type ConfirmationInput = IAdminForthEndpointHandlerInput & {
|
|
14
|
+
adminUser: AdminUser;
|
|
15
|
+
};
|
|
13
16
|
export type McpConnection = {
|
|
14
17
|
id: string;
|
|
15
18
|
name: string;
|
|
@@ -25,23 +28,28 @@ export declare class McpAdminPanelConfirmation {
|
|
|
25
28
|
toolDefinition(): Tool;
|
|
26
29
|
runUnverified<T>(extra: HttpExtra, callback: () => Promise<T>): Promise<unknown>;
|
|
27
30
|
offer(output: unknown, connection: McpConnection, adminUser: AdminUserWithExecutor, path: string, args: Record<string, unknown>): ToolCallResult | undefined;
|
|
31
|
+
/** The user confirms the other pending actions of the same agent together with the one of the link. */
|
|
28
32
|
describe(confirmationId: string, adminUser: AdminUser): {
|
|
29
33
|
error: string;
|
|
30
34
|
} | {
|
|
31
|
-
actionDescription: string | undefined;
|
|
32
35
|
connectionName: string;
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
actions: {
|
|
37
|
+
id: string;
|
|
38
|
+
actionDescription: string | undefined;
|
|
39
|
+
path: string;
|
|
40
|
+
arguments: Record<string, unknown>;
|
|
41
|
+
}[];
|
|
35
42
|
};
|
|
36
|
-
run(
|
|
37
|
-
|
|
38
|
-
}): Promise<unknown>;
|
|
39
|
-
decline(confirmationId: string, adminUser: AdminUser): {
|
|
43
|
+
run(ids: string[], verificationResult: Record<string, unknown>, input: ConfirmationInput): Promise<unknown>;
|
|
44
|
+
decline(ids: string[], adminUser: AdminUser): {
|
|
40
45
|
ok: true;
|
|
41
46
|
} | {
|
|
42
47
|
error: string;
|
|
43
48
|
};
|
|
44
49
|
wait(confirmationId: string, connectionId: string): Promise<ToolCallResult>;
|
|
50
|
+
private execute;
|
|
51
|
+
/** All actions of a group belong to one user and run as one agent, so they can share one verification. */
|
|
52
|
+
private openGroup;
|
|
45
53
|
private open;
|
|
46
54
|
}
|
|
47
55
|
export {};
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { MAX_ACTIONS_PER_CONFIRMATION } from './schemas/confirmation.js';
|
|
2
3
|
import { adminApiPrefix, CONFIRMATION_PAGE_PATH } from './urls.js';
|
|
3
4
|
export const WAIT_FOR_CONFIRMATION_TOOL_NAME = 'wait_for_confirmation';
|
|
4
5
|
const CONFIRMATION_TTL_MS = 10 * 60 * 1000;
|
|
5
6
|
const WAIT_MS = 50_000;
|
|
6
7
|
const TWO_FACTOR_REQUIRED_CODE = 'TWO_FACTOR_REQUIRED';
|
|
8
|
+
/** The verification is cached per user, so it must be checked as the same agent identity the actions run with. */
|
|
9
|
+
function actingUser(adminUser, pending) {
|
|
10
|
+
return { ...adminUser, executedBy: pending.executedBy };
|
|
11
|
+
}
|
|
12
|
+
function actsAsOne(pending, other) {
|
|
13
|
+
return pending.connection.id === other.connection.id && pending.executedBy === other.executedBy;
|
|
14
|
+
}
|
|
7
15
|
export class McpAdminPanelConfirmation {
|
|
8
16
|
adminforth;
|
|
9
17
|
twoFactorsAuth;
|
|
@@ -37,9 +45,10 @@ export class McpAdminPanelConfirmation {
|
|
|
37
45
|
const { code, actionDescription } = (output ?? {});
|
|
38
46
|
if (code !== TWO_FACTOR_REQUIRED_CODE)
|
|
39
47
|
return undefined;
|
|
40
|
-
const
|
|
48
|
+
const id = randomUUID();
|
|
41
49
|
let resolve;
|
|
42
50
|
const pending = {
|
|
51
|
+
id,
|
|
43
52
|
userPk: adminUser.pk,
|
|
44
53
|
connection,
|
|
45
54
|
executedBy: adminUser.executedBy,
|
|
@@ -54,35 +63,99 @@ export class McpAdminPanelConfirmation {
|
|
|
54
63
|
},
|
|
55
64
|
result: new Promise((settle) => { resolve = settle; }),
|
|
56
65
|
};
|
|
57
|
-
this.pending.set(
|
|
58
|
-
setTimeout(() => this.pending.delete(
|
|
66
|
+
this.pending.set(id, pending);
|
|
67
|
+
setTimeout(() => this.pending.delete(id), CONFIRMATION_TTL_MS).unref();
|
|
59
68
|
return {
|
|
60
69
|
output: {
|
|
61
|
-
error: `This action needs a two-factor verification, which only the admin panel can ask for. Show the user the confirmationUrl, then call ${WAIT_FOR_CONFIRMATION_TOOL_NAME} with the confirmationId: it returns the result once the user confirms or declines the action there.`,
|
|
62
|
-
confirmationUrl: `${this.pageUrl}?id=${
|
|
63
|
-
confirmationId,
|
|
70
|
+
error: `This action needs a two-factor verification, which only the admin panel can ask for. Show the user the confirmationUrl, then call ${WAIT_FOR_CONFIRMATION_TOOL_NAME} with the confirmationId: it returns the result once the user confirms or declines the action there. A link confirms up to ${MAX_ACTIONS_PER_CONFIRMATION} pending actions of this connection at once, so after several such actions show the user only one link.`,
|
|
71
|
+
confirmationUrl: `${this.pageUrl}?id=${id}`,
|
|
72
|
+
confirmationId: id,
|
|
64
73
|
},
|
|
65
74
|
isError: true,
|
|
66
75
|
};
|
|
67
76
|
}
|
|
77
|
+
/** The user confirms the other pending actions of the same agent together with the one of the link. */
|
|
68
78
|
describe(confirmationId, adminUser) {
|
|
69
79
|
const pending = this.open(confirmationId, adminUser);
|
|
70
80
|
if ('error' in pending)
|
|
71
81
|
return pending;
|
|
82
|
+
const others = [...this.pending.values()].filter((other) => other !== pending
|
|
83
|
+
&& actsAsOne(other, pending)
|
|
84
|
+
&& !('error' in this.open(other.id, adminUser)));
|
|
72
85
|
return {
|
|
73
|
-
actionDescription: pending.actionDescription,
|
|
74
86
|
connectionName: pending.connection.name,
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
actions: [pending, ...others].slice(0, MAX_ACTIONS_PER_CONFIRMATION).map((action) => ({
|
|
88
|
+
id: action.id,
|
|
89
|
+
actionDescription: action.actionDescription,
|
|
90
|
+
path: action.path,
|
|
91
|
+
arguments: action.arguments,
|
|
92
|
+
})),
|
|
77
93
|
};
|
|
78
94
|
}
|
|
79
|
-
async run(
|
|
80
|
-
const
|
|
81
|
-
if ('error' in
|
|
95
|
+
async run(ids, verificationResult, input) {
|
|
96
|
+
const group = this.openGroup(ids, input.adminUser);
|
|
97
|
+
if ('error' in group) {
|
|
82
98
|
input.response.setStatus(400);
|
|
83
|
-
return
|
|
99
|
+
return group;
|
|
100
|
+
}
|
|
101
|
+
group.forEach((pending) => { pending.running = true; });
|
|
102
|
+
const extra = {
|
|
103
|
+
body: {},
|
|
104
|
+
query: {},
|
|
105
|
+
headers: input.headers,
|
|
106
|
+
cookies: input.cookies,
|
|
107
|
+
requestUrl: input.requestUrl,
|
|
108
|
+
response: input.response,
|
|
109
|
+
};
|
|
110
|
+
const failAll = (output) => group.map(() => ({ output: { ok: false, ...output }, isError: true }));
|
|
111
|
+
let results;
|
|
112
|
+
try {
|
|
113
|
+
const outcome = await this.twoFactorsAuth.withVerificationContext(verificationResult, extra, async () => {
|
|
114
|
+
// A passkey can be used only once: the actions reuse this check, and a failed one ends the context with its code.
|
|
115
|
+
await this.twoFactorsAuth.verifyAuto(actingUser(input.adminUser, group[0]));
|
|
116
|
+
const results = [];
|
|
117
|
+
for (const pending of group)
|
|
118
|
+
results.push(await this.execute(pending, verificationResult, input));
|
|
119
|
+
return results;
|
|
120
|
+
});
|
|
121
|
+
results = Array.isArray(outcome) ? outcome : failAll(outcome);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
results = failAll({ code: 'MCP_CONFIRMATION_FAILED', error: error instanceof Error ? error.message : String(error) });
|
|
125
|
+
}
|
|
126
|
+
group.forEach((pending, index) => {
|
|
127
|
+
pending.running = false;
|
|
128
|
+
pending.settle(results[index]);
|
|
129
|
+
});
|
|
130
|
+
return { results: group.map((pending, index) => ({ id: pending.id, ...results[index] })) };
|
|
131
|
+
}
|
|
132
|
+
decline(ids, adminUser) {
|
|
133
|
+
const group = this.openGroup(ids, adminUser);
|
|
134
|
+
if ('error' in group)
|
|
135
|
+
return group;
|
|
136
|
+
group.forEach((pending) => pending.settle({ output: { error: 'The user declined the action in the admin panel.' }, isError: true }));
|
|
137
|
+
return { ok: true };
|
|
138
|
+
}
|
|
139
|
+
async wait(confirmationId, connectionId) {
|
|
140
|
+
const pending = this.pending.get(confirmationId);
|
|
141
|
+
if (pending?.connection.id !== connectionId) {
|
|
142
|
+
return { output: { error: 'Unknown or expired confirmationId.' }, isError: true };
|
|
143
|
+
}
|
|
144
|
+
let timer;
|
|
145
|
+
const timeout = new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(() => resolve({
|
|
147
|
+
output: { status: 'waiting', note: `The user has not confirmed or declined the action yet. Call ${WAIT_FOR_CONFIRMATION_TOOL_NAME} again to keep waiting.` },
|
|
148
|
+
isError: false,
|
|
149
|
+
}), WAIT_MS);
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
return await Promise.race([pending.result, timeout]);
|
|
84
153
|
}
|
|
85
|
-
|
|
154
|
+
finally {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async execute(pending, verificationResult, input) {
|
|
86
159
|
const controller = new AbortController();
|
|
87
160
|
const abort = () => controller.abort();
|
|
88
161
|
input.abortSignal?.addEventListener('abort', abort);
|
|
@@ -110,14 +183,14 @@ export class McpAdminPanelConfirmation {
|
|
|
110
183
|
requestUrl: schema.path,
|
|
111
184
|
response,
|
|
112
185
|
};
|
|
113
|
-
const execution =
|
|
186
|
+
const execution = schema.handler({
|
|
114
187
|
...request,
|
|
115
|
-
adminUser:
|
|
188
|
+
adminUser: actingUser(input.adminUser, pending),
|
|
116
189
|
abortSignal: controller.signal,
|
|
117
190
|
tr: input.tr,
|
|
118
191
|
_raw_express_req: undefined,
|
|
119
192
|
_raw_express_res: undefined,
|
|
120
|
-
})
|
|
193
|
+
});
|
|
121
194
|
const timeout = new Promise((resolve) => {
|
|
122
195
|
timer = setTimeout(() => {
|
|
123
196
|
controller.abort();
|
|
@@ -134,36 +207,22 @@ export class McpAdminPanelConfirmation {
|
|
|
134
207
|
finally {
|
|
135
208
|
clearTimeout(timer);
|
|
136
209
|
input.abortSignal?.removeEventListener('abort', abort);
|
|
137
|
-
pending.running = false;
|
|
138
210
|
}
|
|
139
|
-
|
|
140
|
-
return output;
|
|
141
|
-
}
|
|
142
|
-
decline(confirmationId, adminUser) {
|
|
143
|
-
const pending = this.open(confirmationId, adminUser);
|
|
144
|
-
if ('error' in pending)
|
|
145
|
-
return pending;
|
|
146
|
-
pending.settle({ output: { error: 'The user declined the action in the admin panel.' }, isError: true });
|
|
147
|
-
return { ok: true };
|
|
211
|
+
return { output, isError: responseStatus >= 400 || Boolean(output && typeof output === 'object' && ('error' in output || output.ok === false)) };
|
|
148
212
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
});
|
|
161
|
-
try {
|
|
162
|
-
return await Promise.race([pending.result, timeout]);
|
|
163
|
-
}
|
|
164
|
-
finally {
|
|
165
|
-
clearTimeout(timer);
|
|
213
|
+
/** All actions of a group belong to one user and run as one agent, so they can share one verification. */
|
|
214
|
+
openGroup(ids, adminUser) {
|
|
215
|
+
const group = [];
|
|
216
|
+
for (const id of new Set(ids)) {
|
|
217
|
+
const pending = this.open(id, adminUser);
|
|
218
|
+
if ('error' in pending)
|
|
219
|
+
return pending;
|
|
220
|
+
if (group.length && !actsAsOne(pending, group[0])) {
|
|
221
|
+
return { error: 'These actions were requested by different MCP clients.' };
|
|
222
|
+
}
|
|
223
|
+
group.push(pending);
|
|
166
224
|
}
|
|
225
|
+
return group;
|
|
167
226
|
}
|
|
168
227
|
open(confirmationId, adminUser) {
|
|
169
228
|
const pending = this.pending.get(confirmationId);
|
package/dist/apiTools.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { Tool } from '@modelcontextprotocol/server';
|
|
2
2
|
import type { McpAdminPanelConfirmation, McpConnection } from './adminPanelConfirmation.js';
|
|
3
|
+
import type { McpResourceVisibility } from './resourceVisibility.js';
|
|
3
4
|
import type { McpPageSize } from './types.js';
|
|
4
5
|
import type { AdminUser, IAdminForth } from 'adminforth';
|
|
5
6
|
export declare class AdminForthApiTools {
|
|
6
7
|
private readonly adminforth;
|
|
7
|
-
private readonly
|
|
8
|
+
private readonly resourceVisibility;
|
|
8
9
|
private readonly toolTimeoutMs;
|
|
9
10
|
private readonly adminPanelConfirmation;
|
|
10
|
-
constructor(adminforth: IAdminForth,
|
|
11
|
+
constructor(adminforth: IAdminForth, resourceVisibility: McpResourceVisibility, pageSize: McpPageSize, toolTimeoutMs: number, adminPanelConfirmation: McpAdminPanelConfirmation | undefined);
|
|
11
12
|
private readonly toolOverrides;
|
|
12
13
|
private fullCatalog?;
|
|
13
14
|
private readOnlyCatalog?;
|
package/dist/apiTools.js
CHANGED
|
@@ -159,12 +159,12 @@ function createDirectResponse() {
|
|
|
159
159
|
}
|
|
160
160
|
export class AdminForthApiTools {
|
|
161
161
|
adminforth;
|
|
162
|
-
|
|
162
|
+
resourceVisibility;
|
|
163
163
|
toolTimeoutMs;
|
|
164
164
|
adminPanelConfirmation;
|
|
165
|
-
constructor(adminforth,
|
|
165
|
+
constructor(adminforth, resourceVisibility, pageSize, toolTimeoutMs, adminPanelConfirmation) {
|
|
166
166
|
this.adminforth = adminforth;
|
|
167
|
-
this.
|
|
167
|
+
this.resourceVisibility = resourceVisibility;
|
|
168
168
|
this.toolTimeoutMs = toolTimeoutMs;
|
|
169
169
|
this.adminPanelConfirmation = adminPanelConfirmation;
|
|
170
170
|
this.toolOverrides = createToolOverrides(pageSize, adminforth);
|
|
@@ -239,12 +239,9 @@ export class AdminForthApiTools {
|
|
|
239
239
|
if (!schema) {
|
|
240
240
|
return { output: { error: `Unknown tool: ${params.name}` }, isError: true };
|
|
241
241
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
output: { error: 'This resource is not available through MCP.' },
|
|
246
|
-
isError: true,
|
|
247
|
-
};
|
|
242
|
+
const resourceId = params.arguments?.resourceId;
|
|
243
|
+
if (typeof resourceId === 'string' && !await this.resourceVisibility.isVisible(params.adminUser, resourceId)) {
|
|
244
|
+
return { output: { error: `Resource ${resourceId} not found` }, isError: true };
|
|
248
245
|
}
|
|
249
246
|
const override = this.toolOverrides[params.name];
|
|
250
247
|
const args = { ...override?.defaultArguments, ...params.arguments };
|
|
@@ -5,18 +5,17 @@
|
|
|
5
5
|
<h1 class="text-xl font-medium text-gray-900 dark:text-white">{{ title }}</h1>
|
|
6
6
|
<p class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
|
7
7
|
<b>{{ pending.connectionName }}</b>
|
|
8
|
-
{{ pending.
|
|
9
|
-
? $t('asks to run this action on your behalf.')
|
|
10
|
-
: $t('asks to run {path} on your behalf with these arguments:', { path: pending.path }) }}
|
|
8
|
+
{{ $t('asks to run this action on your behalf. | asks to run these actions on your behalf.', pending.actions.length) }}
|
|
11
9
|
</p>
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
</
|
|
10
|
+
<template v-if="resultMessage">
|
|
11
|
+
<p class="mt-4 text-sm font-medium" :class="failed ? 'text-red-700 dark:text-red-300' : 'text-green-700 dark:text-green-300'">
|
|
12
|
+
{{ resultMessage }}
|
|
13
|
+
</p>
|
|
14
|
+
<ul v-if="failedErrors.length" class="mt-2 list-disc space-y-1 pl-5 text-sm text-red-700 dark:text-red-300">
|
|
15
|
+
<li v-for="(failedError, i) in failedErrors" :key="i">{{ failedError }}</li>
|
|
16
|
+
</ul>
|
|
17
|
+
</template>
|
|
18
|
+
<Spinner v-else class="mx-auto mt-4 h-8 w-8" />
|
|
20
19
|
</template>
|
|
21
20
|
<template v-else-if="error">
|
|
22
21
|
<h1 class="text-xl font-medium text-gray-900 dark:text-white">{{ $t('Cannot run the action') }}</h1>
|
|
@@ -31,67 +30,93 @@
|
|
|
31
30
|
import { computed, onMounted, ref } from 'vue';
|
|
32
31
|
import { useRoute } from 'vue-router';
|
|
33
32
|
import { useI18n } from 'vue-i18n';
|
|
34
|
-
import {
|
|
33
|
+
import { Spinner } from '@/afcl';
|
|
35
34
|
import { callAdminForthApi } from '@/utils';
|
|
36
35
|
|
|
37
|
-
type
|
|
36
|
+
type PendingAction = {
|
|
37
|
+
id: string;
|
|
38
38
|
actionDescription?: string;
|
|
39
|
-
connectionName: string;
|
|
40
39
|
path: string;
|
|
41
40
|
arguments: Record<string, unknown>;
|
|
42
41
|
};
|
|
42
|
+
type PendingConfirmation = {
|
|
43
|
+
connectionName: string;
|
|
44
|
+
actions: PendingAction[];
|
|
45
|
+
};
|
|
46
|
+
type ActionResult = { id: string; output: { error?: string }; isError: boolean };
|
|
43
47
|
const { t } = useI18n();
|
|
44
48
|
const route = useRoute();
|
|
45
|
-
const id = String(route.query.id);
|
|
46
49
|
const pending = ref<PendingConfirmation | null>(null);
|
|
47
50
|
const error = ref('');
|
|
48
|
-
const running = ref(false);
|
|
49
|
-
const finished = ref(false);
|
|
50
51
|
const failed = ref(false);
|
|
51
52
|
const resultMessage = ref('');
|
|
52
|
-
const
|
|
53
|
-
const
|
|
53
|
+
const failedErrors = ref<string[]>([]);
|
|
54
|
+
const ids = computed(() => pending.value!.actions.map((action) => action.id));
|
|
55
|
+
const title = computed(() => {
|
|
56
|
+
const actions = pending.value!.actions;
|
|
57
|
+
return actions.length > 1
|
|
58
|
+
? t('Confirm {count} actions of your agent', { count: actions.length })
|
|
59
|
+
: actions[0].actionDescription ?? t('Confirm the action of your agent');
|
|
60
|
+
});
|
|
61
|
+
// Without a description of an action, the modal shows the raw call of the agent instead.
|
|
62
|
+
const modalContent = computed(() => {
|
|
63
|
+
const actions = pending.value!.actions;
|
|
64
|
+
if (actions.length > 1) {
|
|
65
|
+
return { items: actions.map((action) => action.actionDescription ?? `${action.path} ${JSON.stringify(action.arguments)}`) };
|
|
66
|
+
}
|
|
67
|
+
const [action] = actions;
|
|
68
|
+
return action.actionDescription ? {} : { details: `${action.path}\n${JSON.stringify(action.arguments, null, 2)}` };
|
|
69
|
+
});
|
|
54
70
|
|
|
55
71
|
onMounted(async () => {
|
|
56
|
-
const response = await callAdminForthApi({ method: 'GET', path: `/mcp/confirmation?id=${encodeURIComponent(id)}` });
|
|
72
|
+
const response = await callAdminForthApi({ method: 'GET', path: `/mcp/confirmation?id=${encodeURIComponent(String(route.query.id))}` });
|
|
57
73
|
if (response?.error) error.value = response.error;
|
|
58
|
-
else if (response)
|
|
74
|
+
else if (response) {
|
|
75
|
+
pending.value = response;
|
|
76
|
+
await confirm();
|
|
77
|
+
}
|
|
59
78
|
});
|
|
60
79
|
|
|
61
80
|
async function confirm() {
|
|
62
|
-
running.value = true;
|
|
63
81
|
try {
|
|
64
82
|
let verificationResult;
|
|
65
83
|
try {
|
|
66
|
-
verificationResult = await (window as any).adminforthTwoFaModal.get2FaConfirmationResult(title.value);
|
|
84
|
+
verificationResult = await (window as any).adminforthTwoFaModal.get2FaConfirmationResult(title.value, undefined, modalContent.value);
|
|
67
85
|
} catch (error) {
|
|
68
86
|
await decline();
|
|
69
|
-
if (error !== 'Cancel' &&
|
|
87
|
+
if (error !== 'Cancel' && !failed.value) {
|
|
70
88
|
failed.value = true;
|
|
71
89
|
resultMessage.value = error instanceof Error ? error.message : String(error);
|
|
72
90
|
}
|
|
73
91
|
return;
|
|
74
92
|
}
|
|
75
|
-
const response = await callAdminForthApi({ method: 'POST', path: '/mcp/confirmation/run', body: {
|
|
76
|
-
|
|
77
|
-
finished.value = true;
|
|
78
|
-
resultMessage.value = failed.value
|
|
79
|
-
? response?.error ?? t('The action failed.')
|
|
80
|
-
: t('Done. Your agent got the result. You can close this page.');
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (error !== 'Cancel') {
|
|
93
|
+
const response = await callAdminForthApi({ method: 'POST', path: '/mcp/confirmation/run', body: { ids: ids.value, verificationResult } });
|
|
94
|
+
if (!response || response.error) {
|
|
83
95
|
failed.value = true;
|
|
84
|
-
resultMessage.value = error
|
|
96
|
+
resultMessage.value = response?.error ?? t('The action failed.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const errors = (response.results as ActionResult[])
|
|
100
|
+
.filter((result) => result.isError)
|
|
101
|
+
.map((result) => result.output?.error ?? t('The action failed.'));
|
|
102
|
+
failed.value = errors.length > 0;
|
|
103
|
+
if (!errors.length) {
|
|
104
|
+
resultMessage.value = t('Done. Your agent got the result. You can close this page.');
|
|
105
|
+
} else if (ids.value.length === 1) {
|
|
106
|
+
resultMessage.value = errors[0];
|
|
107
|
+
} else {
|
|
108
|
+
resultMessage.value = t('{failed} of {count} actions failed:', { failed: errors.length, count: ids.value.length });
|
|
109
|
+
failedErrors.value = errors;
|
|
85
110
|
}
|
|
86
|
-
}
|
|
87
|
-
|
|
111
|
+
} catch (error) {
|
|
112
|
+
failed.value = true;
|
|
113
|
+
resultMessage.value = error instanceof Error ? error.message : String(error);
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
async function decline() {
|
|
92
|
-
const response = await callAdminForthApi({ method: 'POST', path: '/mcp/confirmation/decline', body: {
|
|
118
|
+
const response = await callAdminForthApi({ method: 'POST', path: '/mcp/confirmation/decline', body: { ids: ids.value } });
|
|
93
119
|
failed.value = Boolean(response?.error);
|
|
94
|
-
finished.value = !failed.value;
|
|
95
120
|
resultMessage.value = response?.error ?? t('Declined. Your agent was told.');
|
|
96
121
|
}
|
|
97
122
|
</script>
|
|
@@ -19,7 +19,7 @@ export function registerConfirmationEndpoints(server, confirmation) {
|
|
|
19
19
|
request_schema: runConfirmationBodySchema,
|
|
20
20
|
handler: async (input) => {
|
|
21
21
|
const body = input.body;
|
|
22
|
-
return confirmation.run(body.
|
|
22
|
+
return confirmation.run(body.ids, body.verificationResult, input);
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
server.endpoint({
|
|
@@ -29,7 +29,7 @@ export function registerConfirmationEndpoints(server, confirmation) {
|
|
|
29
29
|
request_schema: declineConfirmationBodySchema,
|
|
30
30
|
handler: async (input) => {
|
|
31
31
|
const body = input.body;
|
|
32
|
-
const result = confirmation.decline(body.
|
|
32
|
+
const result = confirmation.decline(body.ids, input.adminUser);
|
|
33
33
|
if ('error' in result)
|
|
34
34
|
input.response.setStatus(400);
|
|
35
35
|
return result;
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { IAdminForth, IHttpServer } from 'adminforth';
|
|
2
|
-
|
|
2
|
+
import type { McpResourceVisibility } from '../resourceVisibility.js';
|
|
3
|
+
export declare function registerToolEndpoints(server: IHttpServer, adminforth: IAdminForth, resourceVisibility: McpResourceVisibility): void;
|
package/dist/endpoints/tools.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { resourcesListResponseSchema } from '../schemas/tools.js';
|
|
2
|
-
export function registerToolEndpoints(server, adminforth) {
|
|
2
|
+
export function registerToolEndpoints(server, adminforth, resourceVisibility) {
|
|
3
3
|
server.endpoint({
|
|
4
4
|
method: 'GET',
|
|
5
5
|
path: '/get_resources_list',
|
|
6
|
-
description: 'Lists the resourceId and label of every resource (data table)
|
|
6
|
+
description: 'Lists the resourceId and label of every resource (data table) whose records the user may list or show. Call this first to discover valid resourceId values before using get_resource, get_resource_data, aggregate, create_record, update_record, delete_record or other resource tools.',
|
|
7
7
|
agent: {
|
|
8
8
|
onlyReadsData: true,
|
|
9
9
|
},
|
|
10
10
|
response_schema: resourcesListResponseSchema,
|
|
11
|
-
handler: async ({ tr }) => {
|
|
12
|
-
const resources = await Promise.all(
|
|
11
|
+
handler: async ({ adminUser, tr }) => {
|
|
12
|
+
const resources = await Promise.all((await resourceVisibility.visibleResources(adminUser)).map(async (resource) => ({
|
|
13
13
|
resourceId: resource.resourceId,
|
|
14
14
|
label: await tr(resource.label, `resource.${resource.resourceId}`),
|
|
15
15
|
})));
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { registerToolEndpoints } from './endpoints/tools.js';
|
|
|
12
12
|
import { AuthSecretRepository } from './repositories/authSecret.js';
|
|
13
13
|
import { setupOAuth } from './oauth/setup.js';
|
|
14
14
|
import { requestExtra } from './requestExtra.js';
|
|
15
|
+
import { McpResourceVisibility } from './resourceVisibility.js';
|
|
15
16
|
import { createServerReadOnly } from './serverReadOnly.js';
|
|
16
17
|
import { FETCH_SKILL_TOOL_NAME, McpSkills } from './skills.js';
|
|
17
18
|
import { findContextualTwoFactorsAuth } from './twoFactorContext.js';
|
|
@@ -42,6 +43,7 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
42
43
|
authSecretStore;
|
|
43
44
|
oauth;
|
|
44
45
|
apiTools;
|
|
46
|
+
resourceVisibility;
|
|
45
47
|
adminPanelConfirmation;
|
|
46
48
|
skills;
|
|
47
49
|
urls;
|
|
@@ -99,7 +101,8 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
99
101
|
default: this.options.pageSize?.default ?? DEFAULT_PAGE_SIZE,
|
|
100
102
|
max: this.options.pageSize?.max ?? MAX_PAGE_SIZE,
|
|
101
103
|
};
|
|
102
|
-
this.
|
|
104
|
+
this.resourceVisibility = new McpResourceVisibility(adminforth, new Set([this.options.authSecretResource.resourceId]));
|
|
105
|
+
this.apiTools = new AdminForthApiTools(adminforth, this.resourceVisibility, pageSize, toolTimeoutMs, this.adminPanelConfirmation);
|
|
103
106
|
this.skills = new McpSkills(this.customFolderPath, {
|
|
104
107
|
'pageSize.default': pageSize.default,
|
|
105
108
|
'pageSize.max': pageSize.max,
|
|
@@ -128,7 +131,7 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
128
131
|
}
|
|
129
132
|
}
|
|
130
133
|
setupEndpoints(server) {
|
|
131
|
-
registerToolEndpoints(server, this.adminforth);
|
|
134
|
+
registerToolEndpoints(server, this.adminforth, this.resourceVisibility);
|
|
132
135
|
registerSettingsEndpoints(server, this.authSecretStore, this.urls.mcpUrl, this.serverReadOnly);
|
|
133
136
|
registerOAuthEndpoints(server, this.oauth, adminApiPrefix(this.adminforth.config.baseUrl));
|
|
134
137
|
if (this.adminPanelConfirmation)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type AdminForthResource, type AdminUser, type IAdminForth } from 'adminforth';
|
|
2
|
+
export declare class McpResourceVisibility {
|
|
3
|
+
private readonly adminforth;
|
|
4
|
+
private readonly hiddenResourceIds;
|
|
5
|
+
constructor(adminforth: IAdminForth, hiddenResourceIds: ReadonlySet<string>);
|
|
6
|
+
visibleResources(adminUser: AdminUser): Promise<AdminForthResource[]>;
|
|
7
|
+
isVisible(adminUser: AdminUser, resourceId: string): Promise<boolean>;
|
|
8
|
+
private canListOrShow;
|
|
9
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ActionCheckSource, interpretResource, } from 'adminforth';
|
|
2
|
+
export class McpResourceVisibility {
|
|
3
|
+
adminforth;
|
|
4
|
+
hiddenResourceIds;
|
|
5
|
+
constructor(adminforth, hiddenResourceIds) {
|
|
6
|
+
this.adminforth = adminforth;
|
|
7
|
+
this.hiddenResourceIds = hiddenResourceIds;
|
|
8
|
+
}
|
|
9
|
+
async visibleResources(adminUser) {
|
|
10
|
+
const resources = this.adminforth.config.resources;
|
|
11
|
+
const visible = await Promise.all(resources.map((resource) => this.canListOrShow(adminUser, resource)));
|
|
12
|
+
return resources.filter((_, index) => visible[index]);
|
|
13
|
+
}
|
|
14
|
+
async isVisible(adminUser, resourceId) {
|
|
15
|
+
const resource = this.adminforth.config.resources.find((candidate) => candidate.resourceId === resourceId);
|
|
16
|
+
return resource !== undefined && this.canListOrShow(adminUser, resource);
|
|
17
|
+
}
|
|
18
|
+
async canListOrShow(adminUser, resource) {
|
|
19
|
+
if (this.hiddenResourceIds.has(resource.resourceId))
|
|
20
|
+
return false;
|
|
21
|
+
const [{ allowedActions: { list } }, { allowedActions: { show } }] = await Promise.all([
|
|
22
|
+
interpretResource(adminUser, resource, {}, ActionCheckSource.ListRequest, this.adminforth),
|
|
23
|
+
interpretResource(adminUser, resource, {}, ActionCheckSource.ShowRequest, this.adminforth),
|
|
24
|
+
]);
|
|
25
|
+
return list === true || show === true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
/** One confirmation in the admin panel covers at most this many pending actions, all shown to the user. */
|
|
3
|
+
export declare const MAX_ACTIONS_PER_CONFIRMATION = 20;
|
|
2
4
|
declare const runConfirmationBody: z.ZodObject<{
|
|
3
|
-
|
|
5
|
+
ids: z.ZodArray<z.ZodString>;
|
|
4
6
|
verificationResult: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
5
7
|
}, z.core.$strip>;
|
|
6
8
|
declare const declineConfirmationBody: z.ZodObject<{
|
|
7
|
-
|
|
9
|
+
ids: z.ZodArray<z.ZodString>;
|
|
8
10
|
}, z.core.$strip>;
|
|
9
11
|
export type RunConfirmationBody = z.infer<typeof runConfirmationBody>;
|
|
10
12
|
export type DeclineConfirmationBody = z.infer<typeof declineConfirmationBody>;
|
|
11
13
|
export declare const runConfirmationBodySchema: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
|
|
12
|
-
|
|
14
|
+
ids: z.ZodArray<z.ZodString>;
|
|
13
15
|
verificationResult: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
14
16
|
}, z.core.$strip>>;
|
|
15
17
|
export declare const declineConfirmationBodySchema: z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
|
|
16
|
-
|
|
18
|
+
ids: z.ZodArray<z.ZodString>;
|
|
17
19
|
}, z.core.$strip>>;
|
|
18
20
|
export {};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
/** One confirmation in the admin panel covers at most this many pending actions, all shown to the user. */
|
|
3
|
+
export const MAX_ACTIONS_PER_CONFIRMATION = 20;
|
|
4
|
+
const confirmationIds = z.array(z.string()).min(1).max(MAX_ACTIONS_PER_CONFIRMATION);
|
|
2
5
|
const runConfirmationBody = z.object({
|
|
3
|
-
|
|
6
|
+
ids: confirmationIds,
|
|
4
7
|
verificationResult: z.record(z.string(), z.unknown()),
|
|
5
8
|
});
|
|
6
|
-
const declineConfirmationBody = z.object({
|
|
9
|
+
const declineConfirmationBody = z.object({ ids: confirmationIds });
|
|
7
10
|
// Convert with the same Zod version that created the schema. AdminForth may use another version.
|
|
8
11
|
export const runConfirmationBodySchema = z.toJSONSchema(runConfirmationBody, { target: 'draft-07' });
|
|
9
12
|
export const declineConfirmationBodySchema = z.toJSONSchema(declineConfirmationBody, { target: 'draft-07' });
|
package/dist/schemas/tools.js
CHANGED
|
@@ -3,5 +3,5 @@ export const resourcesListResponseSchema = z.object({
|
|
|
3
3
|
resources: z.array(z.object({
|
|
4
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
5
|
label: z.string().describe('Human readable resource name, translated for the current user.'),
|
|
6
|
-
})).describe('Every resource
|
|
6
|
+
})).describe('Every resource whose records the user may list or show. Other actions are checked when a resource is used, so the user may still be forbidden to create, edit or delete records of a listed resource.'),
|
|
7
7
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { HttpExtra, IAdminForth } from 'adminforth';
|
|
1
|
+
import type { AdminUser, HttpExtra, IAdminForth } from 'adminforth';
|
|
2
2
|
export interface ContextualTwoFactorsAuth {
|
|
3
3
|
withVerificationContext<T>(verificationResult: Record<string, unknown> | undefined, extra: HttpExtra, callback: () => Promise<T>): Promise<unknown>;
|
|
4
|
+
verifyAuto(adminUser: AdminUser, actionDescription?: string): Promise<unknown>;
|
|
4
5
|
}
|
|
5
6
|
/** Only newer 2FA plugins can verify verifyAuto calls within an external request. */
|
|
6
7
|
export declare function findContextualTwoFactorsAuth(adminforth: IAdminForth): ContextualTwoFactorsAuth | undefined;
|