@ampless/admin 0.2.0-alpha.18 → 0.2.0-alpha.19
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/api/index.d.ts +2 -48
- package/dist/api/index.js +0 -562
- package/dist/chunk-4QYY7CO7.js +358 -0
- package/dist/{chunk-T3ONEJ6K.js → chunk-5JKOPRCO.js} +2 -2
- package/dist/{chunk-KAMD3SDE.js → chunk-5Q6KVRZ2.js} +2 -2
- package/dist/{chunk-WJTZ5BNQ.js → chunk-A3SWBQA6.js} +1 -1
- package/dist/{chunk-I5S7J7UZ.js → chunk-BC4B6DLO.js} +2 -2
- package/dist/{chunk-5VDMBDFB.js → chunk-CQY55RDG.js} +1 -1
- package/dist/{chunk-3EDGG6ST.js → chunk-CVJCMTYB.js} +2 -2
- package/dist/{chunk-LIGSQETK.js → chunk-IM5MVZOH.js} +2 -2
- package/dist/{chunk-YECVXCET.js → chunk-OSUTPPAU.js} +79 -63
- package/dist/{chunk-G7B3R6WQ.js → chunk-QXJIIBUQ.js} +2 -2
- package/dist/{chunk-IJKYZNII.js → chunk-S66L5CDS.js} +1 -1
- package/dist/{chunk-XFJXMCXX.js → chunk-SRNH2IVA.js} +1 -1
- package/dist/{chunk-OYKHRBCF.js → chunk-W6BXESPW.js} +1 -1
- package/dist/{chunk-K4GZPMPL.js → chunk-XY4JWSMS.js} +1 -1
- package/dist/components/admin-dashboard.js +3 -3
- package/dist/components/edit-post-view.js +5 -5
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +6 -6
- package/dist/components/login-view.js +3 -3
- package/dist/components/mcp-tokens-view.d.ts +19 -2
- package/dist/components/mcp-tokens-view.js +3 -3
- package/dist/components/media-view.js +5 -5
- package/dist/components/new-post-view.js +5 -5
- package/dist/components/posts-list-view.js +3 -3
- package/dist/components/users-list-view.js +3 -3
- package/dist/{i18n-B1gZ90FD.d.ts → i18n-MWvAMHzn.d.ts} +118 -94
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/metafile-esm.json +1 -1
- package/dist/pages/index.d.ts +15 -3
- package/dist/pages/index.js +29 -15
- package/package.json +3 -3
- package/dist/chunk-Q7WU724A.js +0 -205
package/dist/api/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
2
2
|
import { Admin } from '../index.js';
|
|
3
3
|
import 'ampless';
|
|
4
4
|
import '@ampless/runtime';
|
|
5
|
-
import '../i18n-
|
|
5
|
+
import '../i18n-MWvAMHzn.js';
|
|
6
6
|
import '@aws-amplify/adapter-nextjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -21,50 +21,4 @@ declare function createMediaProxyRoute(admin: Admin): {
|
|
|
21
21
|
}) => Promise<NextResponse<unknown>>;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
* HTTP MCP transport.
|
|
26
|
-
*
|
|
27
|
-
* Mounts at `/api/mcp` on the user's site. Speaks the MCP JSON-RPC
|
|
28
|
-
* protocol over POST (no SSE streaming — single request/response per
|
|
29
|
-
* tool call, which is what every MCP client uses for `tools/call`).
|
|
30
|
-
*
|
|
31
|
-
* Auth: `Authorization: Bearer amp_mcp_<...>`. The Bearer token is
|
|
32
|
-
* hashed (sha256) and looked up in KvStore (`mcp-tokens` PK). On
|
|
33
|
-
* match the route runs the tool with a `ToolContext` backed by:
|
|
34
|
-
* - The Cognito service user's id token (server-side cached)
|
|
35
|
-
* - The site's AppSync endpoint
|
|
36
|
-
*
|
|
37
|
-
* Per-token role enforcement happens before tool dispatch — an
|
|
38
|
-
* `editor`-scoped token can't reach admin-only mutations even though
|
|
39
|
-
* the underlying Cognito identity could.
|
|
40
|
-
*
|
|
41
|
-
* `upload_media` is intentionally NOT supported over HTTP in v0.x:
|
|
42
|
-
* the SSR Lambda's execution role doesn't have direct S3 PUT
|
|
43
|
-
* permission, and granting it across Amplify Hosting's managed
|
|
44
|
-
* compute model requires extra setup we punt to v0.y. The other six
|
|
45
|
-
* tools (read + write Post / read schema) cover most automation.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
declare function createMcpRoute(admin: Admin): {
|
|
49
|
-
POST: (req: Request) => Promise<Response>;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* MCP token management API.
|
|
54
|
-
*
|
|
55
|
-
* Mounted at `/api/admin/mcp-tokens` (POST = create, DELETE = revoke).
|
|
56
|
-
* Both endpoints require the calling admin's Cognito session to be in
|
|
57
|
-
* the `ampless-admin` group — token issuance is sensitive and
|
|
58
|
-
* `editor` is intentionally excluded.
|
|
59
|
-
*
|
|
60
|
-
* Token format (`amp_mcp_<base64url(32)>`) and storage layout live in
|
|
61
|
-
* `lib/mcp-token-storage.ts`.
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
declare function createMcpTokensRoute(admin: Admin): {
|
|
65
|
-
GET: () => Promise<Response>;
|
|
66
|
-
POST: (req: Request) => Promise<Response>;
|
|
67
|
-
DELETE: (req: Request) => Promise<Response>;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export { createMcpRoute, createMcpTokensRoute, createMediaProxyRoute };
|
|
24
|
+
export { createMediaProxyRoute };
|
package/dist/api/index.js
CHANGED
|
@@ -36,568 +36,6 @@ function createMediaProxyRoute(admin) {
|
|
|
36
36
|
}
|
|
37
37
|
return { GET };
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
// src/api/mcp.ts
|
|
41
|
-
import {
|
|
42
|
-
dispatchToolCall,
|
|
43
|
-
getTools
|
|
44
|
-
} from "@ampless/mcp-server/tools";
|
|
45
|
-
|
|
46
|
-
// src/lib/mcp-service-auth.ts
|
|
47
|
-
import {
|
|
48
|
-
CognitoUserPool,
|
|
49
|
-
CognitoUser,
|
|
50
|
-
AuthenticationDetails,
|
|
51
|
-
CognitoRefreshToken
|
|
52
|
-
} from "amazon-cognito-identity-js";
|
|
53
|
-
var REFRESH_BUFFER_SECONDS = 5 * 60;
|
|
54
|
-
var globals = globalThis;
|
|
55
|
-
if (!globals.navigator) {
|
|
56
|
-
globals.navigator = { userAgent: "ampless-mcp-http" };
|
|
57
|
-
}
|
|
58
|
-
function toSession(result) {
|
|
59
|
-
return {
|
|
60
|
-
idToken: result.getIdToken().getJwtToken(),
|
|
61
|
-
refreshToken: result.getRefreshToken().getToken(),
|
|
62
|
-
expiresAt: result.getIdToken().getExpiration()
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
var McpServiceAuth = class {
|
|
66
|
-
constructor(outputs) {
|
|
67
|
-
this.outputs = outputs;
|
|
68
|
-
}
|
|
69
|
-
outputs;
|
|
70
|
-
pool = null;
|
|
71
|
-
session = null;
|
|
72
|
-
refreshPromise = null;
|
|
73
|
-
getPool() {
|
|
74
|
-
if (this.pool) return this.pool;
|
|
75
|
-
const auth = this.outputs.auth;
|
|
76
|
-
if (!auth?.user_pool_id || !auth?.user_pool_client_id) {
|
|
77
|
-
throw new Error(
|
|
78
|
-
"[mcp] amplify_outputs.json is missing the auth block. Deploy the auth resource first."
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
this.pool = new CognitoUserPool({
|
|
82
|
-
UserPoolId: auth.user_pool_id,
|
|
83
|
-
ClientId: auth.user_pool_client_id
|
|
84
|
-
});
|
|
85
|
-
return this.pool;
|
|
86
|
-
}
|
|
87
|
-
readEnvCreds() {
|
|
88
|
-
const email = process.env.AMPLESS_MCP_SERVICE_EMAIL;
|
|
89
|
-
const password = process.env.AMPLESS_MCP_SERVICE_PASSWORD;
|
|
90
|
-
if (!email || !password) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
"[mcp] AMPLESS_MCP_SERVICE_EMAIL / AMPLESS_MCP_SERVICE_PASSWORD env vars are required. Create a dedicated Cognito user in the `ampless-admin` group (e.g. via /admin/users) and set its credentials as Amplify Hosting environment variables."
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
return { email, password };
|
|
96
|
-
}
|
|
97
|
-
async signIn() {
|
|
98
|
-
const { email, password } = this.readEnvCreds();
|
|
99
|
-
const user = new CognitoUser({ Username: email, Pool: this.getPool() });
|
|
100
|
-
const auth = new AuthenticationDetails({ Username: email, Password: password });
|
|
101
|
-
const session = await new Promise((resolveOk, reject) => {
|
|
102
|
-
user.authenticateUser(auth, {
|
|
103
|
-
onSuccess: (result) => resolveOk(toSession(result)),
|
|
104
|
-
onFailure: (err) => reject(err),
|
|
105
|
-
newPasswordRequired: () => reject(
|
|
106
|
-
new Error(
|
|
107
|
-
"[mcp] Cognito returned NEW_PASSWORD_REQUIRED for the service user. Sign in to /admin/login once with the service-user creds to set a permanent password."
|
|
108
|
-
)
|
|
109
|
-
)
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
this.session = session;
|
|
113
|
-
return session;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Returns a valid id token for the service user, signing in or
|
|
117
|
-
* refreshing as needed. Concurrent callers share the same in-flight
|
|
118
|
-
* refresh via `refreshPromise` so we don't fan out duplicate
|
|
119
|
-
* sign-ins on cold-start bursts.
|
|
120
|
-
*/
|
|
121
|
-
async getIdToken() {
|
|
122
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
123
|
-
if (!this.session) {
|
|
124
|
-
const s = await this.signIn();
|
|
125
|
-
return s.idToken;
|
|
126
|
-
}
|
|
127
|
-
if (this.session.expiresAt - now > REFRESH_BUFFER_SECONDS) {
|
|
128
|
-
return this.session.idToken;
|
|
129
|
-
}
|
|
130
|
-
const fresh = await this.refresh();
|
|
131
|
-
return fresh.idToken;
|
|
132
|
-
}
|
|
133
|
-
async refresh() {
|
|
134
|
-
if (this.refreshPromise) return this.refreshPromise;
|
|
135
|
-
if (!this.session) return this.signIn();
|
|
136
|
-
const refreshToken = new CognitoRefreshToken({ RefreshToken: this.session.refreshToken });
|
|
137
|
-
const { email } = this.readEnvCreds();
|
|
138
|
-
const user = new CognitoUser({ Username: email, Pool: this.getPool() });
|
|
139
|
-
this.refreshPromise = new Promise((resolveOk, reject) => {
|
|
140
|
-
user.refreshSession(refreshToken, (err, result) => {
|
|
141
|
-
if (err || !result) {
|
|
142
|
-
this.session = null;
|
|
143
|
-
this.signIn().then(resolveOk, reject);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
const session = toSession(result);
|
|
147
|
-
this.session = session;
|
|
148
|
-
resolveOk(session);
|
|
149
|
-
});
|
|
150
|
-
}).finally(() => {
|
|
151
|
-
this.refreshPromise = null;
|
|
152
|
-
});
|
|
153
|
-
return this.refreshPromise;
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
var cache = /* @__PURE__ */ new WeakMap();
|
|
157
|
-
function getMcpServiceAuth(outputs) {
|
|
158
|
-
const existing = cache.get(outputs);
|
|
159
|
-
if (existing) return existing;
|
|
160
|
-
const fresh = new McpServiceAuth(outputs);
|
|
161
|
-
cache.set(outputs, fresh);
|
|
162
|
-
return fresh;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// src/lib/kv-provider-server.ts
|
|
166
|
-
import { setKvStore } from "ampless";
|
|
167
|
-
var installed = /* @__PURE__ */ new WeakSet();
|
|
168
|
-
function installServerKvProvider(outputs) {
|
|
169
|
-
if (installed.has(outputs)) return;
|
|
170
|
-
installed.add(outputs);
|
|
171
|
-
const serviceAuth = getMcpServiceAuth(outputs);
|
|
172
|
-
async function gql(operation, variables) {
|
|
173
|
-
if (!outputs.data?.url) {
|
|
174
|
-
throw new Error(
|
|
175
|
-
"[kv-provider-server] amplify_outputs.json is missing data.url \u2014 KvStore is unreachable."
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
const appsyncUrl = outputs.data.url;
|
|
179
|
-
const idToken = await serviceAuth.getIdToken();
|
|
180
|
-
const res = await fetch(appsyncUrl, {
|
|
181
|
-
method: "POST",
|
|
182
|
-
headers: {
|
|
183
|
-
"Content-Type": "application/json",
|
|
184
|
-
// AppSync userPool auth: bare id token, no "Bearer" prefix.
|
|
185
|
-
Authorization: idToken
|
|
186
|
-
},
|
|
187
|
-
body: JSON.stringify({ query: operation, variables })
|
|
188
|
-
});
|
|
189
|
-
const body = await res.json();
|
|
190
|
-
if (body.errors && body.errors.length > 0) {
|
|
191
|
-
throw new Error(`[kv-provider-server] AppSync error: ${body.errors[0].message}`);
|
|
192
|
-
}
|
|
193
|
-
if (body.data === void 0) {
|
|
194
|
-
throw new Error("[kv-provider-server] AppSync response had no `data` field");
|
|
195
|
-
}
|
|
196
|
-
return body.data;
|
|
197
|
-
}
|
|
198
|
-
function encodeValue(value) {
|
|
199
|
-
return JSON.stringify(value ?? null);
|
|
200
|
-
}
|
|
201
|
-
function decodeValue(raw) {
|
|
202
|
-
if (typeof raw !== "string") return raw;
|
|
203
|
-
try {
|
|
204
|
-
return JSON.parse(raw);
|
|
205
|
-
} catch {
|
|
206
|
-
return raw;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
const store = {
|
|
210
|
-
async get(pk, sk) {
|
|
211
|
-
const data = await gql(
|
|
212
|
-
`query GetKv($pk: String!, $sk: String!) {
|
|
213
|
-
getKvStore(pk: $pk, sk: $sk) { pk sk value ttl }
|
|
214
|
-
}`,
|
|
215
|
-
{ pk, sk }
|
|
216
|
-
);
|
|
217
|
-
const row = data.getKvStore;
|
|
218
|
-
return row ? decodeValue(row.value) : null;
|
|
219
|
-
},
|
|
220
|
-
async query(pk) {
|
|
221
|
-
const data = await gql(
|
|
222
|
-
`query QueryKv($filter: ModelKvStoreFilterInput, $limit: Int) {
|
|
223
|
-
listKvStores(filter: $filter, limit: $limit) {
|
|
224
|
-
items { pk sk value ttl }
|
|
225
|
-
nextToken
|
|
226
|
-
}
|
|
227
|
-
}`,
|
|
228
|
-
{ filter: { pk: { eq: pk } }, limit: 1e3 }
|
|
229
|
-
);
|
|
230
|
-
return data.listKvStores.items.map((row) => ({
|
|
231
|
-
pk: row.pk,
|
|
232
|
-
sk: row.sk,
|
|
233
|
-
value: decodeValue(row.value),
|
|
234
|
-
ttl: row.ttl ?? void 0
|
|
235
|
-
}));
|
|
236
|
-
},
|
|
237
|
-
async put(pk, sk, value, opts) {
|
|
238
|
-
const ttl = opts?.ttlSeconds ? Math.floor(Date.now() / 1e3) + opts.ttlSeconds : null;
|
|
239
|
-
const existing = await gql(
|
|
240
|
-
`query GetKv($pk: String!, $sk: String!) {
|
|
241
|
-
getKvStore(pk: $pk, sk: $sk) { pk sk }
|
|
242
|
-
}`,
|
|
243
|
-
{ pk, sk }
|
|
244
|
-
);
|
|
245
|
-
if (existing.getKvStore) {
|
|
246
|
-
await gql(
|
|
247
|
-
`mutation UpdateKv($input: UpdateKvStoreInput!) {
|
|
248
|
-
updateKvStore(input: $input) { pk sk }
|
|
249
|
-
}`,
|
|
250
|
-
{ input: { pk, sk, value: encodeValue(value), ttl } }
|
|
251
|
-
);
|
|
252
|
-
} else {
|
|
253
|
-
await gql(
|
|
254
|
-
`mutation CreateKv($input: CreateKvStoreInput!) {
|
|
255
|
-
createKvStore(input: $input) { pk sk }
|
|
256
|
-
}`,
|
|
257
|
-
{ input: { pk, sk, value: encodeValue(value), ttl } }
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
},
|
|
261
|
-
async remove(pk, sk) {
|
|
262
|
-
await gql(
|
|
263
|
-
`mutation DeleteKv($input: DeleteKvStoreInput!) {
|
|
264
|
-
deleteKvStore(input: $input) { pk sk }
|
|
265
|
-
}`,
|
|
266
|
-
{ input: { pk, sk } }
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
};
|
|
270
|
-
setKvStore(store);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// src/lib/mcp-token-storage.ts
|
|
274
|
-
import { createHash, randomBytes } from "crypto";
|
|
275
|
-
import { getKvStore } from "ampless";
|
|
276
|
-
var TOKENS_PK = "mcp-tokens";
|
|
277
|
-
function generateToken() {
|
|
278
|
-
const raw = randomBytes(32).toString("base64url");
|
|
279
|
-
const plaintext = `amp_mcp_${raw}`;
|
|
280
|
-
const hash = hashToken(plaintext);
|
|
281
|
-
return { plaintext, hash };
|
|
282
|
-
}
|
|
283
|
-
function hashToken(plaintext) {
|
|
284
|
-
return createHash("sha256").update(plaintext).digest("hex");
|
|
285
|
-
}
|
|
286
|
-
async function listTokens() {
|
|
287
|
-
const items = await getKvStore().query(TOKENS_PK);
|
|
288
|
-
return items.map((it) => ({ hash: it.sk, ...it.value })).sort((a, b) => {
|
|
289
|
-
return b.createdAt.localeCompare(a.createdAt);
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
async function saveToken(hash, meta) {
|
|
293
|
-
await getKvStore().put(TOKENS_PK, hash, meta);
|
|
294
|
-
}
|
|
295
|
-
async function revokeToken(hash) {
|
|
296
|
-
await getKvStore().remove(TOKENS_PK, hash);
|
|
297
|
-
}
|
|
298
|
-
async function markTokenUsed(hash) {
|
|
299
|
-
try {
|
|
300
|
-
const meta = await getKvStore().get(TOKENS_PK, hash);
|
|
301
|
-
if (!meta) return;
|
|
302
|
-
await getKvStore().put(TOKENS_PK, hash, {
|
|
303
|
-
...meta,
|
|
304
|
-
lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
305
|
-
});
|
|
306
|
-
} catch (err) {
|
|
307
|
-
console.warn("[mcp-token-storage] markTokenUsed failed", err);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// src/api/mcp.ts
|
|
312
|
-
import { getKvStore as getKvStore2 } from "ampless";
|
|
313
|
-
var TOKEN_PREFIX = "amp_mcp_";
|
|
314
|
-
var UNSUPPORTED_OVER_HTTP = /* @__PURE__ */ new Set(["upload_media"]);
|
|
315
|
-
var ADMIN_ONLY_TOOLS = /* @__PURE__ */ new Set(["delete_post"]);
|
|
316
|
-
function createMcpRoute(admin) {
|
|
317
|
-
const { outputs, cmsConfig } = admin;
|
|
318
|
-
const defaultSiteId = cmsConfig.defaultSiteId ?? "default";
|
|
319
|
-
if (!outputs.data?.url) {
|
|
320
|
-
throw new Error(
|
|
321
|
-
"[mcp] createMcpRoute requires amplify_outputs.json with a `data.url` (AppSync endpoint)."
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
const appsyncUrl = outputs.data.url;
|
|
325
|
-
const serviceAuth = getMcpServiceAuth(outputs);
|
|
326
|
-
installServerKvProvider(outputs);
|
|
327
|
-
function makeGraphqlClient() {
|
|
328
|
-
return {
|
|
329
|
-
async query(operation, variables) {
|
|
330
|
-
const idToken = await serviceAuth.getIdToken();
|
|
331
|
-
const res = await fetch(appsyncUrl, {
|
|
332
|
-
method: "POST",
|
|
333
|
-
headers: {
|
|
334
|
-
"Content-Type": "application/json",
|
|
335
|
-
// AppSync userPool auth: bare id token, no "Bearer" prefix.
|
|
336
|
-
Authorization: idToken
|
|
337
|
-
},
|
|
338
|
-
body: JSON.stringify({ query: operation, variables: variables ?? {} })
|
|
339
|
-
});
|
|
340
|
-
const json2 = await res.json();
|
|
341
|
-
if (json2.errors && json2.errors.length > 0) {
|
|
342
|
-
throw new Error(`[mcp] AppSync error: ${json2.errors[0].message}`);
|
|
343
|
-
}
|
|
344
|
-
if (json2.data === void 0) {
|
|
345
|
-
throw new Error("[mcp] AppSync response had no `data` field");
|
|
346
|
-
}
|
|
347
|
-
return json2.data;
|
|
348
|
-
}
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
function unsupportedStorage() {
|
|
352
|
-
return {
|
|
353
|
-
async putObject() {
|
|
354
|
-
throw new Error(
|
|
355
|
-
"[mcp] upload_media is not supported over HTTP in v0.x. Upload via the admin UI instead."
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
async function POST(req) {
|
|
361
|
-
const auth = req.headers.get("authorization") ?? "";
|
|
362
|
-
const plaintext = /^Bearer\s+(.+)$/.exec(auth.trim())?.[1]?.trim();
|
|
363
|
-
if (!plaintext || !plaintext.startsWith(TOKEN_PREFIX)) {
|
|
364
|
-
logMcp({ event: "mcp.auth_failed", reason: "missing-or-malformed-bearer" });
|
|
365
|
-
return jsonRpcError(null, -32e3, "unauthorized", 401);
|
|
366
|
-
}
|
|
367
|
-
let tokenRecord = null;
|
|
368
|
-
let tokenHash;
|
|
369
|
-
try {
|
|
370
|
-
tokenHash = hashToken(plaintext);
|
|
371
|
-
const meta = await getKvStore2().get("mcp-tokens", tokenHash);
|
|
372
|
-
if (meta) tokenRecord = { hash: tokenHash, ...meta };
|
|
373
|
-
} catch (err) {
|
|
374
|
-
console.error("[mcp] token lookup failed", err);
|
|
375
|
-
return jsonRpcError(null, -32e3, "token lookup failed", 500);
|
|
376
|
-
}
|
|
377
|
-
if (!tokenRecord) {
|
|
378
|
-
logMcp({
|
|
379
|
-
event: "mcp.auth_failed",
|
|
380
|
-
reason: "token-not-found",
|
|
381
|
-
tokenHashPrefix: tokenHash ? tokenHash.slice(0, 12) : void 0
|
|
382
|
-
});
|
|
383
|
-
return jsonRpcError(null, -32e3, "unauthorized", 401);
|
|
384
|
-
}
|
|
385
|
-
let body;
|
|
386
|
-
try {
|
|
387
|
-
body = await req.json();
|
|
388
|
-
} catch {
|
|
389
|
-
return jsonRpcError(null, -32700, "parse error", 400);
|
|
390
|
-
}
|
|
391
|
-
if (body.jsonrpc !== "2.0" || typeof body.method !== "string") {
|
|
392
|
-
return jsonRpcError(body.id ?? null, -32600, "invalid request", 400);
|
|
393
|
-
}
|
|
394
|
-
const reqId = body.id ?? null;
|
|
395
|
-
try {
|
|
396
|
-
switch (body.method) {
|
|
397
|
-
case "initialize": {
|
|
398
|
-
return jsonRpcOk(reqId, {
|
|
399
|
-
protocolVersion: "2024-11-05",
|
|
400
|
-
capabilities: { tools: {} },
|
|
401
|
-
serverInfo: { name: "ampless-mcp", version: "0.2.0-alpha" }
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
case "notifications/initialized":
|
|
405
|
-
case "notifications/cancelled": {
|
|
406
|
-
return new Response(null, { status: 204 });
|
|
407
|
-
}
|
|
408
|
-
case "tools/list": {
|
|
409
|
-
const tools = getTools().map((t) => ({
|
|
410
|
-
name: t.name,
|
|
411
|
-
description: t.description,
|
|
412
|
-
inputSchema: t.inputSchema
|
|
413
|
-
}));
|
|
414
|
-
return jsonRpcOk(reqId, { tools });
|
|
415
|
-
}
|
|
416
|
-
case "tools/call": {
|
|
417
|
-
const params = body.params ?? {};
|
|
418
|
-
const name = params.name;
|
|
419
|
-
if (!name || typeof name !== "string") {
|
|
420
|
-
return jsonRpcError(reqId, -32602, "missing tool name", 400);
|
|
421
|
-
}
|
|
422
|
-
const tokenContext = {
|
|
423
|
-
tokenHashPrefix: tokenRecord.hash.slice(0, 12),
|
|
424
|
-
tokenLabel: tokenRecord.label,
|
|
425
|
-
tokenRole: tokenRecord.role
|
|
426
|
-
};
|
|
427
|
-
const startedAt = Date.now();
|
|
428
|
-
logMcp({
|
|
429
|
-
event: "mcp.tool_call",
|
|
430
|
-
...tokenContext,
|
|
431
|
-
tool: name,
|
|
432
|
-
argKeys: Object.keys(params.arguments ?? {})
|
|
433
|
-
});
|
|
434
|
-
if (UNSUPPORTED_OVER_HTTP.has(name)) {
|
|
435
|
-
logMcp({ event: "mcp.tool_unsupported", ...tokenContext, tool: name });
|
|
436
|
-
return jsonRpcError(
|
|
437
|
-
reqId,
|
|
438
|
-
-32001,
|
|
439
|
-
`${name} is not supported over HTTP \u2014 use the admin UI`,
|
|
440
|
-
200
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
if (ADMIN_ONLY_TOOLS.has(name) && tokenRecord.role !== "admin") {
|
|
444
|
-
logMcp({ event: "mcp.role_denied", ...tokenContext, tool: name });
|
|
445
|
-
return jsonRpcError(reqId, -32003, `${name} requires admin role`, 403);
|
|
446
|
-
}
|
|
447
|
-
const ctx = {
|
|
448
|
-
graphql: makeGraphqlClient(),
|
|
449
|
-
storage: unsupportedStorage,
|
|
450
|
-
defaultSiteId
|
|
451
|
-
};
|
|
452
|
-
try {
|
|
453
|
-
const result = await dispatchToolCall(name, params.arguments ?? {}, ctx);
|
|
454
|
-
if (result === null) {
|
|
455
|
-
logMcp({
|
|
456
|
-
event: "mcp.tool_unknown",
|
|
457
|
-
...tokenContext,
|
|
458
|
-
tool: name,
|
|
459
|
-
durationMs: Date.now() - startedAt
|
|
460
|
-
});
|
|
461
|
-
return jsonRpcError(reqId, -32601, `unknown tool: ${name}`, 404);
|
|
462
|
-
}
|
|
463
|
-
logMcp({
|
|
464
|
-
event: "mcp.tool_ok",
|
|
465
|
-
...tokenContext,
|
|
466
|
-
tool: name,
|
|
467
|
-
durationMs: Date.now() - startedAt
|
|
468
|
-
});
|
|
469
|
-
void markTokenUsed(tokenRecord.hash);
|
|
470
|
-
return jsonRpcOk(reqId, {
|
|
471
|
-
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
472
|
-
});
|
|
473
|
-
} catch (err) {
|
|
474
|
-
logMcp({
|
|
475
|
-
event: "mcp.tool_failed",
|
|
476
|
-
...tokenContext,
|
|
477
|
-
tool: name,
|
|
478
|
-
durationMs: Date.now() - startedAt,
|
|
479
|
-
error: err instanceof Error ? err.message : String(err)
|
|
480
|
-
});
|
|
481
|
-
throw err;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
default:
|
|
485
|
-
return jsonRpcError(reqId, -32601, `method not found: ${body.method}`, 404);
|
|
486
|
-
}
|
|
487
|
-
} catch (err) {
|
|
488
|
-
console.error("[mcp] tool dispatch failed", err);
|
|
489
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
490
|
-
return jsonRpcError(reqId, -32e3, message, 500);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
return { POST };
|
|
494
|
-
}
|
|
495
|
-
function jsonRpcOk(id, result) {
|
|
496
|
-
return new Response(JSON.stringify({ jsonrpc: "2.0", id, result }), {
|
|
497
|
-
status: 200,
|
|
498
|
-
headers: { "Content-Type": "application/json" }
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
function logMcp(record) {
|
|
502
|
-
console.log(JSON.stringify({ ...record, ts: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
503
|
-
}
|
|
504
|
-
function jsonRpcError(id, code, message, status) {
|
|
505
|
-
return new Response(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }), {
|
|
506
|
-
status,
|
|
507
|
-
headers: { "Content-Type": "application/json" }
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// src/api/mcp-tokens.ts
|
|
512
|
-
function isRole(value) {
|
|
513
|
-
return value === "admin" || value === "editor";
|
|
514
|
-
}
|
|
515
|
-
function createMcpTokensRoute(admin) {
|
|
516
|
-
installServerKvProvider(admin.outputs);
|
|
517
|
-
async function requireAdminSession() {
|
|
518
|
-
const session = await admin.getServerSession();
|
|
519
|
-
if (!session || !admin.isAdmin(session)) {
|
|
520
|
-
return json({ error: "admin role required" }, 403);
|
|
521
|
-
}
|
|
522
|
-
return { userId: session.userId };
|
|
523
|
-
}
|
|
524
|
-
async function GET() {
|
|
525
|
-
const guard = await requireAdminSession();
|
|
526
|
-
if (guard instanceof Response) return guard;
|
|
527
|
-
try {
|
|
528
|
-
const tokens = await listTokens();
|
|
529
|
-
return json({ tokens });
|
|
530
|
-
} catch (err) {
|
|
531
|
-
console.error("[mcp-tokens] list failed", err);
|
|
532
|
-
return json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
async function POST(req) {
|
|
536
|
-
const guard = await requireAdminSession();
|
|
537
|
-
if (guard instanceof Response) return guard;
|
|
538
|
-
let body;
|
|
539
|
-
try {
|
|
540
|
-
body = await req.json();
|
|
541
|
-
} catch {
|
|
542
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
543
|
-
}
|
|
544
|
-
const label = typeof body.label === "string" ? body.label.replace(/[\x00-\x1f<>]/g, "").trim().slice(0, 80) : "";
|
|
545
|
-
if (!label) {
|
|
546
|
-
return json({ error: "label is required" }, 400);
|
|
547
|
-
}
|
|
548
|
-
if (!isRole(body.role)) {
|
|
549
|
-
return json({ error: 'role must be "admin" or "editor"' }, 400);
|
|
550
|
-
}
|
|
551
|
-
const { plaintext, hash } = generateToken();
|
|
552
|
-
const meta = {
|
|
553
|
-
label,
|
|
554
|
-
role: body.role,
|
|
555
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
556
|
-
createdBy: guard.userId
|
|
557
|
-
};
|
|
558
|
-
try {
|
|
559
|
-
await saveToken(hash, meta);
|
|
560
|
-
} catch (err) {
|
|
561
|
-
console.error("[mcp-tokens] save failed", err);
|
|
562
|
-
return json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
563
|
-
}
|
|
564
|
-
return json({ token: plaintext, record: { hash, ...meta } }, 201);
|
|
565
|
-
}
|
|
566
|
-
async function DELETE(req) {
|
|
567
|
-
const guard = await requireAdminSession();
|
|
568
|
-
if (guard instanceof Response) return guard;
|
|
569
|
-
let body;
|
|
570
|
-
try {
|
|
571
|
-
body = await req.json();
|
|
572
|
-
} catch {
|
|
573
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
574
|
-
}
|
|
575
|
-
let hash;
|
|
576
|
-
if (typeof body.hash === "string" && body.hash.length === 64) {
|
|
577
|
-
hash = body.hash;
|
|
578
|
-
} else if (typeof body.token === "string") {
|
|
579
|
-
hash = hashToken(body.token);
|
|
580
|
-
} else {
|
|
581
|
-
return json({ error: "hash or token required" }, 400);
|
|
582
|
-
}
|
|
583
|
-
try {
|
|
584
|
-
await revokeToken(hash);
|
|
585
|
-
} catch (err) {
|
|
586
|
-
console.error("[mcp-tokens] revoke failed", err);
|
|
587
|
-
return json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
588
|
-
}
|
|
589
|
-
return json({ ok: true });
|
|
590
|
-
}
|
|
591
|
-
return { GET, POST, DELETE };
|
|
592
|
-
}
|
|
593
|
-
function json(body, status = 200) {
|
|
594
|
-
return new Response(JSON.stringify(body), {
|
|
595
|
-
status,
|
|
596
|
-
headers: { "Content-Type": "application/json" }
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
39
|
export {
|
|
600
|
-
createMcpRoute,
|
|
601
|
-
createMcpTokensRoute,
|
|
602
40
|
createMediaProxyRoute
|
|
603
41
|
};
|