@ampless/backend 1.0.0-alpha.36 → 1.0.0-alpha.37
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/functions/mcp-handler.js +195 -2
- package/dist/index.js +1 -1
- package/package.json +3 -3
|
@@ -368,6 +368,161 @@ async function uploadMedia(graphql, storage, args) {
|
|
|
368
368
|
});
|
|
369
369
|
return { media: data.createMedia, url: putResult.url };
|
|
370
370
|
}
|
|
371
|
+
var MEDIA_FIELDS = (
|
|
372
|
+
/* GraphQL */
|
|
373
|
+
`
|
|
374
|
+
fragment MediaFields on Media {
|
|
375
|
+
mediaId
|
|
376
|
+
src
|
|
377
|
+
mimeType
|
|
378
|
+
size
|
|
379
|
+
createdAt
|
|
380
|
+
updatedAt
|
|
381
|
+
}
|
|
382
|
+
`
|
|
383
|
+
);
|
|
384
|
+
function toMediaResult(row, storage) {
|
|
385
|
+
return {
|
|
386
|
+
mediaId: row.mediaId,
|
|
387
|
+
src: row.src,
|
|
388
|
+
url: storage.publicUrl(row.src),
|
|
389
|
+
mimeType: row.mimeType,
|
|
390
|
+
size: row.size,
|
|
391
|
+
createdAt: row.createdAt,
|
|
392
|
+
updatedAt: row.updatedAt
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
var QUERY2 = (
|
|
396
|
+
/* GraphQL */
|
|
397
|
+
`
|
|
398
|
+
${MEDIA_FIELDS}
|
|
399
|
+
query ListMedia($filter: ModelMediaFilterInput, $limit: Int, $nextToken: String) {
|
|
400
|
+
listMedia(filter: $filter, limit: $limit, nextToken: $nextToken) {
|
|
401
|
+
items {
|
|
402
|
+
...MediaFields
|
|
403
|
+
}
|
|
404
|
+
nextToken
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
`
|
|
408
|
+
);
|
|
409
|
+
var listMediaSchema = {
|
|
410
|
+
type: "object",
|
|
411
|
+
properties: {
|
|
412
|
+
limit: {
|
|
413
|
+
type: "integer",
|
|
414
|
+
minimum: 1,
|
|
415
|
+
maximum: 100,
|
|
416
|
+
description: "Max results (default 20)"
|
|
417
|
+
},
|
|
418
|
+
nextToken: { type: "string", description: "Pagination cursor from a previous call" },
|
|
419
|
+
mimeType: {
|
|
420
|
+
type: "string",
|
|
421
|
+
description: 'Filter by MIME type prefix (beginsWith): "image/" matches all images, "image/png" matches PNG only.'
|
|
422
|
+
},
|
|
423
|
+
prefix: {
|
|
424
|
+
type: "string",
|
|
425
|
+
description: 'Filter by S3 key prefix (beginsWith) on `src`, e.g. "public/media/2024/" to find files from a given year.'
|
|
426
|
+
},
|
|
427
|
+
createdAfter: {
|
|
428
|
+
type: "string",
|
|
429
|
+
description: "ISO 8601 timestamp; only return media created at or after this."
|
|
430
|
+
},
|
|
431
|
+
createdBefore: {
|
|
432
|
+
type: "string",
|
|
433
|
+
description: "ISO 8601 timestamp; only return media created at or before this."
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
function buildMediaFilter(args) {
|
|
438
|
+
const conditions = [];
|
|
439
|
+
if (args.mimeType) conditions.push({ mimeType: { beginsWith: args.mimeType } });
|
|
440
|
+
if (args.prefix) conditions.push({ src: { beginsWith: args.prefix } });
|
|
441
|
+
if (args.createdAfter && args.createdBefore) {
|
|
442
|
+
conditions.push({ createdAt: { between: [args.createdAfter, args.createdBefore] } });
|
|
443
|
+
} else if (args.createdAfter) {
|
|
444
|
+
conditions.push({ createdAt: { ge: args.createdAfter } });
|
|
445
|
+
} else if (args.createdBefore) {
|
|
446
|
+
conditions.push({ createdAt: { le: args.createdBefore } });
|
|
447
|
+
}
|
|
448
|
+
if (conditions.length === 0) return void 0;
|
|
449
|
+
if (conditions.length === 1) return conditions[0];
|
|
450
|
+
return { and: conditions };
|
|
451
|
+
}
|
|
452
|
+
async function listMedia(graphql, storage, args = {}) {
|
|
453
|
+
const filter = buildMediaFilter(args);
|
|
454
|
+
const data = await graphql.query(QUERY2, {
|
|
455
|
+
filter,
|
|
456
|
+
limit: args.limit ?? 20,
|
|
457
|
+
nextToken: args.nextToken
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
media: data.listMedia.items.map((row) => toMediaResult(row, storage)),
|
|
461
|
+
nextToken: data.listMedia.nextToken
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
var QUERY3 = (
|
|
465
|
+
/* GraphQL */
|
|
466
|
+
`
|
|
467
|
+
${MEDIA_FIELDS}
|
|
468
|
+
query SearchMedia($filter: ModelMediaFilterInput, $limit: Int, $nextToken: String) {
|
|
469
|
+
listMedia(filter: $filter, limit: $limit, nextToken: $nextToken) {
|
|
470
|
+
items {
|
|
471
|
+
...MediaFields
|
|
472
|
+
}
|
|
473
|
+
nextToken
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
`
|
|
477
|
+
);
|
|
478
|
+
var MAX_PAGES = 20;
|
|
479
|
+
var searchMediaSchema = {
|
|
480
|
+
type: "object",
|
|
481
|
+
required: ["query"],
|
|
482
|
+
properties: {
|
|
483
|
+
query: {
|
|
484
|
+
type: "string",
|
|
485
|
+
description: 'Substring matched (case-sensitive `contains`) against `src` (which includes the filename) and `mimeType`. e.g. "logo", ".png", "image/png".'
|
|
486
|
+
},
|
|
487
|
+
limit: {
|
|
488
|
+
type: "integer",
|
|
489
|
+
minimum: 1,
|
|
490
|
+
maximum: 100,
|
|
491
|
+
description: "Target number of matches to collect before stopping (default 20). A soft target \u2014 the last page may push the result slightly past it."
|
|
492
|
+
},
|
|
493
|
+
nextToken: { type: "string", description: "Pagination cursor from a previous call" }
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
async function searchMedia(graphql, storage, args) {
|
|
497
|
+
if (!args.query) {
|
|
498
|
+
throw new Error("search_media: `query` is required");
|
|
499
|
+
}
|
|
500
|
+
const limit = args.limit ?? 20;
|
|
501
|
+
const filter = {
|
|
502
|
+
or: [{ src: { contains: args.query } }, { mimeType: { contains: args.query } }]
|
|
503
|
+
};
|
|
504
|
+
const media = [];
|
|
505
|
+
let token = args.nextToken ?? null;
|
|
506
|
+
let pages = 0;
|
|
507
|
+
do {
|
|
508
|
+
const data = await graphql.query(QUERY3, {
|
|
509
|
+
filter,
|
|
510
|
+
limit,
|
|
511
|
+
nextToken: token ?? void 0
|
|
512
|
+
});
|
|
513
|
+
pages += 1;
|
|
514
|
+
for (const row of data.listMedia.items) {
|
|
515
|
+
media.push(toMediaResult(row, storage));
|
|
516
|
+
}
|
|
517
|
+
token = data.listMedia.nextToken;
|
|
518
|
+
} while (media.length < limit && token && pages < MAX_PAGES);
|
|
519
|
+
const truncated = media.length < limit && token !== null && pages >= MAX_PAGES;
|
|
520
|
+
return {
|
|
521
|
+
media,
|
|
522
|
+
nextToken: token,
|
|
523
|
+
...truncated ? { truncated: true } : {}
|
|
524
|
+
};
|
|
525
|
+
}
|
|
371
526
|
var GET_BY_ID2 = (
|
|
372
527
|
/* GraphQL */
|
|
373
528
|
`
|
|
@@ -410,6 +565,10 @@ var deleteMediaSchema = {
|
|
|
410
565
|
src: {
|
|
411
566
|
type: "string",
|
|
412
567
|
description: "Full S3 key including `public/` (e.g. 'public/media/2026/05/1714400000000-photo.jpg'). Use when you only have the public URL \u2014 strip `/api/media/` and prepend `public/`. Provide either `mediaId` or `src`."
|
|
568
|
+
},
|
|
569
|
+
dryRun: {
|
|
570
|
+
type: "boolean",
|
|
571
|
+
description: "When true, resolve the target but delete nothing (no S3 delete, no row delete). Returns `{ deleted: false, dryRun: true, ... }` previewing what would be removed. Use it to confirm before the real delete."
|
|
413
572
|
}
|
|
414
573
|
}
|
|
415
574
|
};
|
|
@@ -417,6 +576,7 @@ async function deleteMedia(graphql, storage, args) {
|
|
|
417
576
|
if (!args.mediaId && !args.src) {
|
|
418
577
|
throw new Error("delete_media: provide `mediaId` or `src`");
|
|
419
578
|
}
|
|
579
|
+
const dryRun = args.dryRun === true;
|
|
420
580
|
let mediaId;
|
|
421
581
|
let src;
|
|
422
582
|
if (args.mediaId) {
|
|
@@ -434,6 +594,14 @@ async function deleteMedia(graphql, storage, args) {
|
|
|
434
594
|
}
|
|
435
595
|
if (!mediaId) {
|
|
436
596
|
if (args.src) {
|
|
597
|
+
if (dryRun) {
|
|
598
|
+
return {
|
|
599
|
+
deleted: false,
|
|
600
|
+
dryRun: true,
|
|
601
|
+
reason: "dry run \u2014 media row not found; would attempt S3 object delete",
|
|
602
|
+
src: args.src
|
|
603
|
+
};
|
|
604
|
+
}
|
|
437
605
|
await storage.deleteObject(args.src);
|
|
438
606
|
return {
|
|
439
607
|
deleted: false,
|
|
@@ -443,7 +611,17 @@ async function deleteMedia(graphql, storage, args) {
|
|
|
443
611
|
}
|
|
444
612
|
return {
|
|
445
613
|
deleted: false,
|
|
446
|
-
|
|
614
|
+
...dryRun ? { dryRun: true } : {},
|
|
615
|
+
reason: dryRun ? "dry run \u2014 media row not found for mediaId; nothing to delete" : "media row not found for mediaId"
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
if (dryRun) {
|
|
619
|
+
return {
|
|
620
|
+
deleted: false,
|
|
621
|
+
dryRun: true,
|
|
622
|
+
reason: "dry run \u2014 nothing deleted",
|
|
623
|
+
mediaId,
|
|
624
|
+
src
|
|
447
625
|
};
|
|
448
626
|
}
|
|
449
627
|
await storage.deleteObject(src);
|
|
@@ -1359,9 +1537,21 @@ var tools = [
|
|
|
1359
1537
|
inputSchema: uploadMediaSchema,
|
|
1360
1538
|
handler: (args, ctx) => uploadMedia(ctx.graphql, ctx.storage(), args)
|
|
1361
1539
|
},
|
|
1540
|
+
{
|
|
1541
|
+
name: "list_media",
|
|
1542
|
+
description: 'List media files in the CMS. Optional filters: `mimeType` (prefix match \u2014 "image/" matches all images, "image/png" only PNG), `prefix` (S3 key prefix on `src`, e.g. "public/media/2024/"), `createdAfter` / `createdBefore` (ISO 8601 date range). Returns up to `limit` rows (default 20) \u2014 each `{ mediaId, src, url, mimeType, size, createdAt, updatedAt }` \u2014 plus a `nextToken` cursor. Note: filters apply after the page read, so a page may return fewer than `limit` rows while a `nextToken` remains \u2014 follow the cursor. Use this to find media to delete without remembering `upload_media` responses.',
|
|
1543
|
+
inputSchema: listMediaSchema,
|
|
1544
|
+
handler: (args, ctx) => listMedia(ctx.graphql, ctx.storage(), args)
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
name: "search_media",
|
|
1548
|
+
description: 'Search media by substring (case-sensitive `contains`) across `src` (which includes the filename) and `mimeType`. Pass `query` (e.g. "logo", ".png", "image/png"). Walks DynamoDB pages internally until it collects at least `limit` matches (default 20), exhausts the table, or hits its page cap \u2014 so `limit` is a soft target and the result may run slightly past it. Returns the same row shape as `list_media` plus `nextToken`; `truncated: true` means the page cap was hit with more to scan (pass `nextToken` back to continue).',
|
|
1549
|
+
inputSchema: searchMediaSchema,
|
|
1550
|
+
handler: (args, ctx) => searchMedia(ctx.graphql, ctx.storage(), args)
|
|
1551
|
+
},
|
|
1362
1552
|
{
|
|
1363
1553
|
name: "delete_media",
|
|
1364
|
-
description: "Delete a media file: removes the S3 object and the Media row. Pass `mediaId` (from `upload_media`'s response) or `src` (full S3 key like `public/media/2026/05/...`). When only `src` is given, looks up the Media row via `getMediaBySrc`. S3 delete runs first, then the DDB row delete \u2014 both are idempotent so re-running converges on missing-key cases. Returns `{ deleted: false }` instead of throwing when no Media row matches; if `src` was supplied directly the S3 object is still removed (use this to sweep orphan files).",
|
|
1554
|
+
description: "Delete a media file: removes the S3 object and the Media row. Pass `mediaId` (from `upload_media`'s response, or `list_media` / `search_media`) or `src` (full S3 key like `public/media/2026/05/...`). When only `src` is given, looks up the Media row via `getMediaBySrc`. S3 delete runs first, then the DDB row delete \u2014 both are idempotent so re-running converges on missing-key cases. Pass `dryRun: true` to preview what would be deleted without touching anything (returns `{ deleted: false, dryRun: true, ... }`). Returns `{ deleted: false }` instead of throwing when no Media row matches; if `src` was supplied directly the S3 object is still removed (use this to sweep orphan files).",
|
|
1365
1555
|
inputSchema: deleteMediaSchema,
|
|
1366
1556
|
handler: (args, ctx) => deleteMedia(
|
|
1367
1557
|
ctx.graphql,
|
|
@@ -1532,6 +1722,9 @@ function createMcpStorageClient(opts) {
|
|
|
1532
1722
|
token = res.IsTruncated ? res.NextContinuationToken : void 0;
|
|
1533
1723
|
} while (token);
|
|
1534
1724
|
return out;
|
|
1725
|
+
},
|
|
1726
|
+
publicUrl(key) {
|
|
1727
|
+
return formatPublicAssetUrl(opts.bucket, opts.region, key);
|
|
1535
1728
|
}
|
|
1536
1729
|
};
|
|
1537
1730
|
}
|
package/dist/index.js
CHANGED
|
@@ -188,7 +188,7 @@ function defineAmplessBackend(opts) {
|
|
|
188
188
|
mcpHandlerFn.addToRolePolicy(
|
|
189
189
|
new PolicyStatement({
|
|
190
190
|
effect: Effect.ALLOW,
|
|
191
|
-
actions: ["s3:PutObject"],
|
|
191
|
+
actions: ["s3:PutObject", "s3:DeleteObject"],
|
|
192
192
|
resources: [`${backend.storage.resources.bucket.bucketArn}/public/media/*`]
|
|
193
193
|
})
|
|
194
194
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ampless/backend",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.37",
|
|
4
4
|
"description": "Amplify Gen 2 backend factories for ampless: auth, data, storage, event processors, API key renewer",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"@smithy/protocol-http": "^5.4.4",
|
|
66
66
|
"@smithy/signature-v4": "^5.4.4",
|
|
67
67
|
"fflate": "^0.8.3",
|
|
68
|
-
"ampless": "1.0.0-alpha.
|
|
69
|
-
"
|
|
68
|
+
"@ampless/mcp-server": "1.0.0-alpha.26",
|
|
69
|
+
"ampless": "1.0.0-alpha.20"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
72
|
"@aws-amplify/backend": "^1",
|