@adminforth/mcp 1.0.2 → 1.0.3
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/README.md +5 -0
- package/dist/authSecretStore.d.ts +1 -1
- package/dist/authSecretStore.js +21 -6
- package/dist/clientInfo.js +24 -7
- package/dist/custom/McpSettings.vue +117 -98
- package/dist/index.js +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
The server identifies itself to agents using the configured AdminForth `brandName`. An optional `adminPanelOrigin` can distinguish deployments further; the plugin combines it with the AdminForth `baseUrl`.
|
|
6
6
|
|
|
7
|
+
> **Security:** an MCP auth secret carries the full permissions of the user who created it. An agent
|
|
8
|
+
> holding it can do everything that user can do in the admin panel, including destructive actions and
|
|
9
|
+
> changes to that user's security settings. Issue one secret per agent, treat it like a password, and
|
|
10
|
+
> revoke it as soon as the agent or its machine is compromised.
|
|
11
|
+
|
|
7
12
|
Install with:
|
|
8
13
|
|
|
9
14
|
```bash
|
|
@@ -19,7 +19,7 @@ export declare class McpAuthSecretStore {
|
|
|
19
19
|
name: any;
|
|
20
20
|
createdAt: any;
|
|
21
21
|
lastUsedAt: any;
|
|
22
|
-
lastUsedByAgent:
|
|
22
|
+
lastUsedByAgent: McpClientInfo | null;
|
|
23
23
|
}[]>;
|
|
24
24
|
create(name: string, adminUser: AdminUser, extra: HttpExtra): Promise<{
|
|
25
25
|
error: string;
|
package/dist/authSecretStore.js
CHANGED
|
@@ -5,6 +5,25 @@ const SECRET_PREFIX = 'afmcp_';
|
|
|
5
5
|
function hashSecret(secret) {
|
|
6
6
|
return createHash('sha256').update(secret).digest('hex');
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Stored agent info originates from the MCP client, so it is never trusted to be valid JSON:
|
|
10
|
+
* a database column which truncated an oversized value would otherwise break every next
|
|
11
|
+
* request made with the auth secret, including the page used to revoke it.
|
|
12
|
+
*/
|
|
13
|
+
function parseStoredClient(value) {
|
|
14
|
+
if (typeof value !== 'string' || !value)
|
|
15
|
+
return null;
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(value);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.client !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
return { client: parsed.client, ver: typeof parsed.ver === 'string' ? parsed.ver : null };
|
|
26
|
+
}
|
|
8
27
|
export class McpAuthSecretStore {
|
|
9
28
|
adminforth;
|
|
10
29
|
options;
|
|
@@ -22,9 +41,7 @@ export class McpAuthSecretStore {
|
|
|
22
41
|
name: record[fields.nameField],
|
|
23
42
|
createdAt: record[fields.createdAtField],
|
|
24
43
|
lastUsedAt: record[fields.lastUsedAtField] ?? null,
|
|
25
|
-
lastUsedByAgent: record[fields.lastUsedByAgentField]
|
|
26
|
-
? JSON.parse(record[fields.lastUsedByAgentField])
|
|
27
|
-
: null,
|
|
44
|
+
lastUsedByAgent: parseStoredClient(record[fields.lastUsedByAgentField]),
|
|
28
45
|
}));
|
|
29
46
|
}
|
|
30
47
|
async create(name, adminUser, extra) {
|
|
@@ -76,9 +93,7 @@ export class McpAuthSecretStore {
|
|
|
76
93
|
username: dbUser[auth.usernameField],
|
|
77
94
|
dbUser,
|
|
78
95
|
},
|
|
79
|
-
client: record[fields.lastUsedByAgentField]
|
|
80
|
-
? JSON.parse(record[fields.lastUsedByAgentField])
|
|
81
|
-
: null,
|
|
96
|
+
client: parseStoredClient(record[fields.lastUsedByAgentField]),
|
|
82
97
|
name: record[fields.nameField],
|
|
83
98
|
recordId: record[fields.idField],
|
|
84
99
|
};
|
package/dist/clientInfo.js
CHANGED
|
@@ -1,22 +1,39 @@
|
|
|
1
1
|
const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo';
|
|
2
2
|
const NON_CLIENT_NAME_CHARACTER_RE = /[^a-z0-9]+/g;
|
|
3
3
|
const EDGE_HYPHEN_RE = /^-+|-+$/g;
|
|
4
|
+
const NON_CLIENT_VERSION_CHARACTER_RE = /[^A-Za-z0-9._-]+/g;
|
|
4
5
|
const USER_AGENT_PRODUCT_RE = /^([^/\s]+)(?:\/(\S+))?/;
|
|
6
|
+
// Client name and version are attacker-controlled: they are persisted as JSON and rendered into
|
|
7
|
+
// the audit "executed by" string, so they are sanitized and capped here, at the single entry point.
|
|
8
|
+
const MAX_CLIENT_NAME_LENGTH = 64;
|
|
9
|
+
const MAX_CLIENT_VERSION_LENGTH = 32;
|
|
5
10
|
export const UNKNOWN_CLIENT = 'unknown-agent';
|
|
6
11
|
function normalizeClientName(name) {
|
|
7
|
-
|
|
12
|
+
if (typeof name !== 'string')
|
|
13
|
+
return '';
|
|
14
|
+
return name
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(NON_CLIENT_NAME_CHARACTER_RE, '-')
|
|
17
|
+
.slice(0, MAX_CLIENT_NAME_LENGTH)
|
|
18
|
+
.replace(EDGE_HYPHEN_RE, '');
|
|
19
|
+
}
|
|
20
|
+
function normalizeClientVersion(version) {
|
|
21
|
+
if (typeof version !== 'string')
|
|
22
|
+
return null;
|
|
23
|
+
return version
|
|
24
|
+
.replace(NON_CLIENT_VERSION_CHARACTER_RE, '')
|
|
25
|
+
.slice(0, MAX_CLIENT_VERSION_LENGTH) || null;
|
|
8
26
|
}
|
|
9
27
|
function parseUserAgent(userAgent) {
|
|
10
28
|
const [, name, version] = userAgent.match(USER_AGENT_PRODUCT_RE) ?? [];
|
|
11
|
-
|
|
29
|
+
const client = normalizeClientName(name);
|
|
30
|
+
return client ? { client, ver: normalizeClientVersion(version) } : null;
|
|
12
31
|
}
|
|
13
32
|
export function readMcpClient(body, headers) {
|
|
14
33
|
const clientInfo = body.params?._meta?.[CLIENT_INFO_META_KEY] ?? (body.method === 'initialize' ? body.params?.clientInfo : undefined);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
ver: clientInfo.version ?? null,
|
|
19
|
-
};
|
|
34
|
+
const client = normalizeClientName(clientInfo?.name);
|
|
35
|
+
if (client) {
|
|
36
|
+
return { client, ver: normalizeClientVersion(clientInfo.version) };
|
|
20
37
|
}
|
|
21
38
|
return parseUserAgent(String(headers['user-agent'] ?? '')) ?? {
|
|
22
39
|
client: UNKNOWN_CLIENT,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="flex flex-col justify-center mr-6 md:mr-12">
|
|
2
|
+
<div class="af-mcp-settings flex flex-col justify-center mr-6 md:mr-12">
|
|
3
3
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
4
4
|
<div>
|
|
5
5
|
<h2 class="text-3xl font-semibold leading-none text-gray-800 dark:text-gray-50">
|
|
@@ -9,113 +9,94 @@
|
|
|
9
9
|
{{ $t('Create a separate auth secret for each AI agent. Secrets are shown only once.') }}
|
|
10
10
|
</p>
|
|
11
11
|
</div>
|
|
12
|
-
<
|
|
13
|
-
type="button"
|
|
14
|
-
class="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white hover:bg-blue-800 dark:bg-blue-600 dark:hover:bg-blue-700"
|
|
15
|
-
@click="openCreateDialog"
|
|
16
|
-
>
|
|
12
|
+
<Button @click="openCreateDialog">
|
|
17
13
|
{{ $t('Create auth secret') }}
|
|
18
|
-
</
|
|
14
|
+
</Button>
|
|
19
15
|
</div>
|
|
20
16
|
|
|
21
|
-
<div class="mt-6 rounded-
|
|
17
|
+
<div class="mt-6 rounded-default border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
|
|
22
18
|
<p class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">{{ $t('MCP server URL') }}</p>
|
|
23
19
|
<div class="mt-2 flex items-center gap-2">
|
|
24
20
|
<code class="min-w-0 flex-1 overflow-x-auto rounded bg-white px-3 py-2 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ mcpUrl }}</code>
|
|
25
|
-
<
|
|
21
|
+
<Button variant="secondary" @click="copy(mcpUrl, $t('MCP server URL copied'))">
|
|
26
22
|
{{ $t('Copy') }}
|
|
27
|
-
</
|
|
23
|
+
</Button>
|
|
28
24
|
</div>
|
|
29
25
|
<p class="mt-3 text-sm text-gray-600 dark:text-gray-300">
|
|
30
26
|
<code>Authorization: Bearer <secret></code>
|
|
31
27
|
</p>
|
|
32
28
|
</div>
|
|
33
29
|
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<div
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
v-model="secretName"
|
|
86
|
-
class="mt-2 block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
|
87
|
-
placeholder="Claude Code"
|
|
88
|
-
@keydown.enter="createAuthSecret"
|
|
89
|
-
/>
|
|
90
|
-
<div class="mt-6 flex justify-end gap-3">
|
|
91
|
-
<button class="px-4 py-2 text-sm text-gray-600 dark:text-gray-300" @click="closeDialog">{{ $t('Cancel') }}</button>
|
|
92
|
-
<button
|
|
93
|
-
class="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-blue-600"
|
|
94
|
-
:disabled="creating || !secretName.trim()"
|
|
95
|
-
@click="createAuthSecret"
|
|
96
|
-
>
|
|
97
|
-
{{ creating ? $t('Creating') : $t('Create') }}
|
|
98
|
-
</button>
|
|
99
|
-
</div>
|
|
100
|
-
</template>
|
|
101
|
-
<template v-else>
|
|
102
|
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">{{ $t('Auth secret created') }}</h3>
|
|
103
|
-
<p class="mt-2 text-sm text-amber-700 dark:text-amber-300">{{ $t('Copy it now. You will not be able to view it again.') }}</p>
|
|
104
|
-
<code class="mt-4 block overflow-x-auto rounded-lg bg-gray-100 p-3 text-sm text-gray-900 dark:bg-gray-900 dark:text-gray-100">{{ createdSecret }}</code>
|
|
105
|
-
<p class="mt-5 text-xs font-medium uppercase text-gray-500 dark:text-gray-400">{{ $t('Prompt for your agent') }}</p>
|
|
106
|
-
<pre class="mt-2 whitespace-pre-wrap rounded-lg bg-gray-100 p-3 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ setupPrompt }}</pre>
|
|
107
|
-
<div class="mt-6 flex justify-end gap-3">
|
|
108
|
-
<button class="text-sm font-medium text-blue-700 dark:text-blue-400" @click="copy(setupPrompt)">{{ $t('Copy prompt') }}</button>
|
|
109
|
-
<button class="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white dark:bg-blue-600" @click="closeDialog">{{ $t('Done') }}</button>
|
|
110
|
-
</div>
|
|
111
|
-
</template>
|
|
30
|
+
<Table
|
|
31
|
+
class="mt-6"
|
|
32
|
+
:columns="columns"
|
|
33
|
+
:data="authSecrets"
|
|
34
|
+
:isLoading="loading"
|
|
35
|
+
:pageSize="10"
|
|
36
|
+
>
|
|
37
|
+
<template #cell:name="{ item }">
|
|
38
|
+
<p class="font-medium text-gray-900 dark:text-white">{{ item.name }}</p>
|
|
39
|
+
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ formatDate(item.createdAt) }}</p>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<template #cell:lastUsedByAgent="{ item }">
|
|
43
|
+
<div v-if="item.lastUsedByAgent" class="flex items-center gap-2">
|
|
44
|
+
<img v-if="describeAgent(item.lastUsedByAgent).icon" :src="describeAgent(item.lastUsedByAgent).icon" class="h-5 w-5" alt="" />
|
|
45
|
+
<span>{{ describeAgent(item.lastUsedByAgent).label }}</span>
|
|
46
|
+
</div>
|
|
47
|
+
<span v-else class="text-gray-400">{{ $t('Not used yet') }}</span>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<template #cell:lastUsedAt="{ item }">
|
|
51
|
+
{{ item.lastUsedAt ? formatDate(item.lastUsedAt) : $t('Never') }}
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<template #cell:actions="{ item }">
|
|
55
|
+
<div class="flex justify-end">
|
|
56
|
+
<Button
|
|
57
|
+
variant="danger"
|
|
58
|
+
:loader="revokingId === item.id"
|
|
59
|
+
:disabled="revokingId === item.id"
|
|
60
|
+
@click="revoke(item)"
|
|
61
|
+
>
|
|
62
|
+
{{ revokingId === item.id ? $t('Revoking') : $t('Revoke') }}
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
</Table>
|
|
67
|
+
|
|
68
|
+
<Dialog
|
|
69
|
+
ref="dialogRef"
|
|
70
|
+
class="w-full max-w-xl"
|
|
71
|
+
:header="createdSecret ? $t('Auth secret created') : $t('Create MCP auth secret')"
|
|
72
|
+
:buttons="dialogButtons"
|
|
73
|
+
>
|
|
74
|
+
<!-- enter is handled on the wrapper so it fires once: Input spreads attrs on both its root and the input -->
|
|
75
|
+
<div v-if="!createdSecret" @keydown.enter="createAuthSecret">
|
|
76
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t('Use one auth secret for one agent.') }}</p>
|
|
77
|
+
<label class="mt-5 block text-sm font-medium text-gray-700 dark:text-gray-200">{{ $t('Secret name') }}</label>
|
|
78
|
+
<div class="mt-2">
|
|
79
|
+
<Input v-model="secretName" type="text" fullWidth placeholder="Claude Code" />
|
|
80
|
+
</div>
|
|
112
81
|
</div>
|
|
113
|
-
|
|
82
|
+
<div v-else>
|
|
83
|
+
<p class="text-sm text-amber-700 dark:text-amber-300">{{ $t('Copy it now. You will not be able to view it again.') }}</p>
|
|
84
|
+
<p class="mt-3 rounded-default border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-200">
|
|
85
|
+
{{ $t('This secret lets an agent do everything you can do in the admin panel, including deleting data and changing your security settings. Keep it like a password, give it to one agent only, and revoke it if that agent or its device is compromised.') }}
|
|
86
|
+
</p>
|
|
87
|
+
<code class="mt-4 block overflow-x-auto rounded-default bg-gray-100 p-3 text-sm text-gray-900 dark:bg-gray-900 dark:text-gray-100">{{ createdSecret }}</code>
|
|
88
|
+
<p class="mt-5 text-xs font-medium uppercase text-gray-500 dark:text-gray-400">{{ $t('Prompt for your agent') }}</p>
|
|
89
|
+
<pre class="mt-2 whitespace-pre-wrap rounded-default bg-gray-100 p-3 text-sm text-gray-800 dark:bg-gray-900 dark:text-gray-100">{{ setupPrompt }}</pre>
|
|
90
|
+
</div>
|
|
91
|
+
</Dialog>
|
|
114
92
|
</div>
|
|
115
93
|
</template>
|
|
116
94
|
|
|
117
95
|
<script setup lang="ts">
|
|
118
96
|
import { computed, onMounted, ref } from 'vue';
|
|
97
|
+
import { useI18n } from 'vue-i18n';
|
|
98
|
+
import { Button, Dialog, Input, Table } from '@/afcl';
|
|
99
|
+
import adminforth from '@/adminforth';
|
|
119
100
|
import { callAdminForthApi } from '@/utils';
|
|
120
101
|
import claudeCodeIcon from './icons/claude-code.svg';
|
|
121
102
|
import codexIcon from './icons/codex.svg';
|
|
@@ -130,14 +111,49 @@ type AuthSecret = {
|
|
|
130
111
|
lastUsedByAgent: Agent | null;
|
|
131
112
|
};
|
|
132
113
|
|
|
114
|
+
const { t } = useI18n();
|
|
115
|
+
|
|
133
116
|
const authSecrets = ref<AuthSecret[]>([]);
|
|
134
117
|
const loading = ref(true);
|
|
135
|
-
const
|
|
118
|
+
const dialogRef = ref();
|
|
136
119
|
const secretName = ref('');
|
|
137
120
|
const createdSecret = ref('');
|
|
138
121
|
const creating = ref(false);
|
|
139
122
|
const revokingId = ref<string | null>(null);
|
|
140
123
|
|
|
124
|
+
const columns = computed(() => [
|
|
125
|
+
{ label: t('Name'), fieldName: 'name', sortable: true },
|
|
126
|
+
{ label: t('Last used by'), fieldName: 'lastUsedByAgent' },
|
|
127
|
+
{ label: t('Last usage'), fieldName: 'lastUsedAt', sortable: true },
|
|
128
|
+
{ label: t('Actions'), fieldName: 'actions' },
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const dialogButtons = computed(() => createdSecret.value
|
|
132
|
+
? [
|
|
133
|
+
{
|
|
134
|
+
label: t('Copy prompt'),
|
|
135
|
+
options: { variant: 'secondary' },
|
|
136
|
+
onclick: () => copy(setupPrompt.value, t('Prompt copied')),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
label: t('Done'),
|
|
140
|
+
onclick: (dialog: { hide: () => void }) => dialog.hide(),
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
: [
|
|
144
|
+
{
|
|
145
|
+
label: t('Cancel'),
|
|
146
|
+
options: { variant: 'secondary' },
|
|
147
|
+
onclick: (dialog: { hide: () => void }) => dialog.hide(),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
label: creating.value ? t('Creating') : t('Create'),
|
|
151
|
+
options: { loader: creating.value, disabled: creating.value || !secretName.value.trim() },
|
|
152
|
+
onclick: () => createAuthSecret(),
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
);
|
|
156
|
+
|
|
141
157
|
const mcpUrl = computed(() => {
|
|
142
158
|
const baseUrl = (import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || '').replace(/\/$/, '');
|
|
143
159
|
return `${window.location.origin}${baseUrl}/adminapi/v1/mcp`;
|
|
@@ -164,15 +180,11 @@ async function loadAuthSecrets() {
|
|
|
164
180
|
function openCreateDialog() {
|
|
165
181
|
secretName.value = '';
|
|
166
182
|
createdSecret.value = '';
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function closeDialog() {
|
|
171
|
-
dialogOpen.value = false;
|
|
172
|
-
createdSecret.value = '';
|
|
183
|
+
dialogRef.value?.open();
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
async function createAuthSecret() {
|
|
187
|
+
if (creating.value || !secretName.value.trim()) return;
|
|
176
188
|
creating.value = true;
|
|
177
189
|
try {
|
|
178
190
|
const response = await callAdminForthApi({
|
|
@@ -190,7 +202,13 @@ async function createAuthSecret() {
|
|
|
190
202
|
}
|
|
191
203
|
|
|
192
204
|
async function revoke(authSecret: AuthSecret) {
|
|
193
|
-
|
|
205
|
+
const confirmed = await adminforth.confirm({
|
|
206
|
+
message: t('Revoke MCP auth secret "{name}"? Any agent using it will lose access.', { name: authSecret.name }),
|
|
207
|
+
yes: t('Revoke'),
|
|
208
|
+
no: t('Cancel'),
|
|
209
|
+
dangerous: true,
|
|
210
|
+
});
|
|
211
|
+
if (!confirmed) return;
|
|
194
212
|
revokingId.value = authSecret.id;
|
|
195
213
|
try {
|
|
196
214
|
await callAdminForthApi({ method: 'DELETE', path: '/mcp/auth-secrets', body: { id: authSecret.id } });
|
|
@@ -224,7 +242,8 @@ function formatDate(value: string) {
|
|
|
224
242
|
return new Date(value).toLocaleString();
|
|
225
243
|
}
|
|
226
244
|
|
|
227
|
-
async function copy(value: string) {
|
|
245
|
+
async function copy(value: string, message: string) {
|
|
228
246
|
await navigator.clipboard.writeText(value);
|
|
247
|
+
adminforth.alert({ message, variant: 'success' });
|
|
229
248
|
}
|
|
230
249
|
</script>
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createMcpServerPresentation, handleMcpProtocol, } from './mcpProtocol.j
|
|
|
5
5
|
import { McpAuthSecretStore } from './authSecretStore.js';
|
|
6
6
|
const BEARER_SECRET_RE = /^Bearer (afmcp_[A-Za-z0-9_-]+)$/i;
|
|
7
7
|
const MCP_PATH = '/mcp';
|
|
8
|
+
const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']);
|
|
8
9
|
function requestExtra(input) {
|
|
9
10
|
return {
|
|
10
11
|
body: input.body,
|
|
@@ -15,6 +16,13 @@ function requestExtra(input) {
|
|
|
15
16
|
response: input.response,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* The MCP auth secret is a long-lived credential, so it is dropped as soon as it is verified and
|
|
21
|
+
* never reaches tool handlers, which may log, persist or forward the headers they are given.
|
|
22
|
+
*/
|
|
23
|
+
function withoutCredentials(headers) {
|
|
24
|
+
return Object.fromEntries(Object.entries(headers).filter(([name]) => !CREDENTIAL_HEADERS.has(name.toLowerCase())));
|
|
25
|
+
}
|
|
18
26
|
function jsonRpcAuthError(id = null) {
|
|
19
27
|
return {
|
|
20
28
|
jsonrpc: '2.0',
|
|
@@ -146,7 +154,8 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
146
154
|
input.response.setStatus(403);
|
|
147
155
|
return jsonRpcAuthError(input.body.id ?? null);
|
|
148
156
|
}
|
|
149
|
-
const
|
|
157
|
+
const toolHeaders = withoutCredentials(input.headers);
|
|
158
|
+
const reportedClient = readMcpClient(input.body, toolHeaders);
|
|
150
159
|
const client = reportedClient.client === UNKNOWN_CLIENT && authenticated.client
|
|
151
160
|
? authenticated.client
|
|
152
161
|
: reportedClient;
|
|
@@ -158,13 +167,13 @@ export default class AdminForthMcpPlugin extends AdminForthPlugin {
|
|
|
158
167
|
const protocolResponse = await handleMcpProtocol({
|
|
159
168
|
...this.serverPresentation,
|
|
160
169
|
body: input.body,
|
|
161
|
-
headers:
|
|
170
|
+
headers: toolHeaders,
|
|
162
171
|
listTools: () => this.apiTools.list(),
|
|
163
172
|
callTool: (name, arguments_) => this.apiTools.call({
|
|
164
173
|
name,
|
|
165
174
|
arguments: arguments_,
|
|
166
175
|
adminUser,
|
|
167
|
-
headers:
|
|
176
|
+
headers: toolHeaders,
|
|
168
177
|
requestUrl: input.requestUrl,
|
|
169
178
|
abortSignal: input.abortSignal,
|
|
170
179
|
}),
|