@cloudhal/mcp-server 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +18 -2
- package/dist/src/analytics-reports.d.ts +164 -0
- package/dist/src/analytics-reports.js +632 -0
- package/dist/src/analytics-tools.d.ts +4 -0
- package/dist/src/analytics-tools.js +597 -0
- package/dist/src/asset-tools.d.ts +5 -0
- package/dist/src/asset-tools.js +685 -0
- package/dist/src/body-tools.d.ts +46 -0
- package/dist/src/body-tools.js +708 -0
- package/dist/src/changes.d.ts +10 -0
- package/dist/src/changes.js +112 -0
- package/dist/src/chrome-tools.d.ts +8 -0
- package/dist/src/chrome-tools.js +207 -0
- package/dist/src/comment-tools.d.ts +9 -0
- package/dist/src/comment-tools.js +743 -0
- package/dist/src/comments.d.ts +184 -0
- package/dist/src/comments.js +408 -0
- package/dist/src/common.d.ts +258 -0
- package/dist/src/common.js +488 -0
- package/dist/src/content-shared.d.ts +50 -0
- package/dist/src/content-shared.js +102 -0
- package/dist/src/design-tools.d.ts +9 -0
- package/dist/src/design-tools.js +349 -0
- package/dist/src/elevation-tools.d.ts +3 -0
- package/dist/src/elevation-tools.js +331 -0
- package/dist/src/entry-terms.d.ts +14 -0
- package/dist/src/entry-terms.js +51 -0
- package/dist/src/entry-tools.d.ts +4 -0
- package/dist/src/entry-tools.js +442 -0
- package/dist/src/http.d.ts +48 -0
- package/dist/src/http.js +101 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/menu-tools.d.ts +41 -0
- package/dist/src/menu-tools.js +1058 -0
- package/dist/src/page-tools.d.ts +5 -0
- package/dist/src/page-tools.js +311 -0
- package/dist/src/preview-doc.d.ts +9 -0
- package/dist/src/preview-doc.js +56 -0
- package/dist/src/preview-view.d.ts +19 -0
- package/dist/src/preview-view.js +71 -0
- package/dist/src/publish-tools.d.ts +10 -0
- package/dist/src/publish-tools.js +248 -0
- package/dist/src/render/cdp.d.ts +29 -0
- package/dist/src/render/cdp.js +190 -0
- package/dist/src/render/checks/components.d.ts +5 -0
- package/dist/src/render/checks/components.js +95 -0
- package/dist/src/render/checks/content.d.ts +5 -0
- package/dist/src/render/checks/content.js +63 -0
- package/dist/src/render/checks/contrast.d.ts +5 -0
- package/dist/src/render/checks/contrast.js +72 -0
- package/dist/src/render/checks/index.d.ts +1 -0
- package/dist/src/render/checks/index.js +29 -0
- package/dist/src/render/checks/layout.d.ts +5 -0
- package/dist/src/render/checks/layout.js +44 -0
- package/dist/src/render/checks/motion.d.ts +5 -0
- package/dist/src/render/checks/motion.js +66 -0
- package/dist/src/render/checks/navigation.d.ts +5 -0
- package/dist/src/render/checks/navigation.js +78 -0
- package/dist/src/render/checks/prelude.d.ts +1 -0
- package/dist/src/render/checks/prelude.js +64 -0
- package/dist/src/render/limiter.d.ts +11 -0
- package/dist/src/render/limiter.js +29 -0
- package/dist/src/render/page.d.ts +19 -0
- package/dist/src/render/page.js +135 -0
- package/dist/src/render/types.d.ts +89 -0
- package/dist/src/render/types.js +140 -0
- package/dist/src/render-tools.d.ts +14 -0
- package/dist/src/render-tools.js +314 -0
- package/dist/src/render.d.ts +14 -0
- package/dist/src/render.js +33 -0
- package/dist/src/schedule-tools.d.ts +2 -0
- package/dist/src/schedule-tools.js +1596 -0
- package/dist/src/schedules.d.ts +292 -0
- package/dist/src/schedules.js +890 -0
- package/dist/src/schema-transforms.d.ts +14 -0
- package/dist/src/schema-transforms.js +50 -0
- package/dist/src/search-tools.d.ts +2 -0
- package/dist/src/search-tools.js +112 -0
- package/dist/src/server-approvals.d.ts +51 -0
- package/dist/src/server-approvals.js +273 -0
- package/dist/src/server-call.d.ts +28 -0
- package/dist/src/server-call.js +259 -0
- package/dist/src/server-instructions.d.ts +4 -0
- package/dist/src/server-instructions.js +74 -0
- package/dist/src/server-resources.d.ts +4 -0
- package/dist/src/server-resources.js +83 -0
- package/dist/src/server-schema.d.ts +20 -0
- package/dist/src/server-schema.js +82 -0
- package/dist/src/server.d.ts +80 -0
- package/dist/src/server.js +135 -0
- package/dist/src/stdio.d.ts +9 -0
- package/dist/src/stdio.js +18 -0
- package/dist/src/surface-tools.d.ts +4 -0
- package/dist/src/surface-tools.js +357 -0
- package/dist/src/taxonomy-tools.d.ts +16 -0
- package/dist/src/taxonomy-tools.js +1433 -0
- package/dist/src/terms.d.ts +47 -0
- package/dist/src/terms.js +271 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +511 -0
- package/dist/src/translation-tools.d.ts +3 -0
- package/dist/src/translation-tools.js +422 -0
- package/package.json +86 -3
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Term assignment input and audit deltas for entry writes (RFC-0002 §6.4). */
|
|
2
|
+
import type { ContentEntry, Site } from "@cloudhal/core-contracts";
|
|
3
|
+
import type { ToolContext } from "./common.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `terms` (and deprecated `tags`) input of create_entry / update_entry → the entry's new
|
|
6
|
+
* assignments and `tags`. `tags` is an alias for `terms.tag.add` on sites with a `tag` taxonomy; a
|
|
7
|
+
* site that has not been migrated (no `tag` taxonomy) keeps plain tags.
|
|
8
|
+
*/
|
|
9
|
+
export declare function entryTerms(ctx: ToolContext, site: Site, entry: ContentEntry | undefined, a: Record<string, unknown>): Promise<{
|
|
10
|
+
terms: ContentEntry["terms"];
|
|
11
|
+
tags: string[];
|
|
12
|
+
}>;
|
|
13
|
+
/** Assignment deltas of an entry write into the audit row (`input.effect.terms`). */
|
|
14
|
+
export declare function reportDeltas(ctx: ToolContext, before: ContentEntry | undefined, after: ContentEntry): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Term assignment input and audit deltas for entry writes (RFC-0002 §6.4). */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
applyTermsInput,
|
|
7
|
+
assignmentDeltas,
|
|
8
|
+
auditDeltas,
|
|
9
|
+
|
|
10
|
+
termLookup,
|
|
11
|
+
} from "./terms.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The `terms` (and deprecated `tags`) input of create_entry / update_entry → the entry's new
|
|
15
|
+
* assignments and `tags`. `tags` is an alias for `terms.tag.add` on sites with a `tag` taxonomy; a
|
|
16
|
+
* site that has not been migrated (no `tag` taxonomy) keeps plain tags.
|
|
17
|
+
*/
|
|
18
|
+
export async function entryTerms(
|
|
19
|
+
ctx ,
|
|
20
|
+
site ,
|
|
21
|
+
entry ,
|
|
22
|
+
a ,
|
|
23
|
+
) {
|
|
24
|
+
const input = { ...((a.terms ) ?? {}) };
|
|
25
|
+
const aliased = a.tags ;
|
|
26
|
+
let tags = entry?.tags ?? [];
|
|
27
|
+
if (aliased !== undefined) {
|
|
28
|
+
if (await ctx.store.getTaxonomyByKey(site.id, "tag")) {
|
|
29
|
+
if (aliased.length > 0) {
|
|
30
|
+
const ops = input.tag ?? {};
|
|
31
|
+
input.tag = { ...ops, add: [...(ops.add ?? []), ...aliased] };
|
|
32
|
+
}
|
|
33
|
+
} else tags = aliased;
|
|
34
|
+
}
|
|
35
|
+
const terms =
|
|
36
|
+
Object.keys(input).length > 0
|
|
37
|
+
? await applyTermsInput(ctx, site, entry?.terms, input)
|
|
38
|
+
: entry?.terms;
|
|
39
|
+
return { terms, tags };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Assignment deltas of an entry write into the audit row (`input.effect.terms`). */
|
|
43
|
+
export async function reportDeltas(
|
|
44
|
+
ctx ,
|
|
45
|
+
before ,
|
|
46
|
+
after ,
|
|
47
|
+
) {
|
|
48
|
+
const deltas = assignmentDeltas(before?.terms, after.terms);
|
|
49
|
+
if (deltas.length > 0 && ctx.report)
|
|
50
|
+
ctx.report({ terms: await auditDeltas(deltas, termLookup(ctx)) });
|
|
51
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/** Article (content entry) tools: list, read, create, update, correct and delete. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
autoSnapshot,
|
|
7
|
+
entryLimitCheck,
|
|
8
|
+
id,
|
|
9
|
+
mapError,
|
|
10
|
+
obj,
|
|
11
|
+
quote,
|
|
12
|
+
requireSite,
|
|
13
|
+
SLUG_PATTERN,
|
|
14
|
+
str,
|
|
15
|
+
|
|
16
|
+
ToolError,
|
|
17
|
+
} from "./common.js";
|
|
18
|
+
import {
|
|
19
|
+
COVER_INPUT,
|
|
20
|
+
coverImage,
|
|
21
|
+
ENTRY,
|
|
22
|
+
entryMarkdown,
|
|
23
|
+
mcpWriter,
|
|
24
|
+
SNAPSHOT,
|
|
25
|
+
unknownBlocks,
|
|
26
|
+
unknownBlocksOf,
|
|
27
|
+
} from "./content-shared.js";
|
|
28
|
+
import { entryTerms, reportDeltas } from "./entry-terms.js";
|
|
29
|
+
import { menuHint, menuItemsHiddenBy } from "./menu-tools.js";
|
|
30
|
+
import { assignmentDeltas, describeDeltas, TAGS_INPUT, TERMS_INPUT, termLookup } from "./terms.js";
|
|
31
|
+
|
|
32
|
+
const listEntriesTool = {
|
|
33
|
+
name: "list_entries",
|
|
34
|
+
title: "List articles",
|
|
35
|
+
description:
|
|
36
|
+
'Content entries, newest first. `collection` defaults to posts (unless the filter names one). `filter` selects by terms (RFC-0002 grammar): clauses joined by "+" (AND), "[a,b]" = either, "-" = none of, "primary:category:x" = primary term, "category:x>*" = x or a sub-category, "collection:posts"; values are term slugs (old slugs work) or ids. Example: "category:news+tag:[ai,llm]+-tag:sponsored".',
|
|
37
|
+
inputSchema: obj(
|
|
38
|
+
{
|
|
39
|
+
siteId: id,
|
|
40
|
+
collection: str("Collection slug", { default: "posts", pattern: SLUG_PATTERN }),
|
|
41
|
+
filter: str("Filter over term assignments, e.g. category:news+tag:ai", {
|
|
42
|
+
minLength: 1,
|
|
43
|
+
maxLength: 512,
|
|
44
|
+
}),
|
|
45
|
+
locale: str("Only entries in this BCP 47 locale"),
|
|
46
|
+
status: {
|
|
47
|
+
type: "array",
|
|
48
|
+
description: "Only entries in these statuses",
|
|
49
|
+
items: { type: "string", enum: ["draft", "review", "published", "archived"] },
|
|
50
|
+
minItems: 1,
|
|
51
|
+
uniqueItems: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
["siteId"],
|
|
55
|
+
),
|
|
56
|
+
outputSchema: obj({ entries: { type: "array", items: ENTRY } }, ["entries"]),
|
|
57
|
+
annotations: {
|
|
58
|
+
readOnlyHint: true,
|
|
59
|
+
destructiveHint: false,
|
|
60
|
+
idempotentHint: true,
|
|
61
|
+
openWorldHint: false,
|
|
62
|
+
},
|
|
63
|
+
scopes: ["content:read"],
|
|
64
|
+
handler: async (a, ctx) => {
|
|
65
|
+
await requireSite(ctx, a.siteId );
|
|
66
|
+
const filter = a.filter ;
|
|
67
|
+
const collection =
|
|
68
|
+
(a.collection ) ??
|
|
69
|
+
(filter && /(^|\+)-?collection:/.test(filter) ? undefined : "posts");
|
|
70
|
+
const query = {
|
|
71
|
+
...(collection !== undefined ? { collection } : {}),
|
|
72
|
+
...(filter !== undefined ? { filter } : {}),
|
|
73
|
+
...(a.locale !== undefined ? { locale: a.locale } : {}),
|
|
74
|
+
...(a.status !== undefined ? { status: a.status } : {}),
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
const entries = (await ctx.store.listContentEntries(a.siteId , query)).sort((x, y) =>
|
|
78
|
+
y.updatedAt.localeCompare(x.updatedAt),
|
|
79
|
+
);
|
|
80
|
+
return { entries };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
mapError(e);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const getEntryTool = {
|
|
88
|
+
name: "get_entry",
|
|
89
|
+
title: "Get article",
|
|
90
|
+
description: "One content entry with its body and current revision.",
|
|
91
|
+
inputSchema: obj({ siteId: id, entryId: id }, ["siteId", "entryId"]),
|
|
92
|
+
outputSchema: obj({ entry: ENTRY }, ["entry"]),
|
|
93
|
+
annotations: {
|
|
94
|
+
readOnlyHint: true,
|
|
95
|
+
destructiveHint: false,
|
|
96
|
+
idempotentHint: true,
|
|
97
|
+
openWorldHint: false,
|
|
98
|
+
},
|
|
99
|
+
scopes: ["content:read"],
|
|
100
|
+
handler: async (a, ctx) => {
|
|
101
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
102
|
+
if (!entry || entry.siteId !== a.siteId)
|
|
103
|
+
throw new ToolError("not_found", `entry ${a.entryId} not found`);
|
|
104
|
+
return { entry };
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const createEntryTool = {
|
|
109
|
+
name: "create_entry",
|
|
110
|
+
title: "Create article",
|
|
111
|
+
description:
|
|
112
|
+
"Create a draft content entry (markdown body), optionally filed under existing terms (`terms`; see list_taxonomies and list_terms). Snapshots afterwards.",
|
|
113
|
+
inputSchema: obj(
|
|
114
|
+
{
|
|
115
|
+
siteId: id,
|
|
116
|
+
collection: str("Collection slug", { default: "posts", pattern: SLUG_PATTERN }),
|
|
117
|
+
slug: str("Lower-case hyphenated slug", { pattern: SLUG_PATTERN, maxLength: 128 }),
|
|
118
|
+
locale: str("BCP 47 locale; defaults to the site's default locale"),
|
|
119
|
+
title: str("Title"),
|
|
120
|
+
summary: str("One or two sentences"),
|
|
121
|
+
body: str(
|
|
122
|
+
"Markdown body. Media from the library:  for images, [label](asset:<assetId>) for downloads.",
|
|
123
|
+
),
|
|
124
|
+
terms: TERMS_INPUT,
|
|
125
|
+
tags: TAGS_INPUT,
|
|
126
|
+
coverImage: COVER_INPUT,
|
|
127
|
+
fields: { type: "object", description: "Collection-specific fields" },
|
|
128
|
+
},
|
|
129
|
+
["siteId", "slug", "title", "body"],
|
|
130
|
+
),
|
|
131
|
+
outputSchema: obj({ entry: ENTRY, snapshot: SNAPSHOT, hint: { type: "string" }, unknownBlocks }, [
|
|
132
|
+
"entry",
|
|
133
|
+
"snapshot",
|
|
134
|
+
]),
|
|
135
|
+
annotations: {
|
|
136
|
+
readOnlyHint: false,
|
|
137
|
+
destructiveHint: false,
|
|
138
|
+
idempotentHint: false,
|
|
139
|
+
openWorldHint: false,
|
|
140
|
+
},
|
|
141
|
+
scopes: ["content:write"],
|
|
142
|
+
own: "entry",
|
|
143
|
+
handler: async (a, ctx) => {
|
|
144
|
+
const site = await requireSite(ctx, a.siteId );
|
|
145
|
+
await entryLimitCheck(ctx, "create", undefined, {
|
|
146
|
+
author: ctx.principal.actor,
|
|
147
|
+
status: "draft",
|
|
148
|
+
});
|
|
149
|
+
const { terms, tags } = await entryTerms(ctx, site, undefined, a);
|
|
150
|
+
try {
|
|
151
|
+
const entry = await ctx.store.createContentEntry(
|
|
152
|
+
{
|
|
153
|
+
siteId: site.id,
|
|
154
|
+
collection: (a.collection ) ?? "posts",
|
|
155
|
+
slug: a.slug ,
|
|
156
|
+
locale: (a.locale ) ?? site.defaultLocale,
|
|
157
|
+
status: "draft",
|
|
158
|
+
title: a.title ,
|
|
159
|
+
...(a.summary ? { summary: a.summary } : {}),
|
|
160
|
+
body: { format: "markdown", value: a.body },
|
|
161
|
+
fields: (a.fields ) ?? {},
|
|
162
|
+
tags,
|
|
163
|
+
...(terms ? { terms } : {}),
|
|
164
|
+
...(a.coverImage ? { coverImage: coverImage(a.coverImage) } : {}),
|
|
165
|
+
author: ctx.principal.actor,
|
|
166
|
+
},
|
|
167
|
+
{ writer: mcpWriter(ctx) },
|
|
168
|
+
);
|
|
169
|
+
await reportDeltas(ctx, undefined, entry);
|
|
170
|
+
const snapshot = await autoSnapshot(ctx, site.id, "create_entry");
|
|
171
|
+
const hint = await menuHint(ctx, site.id, { kind: "contentEntry", id: entry.id });
|
|
172
|
+
return {
|
|
173
|
+
entry,
|
|
174
|
+
snapshot,
|
|
175
|
+
...(hint ? { hint } : {}),
|
|
176
|
+
...unknownBlocksOf(ctx, { markdown: entryMarkdown(entry) }),
|
|
177
|
+
};
|
|
178
|
+
} catch (e) {
|
|
179
|
+
mapError(e);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const updateEntryTool = {
|
|
185
|
+
name: "update_entry",
|
|
186
|
+
title: "Update article",
|
|
187
|
+
description:
|
|
188
|
+
"Update title, summary, body, slug, status (draft → review → published) or terms (`terms`: add, remove or set categories and tags by id, slug or name) of an entry. Review and published articles need a primary term in every required taxonomy. Requires the current `rev`. Changing a published article's slug leaves a redirect. Snapshots afterwards.",
|
|
189
|
+
inputSchema: obj(
|
|
190
|
+
{
|
|
191
|
+
siteId: id,
|
|
192
|
+
entryId: id,
|
|
193
|
+
rev: { type: "integer", minimum: 0 },
|
|
194
|
+
title: str("Title"),
|
|
195
|
+
summary: str("Summary"),
|
|
196
|
+
body: str("Markdown body"),
|
|
197
|
+
slug: str("Slug", { pattern: SLUG_PATTERN, maxLength: 128 }),
|
|
198
|
+
terms: TERMS_INPUT,
|
|
199
|
+
tags: TAGS_INPUT,
|
|
200
|
+
coverImage: {
|
|
201
|
+
...COVER_INPUT,
|
|
202
|
+
type: ["object", "null"],
|
|
203
|
+
description: "Cover image; null removes it",
|
|
204
|
+
},
|
|
205
|
+
status: { type: "string", enum: ["draft", "review", "published", "archived"] },
|
|
206
|
+
},
|
|
207
|
+
["siteId", "entryId", "rev"],
|
|
208
|
+
),
|
|
209
|
+
outputSchema: obj({ entry: ENTRY, snapshot: SNAPSHOT, unknownBlocks }, ["entry", "snapshot"]),
|
|
210
|
+
annotations: {
|
|
211
|
+
readOnlyHint: false,
|
|
212
|
+
destructiveHint: false,
|
|
213
|
+
idempotentHint: false,
|
|
214
|
+
openWorldHint: false,
|
|
215
|
+
},
|
|
216
|
+
scopes: ["content:write"],
|
|
217
|
+
own: "entry",
|
|
218
|
+
// Own-limited callers (Authors, Contributors and their agents) only touch their own articles.
|
|
219
|
+
precheck: async (a, ctx) => {
|
|
220
|
+
if (!ctx.limit) return;
|
|
221
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
222
|
+
if (!entry || entry.siteId !== a.siteId) return;
|
|
223
|
+
await entryLimitCheck(ctx, "update", entry, {
|
|
224
|
+
author: entry.author,
|
|
225
|
+
status: (a.status ) ?? entry.status,
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
approvalPrompt: async (a, ctx) => {
|
|
229
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
230
|
+
if (!entry || entry.siteId !== a.siteId) return `Update article ${a.entryId}?`;
|
|
231
|
+
const site = await ctx.store.getSite(entry.siteId);
|
|
232
|
+
const changes = [
|
|
233
|
+
a.title ? `title → "${quote(a.title )}"` : "",
|
|
234
|
+
a.summary !== undefined ? "summary" : "",
|
|
235
|
+
a.body !== undefined ? "body" : "",
|
|
236
|
+
a.slug && a.slug !== entry.slug
|
|
237
|
+
? `slug ${entry.slug} → ${a.slug }${entry.status === "published" || entry.publishedAt ? " (leaves a redirect)" : ""}`
|
|
238
|
+
: "",
|
|
239
|
+
a.status && a.status !== entry.status ? `status → ${a.status }` : "",
|
|
240
|
+
a.coverImage !== undefined
|
|
241
|
+
? a.coverImage === null
|
|
242
|
+
? "remove cover image"
|
|
243
|
+
: "cover image"
|
|
244
|
+
: "",
|
|
245
|
+
].filter(Boolean);
|
|
246
|
+
if (a.terms || a.tags) {
|
|
247
|
+
try {
|
|
248
|
+
const { terms } = await entryTerms(ctx, site , entry, a);
|
|
249
|
+
const d = await describeDeltas(
|
|
250
|
+
assignmentDeltas(entry.terms, terms),
|
|
251
|
+
termLookup(ctx),
|
|
252
|
+
site?.defaultLocale,
|
|
253
|
+
);
|
|
254
|
+
if (d) changes.push(d);
|
|
255
|
+
} catch {
|
|
256
|
+
changes.push("terms");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return `Update article "${quote(entry.title)}"${changes.length ? ` (${changes.join(", ")})` : ""}?`;
|
|
260
|
+
},
|
|
261
|
+
handler: async (a, ctx) => {
|
|
262
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
263
|
+
if (!entry || entry.siteId !== a.siteId)
|
|
264
|
+
throw new ToolError("not_found", `entry ${a.entryId} not found`);
|
|
265
|
+
if (a.body !== undefined && entry.body.format !== "markdown")
|
|
266
|
+
throw new ToolError(
|
|
267
|
+
"invalid",
|
|
268
|
+
`entry ${entry.id} has an HTML body, which only a person can change in Studio; agents write markdown`,
|
|
269
|
+
);
|
|
270
|
+
if (entry.rev !== a.rev)
|
|
271
|
+
throw new ToolError(
|
|
272
|
+
"conflict",
|
|
273
|
+
`entry ${entry.id} is at rev ${entry.rev}, you sent ${a.rev}; reload and retry`,
|
|
274
|
+
);
|
|
275
|
+
const site = await requireSite(ctx, entry.siteId);
|
|
276
|
+
await entryLimitCheck(ctx, "update", entry, {
|
|
277
|
+
author: entry.author,
|
|
278
|
+
status: (a.status ) ?? entry.status,
|
|
279
|
+
});
|
|
280
|
+
const { terms, tags } = await entryTerms(ctx, site, entry, a);
|
|
281
|
+
const { terms: _old, coverImage: oldCover, ...rest } = entry;
|
|
282
|
+
const cover =
|
|
283
|
+
a.coverImage === undefined
|
|
284
|
+
? oldCover
|
|
285
|
+
: a.coverImage === null
|
|
286
|
+
? undefined
|
|
287
|
+
: coverImage(a.coverImage);
|
|
288
|
+
const next = {
|
|
289
|
+
...rest,
|
|
290
|
+
...(cover ? { coverImage: cover } : {}),
|
|
291
|
+
...(a.title ? { title: a.title } : {}),
|
|
292
|
+
...(a.summary !== undefined ? { summary: a.summary } : {}),
|
|
293
|
+
...(a.body !== undefined ? { body: { ...entry.body, value: a.body } } : {}),
|
|
294
|
+
...(a.slug ? { slug: a.slug } : {}),
|
|
295
|
+
tags,
|
|
296
|
+
...(terms ? { terms } : {}),
|
|
297
|
+
...(a.status ? { status: a.status } : {}),
|
|
298
|
+
...(a.status === "published" && !entry.publishedAt
|
|
299
|
+
? { publishedAt: new Date().toISOString() }
|
|
300
|
+
: {}),
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
const saved = await ctx.store.updateContentEntry(next, { writer: mcpWriter(ctx) });
|
|
304
|
+
await reportDeltas(ctx, entry, saved);
|
|
305
|
+
const snapshot = await autoSnapshot(ctx, entry.siteId, "update_entry");
|
|
306
|
+
return {
|
|
307
|
+
entry: saved,
|
|
308
|
+
snapshot,
|
|
309
|
+
...unknownBlocksOf(ctx, { markdown: entryMarkdown(saved) }),
|
|
310
|
+
};
|
|
311
|
+
} catch (e) {
|
|
312
|
+
mapError(e);
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const addCorrectionTool = {
|
|
318
|
+
name: "add_correction",
|
|
319
|
+
title: "Add correction",
|
|
320
|
+
description:
|
|
321
|
+
"Append a correction, clarification or update notice to an article (RFC-0019 §2) when a published story changed in substance. Cloudhal sets its time. It goes live with the next publish, which then waits for a person in every mode. Once live, a notice cannot be removed or reworded by an agent. Snapshots afterwards.",
|
|
322
|
+
inputSchema: obj(
|
|
323
|
+
{
|
|
324
|
+
siteId: id,
|
|
325
|
+
entryId: id,
|
|
326
|
+
rev: { type: "integer", minimum: 0 },
|
|
327
|
+
kind: {
|
|
328
|
+
type: "string",
|
|
329
|
+
enum: ["correction", "clarification", "update"],
|
|
330
|
+
description:
|
|
331
|
+
"correction: something was wrong; clarification: something was unclear; update: new facts since publication",
|
|
332
|
+
},
|
|
333
|
+
note: str("What changed, in one or two sentences of markdown", {
|
|
334
|
+
minLength: 1,
|
|
335
|
+
maxLength: 2000,
|
|
336
|
+
}),
|
|
337
|
+
},
|
|
338
|
+
["siteId", "entryId", "rev", "kind", "note"],
|
|
339
|
+
),
|
|
340
|
+
outputSchema: obj({ entry: ENTRY, snapshot: SNAPSHOT }, ["entry", "snapshot"]),
|
|
341
|
+
annotations: {
|
|
342
|
+
readOnlyHint: false,
|
|
343
|
+
destructiveHint: false,
|
|
344
|
+
idempotentHint: false,
|
|
345
|
+
openWorldHint: false,
|
|
346
|
+
},
|
|
347
|
+
scopes: ["content:write"],
|
|
348
|
+
own: "entry",
|
|
349
|
+
precheck: async (a, ctx) => {
|
|
350
|
+
if (!ctx.limit) return;
|
|
351
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
352
|
+
if (entry && entry.siteId === a.siteId)
|
|
353
|
+
await entryLimitCheck(ctx, "update", entry, {
|
|
354
|
+
author: entry.author,
|
|
355
|
+
status: entry.status,
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
approvalPrompt: async (a, ctx) => {
|
|
359
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
360
|
+
return `Add a ${a.kind } to "${quote(entry?.title ?? (a.entryId ))}": "${quote(a.note )}"?`;
|
|
361
|
+
},
|
|
362
|
+
handler: async (a, ctx) => {
|
|
363
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
364
|
+
if (!entry || entry.siteId !== a.siteId)
|
|
365
|
+
throw new ToolError("not_found", `entry ${a.entryId} not found`);
|
|
366
|
+
if (entry.rev !== a.rev)
|
|
367
|
+
throw new ToolError(
|
|
368
|
+
"conflict",
|
|
369
|
+
`entry ${entry.id} is at rev ${entry.rev}, you sent ${a.rev}; reload and retry`,
|
|
370
|
+
);
|
|
371
|
+
await entryLimitCheck(ctx, "update", entry, { author: entry.author, status: entry.status });
|
|
372
|
+
try {
|
|
373
|
+
const saved = await ctx.store.addCorrection(
|
|
374
|
+
entry,
|
|
375
|
+
{ kind: a.kind , note: a.note },
|
|
376
|
+
{ writer: mcpWriter(ctx) },
|
|
377
|
+
);
|
|
378
|
+
return {
|
|
379
|
+
entry: saved,
|
|
380
|
+
snapshot: await autoSnapshot(ctx, entry.siteId, "add_correction"),
|
|
381
|
+
};
|
|
382
|
+
} catch (e) {
|
|
383
|
+
mapError(e);
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const deleteEntryTool = {
|
|
389
|
+
name: "delete_entry",
|
|
390
|
+
title: "Delete article",
|
|
391
|
+
description: "Delete a content entry. Destructive: requires approval. Snapshots afterwards.",
|
|
392
|
+
inputSchema: obj({ siteId: id, entryId: id, rev: { type: "integer", minimum: 0 } }, [
|
|
393
|
+
"siteId",
|
|
394
|
+
"entryId",
|
|
395
|
+
"rev",
|
|
396
|
+
]),
|
|
397
|
+
outputSchema: obj({ snapshot: SNAPSHOT }, ["snapshot"]),
|
|
398
|
+
annotations: {
|
|
399
|
+
readOnlyHint: false,
|
|
400
|
+
destructiveHint: true,
|
|
401
|
+
idempotentHint: false,
|
|
402
|
+
openWorldHint: false,
|
|
403
|
+
},
|
|
404
|
+
scopes: ["content:write"],
|
|
405
|
+
own: "entry",
|
|
406
|
+
precheck: async (a, ctx) => {
|
|
407
|
+
if (!ctx.limit) return;
|
|
408
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
409
|
+
if (entry && entry.siteId === a.siteId) await entryLimitCheck(ctx, "delete", entry);
|
|
410
|
+
},
|
|
411
|
+
approvalPrompt: async (a, ctx) => {
|
|
412
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
413
|
+
const menus = entry
|
|
414
|
+
? await menuItemsHiddenBy(ctx, entry.siteId, { kind: "contentEntry", id: entry.id })
|
|
415
|
+
: "";
|
|
416
|
+
return `Delete article "${entry?.title ?? a.entryId}"? Earlier snapshots keep a copy.${menus}`;
|
|
417
|
+
},
|
|
418
|
+
handler: async (a, ctx) => {
|
|
419
|
+
const entry = await ctx.store.getContentEntry(a.entryId );
|
|
420
|
+
if (!entry || entry.siteId !== a.siteId)
|
|
421
|
+
throw new ToolError("not_found", `entry ${a.entryId} not found`);
|
|
422
|
+
await entryLimitCheck(ctx, "delete", entry);
|
|
423
|
+
try {
|
|
424
|
+
await ctx.store.deleteContentEntry({ ...entry, rev: a.rev });
|
|
425
|
+
return { snapshot: await autoSnapshot(ctx, entry.siteId, "delete_entry") };
|
|
426
|
+
} catch (e) {
|
|
427
|
+
mapError(e);
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** list_entries … delete_entry, in `tools/list` order. */
|
|
433
|
+
export function entryTools() {
|
|
434
|
+
return [
|
|
435
|
+
listEntriesTool,
|
|
436
|
+
getEntryTool,
|
|
437
|
+
createEntryTool,
|
|
438
|
+
updateEntryTool,
|
|
439
|
+
addCorrectionTool,
|
|
440
|
+
deleteEntryTool,
|
|
441
|
+
];
|
|
442
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport (web-standard `fetch`): stateless, one server instance per request, so it runs
|
|
3
|
+
* unchanged on Node (behind the Hono API) and on Cloudflare Workers. The caller authenticates the
|
|
4
|
+
* request and hands over the resolved `Principal`; this module never reads credentials itself.
|
|
5
|
+
*/
|
|
6
|
+
import type { ApprovalStore, AuditLog, LoadedSkill, OwnershipResolver, Principal } from "@cloudhal/agent-runtime";
|
|
7
|
+
import type { AnalyticsService } from "@cloudhal/analytics/service";
|
|
8
|
+
import type { SiteGraphStore } from "@cloudhal/core-ts";
|
|
9
|
+
import type { CommentsDeps } from "./comments.js";
|
|
10
|
+
import type { BuildObserver, MediaToolOptions, RenderDeps } from "./common.js";
|
|
11
|
+
import type { SchedulesDeps } from "./schedules.js";
|
|
12
|
+
export interface HttpOptions {
|
|
13
|
+
store: SiteGraphStore;
|
|
14
|
+
/** Clock for time-based answers (`get_mode`); tests inject one. */
|
|
15
|
+
now?: (() => Date) | undefined;
|
|
16
|
+
audit?: AuditLog | undefined;
|
|
17
|
+
skills?: LoadedSkill[] | undefined;
|
|
18
|
+
previewBaseUrl?: string | undefined;
|
|
19
|
+
previewSecret?: string | undefined;
|
|
20
|
+
/** Media settings for the asset tools (RFC-0004). */
|
|
21
|
+
media?: MediaToolOptions | undefined;
|
|
22
|
+
/** Site analytics for the read-only analytics tools (RFC-0006 §9.3). */
|
|
23
|
+
analytics?: AnalyticsService | undefined;
|
|
24
|
+
/** Reader comments for the comment tools (RFC-0007 §11). */
|
|
25
|
+
comments?: CommentsDeps | undefined;
|
|
26
|
+
/** Schedules for the schedule tools and automation runs (RFC-0008 §12). */
|
|
27
|
+
schedules?: SchedulesDeps | undefined;
|
|
28
|
+
/** A browser for `render_page` (RFC-0021 §4). */
|
|
29
|
+
render?: RenderDeps | undefined;
|
|
30
|
+
/** Shared secret (≥ 32 bytes) so approval state survives across instances/requests. */
|
|
31
|
+
stateKey: Uint8Array | string;
|
|
32
|
+
/** Told about page writes and refused builds (RFC-0020 §6, Studio's Build view). */
|
|
33
|
+
build?: BuildObserver | undefined;
|
|
34
|
+
confirmTokenFallback?: boolean | undefined;
|
|
35
|
+
/** Out-of-band approvals: agents' gated calls wait for a person in Studio (ADR-0014). */
|
|
36
|
+
approvals?: ApprovalStore | undefined;
|
|
37
|
+
/** Who owns what: the person behind an agent's key (RFC-0005 §4). */
|
|
38
|
+
ownership?: OwnershipResolver | undefined;
|
|
39
|
+
/** `"reject"` refuses 2025-era clients (strict, ADR-0004); default serves them statelessly. */
|
|
40
|
+
legacy?: "stateless" | "reject" | undefined;
|
|
41
|
+
onerror?: ((error: Error) => void) | undefined;
|
|
42
|
+
}
|
|
43
|
+
export interface CloudhalMcpHttp {
|
|
44
|
+
/** Serves one MCP request for an already-authenticated principal. */
|
|
45
|
+
fetch(request: Request, principal: Principal): Promise<Response>;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export declare function createCloudhalMcpHandler(options: HttpOptions): CloudhalMcpHttp;
|
package/dist/src/http.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport (web-standard `fetch`): stateless, one server instance per request, so it runs
|
|
3
|
+
* unchanged on Node (behind the Hono API) and on Cloudflare Workers. The caller authenticates the
|
|
4
|
+
* request and hands over the resolved `Principal`; this module never reads credentials itself.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import { createMcpHandler, } from "@modelcontextprotocol/server";
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
import { createCloudhalMcpServer } from "./server.js";
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
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
|
+
export function createCloudhalMcpHandler(options ) {
|
|
60
|
+
const handler = createMcpHandler(
|
|
61
|
+
(ctx) => {
|
|
62
|
+
const principal = ctx.authInfo?.extra?.principal ;
|
|
63
|
+
if (!principal) throw new Error("mcp handler called without a principal");
|
|
64
|
+
return createCloudhalMcpServer({
|
|
65
|
+
store: options.store,
|
|
66
|
+
principal,
|
|
67
|
+
audit: options.audit,
|
|
68
|
+
skills: options.skills,
|
|
69
|
+
previewBaseUrl: options.previewBaseUrl,
|
|
70
|
+
previewSecret: options.previewSecret,
|
|
71
|
+
build: options.build,
|
|
72
|
+
media: options.media,
|
|
73
|
+
analytics: options.analytics,
|
|
74
|
+
comments: options.comments,
|
|
75
|
+
schedules: options.schedules,
|
|
76
|
+
render: options.render,
|
|
77
|
+
now: options.now,
|
|
78
|
+
stateKey: options.stateKey,
|
|
79
|
+
confirmTokenFallback: options.confirmTokenFallback,
|
|
80
|
+
approvals: options.approvals,
|
|
81
|
+
ownership: options.ownership,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
...(options.legacy ? { legacy: options.legacy } : {}),
|
|
86
|
+
...(options.onerror ? { onerror: options.onerror } : {}),
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
fetch: (request, principal) =>
|
|
91
|
+
handler.fetch(request, {
|
|
92
|
+
authInfo: {
|
|
93
|
+
token: "",
|
|
94
|
+
clientId: principal.actor.id,
|
|
95
|
+
scopes: principal.scopes,
|
|
96
|
+
extra: { principal },
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
close: () => handler.close(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloudhal/mcp-server — the semantic tool surface agents use (ADR-0004, ADR-0011).
|
|
3
|
+
* `tools.ts` is SDK-independent; `server.ts` wraps it in `@modelcontextprotocol/server`;
|
|
4
|
+
* `stdio.ts` / `http.ts` are the transports.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./http.js";
|
|
7
|
+
export * from "./preview-view.js";
|
|
8
|
+
export * from "./server.js";
|
|
9
|
+
export * from "./stdio.js";
|
|
10
|
+
export * from "./tools.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloudhal/mcp-server — the semantic tool surface agents use (ADR-0004, ADR-0011).
|
|
3
|
+
* `tools.ts` is SDK-independent; `server.ts` wraps it in `@modelcontextprotocol/server`;
|
|
4
|
+
* `stdio.ts` / `http.ts` are the transports.
|
|
5
|
+
*/
|
|
6
|
+
export * from "./http.js";
|
|
7
|
+
export * from "./preview-view.js";
|
|
8
|
+
export * from "./server.js";
|
|
9
|
+
export * from "./stdio.js";
|
|
10
|
+
export * from "./tools.js";
|