@ampless/mcp-server 1.0.0-alpha.25 → 1.0.0-alpha.27

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.ja.md CHANGED
@@ -68,7 +68,9 @@ import type { ToolDefinition, ToolContext, ResolvedSite } from '@ampless/mcp-ser
68
68
  | `update_post` | editor | 既存の投稿のフィールドをパッチ更新 |
69
69
  | `delete_post` | editor | 投稿を削除しタグインデックスをクリーンアップ |
70
70
  | `upload_media` | editor | Base64 エンコードされたバイト列を S3 にアップロードし Media レコードを作成 |
71
- | `delete_media` | editor | メディアファイルを削除(S3 オブジェクト + Media 行)。`mediaId` または `src` で指定 |
71
+ | `list_media` | reader | メディア一覧を取得。`mimeType`(前方一致)/ `prefix` / `createdAfter` / `createdBefore` フィルターとページネーション対応 |
72
+ | `search_media` | reader | ファイル名 / `src` / `mimeType` への部分一致でメディアを検索 |
73
+ | `delete_media` | editor | メディアファイルを削除(S3 オブジェクト + Media 行)。`mediaId` または `src` で指定。`dryRun: true` で削除せずにプレビュー |
72
74
  | `get_schema` | reader | CMS コンテンツスキーマを返す |
73
75
  | `upload_static_bundle` | editor | ビルド済み静的バンドルを S3 にアップロード |
74
76
  | `list_static_files` | reader | 静的バンドルファイルを一覧表示 |
package/README.md CHANGED
@@ -68,7 +68,9 @@ import type { ToolDefinition, ToolContext, ResolvedSite } from '@ampless/mcp-ser
68
68
  | `update_post` | editor | Patch fields on an existing post |
69
69
  | `delete_post` | editor | Delete a post and clean up its tag index |
70
70
  | `upload_media` | editor | Upload base64-encoded bytes to S3 and create a Media record |
71
- | `delete_media` | editor | Delete a media file (S3 object + Media row). Pass `mediaId` or `src` |
71
+ | `list_media` | reader | List media with optional `mimeType` (prefix) / `prefix` / `createdAfter` / `createdBefore` filters + pagination |
72
+ | `search_media` | reader | Search media by substring across filename / `src` / `mimeType` |
73
+ | `delete_media` | editor | Delete a media file (S3 object + Media row). Pass `mediaId` or `src`; `dryRun: true` previews without deleting |
72
74
  | `get_schema` | reader | Return the CMS content schema |
73
75
  | `upload_static_bundle` | editor | Upload a pre-built static bundle to S3 |
74
76
  | `list_static_files` | reader | List static bundle files |
package/dist/index.d.ts CHANGED
@@ -68,6 +68,14 @@ interface StorageClient {
68
68
  * a few hundred entries per bundle in practice.
69
69
  */
70
70
  listObjects(prefix: string): Promise<StorageObject[]>;
71
+ /**
72
+ * Build the public URL for an object `key`, in the same format
73
+ * `putObject` returns (`https://{bucket}.s3.{region}.amazonaws.com/{key}`).
74
+ * Pure string formatting — no S3 round-trip. `list_media` /
75
+ * `search_media` use this to surface a `url` alongside each Media row
76
+ * without re-deriving the bucket/region in the tool layer.
77
+ */
78
+ publicUrl(key: string): string;
71
79
  }
