@ampless/mcp-server 0.2.0-alpha.8 → 1.0.0-alpha.10

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.
@@ -1,592 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/tools/post-mapping.ts
4
- import {
5
- decodeAwsJson
6
- } from "ampless";
7
- var POST_FIELDS = (
8
- /* GraphQL */
9
- `
10
- fragment PostFields on Post {
11
- siteId
12
- postId
13
- slug
14
- title
15
- excerpt
16
- format
17
- body
18
- status
19
- publishedAt
20
- tags
21
- metadata
22
- }
23
- `
24
- );
25
- function toCorePost(p) {
26
- const metadata = decodeAwsJson(p.metadata);
27
- return {
28
- siteId: p.siteId,
29
- postId: p.postId,
30
- slug: p.slug,
31
- title: p.title,
32
- excerpt: p.excerpt ?? void 0,
33
- format: p.format ?? "markdown",
34
- body: decodeAwsJson(p.body),
35
- status: p.status ?? "draft",
36
- publishedAt: p.publishedAt ?? void 0,
37
- tags: (p.tags ?? []).filter((t) => typeof t === "string"),
38
- metadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : void 0
39
- };
40
- }
41
-
42
- // src/tools/list-posts.ts
43
- var QUERY = (
44
- /* GraphQL */
45
- `
46
- ${POST_FIELDS}
47
- query ListPosts($filter: ModelPostFilterInput, $limit: Int, $nextToken: String) {
48
- listPosts(filter: $filter, limit: $limit, nextToken: $nextToken) {
49
- items {
50
- ...PostFields
51
- }
52
- nextToken
53
- }
54
- }
55
- `
56
- );
57
- var listPostsSchema = {
58
- type: "object",
59
- properties: {
60
- siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
61
- status: {
62
- type: "string",
63
- enum: ["draft", "published", "all"],
64
- description: 'Filter by status (default "all")'
65
- },
66
- limit: { type: "integer", minimum: 1, maximum: 100, description: "Max results (default 20)" },
67
- nextToken: { type: "string", description: "Pagination cursor from a previous call" }
68
- }
69
- };
70
- async function listPosts(client, defaultSiteId, args = {}) {
71
- const siteId = args.siteId ?? defaultSiteId;
72
- const status = args.status ?? "all";
73
- const filter = { siteId: { eq: siteId } };
74
- if (status !== "all") filter.status = { eq: status };
75
- const data = await client.query(QUERY, { filter, limit: args.limit ?? 20, nextToken: args.nextToken });
76
- return {
77
- posts: data.listPosts.items.map(toCorePost),
78
- nextToken: data.listPosts.nextToken
79
- };
80
- }
81
-
82
- // src/tools/get-post.ts
83
- var GET_BY_ID = (
84
- /* GraphQL */
85
- `
86
- ${POST_FIELDS}
87
- query GetPost($siteId: String!, $postId: ID!) {
88
- getPost(siteId: $siteId, postId: $postId) {
89
- ...PostFields
90
- }
91
- }
92
- `
93
- );
94
- var LIST_BY_SLUG = (
95
- /* GraphQL */
96
- `
97
- ${POST_FIELDS}
98
- query GetPostBySlug($filter: ModelPostFilterInput!) {
99
- listPosts(filter: $filter, limit: 1) {
100
- items {
101
- ...PostFields
102
- }
103
- }
104
- }
105
- `
106
- );
107
- var getPostSchema = {
108
- type: "object",
109
- properties: {
110
- siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
111
- slug: { type: "string", description: "Post slug" },
112
- postId: { type: "string", description: "Post id (used when slug is omitted)" }
113
- }
114
- };
115
- async function getPost(client, defaultSiteId, args) {
116
- if (!args.slug && !args.postId) {
117
- throw new Error("get_post requires either `slug` or `postId`");
118
- }
119
- const siteId = args.siteId ?? defaultSiteId;
120
- if (args.postId) {
121
- const data2 = await client.query(GET_BY_ID, { siteId, postId: args.postId });
122
- return data2.getPost ? toCorePost(data2.getPost) : null;
123
- }
124
- const data = await client.query(LIST_BY_SLUG, {
125
- filter: { siteId: { eq: siteId }, slug: { eq: args.slug } }
126
- });
127
- const item = data.listPosts.items[0];
128
- return item ? toCorePost(item) : null;
129
- }
130
-
131
- // src/tools/create-post.ts
132
- import {
133
- composeSiteIdStatus,
134
- composeSiteIdSlug,
135
- encodeAwsJson
136
- } from "ampless";
137
-
138
- // src/posttag.ts
139
- function entries(post) {
140
- if (post.status !== "published" || !post.publishedAt || !post.tags?.length) return [];
141
- return post.tags.map((tag) => ({
142
- siteIdTag: `${post.siteId}#${tag}`,
143
- publishedAtPostId: `${post.publishedAt}#${post.postId}`
144
- }));
145
- }
146
- function entryKey(e) {
147
- return `${e.siteIdTag}|${e.publishedAtPostId}`;
148
- }
149
- var CREATE_POST_TAG = (
150
- /* GraphQL */
151
- `
152
- mutation CreatePostTag($input: CreatePostTagInput!) {
153
- createPostTag(input: $input) {
154
- siteIdTag
155
- publishedAtPostId
156
- }
157
- }
158
- `
159
- );
160
- var UPDATE_POST_TAG = (
161
- /* GraphQL */
162
- `
163
- mutation UpdatePostTag($input: UpdatePostTagInput!) {
164
- updatePostTag(input: $input) {
165
- siteIdTag
166
- publishedAtPostId
167
- }
168
- }
169
- `
170
- );
171
- var DELETE_POST_TAG = (
172
- /* GraphQL */
173
- `
174
- mutation DeletePostTag($input: DeletePostTagInput!) {
175
- deletePostTag(input: $input) {
176
- siteIdTag
177
- publishedAtPostId
178
- }
179
- }
180
- `
181
- );
182
- async function syncPostTags(client, post, oldPost) {
183
- const oldEntries = oldPost ? entries(oldPost) : [];
184
- const newEntries = entries(post);
185
- const oldKeys = new Set(oldEntries.map(entryKey));
186
- const newKeys = new Set(newEntries.map(entryKey));
187
- await Promise.all(
188
- oldEntries.filter((e) => !newKeys.has(entryKey(e))).map(
189
- (e) => client.query(DELETE_POST_TAG, {
190
- input: { siteIdTag: e.siteIdTag, publishedAtPostId: e.publishedAtPostId }
191
- })
192
- )
193
- );
194
- await Promise.all(
195
- newEntries.filter((e) => !oldKeys.has(entryKey(e))).map(
196
- (e) => client.query(CREATE_POST_TAG, {
197
- input: {
198
- siteIdTag: e.siteIdTag,
199
- publishedAtPostId: e.publishedAtPostId,
200
- siteId: post.siteId,
201
- tag: e.siteIdTag.slice(post.siteId.length + 1),
202
- postId: post.postId,
203
- publishedAt: post.publishedAt,
204
- slug: post.slug,
205
- title: post.title,
206
- excerpt: post.excerpt,
207
- tags: post.tags ?? []
208
- }
209
- })
210
- )
211
- );
212
- await Promise.all(
213
- newEntries.filter((e) => oldKeys.has(entryKey(e))).map(
214
- (e) => client.query(UPDATE_POST_TAG, {
215
- input: {
216
- siteIdTag: e.siteIdTag,
217
- publishedAtPostId: e.publishedAtPostId,
218
- slug: post.slug,
219
- title: post.title,
220
- excerpt: post.excerpt,
221
- tags: post.tags ?? []
222
- }
223
- })
224
- )
225
- );
226
- }
227
-
228
- // src/tools/create-post.ts
229
- var MUTATION = (
230
- /* GraphQL */
231
- `
232
- ${POST_FIELDS}
233
- mutation CreatePost($input: CreatePostInput!) {
234
- createPost(input: $input) {
235
- ...PostFields
236
- }
237
- }
238
- `
239
- );
240
- var createPostSchema = {
241
- type: "object",
242
- required: ["slug", "title", "format", "body"],
243
- properties: {
244
- siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
245
- postId: { type: "string", description: "Optional explicit id; auto-generated if omitted" },
246
- slug: { type: "string" },
247
- title: { type: "string" },
248
- format: {
249
- type: "string",
250
- enum: ["tiptap", "markdown", "html"],
251
- description: "tiptap = rich text JSON tree; markdown = source string (GFM extensions enabled); html = raw HTML string (rendered verbatim, no sanitization)."
252
- },
253
- body: {
254
- description: "tiptap JSON (when format=tiptap), markdown source string (format=markdown), or raw HTML string (format=html)."
255
- },
256
- status: { type: "string", enum: ["draft", "published"], default: "draft" },
257
- excerpt: { type: "string" },
258
- publishedAt: { type: "string", description: "ISO 8601 timestamp; required when status=published" },
259
- tags: { type: "array", items: { type: "string" } },
260
- metadata: {
261
- type: "object",
262
- description: "Free-form per-post metadata. Reserved well-known keys: `no_layout` (boolean) \u2014 when true, the public page is served as bare HTML with no theme chrome (the route redirects to /raw/<slug>); meaningful only with format=html. Other keys pass through unchanged for themes/plugins.",
263
- properties: {
264
- no_layout: {
265
- type: "boolean",
266
- description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
267
- }
268
- },
269
- additionalProperties: true
270
- }
271
- }
272
- };
273
- async function createPost(client, defaultSiteId, args) {
274
- const siteId = args.siteId ?? defaultSiteId;
275
- const postId = args.postId ?? `post-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
276
- const status = args.status ?? "draft";
277
- const publishedAt = args.publishedAt ?? (status === "published" ? (/* @__PURE__ */ new Date()).toISOString() : void 0);
278
- const data = await client.query(MUTATION, {
279
- input: {
280
- siteId,
281
- postId,
282
- slug: args.slug,
283
- title: args.title,
284
- excerpt: args.excerpt,
285
- format: args.format,
286
- body: encodeAwsJson(args.body),
287
- status,
288
- publishedAt,
289
- tags: args.tags,
290
- metadata: args.metadata !== void 0 ? encodeAwsJson(args.metadata) : void 0,
291
- // Denormalized GSI keys.
292
- siteIdStatus: composeSiteIdStatus(siteId, status),
293
- siteIdSlug: composeSiteIdSlug(siteId, args.slug)
294
- }
295
- });
296
- const created = toCorePost(data.createPost);
297
- await syncPostTags(client, created, null);
298
- return created;
299
- }
300
-
301
- // src/tools/update-post.ts
302
- import {
303
- composeSiteIdStatus as composeSiteIdStatus2,
304
- composeSiteIdSlug as composeSiteIdSlug2,
305
- encodeAwsJson as encodeAwsJson2
306
- } from "ampless";
307
- var MUTATION2 = (
308
- /* GraphQL */
309
- `
310
- ${POST_FIELDS}
311
- mutation UpdatePost($input: UpdatePostInput!) {
312
- updatePost(input: $input) {
313
- ...PostFields
314
- }
315
- }
316
- `
317
- );
318
- var updatePostSchema = {
319
- type: "object",
320
- required: ["postId"],
321
- properties: {
322
- postId: { type: "string" },
323
- siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
324
- slug: { type: "string" },
325
- title: { type: "string" },
326
- excerpt: { type: "string" },
327
- format: {
328
- type: "string",
329
- enum: ["tiptap", "markdown", "html"],
330
- description: "tiptap = rich text JSON tree; markdown = source string; html = raw HTML string (no sanitization)."
331
- },
332
- body: { description: "tiptap JSON, markdown source, or raw HTML string" },
333
- status: { type: "string", enum: ["draft", "published"] },
334
- publishedAt: { type: "string", description: "ISO 8601 timestamp" },
335
- tags: { type: "array", items: { type: "string" } },
336
- metadata: {
337
- type: "object",
338
- description: "Free-form per-post metadata. Reserved well-known keys: `no_layout` (boolean) \u2014 when true, the public page is served as bare HTML with no theme chrome (the route redirects to /raw/<slug>); meaningful only with format=html. Passing metadata replaces the existing metadata object \u2014 read the current post first if you only want to add a key.",
339
- properties: {
340
- no_layout: {
341
- type: "boolean",
342
- description: "Serve the post as bare HTML with no theme chrome. Only meaningful when format=html; ignored otherwise."
343
- }
344
- },
345
- additionalProperties: true
346
- }
347
- }
348
- };
349
- async function updatePost(client, defaultSiteId, args) {
350
- const siteId = args.siteId ?? defaultSiteId;
351
- const oldPost = await getPost(client, defaultSiteId, { postId: args.postId, siteId });
352
- const input = { siteId, postId: args.postId };
353
- if (args.slug !== void 0) input.slug = args.slug;
354
- if (args.title !== void 0) input.title = args.title;
355
- if (args.excerpt !== void 0) input.excerpt = args.excerpt;
356
- if (args.format !== void 0) input.format = args.format;
357
- if (args.body !== void 0) input.body = encodeAwsJson2(args.body);
358
- if (args.status !== void 0) input.status = args.status;
359
- if (args.publishedAt !== void 0) input.publishedAt = args.publishedAt;
360
- if (args.tags !== void 0) input.tags = args.tags;
361
- if (args.metadata !== void 0) input.metadata = encodeAwsJson2(args.metadata);
362
- if (args.status !== void 0) {
363
- input.siteIdStatus = composeSiteIdStatus2(siteId, args.status);
364
- }
365
- if (args.slug !== void 0) {
366
- input.siteIdSlug = composeSiteIdSlug2(siteId, args.slug);
367
- }
368
- const data = await client.query(MUTATION2, { input });
369
- const updated = toCorePost(data.updatePost);
370
- await syncPostTags(client, updated, oldPost);
371
- return updated;
372
- }
373
-
374
- // src/tools/delete-post.ts
375
- var MUTATION3 = (
376
- /* GraphQL */
377
- `
378
- mutation DeletePost($input: DeletePostInput!) {
379
- deletePost(input: $input) {
380
- siteId
381
- postId
382
- }
383
- }
384
- `
385
- );
386
- var deletePostSchema = {
387
- type: "object",
388
- required: ["postId"],
389
- properties: {
390
- postId: { type: "string" },
391
- siteId: { type: "string", description: 'Site identifier (defaults to "default")' }
392
- }
393
- };
394
- async function deletePost(client, defaultSiteId, args) {
395
- const siteId = args.siteId ?? defaultSiteId;
396
- const oldPost = await getPost(client, defaultSiteId, { postId: args.postId, siteId });
397
- if (oldPost) {
398
- await syncPostTags(client, { ...oldPost, status: "draft" }, oldPost);
399
- }
400
- const data = await client.query(MUTATION3, { input: { siteId, postId: args.postId } });
401
- return { deleted: data.deletePost };
402
- }
403
-
404
- // src/tools/media-key.ts
405
- function sanitizeName(name) {
406
- return name.replace(/[\x00-\x1f\x7f]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
407
- }
408
- function buildMediaKey(filename, now = /* @__PURE__ */ new Date()) {
409
- const safe = sanitizeName(filename);
410
- const yyyy = now.getFullYear();
411
- const mm = String(now.getMonth() + 1).padStart(2, "0");
412
- return `public/media/${yyyy}/${mm}/${now.getTime()}-${safe}`;
413
- }
414
-
415
- // src/tools/upload-media.ts
416
- var MUTATION4 = (
417
- /* GraphQL */
418
- `
419
- mutation CreateMedia($input: CreateMediaInput!) {
420
- createMedia(input: $input) {
421
- siteId
422
- mediaId
423
- src
424
- mimeType
425
- size
426
- delivery
427
- }
428
- }
429
- `
430
- );
431
- var uploadMediaSchema = {
432
- type: "object",
433
- required: ["filename", "mimeType", "base64Data"],
434
- properties: {
435
- siteId: { type: "string", description: 'Site identifier (defaults to "default")' },
436
- filename: { type: "string", description: "Original filename; sanitized server-side" },
437
- mimeType: {
438
- type: "string",
439
- description: "IANA media type (e.g. image/webp, image/jpeg). The MCP server does not transcode \u2014 pass the MIME of the bytes you actually send."
440
- },
441
- base64Data: {
442
- type: "string",
443
- description: "Base64-encoded file contents. NO data:URL prefix. The MCP server uploads bytes verbatim \u2014 resize/encode client-side first if needed."
444
- }
445
- }
446
- };
447
- async function uploadMedia(graphql, storage, defaultSiteId, args) {
448
- const siteId = args.siteId ?? defaultSiteId;
449
- const body = Buffer.from(args.base64Data, "base64");
450
- const key = buildMediaKey(args.filename);
451
- const url = await storage.putObject(key, body, args.mimeType);
452
- const mediaId = `media-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
453
- const data = await graphql.query(MUTATION4, {
454
- input: {
455
- siteId,
456
- mediaId,
457
- src: key,
458
- mimeType: args.mimeType,
459
- size: body.length,
460
- delivery: "nextjs"
461
- }
462
- });
463
- return { media: data.createMedia, url };
464
- }
465
-
466
- // src/tools/get-schema.ts
467
- var getSchemaSchema = {
468
- type: "object",
469
- properties: {}
470
- };
471
- function getSchema() {
472
- return {
473
- contentTypes: [
474
- {
475
- name: "post",
476
- identifier: ["siteId", "postId"],
477
- fields: {
478
- siteId: { type: "string", required: true },
479
- postId: { type: "string", required: true, description: "auto-generated if omitted on create" },
480
- slug: { type: "string", required: true, description: "URL slug, unique per site" },
481
- title: { type: "string", required: true },
482
- excerpt: { type: "string" },
483
- format: { type: "enum", values: ["tiptap", "markdown", "html"], required: true },
484
- body: {
485
- type: "json",
486
- description: "tiptap node JSON when format=tiptap; markdown source string when format=markdown; raw HTML string when format=html"
487
- },
488
- status: { type: "enum", values: ["draft", "published"], default: "draft" },
489
- publishedAt: { type: "datetime", description: "ISO 8601" },
490
- tags: { type: "string[]" },
491
- metadata: {
492
- type: "json",
493
- description: "Free-form per-post key/value bag. Reserved well-known keys (owned by ampless): `no_layout` (boolean). Other keys pass through unchanged for themes/plugins."
494
- }
495
- }
496
- },
497
- {
498
- name: "page",
499
- identifier: ["siteId", "pageId"],
500
- fields: {
501
- siteId: { type: "string", required: true },
502
- pageId: { type: "string", required: true },
503
- slug: { type: "string", required: true },
504
- title: { type: "string", required: true },
505
- format: { type: "enum", values: ["tiptap", "markdown", "html"], required: true },
506
- body: { type: "json" },
507
- status: { type: "enum", values: ["draft", "published"] },
508
- publishedAt: { type: "datetime" }
509
- }
510
- },
511
- {
512
- name: "media",
513
- identifier: ["siteId", "mediaId"],
514
- fields: {
515
- siteId: { type: "string", required: true },
516
- mediaId: { type: "string", required: true },
517
- src: { type: "string", required: true, description: "S3 key" },
518
- mimeType: { type: "string", required: true },
519
- size: { type: "integer" },
520
- delivery: { type: "string" }
521
- }
522
- }
523
- ],
524
- formats: ["tiptap", "markdown", "html"],
525
- notes: {
526
- editorTrust: 'editor stores arbitrary HTML/JS verbatim \u2014 same trust shape as WordPress unfiltered_html capability. See docs/architecture/04-access-layer-mcp.md \xA7"editor \u306E\u4FE1\u983C\u30E2\u30C7\u30EB".',
527
- tiptapBody: 'When format=tiptap, body is the tiptap document JSON: { type: "doc", content: [...] }. The renderer expects this shape.',
528
- noLayout: "metadata.no_layout=true serves the post as bare HTML with no theme chrome \u2014 the public route at /<slug> 302-redirects to /raw/<slug>, and that route renders the body verbatim with no wrapping <html>/<head>/layout. Use this for landing pages, embeds, or any post whose body is a full HTML document. Only meaningful with format=html (the other formats need the theme renderer).",
529
- staticFormat: "A fourth format value `static` exists on the underlying data model for posts whose body is a JSON manifest pointing to a pre-uploaded HTML/CSS/JS bundle in S3 at public/static/<siteId>/<slug>/. Static uploads currently only flow through the admin UI; the MCP `upload_media` tool writes to public/media/ and does not handle static bundles. Use the admin StaticUploader for now."
530
- }
531
- };
532
- }
533
-
534
- // src/tools/index.ts
535
- var tools = [
536
- {
537
- name: "list_posts",
538
- description: "List posts in the CMS with optional filters by status. Returns up to `limit` posts (default 20) plus a `nextToken` cursor for pagination.",
539
- inputSchema: listPostsSchema,
540
- handler: (args, ctx) => listPosts(ctx.graphql, ctx.defaultSiteId, args)
541
- },
542
- {
543
- name: "get_post",
544
- description: "Fetch a single post by slug or postId. Returns null if not found.",
545
- inputSchema: getPostSchema,
546
- handler: (args, ctx) => getPost(ctx.graphql, ctx.defaultSiteId, args)
547
- },
548
- {
549
- name: "create_post",
550
- description: "Create a new post. Title and slug are required. Body shape depends on format: tiptap=JSON node tree, markdown=source string, html=raw HTML string. Defaults to status=draft. Pass `metadata: { no_layout: true }` alongside format=html to publish the body as a bare HTML page with no theme chrome (the public route redirects to /raw/<slug>).",
551
- inputSchema: createPostSchema,
552
- handler: (args, ctx) => createPost(ctx.graphql, ctx.defaultSiteId, args)
553
- },
554
- {
555
- name: "update_post",
556
- description: "Update an existing post by postId. Only the fields you pass are changed. Tag list / publishedAt changes also update the PostTag denormalized index. Passing `metadata` REPLACES the existing object \u2014 call get_post first if you only want to add or change one key.",
557
- inputSchema: updatePostSchema,
558
- handler: (args, ctx) => updatePost(ctx.graphql, ctx.defaultSiteId, args)
559
- },
560
- {
561
- name: "delete_post",
562
- description: "Delete a post by postId. Also drops associated PostTag index entries.",
563
- inputSchema: deletePostSchema,
564
- handler: (args, ctx) => deletePost(ctx.graphql, ctx.defaultSiteId, args)
565
- },
566
- {
567
- name: "upload_media",
568
- description: "Upload a file to the site's media S3 bucket. Pass base64-encoded bytes; the server stores them verbatim under public/media/YYYY/MM/. Returns the public URL and Media record. The server does not transcode \u2014 pre-process (e.g. resize/webp) on the client.",
569
- inputSchema: uploadMediaSchema,
570
- handler: (args, ctx) => uploadMedia(ctx.graphql, ctx.storage(), ctx.defaultSiteId, args)
571
- },
572
- {
573
- name: "get_schema",
574
- description: "Returns the CMS content schema (Post/Page/Media field shapes, format enum, notes). Useful as the first call to understand what fields are available.",
575
- inputSchema: getSchemaSchema,
576
- handler: async () => getSchema()
577
- }
578
- ];
579
- async function dispatchToolCall(name, args, ctx) {
580
- const tool = tools.find((t) => t.name === name);
581
- if (!tool) return null;
582
- return tool.handler(args, ctx);
583
- }
584
- function getTools() {
585
- return tools;
586
- }
587
-
588
- export {
589
- tools,
590
- dispatchToolCall,
591
- getTools
592
- };