@sutraspaces/mcp-server 1.0.1 → 1.1.2

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.
@@ -0,0 +1,204 @@
1
+ import { z } from "zod";
2
+
3
+ // MCP tools for the Sutra Blog (public surface at /resources). Mirrors
4
+ // help-center.js, blog-shaped: category is optional, tags are supported, and
5
+ // the support-only fields (product_area, intent_type, applies_to_plan, …) are
6
+ // dropped. Calls the admin API under /api/v4/lotus/blog.
7
+ const BLOG_BASE_URL = process.env.SUTRA_BLOG_BASE_URL || "https://api.sutra.co/api/v4/lotus/blog";
8
+
9
+ export function registerBlogTools(server) {
10
+ const token = process.env.SUTRA_BLOG_TOKEN || process.env.SUTRA_HELP_CENTER_TOKEN;
11
+ if (!token) return;
12
+
13
+ async function request(method, path, { params, body } = {}) {
14
+ const url = new URL(`${BLOG_BASE_URL}${path}`);
15
+ if (params) {
16
+ for (const [k, v] of Object.entries(params)) {
17
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
18
+ }
19
+ }
20
+ const headers = {
21
+ Authorization: `Bearer ${token}`,
22
+ Accept: "application/json",
23
+ ...(body ? { "Content-Type": "application/json" } : {}),
24
+ };
25
+ const res = await fetch(url.toString(), {
26
+ method,
27
+ headers,
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ });
30
+ const text = await res.text();
31
+ if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${text}`);
32
+ return text ? JSON.parse(text) : {};
33
+ }
34
+
35
+ // Shared optional post fields (used by create / propose / update).
36
+ const postFields = {
37
+ title: z.string().optional().describe("Post title"),
38
+ slug: z.string().optional().describe("URL slug (auto-generated from title if omitted; cannot equal a category slug)"),
39
+ subtitle: z.string().optional().describe("Subtitle / deck"),
40
+ excerpt: z.string().optional().describe("Short summary shown on cards"),
41
+ blog_category_id: z.number().optional().describe("Category ID — OPTIONAL (posts can be uncategorized)"),
42
+ hero_image_url: z.string().optional().describe("Hero / cover image URL"),
43
+ og_image_url: z.string().optional().describe("Open Graph image URL"),
44
+ meta_title: z.string().optional().describe("SEO meta title"),
45
+ meta_description: z.string().optional().describe("SEO meta description"),
46
+ featured: z.boolean().optional().describe("Feature on the landing page"),
47
+ position: z.number().optional().describe("Sort position"),
48
+ search_keywords: z.array(z.string()).optional().describe("Extra search keywords for recall"),
49
+ tags: z.array(z.string()).optional().describe("Tag slugs (created if missing)"),
50
+ };
51
+
52
+ // --- Posts ---
53
+
54
+ server.tool(
55
+ "blog_list_posts",
56
+ "List blog posts (the public Resources surface, served at /resources) with optional filters. Returns post summaries and stats.",
57
+ {
58
+ status: z.enum(["draft", "published", "archived"]).optional().describe("Filter by status"),
59
+ review_status: z.string().optional().describe("Filter by review status"),
60
+ category_id: z.number().optional().describe("Filter by category ID"),
61
+ featured: z.boolean().optional().describe("Filter by featured flag"),
62
+ q: z.string().optional().describe("Search title and excerpt"),
63
+ },
64
+ async (params) => json(await request("GET", "/posts.json", { params }))
65
+ );
66
+
67
+ server.tool(
68
+ "blog_get_post",
69
+ "Get a blog post with full content, metadata, and version history.",
70
+ { id: z.number().describe("Post ID") },
71
+ async ({ id }) => json(await request("GET", `/posts/${id}.json`))
72
+ );
73
+
74
+ server.tool(
75
+ "blog_create_post",
76
+ "Create a new blog post. Returns the post and its first version.",
77
+ {
78
+ ...postFields,
79
+ title: z.string().describe("Post title"),
80
+ status: z.enum(["draft", "published", "archived"]).optional().describe("Initial status (default: draft)"),
81
+ content: z.any().optional().describe("Tiptap JSON content"),
82
+ commit_message: z.string().optional().describe("Version commit message"),
83
+ publish: z.boolean().optional().describe("Publish immediately after creation"),
84
+ },
85
+ async ({ publish, ...post }) => json(await request("POST", "/posts.json", { body: { post, publish } }))
86
+ );
87
+
88
+ server.tool(
89
+ "blog_propose_post",
90
+ "Submit an AI-authored blog post draft for human review. Creates an ai_cobolt version and marks the post as AI changes pending (not published).",
91
+ {
92
+ ...postFields,
93
+ title: z.string().describe("Post title"),
94
+ content: z.any().describe("Tiptap JSON content"),
95
+ commit_message: z.string().optional().describe("Version commit message"),
96
+ ai_agent_id: z.string().optional().describe("Identifier for the AI agent submitting the draft"),
97
+ source_signal: z.record(z.any()).optional().describe("Evidence or trigger that led to this draft"),
98
+ },
99
+ async ({ ai_agent_id, source_signal, ...post }) =>
100
+ json(await request("POST", "/posts/propose.json", { body: { post, ai_agent_id, source_signal } }))
101
+ );
102
+
103
+ server.tool(
104
+ "blog_update_post",
105
+ "Update an existing blog post and create a new version.",
106
+ {
107
+ id: z.number().describe("Post ID"),
108
+ ...postFields,
109
+ status: z.string().optional().describe("Status (draft/published/archived)"),
110
+ content: z.any().optional().describe("Tiptap JSON content"),
111
+ commit_message: z.string().optional().describe("Version commit message"),
112
+ publish: z.boolean().optional().describe("Publish this version immediately"),
113
+ },
114
+ async ({ id, publish, ...post }) => json(await request("PUT", `/posts/${id}.json`, { body: { post, publish } }))
115
+ );
116
+
117
+ server.tool(
118
+ "blog_propose_post_update",
119
+ "Submit AI-authored changes to an existing blog post for human review. Creates an ai_cobolt pending version without publishing.",
120
+ {
121
+ id: z.number().describe("Post ID"),
122
+ ...postFields,
123
+ content: z.any().optional().describe("Proposed Tiptap content; omit for metadata-only proposals"),
124
+ commit_message: z.string().optional().describe("Version commit message"),
125
+ ai_agent_id: z.string().optional().describe("Identifier for the AI agent submitting the proposal"),
126
+ source_signal: z.record(z.any()).optional().describe("Evidence or trigger that led to this update"),
127
+ },
128
+ async ({ id, ai_agent_id, source_signal, ...post }) =>
129
+ json(await request("POST", `/posts/${id}/propose_update.json`, { body: { post, ai_agent_id, source_signal } }))
130
+ );
131
+
132
+ server.tool(
133
+ "blog_publish_post",
134
+ "Publish a blog post version. Publishes the latest version if no version_id is given.",
135
+ {
136
+ id: z.number().describe("Post ID"),
137
+ version_id: z.number().optional().describe("Specific version ID to publish (defaults to latest)"),
138
+ },
139
+ async ({ id, version_id }) =>
140
+ json(await request("POST", `/posts/${id}/publish.json`, { body: version_id ? { version_id } : {} }))
141
+ );
142
+
143
+ server.tool(
144
+ "blog_review_post",
145
+ "Approve or reject a pending blog post version.",
146
+ {
147
+ id: z.number().describe("Post ID"),
148
+ version_id: z.number().describe("Version ID to review"),
149
+ decision: z.enum(["approve", "reject"]).describe("Review decision"),
150
+ comment: z.string().optional().describe("Review comment"),
151
+ },
152
+ async ({ id, ...body }) => json(await request("POST", `/posts/${id}/review.json`, { body }))
153
+ );
154
+
155
+ // --- Categories ---
156
+
157
+ server.tool("blog_list_categories", "List all blog categories.", {}, async () =>
158
+ json(await request("GET", "/categories.json")));
159
+
160
+ server.tool(
161
+ "blog_create_category",
162
+ "Create a new blog category.",
163
+ {
164
+ name: z.string().describe("Category name"),
165
+ slug: z.string().optional().describe("URL slug"),
166
+ description: z.string().optional().describe("Category description"),
167
+ icon_library: z.string().optional().describe("Icon library name"),
168
+ icon_name: z.string().optional().describe("Icon name"),
169
+ icon_color: z.string().optional().describe("Icon color hex"),
170
+ position: z.number().optional().describe("Sort position"),
171
+ published: z.boolean().optional().describe("Whether the category is published"),
172
+ },
173
+ async (category) => json(await request("POST", "/categories.json", { body: { category } }))
174
+ );
175
+
176
+ server.tool(
177
+ "blog_update_category",
178
+ "Update a blog category.",
179
+ {
180
+ id: z.number().describe("Category ID"),
181
+ name: z.string().optional(),
182
+ slug: z.string().optional(),
183
+ description: z.string().optional(),
184
+ icon_library: z.string().optional(),
185
+ icon_name: z.string().optional(),
186
+ icon_color: z.string().optional(),
187
+ position: z.number().optional(),
188
+ published: z.boolean().optional(),
189
+ },
190
+ async ({ id, ...category }) => json(await request("PUT", `/categories/${id}.json`, { body: { category } }))
191
+ );
192
+
193
+ server.tool("blog_delete_category", "Delete a blog category.", { id: z.number().describe("Category ID") },
194
+ async ({ id }) => json(await request("DELETE", `/categories/${id}.json`)));
195
+
196
+ // --- Tags ---
197
+
198
+ server.tool("blog_list_tags", "List all blog tags.", {}, async () =>
199
+ json(await request("GET", "/tags.json")));
200
+ }
201
+
202
+ function json(data) {
203
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
204
+ }
@@ -0,0 +1,136 @@
1
+ import { z } from "zod";
2
+
3
+ const BUNDLED_DESIGN_CAPABILITIES = {
4
+ version: "1.0.0-bundled",
5
+ rules: {
6
+ grid_distribution: "Use 12-unit dist arrays such as [6,6], [8,4], or [4,4,4].",
7
+ buttons: "Use actionCallbackValue and actionCallbackTarget for URLs and navigation targets.",
8
+ images: "Use a real URL for image src, include alt text, and avoid data: URLs.",
9
+ conflicts: "When publish returns 409, re-read the design and rebase before retrying.",
10
+ },
11
+ warning: "Bundled fallback only. The live Sutra API capabilities endpoint was unreachable, so this manifest may be stale.",
12
+ };
13
+
14
+ export function registerDesignTools(server, client) {
15
+ server.tool(
16
+ "get_design_capabilities",
17
+ "Get the live Sutra design capability manifest. Read this before creating or editing page designs.",
18
+ {},
19
+ async () => {
20
+ try {
21
+ return json(await client.get("/design/capabilities"));
22
+ } catch (err) {
23
+ return json({
24
+ data: BUNDLED_DESIGN_CAPABILITIES,
25
+ warning: `Live capabilities unavailable: ${err.message}`,
26
+ });
27
+ }
28
+ }
29
+ );
30
+
31
+ server.tool(
32
+ "get_space_design",
33
+ "Fetch the current Sutra-rendered design for a space, including nodes, content_digest, circles, interactive blocks, and published URL.",
34
+ {
35
+ space_id: z.string().describe("Space ID (sp_...)"),
36
+ },
37
+ async ({ space_id }) => json(await client.get(`/spaces/${space_id}/design`))
38
+ );
39
+
40
+ server.tool(
41
+ "validate_space_design",
42
+ "Validate proposed Sutra design nodes and return structured diagnostics. Validate before creating or publishing a draft.",
43
+ {
44
+ space_id: z.string().describe("Space ID (sp_...)"),
45
+ nodes: z.any().describe("Tiptap design nodes array or wrapped { default: { type: 'doc', content: [...] } } document"),
46
+ base_digest: z.string().optional().describe("Digest from get_space_design, if available"),
47
+ },
48
+ async ({ space_id, nodes, base_digest }) => {
49
+ const body = { nodes, base_digest };
50
+ return json(await client.post(`/spaces/${space_id}/design/validate`, body));
51
+ }
52
+ );
53
+
54
+ server.tool(
55
+ "create_or_update_design_draft",
56
+ "Create or update a design draft for a space. Use content_digest from get_space_design as base_digest. Mutating retries should pass idempotency_key.",
57
+ {
58
+ space_id: z.string().describe("Space ID (sp_...)"),
59
+ nodes: z.any().describe("Tiptap design nodes array or wrapped document"),
60
+ base_digest: z.string().describe("Digest from get_space_design"),
61
+ client_draft_key: z.string().describe("Stable key for this draft variant/session"),
62
+ title: z.string().optional().describe("Human-readable draft title"),
63
+ note: z.string().optional().describe("Short note about the draft"),
64
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
65
+ },
66
+ async ({ space_id, idempotency_key, ...body }) => {
67
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
68
+ return json(await client.post(`/spaces/${space_id}/design_drafts`, body, headers));
69
+ }
70
+ );
71
+
72
+ server.tool(
73
+ "publish_design_draft",
74
+ "Publish a design draft. If the API returns a conflict, re-read/rebase or provide the explicit destructive confirmation arrays from the conflict response.",
75
+ {
76
+ draft_id: z.string().describe("Design draft ID (dsdft_...)"),
77
+ confirm_destroy_circles: z.array(z.string()).optional().describe("Circle slugs or sp_ IDs explicitly confirmed for removal"),
78
+ confirm_destroy_interactive_with_responses: z.array(z.string()).optional().describe("Interactive confirm_token values explicitly confirmed for removal"),
79
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
80
+ },
81
+ async ({ draft_id, idempotency_key, confirm_destroy_circles, confirm_destroy_interactive_with_responses }) => {
82
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
83
+ const body = {
84
+ confirm_destroy_circles: confirm_destroy_circles || [],
85
+ confirm_destroy_interactive_with_responses: confirm_destroy_interactive_with_responses || [],
86
+ };
87
+ return json(await client.post(`/design_drafts/${draft_id}/publish`, body, headers));
88
+ }
89
+ );
90
+
91
+ server.tool(
92
+ "restore_design_draft",
93
+ "Restore the pre-publish backup for a published design draft. If live content changed after publish, pass force_restore with current_digest from the conflict response.",
94
+ {
95
+ draft_id: z.string().describe("Design draft ID (dsdft_...)"),
96
+ force_restore: z.boolean().optional().describe("Confirm restore even when live content changed after publish"),
97
+ current_digest: z.string().optional().describe("Current digest required when force_restore is true"),
98
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
99
+ },
100
+ async ({ draft_id, idempotency_key, force_restore, current_digest }) => {
101
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
102
+ const body = { force_restore: force_restore === true, current_digest };
103
+ return json(await client.post(`/design_drafts/${draft_id}/restore`, body, headers));
104
+ }
105
+ );
106
+
107
+ server.tool(
108
+ "import_design_asset",
109
+ "Import an external image URL into Sutra-controlled storage and return the canonical URL to use as image.attrs.src.",
110
+ {
111
+ space_id: z.string().describe("Space ID (sp_...) used for authorization and asset organization"),
112
+ source_url: z.string().url().describe("Public http(s) image URL to fetch and re-host"),
113
+ alt: z.string().optional().describe("Alt text for the image"),
114
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
115
+ },
116
+ async ({ idempotency_key, ...body }) => {
117
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
118
+ return json(await client.post("/design/assets/import", body, headers));
119
+ }
120
+ );
121
+
122
+ server.tool(
123
+ "get_design_preview_link",
124
+ "Get a short-lived, signed, read-only link that opens a design draft's rendered preview in a browser with no Sutra login required. Share it with the user (or open it yourself if you can view web pages) to review the design before publishing.",
125
+ {
126
+ draft_id: z.string().describe("Design draft ID (dsdft_...)"),
127
+ },
128
+ async ({ draft_id }) => {
129
+ return json(await client.post(`/design_drafts/${draft_id}/preview_link`, {}));
130
+ }
131
+ );
132
+ }
133
+
134
+ function json(data) {
135
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
136
+ }
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+
3
+ const documentPlacementSchema = z.object({
4
+ insert: z.enum(["append", "before", "after", "inside"]).optional().describe("Where to insert or move nodes."),
5
+ anchor_node_uid: z.string().optional().describe("Tiptap node UID required for before/after/inside."),
6
+ }).optional();
7
+
8
+ export function registerDocumentTools(server, client) {
9
+ server.tool(
10
+ "get_document_capabilities",
11
+ "Get supported Tiptap document node capabilities and reserved-node policy. Read this before writing document nodes.",
12
+ {},
13
+ async () => json(await client.get("/document/capabilities"))
14
+ );
15
+
16
+ server.tool(
17
+ "get_document",
18
+ "Get the visible Tiptap document for a space by sp_ ID. Use the returned revision for replace_document.",
19
+ {
20
+ space_id: z.string().describe("Space ID (sp_...). Do not use a slug."),
21
+ },
22
+ async ({ space_id }) => json(await client.get(`/spaces/${space_id}/document`))
23
+ );
24
+
25
+ server.tool(
26
+ "replace_document",
27
+ "Replace a space document. Preserve managed space/media nodes; use structure/media tools for those. Requires base_revision or force.",
28
+ {
29
+ space_id: z.string().describe("Space ID (sp_...). Do not use a slug."),
30
+ document: z.any().describe("Tiptap doc: { type: 'doc', content: [...] }"),
31
+ base_revision: z.string().optional().describe("Revision from get_document"),
32
+ force: z.boolean().optional().describe("Explicitly replace without a base revision"),
33
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
34
+ },
35
+ async ({ space_id, idempotency_key, ...body }) => {
36
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
37
+ return json(await client.put(`/spaces/${space_id}/document`, body, headers));
38
+ }
39
+ );
40
+
41
+ server.tool(
42
+ "insert_document_nodes",
43
+ "Insert visible Tiptap nodes into a space document. Use structure tools for space cards and media tools for uploads.",
44
+ {
45
+ space_id: z.string().describe("Space ID (sp_...). Do not use a slug."),
46
+ nodes: z.array(z.any()).describe("Array of Tiptap node objects"),
47
+ placement: documentPlacementSchema,
48
+ base_revision: z.string().optional(),
49
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
50
+ },
51
+ async ({ space_id, idempotency_key, ...body }) => {
52
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
53
+ return json(await client.post(`/spaces/${space_id}/document/nodes`, body, headers));
54
+ }
55
+ );
56
+
57
+ server.tool(
58
+ "update_document_node",
59
+ "Replace one document node by its Tiptap attrs.uid.",
60
+ {
61
+ space_id: z.string().describe("Space ID (sp_...). Do not use a slug."),
62
+ node_uid: z.string().describe("Tiptap node attrs.uid"),
63
+ node: z.any().describe("Replacement Tiptap node object"),
64
+ base_revision: z.string().optional(),
65
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
66
+ },
67
+ async ({ space_id, node_uid, idempotency_key, ...body }) => {
68
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
69
+ return json(await client.patch(`/spaces/${space_id}/document/nodes/${node_uid}`, body, headers));
70
+ }
71
+ );
72
+
73
+ server.tool(
74
+ "delete_document_node",
75
+ "Delete one document node by its Tiptap attrs.uid. Managed space cards must be detached with detach_child_space.",
76
+ {
77
+ space_id: z.string().describe("Space ID (sp_...). Do not use a slug."),
78
+ node_uid: z.string().describe("Tiptap node attrs.uid"),
79
+ base_revision: z.string().optional(),
80
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
81
+ },
82
+ async ({ space_id, node_uid, base_revision, idempotency_key }) => {
83
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
84
+ const query = base_revision ? `?base_revision=${encodeURIComponent(base_revision)}` : "";
85
+ return json(await client.delete(`/spaces/${space_id}/document/nodes/${node_uid}${query}`, headers));
86
+ }
87
+ );
88
+
89
+ server.tool(
90
+ "move_document_node",
91
+ "Move one document node by its Tiptap attrs.uid.",
92
+ {
93
+ space_id: z.string().describe("Space ID (sp_...). Do not use a slug."),
94
+ node_uid: z.string().describe("Tiptap node attrs.uid"),
95
+ placement: documentPlacementSchema,
96
+ base_revision: z.string().optional(),
97
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
98
+ },
99
+ async ({ space_id, node_uid, idempotency_key, ...body }) => {
100
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
101
+ return json(await client.patch(`/spaces/${space_id}/document/nodes/${node_uid}/placement`, body, headers));
102
+ }
103
+ );
104
+ }
105
+
106
+ function json(data) {
107
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
108
+ }