72
80
  interface ToolContext {
73
81
  graphql: GraphqlClient;
package/dist/index.js CHANGED
@@ -353,6 +353,167 @@ async function uploadMedia(graphql, storage, args) {
353
353
  return { media: data.createMedia, url: putResult.url };
354
354
  }
355
355
 
356
+ // src/tools/media-mapping.ts
357
+ var MEDIA_FIELDS = (
358
+ /* GraphQL */
359
+ `
360
+ fragment MediaFields on Media {
361
+ mediaId
362
+ src
363
+ mimeType
364
+ size
365
+ createdAt
366
+ updatedAt
367
+ }
368
+ `
369
+ );
370
+ function toMediaResult(row, storage) {
371
+ return {
372
+ mediaId: row.mediaId,
373
+ src: row.src,
374
+ url: storage.publicUrl(row.src),
375
+ mimeType: row.mimeType,
376
+ size: row.size,
377
+ createdAt: row.createdAt,
378
+ updatedAt: row.updatedAt
379
+ };
380
+ }
381
+
382
+ // src/tools/list-media.ts
383
+ var QUERY2 = (
384
+ /* GraphQL */
385
+ `
386
+ ${MEDIA_FIELDS}
387
+ query ListMedia($filter: ModelMediaFilterInput, $limit: Int, $nextToken: String) {
388
+ listMedia(filter: $filter, limit: $limit, nextToken: $nextToken) {
389
+ items {
390
+ ...MediaFields
391
+ }
392
+ nextToken
393
+ }
394
+ }
395
+ `
396
+ );
397
+ var listMediaSchema = {
398
+ type: "object",
399
+ properties: {
400
+ limit: {
401
+ type: "integer",
402
+ minimum: 1,
403
+ maximum: 100,
404
+ description: "Max results (default 20)"
405
+ },
406
+ nextToken: { type: "string", description: "Pagination cursor from a previous call" },
407
+ mimeType: {
408
+ type: "string",
409
+ description: 'Filter by MIME type prefix (beginsWith): "image/" matches all images, "image/png" matches PNG only.'
410
+ },
411
+ prefix: {
412
+ type: "string",
413
+ description: 'Filter by S3 key prefix (beginsWith) on `src`, e.g. "public/media/2024/" to find files from a given year.'
414
+ },
415
+ createdAfter: {
416
+ type: "string",
417
+ description: "ISO 8601 timestamp; only return media created at or after this."
418
+ },
419
+ createdBefore: {
420
+ type: "string",
421
+ description: "ISO 8601 timestamp; only return media created at or before this."
422
+ }
423
+ }
424
+ };
425
+ function buildMediaFilter(args) {
426
+ const conditions = [];
427
+ if (args.mimeType) conditions.push({ mimeType: { beginsWith: args.mimeType } });
428
+ if (args.prefix) conditions.push({ src: { beginsWith: args.prefix } });
429
+ if (args.createdAfter && args.createdBefore) {
430
+ conditions.push({ createdAt: { between: [args.createdAfter, args.createdBefore] } });
431
+ } else if (args.createdAfter) {
432
+ conditions.push({ createdAt: { ge: args.createdAfter } });
433
+ } else if (args.createdBefore) {
434
+ conditions.push({ createdAt: { le: args.createdBefore } });
435
+ }
436
+ if (conditions.length === 0) return void 0;
437
+ if (conditions.length === 1) return conditions[0];
438
+ return { and: conditions };
439
+ }
440
+ async function listMedia(graphql, storage, args = {}) {
441
+ const filter = buildMediaFilter(args);
442
+ const data = await graphql.query(QUERY2, {
443
+ filter,
444
+ limit: args.limit ?? 20,
445
+ nextToken: args.nextToken
446
+ });
447
+ return {
448
+ media: data.listMedia.items.map((row) => toMediaResult(row, storage)),
449
+ nextToken: data.listMedia.nextToken
450
+ };
451
+ }
452
+
453
+ // src/tools/search-media.ts
454
+ var QUERY3 = (
455
+ /* GraphQL */
456
+ `
457
+ ${MEDIA_FIELDS}
458
+ query SearchMedia($filter: ModelMediaFilterInput, $limit: Int, $nextToken: String) {
459
+ listMedia(filter: $filter, limit: $limit, nextToken: $nextToken) {
460
+ items {
461
+ ...MediaFields
462
+ }
463
+ nextToken
464
+ }
465
+ }
466
+ `
467
+ );
468
+ var MAX_PAGES = 20;
469
+ var searchMediaSchema = {
470
+ type: "object",
471
+ required: ["query"],
472
+ properties: {
473
+ query: {
474
+ type: "string",
475
+ description: 'Substring matched (case-sensitive `contains`) against `src` (which includes the filename) and `mimeType`. e.g. "logo", ".png", "image/png".'
476
+ },
477
+ limit: {
478
+ type: "integer",
479
+ minimum: 1,
480
+ maximum: 100,
481
+ 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."
482
+ },
483
+ nextToken: { type: "string", description: "Pagination cursor from a previous call" }
484
+ }
485
+ };
486
+ async function searchMedia(graphql, storage, args) {
487
+ if (!args.query) {
488
+ throw new Error("search_media: `query` is required");
489
+ }
490
+ const limit = args.limit ?? 20;
491
+ const filter = {
492
+ or: [{ src: { contains: args.query } }, { mimeType: { contains: args.query } }]
493
+ };
494
+ const media = [];
495
+ let token = args.nextToken ?? null;
496
+ let pages = 0;
497
+ do {
498
+ const data = await graphql.query(QUERY3, {
499
+ filter,
500
+ limit,
501
+ nextToken: token ?? void 0
502
+ });
503
+ pages += 1;
504
+ for (const row of data.listMedia.items) {
505
+ media.push(toMediaResult(row, storage));
506
+ }
507
+ token = data.listMedia.nextToken;
508
+ } while (media.length < limit && token && pages < MAX_PAGES);
509
+ const truncated = media.length < limit && token !== null && pages >= MAX_PAGES;
510
+ return {
511
+ media,
512
+ nextToken: token,
513
+ ...truncated ? { truncated: true } : {}
514
+ };
515
+ }
516
+
356
517
  // src/tools/delete-media.ts
357
518
  var GET_BY_ID2 = (
358
519
  /* GraphQL */
@@ -396,6 +557,10 @@ var deleteMediaSchema = {
396
557
  src: {
397
558
  type: "string",
398
559
  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`."
560
+ },
561
+ dryRun: {
562
+ type: "boolean",
563
+ 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."
399
564
  }
400
565
  }
401
566
  };
@@ -403,6 +568,7 @@ async function deleteMedia(graphql, storage, args) {
403
568
  if (!args.mediaId && !args.src) {
404
569
  throw new Error("delete_media: provide `mediaId` or `src`");
405
570
  }
571
+ const dryRun = args.dryRun === true;
406
572
  let mediaId;
407
573
  let src;
408
574
  if (args.mediaId) {
@@ -420,6 +586,14 @@ async function deleteMedia(graphql, storage, args) {
420
586
  }
421
587
  if (!mediaId) {
422
588
  if (args.src) {
589
+ if (dryRun) {
590
+ return {
591
+ deleted: false,
592
+ dryRun: true,
593
+ reason: "dry run \u2014 media row not found; would attempt S3 object delete",
594
+ src: args.src
595
+ };
596
+ }
423
597
  await storage.deleteObject(args.src);
424
598
  return {
425
599
  deleted: false,
@@ -429,7 +603,17 @@ async function deleteMedia(graphql, storage, args) {
429
603
  }
430
604
  return {
431
605
  deleted: false,
432
- reason: "media row not found for mediaId"
606
+ ...dryRun ? { dryRun: true } : {},
607
+ reason: dryRun ? "dry run \u2014 media row not found for mediaId; nothing to delete" : "media row not found for mediaId"
608
+ };
609
+ }
610
+ if (dryRun) {
611
+ return {
612
+ deleted: false,
613
+ dryRun: true,
614
+ reason: "dry run \u2014 nothing deleted",
615
+ mediaId,
616
+ src
433
617
  };
434
618
  }
435
619
  await storage.deleteObject(src);
@@ -1395,9 +1579,21 @@ var tools = [
1395
1579
  inputSchema: uploadMediaSchema,
1396
1580
  handler: (args, ctx) => uploadMedia(ctx.graphql, ctx.storage(), args)
1397
1581
  },
1582
+ {
1583
+ name: "list_media",
1584
+ 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.',
1585
+ inputSchema: listMediaSchema,
1586
+ handler: (args, ctx) => listMedia(ctx.graphql, ctx.storage(), args)
1587
+ },
1588
+ {
1589
+ name: "search_media",
1590
+ 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).',
1591
+ inputSchema: searchMediaSchema,
1592
+ handler: (args, ctx) => searchMedia(ctx.graphql, ctx.storage(), args)
1593
+ },
1398
1594
  {
1399
1595
  name: "delete_media",
1400
- 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).",
1596
+ 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).",
1401
1597
  inputSchema: deleteMediaSchema,
1402
1598
  handler: (args, ctx) => deleteMedia(
1403
1599
  ctx.graphql,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/mcp-server",
3
- "version": "1.0.0-alpha.25",
3
+ "version": "1.0.0-alpha.27",
4
4
  "description": "MCP tool registry shared by @ampless/backend mcp-handler Lambda. Installed transitively via @ampless/admin / @ampless/backend; no direct install needed.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -26,7 +26,7 @@
26
26
  "bugs": "https://github.com/heavymoons/ampless/issues",
27
27
  "dependencies": {
28
28
  "fflate": "^0.8.3",
29
- "ampless": "1.0.0-alpha.20"
29
+ "ampless": "1.0.0-alpha.21"
30
30
  },
31
31
  "keywords": [
32
32
  "ampless",