@sutraspaces/mcp-server 1.2.1 → 1.4.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/README.md CHANGED
@@ -90,8 +90,11 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server`. The server communicat
90
90
  | `SUTRA_SCOPES` | No | Space-separated scopes to request during OAuth login (default: all scopes your account can authorize) |
91
91
  | `SUTRA_CONFIG_DIR` | No | Directory for cached OAuth credentials (default: `~/.sutra`) |
92
92
  | `SUTRA_BASE_URL` | No | Override the API URL (default: `https://api.sutra.co/api/admin/v1`) |
93
- | `SUTRA_HELP_CENTER_TOKEN` | No | Internal scoped admin key (`sutra_admin_...`) for Lotus Help Center tools |
93
+ | `SUTRA_LOTUS_TOKEN` | No | Internal scoped admin key (`sutra_admin_...`) for Lotus tools. Add `help_center:read`, `help_center:write`, `blog:read`, and/or `blog:write` to the same key as needed. |
94
94
  | `SUTRA_HELP_CENTER_BASE_URL` | No | Override the Help Center API URL (default: `https://api.sutra.co/api/v4/lotus/help`) |
95
+ | `SUTRA_BLOG_BASE_URL` | No | Override the Blog API URL (default: `https://api.sutra.co/api/v4/lotus/blog`) |
96
+
97
+ `SUTRA_HELP_CENTER_TOKEN` and `SUTRA_BLOG_TOKEN` are still accepted as backwards-compatible aliases, but new setups should use one `SUTRA_LOTUS_TOKEN`.
95
98
 
96
99
  ## Available Tools
97
100
 
@@ -108,12 +111,18 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server`. The server communicat
108
111
  - **attach_child_space** — Attach an existing space under a parent and make it visible
109
112
  - **update_child_space_placement** — Move or repair a child space display
110
113
  - **detach_child_space** — Detach a child from one parent without deleting it
111
- - **update_space** — Update space name, description, type, privacy, or state
114
+ - **update_space** — Update General settings: name, description, format, privacy, visibility, member limit, registration gates, behavior flags, shareable link, and state (archive/unarchive)
112
115
  - **delete_space** — Delete/archive a space
113
116
  - **reorder_child_spaces** — Reorder children within a parent
114
117
 
115
118
  Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement.surface = "auto"`.
116
119
 
120
+ ### Space Settings, Appearance, Images & Payment
121
+ - **update_space_settings** — Behavioral settings: scheduling (launch/archive/readonly), membership & notifications, content behavior, sidebar/page display, registration display, SEO, members map, Cobolt AI indexing, and list/post display
122
+ - **update_space_appearance** — Theme colors, fonts, accent palette, gradients, corner style, density, and hidden layout elements (a base-color write cascades the full theme to descendants)
123
+ - **set_space_image** — Set or remove the cover, logo, thumbnail, or social-share (meta) image from a URL
124
+ - **update_space_payment** — Enable/disable paid access (plans system) with guarded preconditions and teardown, plus checkout toggles (multi-plan selection, guest checkout, terms)
125
+
117
126
  ### Members
118
127
  - **list_members** — List space members with optional email, user_id, search, role, state, and custom property filtering
119
128
  - **get_member** — Get member details
@@ -153,6 +162,15 @@ Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement
153
162
  - **publish_design_draft** — Publish a draft with conflict and destructive-omission protection
154
163
  - **restore_design_draft** — Restore the pre-publish backup for a published design draft
155
164
  - **import_design_asset** — Re-host an external image URL and return the canonical Sutra URL
165
+
166
+ ### Website Tools
167
+
168
+ Website mode is root-scoped (any managed space resolves to its website root) and tier-gated (enabling requires the Silver plan or above). Enabling auto-creates a default header (a navbar) and footer; you then author those configs. A header's nav lives in a NavbarConfig referenced by the site_header share block's navbar node.
169
+
170
+ - **get_space_website** / **update_space_website** — Read and set website-mode state: enable/disable, `show_spaces_in_page`, and the header/footer share-block pointers
171
+ - **list_share_block_configs** / **get_share_block_config** / **create_share_block_config** / **update_share_block_config** / **delete_share_block_config** — Manage site_header / site_footer / generic share blocks (deleting an in-use header or footer returns 409)
172
+ - **list_navbar_configs** / **get_navbar_config** / **create_navbar_config** / **update_navbar_config** / **delete_navbar_config** / **duplicate_navbar_config** — Manage the website header's navbar (links, logo, title, alignment)
173
+
156
174
  - **list_messages** / **get_message** / **create_message** / **update_message** / **delete_message** — Manage discussion messages
157
175
  - **list_reflections** / **get_reflection** / **create_reflection** / **update_reflection** / **delete_reflection** — Manage threaded replies
158
176
 
@@ -173,7 +191,7 @@ Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement
173
191
  - **get_survey_submissions** — Read survey responses
174
192
 
175
193
  ### Plans & Enrollments
176
- - **list_plans** / **get_plan** / **create_plan** / **update_plan** / **delete_plan** — Manage plans
194
+ - **list_plans** / **get_plan** / **create_plan** / **update_plan** / **delete_plan** — Manage payment plans (full pricing surface; `amount` is in integer cents). Creating plans does not enable paid access — use **update_space_payment** for the gate.
177
195
  - **list_enrollments** / **get_enrollment** — Read enrollments
178
196
  - **list_payments** / **get_payment** — Read payment history
179
197
 
@@ -186,7 +204,7 @@ Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement
186
204
  - **get_broadcast_delivery_status** — Check delivery progress
187
205
 
188
206
  ### Help Center
189
- These tools are available only when `SUTRA_HELP_CENTER_TOKEN` is set.
207
+ These tools are available when `SUTRA_LOTUS_TOKEN` is set with `help_center:read` / `help_center:write` scopes.
190
208
 
191
209
  - **help_list_articles** / **help_get_article** — Read Lotus Help Center articles, metadata, and version history
192
210
  - **help_create_article** / **help_update_article** — Human/admin-style create and update through Lotus
@@ -196,6 +214,18 @@ These tools are available only when `SUTRA_HELP_CENTER_TOKEN` is set.
196
214
  - **help_publish_article** — Explicitly publish an article version
197
215
  - **help_list_collections** / **help_create_collection** / **help_update_collection** / **help_delete_collection** — Manage Help Center collections
198
216
 
217
+ ### Blog
218
+ These tools are available when `SUTRA_LOTUS_TOKEN` is set. The token needs `blog:read` for list/get tools and `blog:write` for create, update, proposal, publish, review, and category tools.
219
+
220
+ - **blog_list_posts** / **blog_get_post** — Read Lotus Blog posts, metadata, and version history
221
+ - **blog_create_post** / **blog_update_post** — Human/admin-style create and update through Lotus
222
+ - **blog_propose_post** — Submit an AI-authored draft as `ai_cobolt` and mark it pending human review
223
+ - **blog_propose_post_update** — Submit AI-authored changes to an existing post without publishing them
224
+ - **blog_review_post** — Approve or reject a pending post version
225
+ - **blog_publish_post** — Explicitly publish a post version
226
+ - **blog_list_categories** / **blog_create_category** / **blog_update_category** / **blog_delete_category** — Manage Blog categories
227
+ - **blog_list_tags** — List Blog tags
228
+
199
229
  ## Available Resources
200
230
 
201
231
  - **sutra://admin-api/overview** — Core Admin API concepts, public ID prefixes, scopes, pagination, and filtering
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sutraspaces/mcp-server",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for the Sutra Admin API — manage spaces, members, contacts, content, and more via AI agents",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -37,7 +37,7 @@
37
37
  "homepage": "https://github.com/lorenzsell/sutra-mcp",
38
38
  "repository": {
39
39
  "type": "git",
40
- "url": "https://github.com/lorenzsell/sutra-mcp.git"
40
+ "url": "git+https://github.com/lorenzsell/sutra-mcp.git"
41
41
  },
42
42
  "bugs": {
43
43
  "url": "https://github.com/lorenzsell/sutra-mcp/issues"
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
 
6
6
  import { SutraAdminClient } from "./client.js";
7
7
  import { registerSpaceTools } from "./tools/spaces.js";
8
+ import { registerSpaceSettingsTools } from "./tools/space-settings.js";
8
9
  import { registerMemberTools } from "./tools/members.js";
9
10
  import { registerContentTools } from "./tools/content.js";
10
11
  import { registerDocumentTools } from "./tools/documents.js";
@@ -20,6 +21,7 @@ import { registerAutomationTools } from "./tools/automations.js";
20
21
  import { registerHelpCenterTools } from "./tools/help-center.js";
21
22
  import { registerBlogTools } from "./tools/blog.js";
22
23
  import { registerDesignTools } from "./tools/design.js";
24
+ import { registerWebsiteTools } from "./tools/website.js";
23
25
  import { registerAdminApiResources } from "./resources/admin-api.js";
24
26
  import { registerDesignResources } from "./resources/design.js";
25
27
  import { getValidAccessToken, login, refresh } from "./auth/oauth.js";
@@ -119,12 +121,14 @@ if (SUTRA_API_TOKEN) {
119
121
 
120
122
  const server = new McpServer({
121
123
  name: "sutra",
122
- version: "1.2.0",
124
+ // Keep in lockstep with package.json.
125
+ version: "1.4.0",
123
126
  description:
124
127
  "Sutra Admin API — manage spaces, members, contacts, content, discussions, surveys, plans, broadcasts, and more.",
125
128
  });
126
129
 
127
130
  registerSpaceTools(server, client);
131
+ registerSpaceSettingsTools(server, client);
128
132
  registerMemberTools(server, client);
129
133
  registerContentTools(server, client);
130
134
  registerDocumentTools(server, client);
@@ -138,6 +142,7 @@ registerBroadcastTools(server, client);
138
142
  registerDeepTool(server, client);
139
143
  registerAutomationTools(server, client);
140
144
  registerDesignTools(server, client);
145
+ registerWebsiteTools(server, client);
141
146
  registerHelpCenterTools(server);
142
147
  registerBlogTools(server);
143
148
  registerAdminApiResources(server);
@@ -8,6 +8,15 @@ const RESOURCES = [
8
8
 
9
9
  The Sutra Admin API lets authorized tokens manage spaces, members, contacts, content, invitations, properties, surveys, plans, coupons, and broadcasts.
10
10
 
11
+ Space configuration tools:
12
+ - update_space covers the General settings core: name, description, format (type), privacy, visibility, member limit, registration gates, behavior flags, shareable link, and archive/unarchive.
13
+ - update_space_settings covers behavioral settings: scheduling, notifications, content behavior, display, registration, SEO, members map, Cobolt indexing, and list/post display.
14
+ - update_space_appearance covers theme colors, fonts, accents, gradients, corner style, density, and hidden layout elements.
15
+ - set_space_image sets or removes the cover, logo, thumbnail, or meta image from a URL.
16
+ - update_space_payment enables/disables paid access (plans system) and configures checkout toggles. Plans themselves are managed with create_plan/update_plan/delete_plan (amounts in integer cents).
17
+ - update_space_website enables/disables website mode (root-scoped; Silver+ to enable) and points the site at header/footer share blocks. Enabling auto-creates a default header + footer. Author the header with create/update_navbar_config (links, logo, title) referenced by a site_header share block, and the footer with update_share_block_config. get_space_website reads the current state.
18
+ - get_space returns the detail read with nested settings, appearance, payment, and website objects.
19
+
11
20
  Important IDs:
12
21
  - Spaces use sp_ IDs.
13
22
  - Members use mem_ IDs.
@@ -15,6 +24,8 @@ Important IDs:
15
24
  - Users use usr_ IDs.
16
25
  - Member property definitions use mprop_ IDs.
17
26
  - Space property definitions use sprop_ IDs.
27
+ - Share-block configs (website header/footer) use shbk_ IDs.
28
+ - Navbar configs use nvbr_ IDs.
18
29
 
19
30
  Pagination:
20
31
  - List tools return data and pagination.
@@ -25,6 +36,8 @@ Scopes:
25
36
  - members:read reads members and contacts.
26
37
  - members:write writes member/contact-related values where supported.
27
38
  - spaces:read lists spaces where the token owner has admin access.
39
+ - spaces:write creates, updates, configures, and archives spaces — including settings, appearance, images, and the payment gate (update_space_payment).
40
+ - plans:read / plans:write manage payment plans; the payment gate itself needs spaces:write.
28
41
  - membership_spaces:read lists basic read-only inventory for spaces where the token owner is a member.
29
42
  - members.email:read is required to see or filter by email.
30
43
  - member_properties:read is required to list definitions, read values, and filter people by properties.
package/src/tools/blog.js CHANGED
@@ -7,7 +7,7 @@ import { z } from "zod";
7
7
  const BLOG_BASE_URL = process.env.SUTRA_BLOG_BASE_URL || "https://api.sutra.co/api/v4/lotus/blog";
8
8
 
9
9
  export function registerBlogTools(server) {
10
- const token = process.env.SUTRA_BLOG_TOKEN || process.env.SUTRA_HELP_CENTER_TOKEN;
10
+ const token = process.env.SUTRA_LOTUS_TOKEN || process.env.SUTRA_BLOG_TOKEN || process.env.SUTRA_HELP_CENTER_TOKEN;
11
11
  if (!token) return;
12
12
 
13
13
  async function request(method, path, { params, body } = {}) {
@@ -47,6 +47,7 @@ export function registerBlogTools(server) {
47
47
  position: z.number().optional().describe("Sort position"),
48
48
  search_keywords: z.array(z.string()).optional().describe("Extra search keywords for recall"),
49
49
  tags: z.array(z.string()).optional().describe("Tag slugs (created if missing)"),
50
+ published_at: z.string().optional().describe("ISO8601 publish date-time to preserve (e.g. '2025-06-30T00:00:00Z'). If omitted, publishing stamps the current time. Useful for content migrations that need to keep original dates."),
50
51
  };
51
52
 
52
53
  // --- Posts ---
@@ -42,7 +42,11 @@ export function registerDesignTools(server, client) {
42
42
  "Validate proposed Sutra design nodes and return structured diagnostics. Validate before creating or publishing a draft.",
43
43
  {
44
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"),
45
+ // Typed as an array (not z.any()) so MCP clients serialize it as
46
+ // structured JSON. With an untyped schema the harness can stringify the
47
+ // value, and the API's Document.wrap rejects a JSON string. Pass the bare
48
+ // node array; the API still also accepts a wrapped doc when not stringified.
49
+ nodes: z.array(z.any()).describe("Array of Tiptap design node objects (bare array — a stringified JSON doc is rejected by the API)."),
46
50
  base_digest: z.string().optional().describe("Digest from get_space_design, if available"),
47
51
  },
48
52
  async ({ space_id, nodes, base_digest }) => {
@@ -56,7 +60,9 @@ export function registerDesignTools(server, client) {
56
60
  "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
61
  {
58
62
  space_id: z.string().describe("Space ID (sp_...)"),
59
- nodes: z.any().describe("Tiptap design nodes array or wrapped document"),
63
+ // Typed as an array (see validate_space_design) so the value is sent as
64
+ // structured JSON rather than being stringified by the MCP client.
65
+ nodes: z.array(z.any()).describe("Array of Tiptap design node objects (bare array — a stringified JSON doc is rejected by the API)."),
60
66
  base_digest: z.string().describe("Digest from get_space_design"),
61
67
  client_draft_key: z.string().describe("Stable key for this draft variant/session"),
62
68
  title: z.string().optional().describe("Human-readable draft title"),
@@ -200,7 +200,7 @@ function normalizeNode(node) {
200
200
  }
201
201
 
202
202
  export function registerHelpCenterTools(server) {
203
- const token = process.env.SUTRA_HELP_CENTER_TOKEN;
203
+ const token = process.env.SUTRA_LOTUS_TOKEN || process.env.SUTRA_HELP_CENTER_TOKEN;
204
204
  if (!token) return;
205
205
 
206
206
  async function request(method, path, { params, body } = {}) {
@@ -32,42 +32,74 @@ export function registerPlanTools(server, client) {
32
32
  }
33
33
  );
34
34
 
35
+ // Shared write surface for create_plan / update_plan. The API takes
36
+ // `amount` in integer CENTS (5000 = $50.00) — there is no `price` param.
37
+ const planWriteShape = {
38
+ name: z.string().optional().describe("Plan name"),
39
+ short_description: z.string().nullable().optional().describe("One-line description shown on the plan card"),
40
+ long_description: z.string().nullable().optional().describe("Full description (HTML)"),
41
+ amount: z.number().int().optional().describe("Price in integer CENTS (e.g. 5000 = $50.00). Must be > 0 unless pwyc_enabled."),
42
+ amount_cents: z.number().int().optional().describe("Alias for amount (matches the read shape); amount wins when both are sent"),
43
+ currency: z.string().optional().describe('ISO currency code, e.g. "usd"'),
44
+ frequency: z.enum(["once", "monthly", "quarterly", "yearly"]).optional().describe("Billing frequency"),
45
+ billing_limit_type: z.enum(["ongoing", "fixed_count", "until_date"]).optional().describe("How long recurring billing runs"),
46
+ billing_count: z.number().int().min(1).nullable().optional().describe("Number of charges when billing_limit_type is fixed_count"),
47
+ billing_until_date: z.string().nullable().optional().describe("ISO date for billing_limit_type until_date. Null clears."),
48
+ trial_period_days: z.number().int().min(0).max(730).optional().describe("Free trial length in days"),
49
+ pwyc_enabled: z.boolean().optional().describe("Pay-what-you-can pricing (tier-gated in the web UI)"),
50
+ pwyc_min: z.number().int().min(0).optional().describe("PWYC minimum in cents"),
51
+ pwyc_max: z.number().int().nullable().optional().describe("PWYC maximum in cents"),
52
+ pwyc_default: z.number().int().nullable().optional().describe("PWYC suggested amount in cents"),
53
+ sort_order: z.number().int().optional().describe("Display order on the checkout page"),
54
+ status: z.enum(["active", "inactive"]).optional(),
55
+ visibility: z.enum(["visible", "hidden"]).optional().describe("hidden plans are purchasable only by direct link"),
56
+ recommended: z.boolean().optional().describe("Highlight as the recommended plan"),
57
+ features: z.record(z.any()).optional().describe("Feature list metadata shown on the plan card"),
58
+ max_purchasers: z.number().int().min(1).nullable().optional().describe("Cap on total purchasers. Null = unlimited."),
59
+ redirect_after_purchase: z.string().nullable().optional().describe("URL to send buyers to after checkout. Null clears."),
60
+ show_on_registration: z.boolean().optional().describe("Offer this plan on the registration page"),
61
+ };
62
+
35
63
  server.tool(
36
64
  "create_plan",
37
- "Create a plan for a space.",
65
+ "Create a payment plan for a space. amount is in integer CENTS (5000 = $50.00). Creating a plan does NOT enable paid access by itself — when the response carries a warning that the payment gate is off, call update_space_payment(enabled: true) to make plans purchasable. While multiple plan selection is enabled on the space, all active plans must share one currency and one billing family (all one-time or all recurring).",
38
66
  {
39
67
  space_id: z.string().describe("Space ID (sp_...)"),
40
- name: z.string().optional().describe("Plan name"),
41
- price: z.number().optional().describe("Price amount"),
68
+ ...planWriteShape,
69
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
42
70
  },
43
- async ({ space_id, ...body }) => {
44
- const data = await client.post(`/spaces/${space_id}/plans`, body);
71
+ async ({ space_id, idempotency_key, ...body }) => {
72
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
73
+ const data = await client.post(`/spaces/${space_id}/plans`, body, headers);
45
74
  return json(data);
46
75
  }
47
76
  );
48
77
 
49
78
  server.tool(
50
79
  "update_plan",
51
- "Update a plan.",
80
+ "Update a payment plan. Only the keys you send are written; nullable fields are cleared with null. amount is in integer CENTS. Deactivating the last active plan behind an enabled paywall returns a warning (visitors would reach an empty paywall).",
52
81
  {
53
82
  plan_id: z.string().describe("Plan ID (plan_...)"),
54
- name: z.string().optional(),
55
- price: z.number().optional(),
83
+ ...planWriteShape,
84
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
56
85
  },
57
- async ({ plan_id, ...body }) => {
58
- const data = await client.patch(`/plans/${plan_id}`, body);
86
+ async ({ plan_id, idempotency_key, ...body }) => {
87
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
88
+ const data = await client.patch(`/plans/${plan_id}`, body, headers);
59
89
  return json(data);
60
90
  }
61
91
  );
62
92
 
63
93
  server.tool(
64
94
  "delete_plan",
65
- "Delete a plan.",
95
+ "Deactivate a plan (soft delete — enrollments are preserved and the plan can be reactivated with update_plan). Deactivating the last active plan behind an enabled paywall returns a warning.",
66
96
  {
67
97
  plan_id: z.string().describe("Plan ID (plan_...)"),
98
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
68
99
  },
69
- async ({ plan_id }) => {
70
- const data = await client.delete(`/plans/${plan_id}`);
100
+ async ({ plan_id, idempotency_key }) => {
101
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
102
+ const data = await client.delete(`/plans/${plan_id}`, headers);
71
103
  return json(data);
72
104
  }
73
105
  );
@@ -0,0 +1,213 @@
1
+ import { z } from "zod";
2
+
3
+ // Space → Settings → General tools beyond the core update_space surface:
4
+ // behavioral settings (update_space_settings), appearance, images, and
5
+ // payment. Split from spaces.js because these schemas are large.
6
+ export function registerSpaceSettingsTools(server, client) {
7
+ server.tool(
8
+ "update_space_settings",
9
+ "Update a space's behavioral settings: scheduling (launch/archive/readonly), membership and notifications, content behavior, sidebar and page display, registration display, SEO, members map, Cobolt AI indexing, and list/post display. Partial update — only the keys you send are written; nullable fields are cleared with null. Scheduling gates need their time in the same call (launch_on_date + launch_time); while a future launch is scheduled the space state reads as pending, and any settings write re-pends it. Changing display fields (space_width, show_page_icon, apply_to_sub_spaces) propagates the parent's display settings to children when apply_to_sub_spaces is on.",
10
+ {
11
+ space_id: z.string().describe("Space ID (sp_...)"),
12
+
13
+ // Scheduling (tier-gated professional/organization)
14
+ show_start_date: z.boolean().optional().describe("Show the space's start date publicly"),
15
+ start_date: z.string().nullable().optional().describe("ISO-8601 start date(time). Null clears."),
16
+ end_date: z.string().nullable().optional().describe("ISO-8601 end date(time). Null clears."),
17
+ launch_on_date: z.boolean().optional().describe("Schedule the space to launch at launch_time (send both together). Turning this off clears launch_time and un-pends the space."),
18
+ launch_time: z.string().nullable().optional().describe("ISO-8601 launch datetime"),
19
+ archive_on_date: z.boolean().optional().describe("Schedule automatic archiving at archive_time"),
20
+ archive_time: z.string().nullable().optional().describe("ISO-8601 archive datetime"),
21
+ readonly_on_date: z.boolean().optional().describe("Schedule the space to become read-only at readonly_time"),
22
+ readonly_time: z.string().nullable().optional().describe("ISO-8601 read-only datetime"),
23
+
24
+ // Membership & notifications
25
+ new_member_notifications: z.enum(["manager", "none"]).optional().describe("Who is notified when someone joins"),
26
+ hide_member_emails: z.boolean().optional(),
27
+ allow_member_copy: z.boolean().optional().describe("Allow members to copy/duplicate the space"),
28
+ max_sub_space_membership: z.number().int().min(0).nullable().optional().describe("Max sub-spaces a member can join. Null = unlimited."),
29
+
30
+ // Content behavior
31
+ is_focus_mode: z.boolean().optional(),
32
+ allow_comments: z.boolean().optional(),
33
+ allow_completions: z.boolean().optional().describe("Enable mark-as-complete (tier-gated when enabling). Must agree with show_mark_complete_button_in_header when both are sent."),
34
+ show_mark_complete_button_in_header: z.boolean().optional().describe("Show the complete button in the header. Sent alone, allow_completions is set to match."),
35
+ allow_members_to_create_tags: z.boolean().optional(),
36
+ list_auto_tag: z.boolean().optional().describe("Auto-tag posts with AI (tier-gated when enabling)"),
37
+ redirect_to_space: z.string().nullable().optional().describe("Slug of an existing active space to redirect visitors to. Null clears."),
38
+
39
+ // Sidebar / page display
40
+ sidebar_default_opened: z.boolean().optional(),
41
+ sidebar_hide_children: z.boolean().optional(),
42
+ show_page_icon: z.boolean().optional(),
43
+ apply_to_sub_spaces: z.boolean().optional().describe("Apply display settings (width, page icon, hidden layout elements) to sub-spaces"),
44
+ is_cover_full_width: z.boolean().optional(),
45
+ space_width: z.enum(["wide", "narrow"]).optional(),
46
+
47
+ // Registration display
48
+ show_join_button: z.boolean().optional(),
49
+ show_registration_sidebar: z.boolean().optional(),
50
+ registration_button_text: z.string().nullable().optional().describe("Custom join-button label. Null restores the default."),
51
+ registration_width: z.enum(["wide", "narrow"]).optional(),
52
+ allow_interested_on_join: z.boolean().optional(),
53
+
54
+ // SEO
55
+ meta_title: z.string().nullable().optional(),
56
+ meta_description: z.string().nullable().optional(),
57
+ seo_indexable: z.boolean().optional(),
58
+
59
+ // Members map
60
+ members_map_interests_enabled: z.boolean().optional(),
61
+ members_map_show_ai_interests: z.boolean().optional(),
62
+
63
+ // Cobolt AI indexing
64
+ cobolt_indexing_excluded: z.boolean().optional().describe("Exclude this space (and subtree) from Cobolt AI indexing. Turning on purges the subtree's index and denormalizes the flag to descendants; turning off restores indexing for this space only."),
65
+
66
+ // List / posts display
67
+ list_action_text: z.string().nullable().optional(),
68
+ list_template_id: z.string().nullable().optional().describe("Template space slug for template-backed creation. Requires list_child_version: template; auto-cleared when switching away."),
69
+ list_child_version: z.enum(["posts", "content", "discussions", "link", "template"]).optional(),
70
+ list_link_action: z.enum(["subspace", "linked"]).optional(),
71
+ list_show_author: z.boolean().optional(),
72
+ list_show_preview_text: z.boolean().optional(),
73
+ list_show_thumbnail: z.boolean().optional(),
74
+ list_show_comments_count: z.boolean().optional(),
75
+ list_show_members: z.boolean().optional(),
76
+ list_show_timestamp: z.boolean().optional(),
77
+ list_auto_thumbnail: z.boolean().optional(),
78
+ list_flat_view: z.boolean().optional(),
79
+ list_open_in_modal: z.boolean().optional(),
80
+ list_allow_likes: z.boolean().optional(),
81
+ list_filter_by: z.enum([
82
+ "custom_order",
83
+ "pods.created_at DESC",
84
+ "pods.created_at ASC",
85
+ "pods.last_active DESC",
86
+ "pods.updated_at ASC",
87
+ "LOWER(pods.intention) ASC",
88
+ "LOWER(pods.intention) DESC",
89
+ ]).optional().describe("Child ordering"),
90
+ list_privacy_control: z.enum(["open", "private", "any"]).optional(),
91
+ list_title_line_clamp: z.number().int().min(1).nullable().optional().describe("The web UI writes only 2 or 20 and round-trips its own values"),
92
+ list_abstract_line_clamp: z.number().int().min(1).nullable().optional(),
93
+ list_add_creator_as_role: z.enum(["moderator", "editor", "manager"]).optional().describe("member is rejected: posts force editor, courses/sections force manager"),
94
+ list_children_capabilities: z.array(z.enum(["full", "limited"])).optional().describe('The web UI writes exactly ["full"] or ["limited"]'),
95
+
96
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
97
+ },
98
+ async ({ space_id, idempotency_key, ...updates }) => {
99
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
100
+ const data = await client.patch(`/spaces/${space_id}/settings`, updates, headers);
101
+ return json(data);
102
+ }
103
+ );
104
+
105
+ const hexColor = (desc) =>
106
+ z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "must be a hex color like #1a2b3c")
107
+ .nullable().optional().describe(desc);
108
+
109
+ server.tool(
110
+ "update_space_appearance",
111
+ "Update a space's theme: colors, fonts, accent palette, gradients, corner style, density, and hidden layout elements. Tier-gated to professional/organization accounts (hidden_layout_elements: personal and up). Null clears a theme override. WARNING: writing any base color cascades the parent's FULL current theme — fonts, font weights, corner style, density, accents, gradients, sidebar style, and the logo file — to every descendant space.",
112
+ {
113
+ space_id: z.string().describe("Space ID (sp_...)"),
114
+
115
+ // Base colors (writing any of these triggers the descendant cascade)
116
+ header_color: hexColor("Header background color"),
117
+ header_link_color: hexColor("Header link color"),
118
+ primary_button_background_color: hexColor("Primary button background"),
119
+ primary_button_text_color: hexColor("Primary button text"),
120
+ secondary_button_background_color: hexColor("Secondary button background"),
121
+ secondary_button_text_color: hexColor("Secondary button text"),
122
+ background_color: hexColor("Page background"),
123
+ secondary_background_color: hexColor("Secondary background"),
124
+ default_text_color: hexColor("Default text color"),
125
+ sub_header_text_color: hexColor("Sub-header text color"),
126
+ sidebar_background_color: hexColor("Sidebar background"),
127
+ sidebar_text_color: hexColor("Sidebar text"),
128
+ default_link_color: hexColor("Link color"),
129
+ default_badge_color: hexColor("Badge background"),
130
+ default_badge_text_color: hexColor("Badge text"),
131
+
132
+ // Registration page (no cascade)
133
+ registration_page_background_color: hexColor("Registration page background"),
134
+ registration_page_default_text_color: hexColor("Registration page text"),
135
+ registration_page_sidebar_background_color: hexColor("Registration sidebar background"),
136
+ registration_page_sidebar_text_color: hexColor("Registration sidebar text"),
137
+ registration_page_button_background_color: hexColor("Registration button background"),
138
+ registration_page_button_text_color: hexColor("Registration button text"),
139
+ registration_page_link_color: hexColor("Registration link color"),
140
+ registration_page_heading_font: z.string().nullable().optional(),
141
+ registration_page_body_font: z.string().nullable().optional(),
142
+
143
+ // Fonts & expansion theme
144
+ heading_font: z.string().nullable().optional().describe("Heading font family name"),
145
+ body_font: z.string().nullable().optional().describe("Body font family name"),
146
+ accent_font: z.string().nullable().optional().describe("Accent font family name"),
147
+ accent_color: hexColor("Accent color"),
148
+ accents: z.array(z.string()).min(3).max(5).nullable().optional().describe("3-5 hex colors used by templates for accent elements. Null clears."),
149
+ gradients: z.object({
150
+ hero: z.string().optional(),
151
+ cta: z.string().optional(),
152
+ ambient: z.string().optional(),
153
+ }).nullable().optional().describe("Slot → gradient ID from the frontend gradient library. Null clears."),
154
+ corner_style: z.enum(["sharp", "subtle", "soft", "rounded"]).nullable().optional(),
155
+ density: z.enum(["compact", "standard", "comfortable", "spacious"]).nullable().optional(),
156
+ heading_font_weight: z.string().nullable().optional().describe('CSS font weight, e.g. "600"'),
157
+ body_font_weight: z.string().nullable().optional(),
158
+
159
+ // Layout
160
+ hidden_layout_elements: z.array(z.enum(["sidebar", "header", "cover", "title", "breadcrumbs"])).optional().describe("Layout chrome to hide. Empty array shows everything."),
161
+
162
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
163
+ },
164
+ async ({ space_id, idempotency_key, ...updates }) => {
165
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
166
+ const data = await client.patch(`/spaces/${space_id}/appearance`, updates, headers);
167
+ return json(data);
168
+ }
169
+ );
170
+
171
+ server.tool(
172
+ "set_space_image",
173
+ "Set or remove a space image by URL. Kinds: cover = page banner (center-cropped toward 2400×600, never upscaled; jpg/jpeg/gif/png/webp), logo = 250×80 crop (jpg/jpeg/gif/png; professional/organization only; propagates to all descendant spaces), thumbnail = list-card image (600×300 crop; setting one protects it from post-driven auto-generation, removing re-arms it), meta = social-share image (1200×630 crop). Processing is synchronous (ImageMagick + storage) — use reasonably sized source files (hard cap 25 MB) and expect the call to take a few seconds. Unlike the web app, cover writes only the banner, never the thumbnail.",
174
+ {
175
+ space_id: z.string().describe("Space ID (sp_...)"),
176
+ kind: z.enum(["cover", "logo", "thumbnail", "meta"]).describe("Which space image to set"),
177
+ source_url: z.string().nullable().describe("Public http(s) URL of the image, or null to remove the current image"),
178
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries (a retry re-downloads the image)"),
179
+ },
180
+ async ({ space_id, kind, source_url, idempotency_key }) => {
181
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
182
+ const data = await client.post(`/spaces/${space_id}/images`, { kind, source_url }, headers);
183
+ return json(data);
184
+ }
185
+ );
186
+
187
+ server.tool(
188
+ "update_space_payment",
189
+ "Enable, disable, or configure paid access for a space (plans system; legacy single-price config is not accessible through the API). Enabling requires at least one active plan (create_plan first) AND a connected Stripe account — each missing precondition returns a specific 422 (no_active_plans / stripe_not_connected). Disabling is a guarded teardown: it returns 409 (live_enrollments) while live enrollments exist unless force: true, which cancels their Stripe subscriptions; active plans are soft-deactivated and the space's privacy moves to target_privacy (default private). Disabling a space that was never paid is a harmless no-op. The space's payment state reads back under payment in get_space.",
190
+ {
191
+ space_id: z.string().describe("Space ID (sp_...)"),
192
+ enabled: z.boolean().optional().describe("true = turn paid access on (after preconditions); false = guarded teardown"),
193
+ force: z.boolean().optional().describe("With enabled: false — cancel live Stripe subscriptions (active/trialing/pending/past_due/requires_action) instead of failing with 409"),
194
+ target_privacy: z.enum(["open", "private"]).optional().describe("Privacy the space moves to on disable (default private)"),
195
+ allow_multiple_space_plan_selections: z.boolean().optional().describe("Let buyers pick multiple plans at checkout. Requires ALL active plans (including hidden) to share one currency and one billing family."),
196
+ guest_checkout_enabled: z.boolean().optional().describe("Allow purchase without creating an account first. Rejected while any active plan has prerequisites."),
197
+ guest_invitation_message: z.string().nullable().optional().describe("Message in the guest's invitation email. Null clears."),
198
+ terms_enabled: z.boolean().optional().describe("Require accepting terms at checkout"),
199
+ terms_link_label: z.string().nullable().optional().describe('Label for the terms link (default "Terms and Conditions")'),
200
+ terms_content: z.string().nullable().optional().describe("Terms content (HTML). Null clears."),
201
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
202
+ },
203
+ async ({ space_id, idempotency_key, ...updates }) => {
204
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
205
+ const data = await client.patch(`/spaces/${space_id}/payment`, updates, headers);
206
+ return json(data);
207
+ }
208
+ );
209
+ }
210
+
211
+ function json(data) {
212
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
213
+ }
@@ -19,7 +19,7 @@ export function registerSpaceTools(server, client) {
19
19
  q: z.string().optional().describe("Search spaces by name (case-insensitive)"),
20
20
  state: z.enum(["active", "archived"]).optional().describe("Filter by state"),
21
21
  type: z.string().optional().describe("Filter by type (e.g. content, forum)"),
22
- privacy: z.enum(["open", "closed", "secret"]).optional().describe("Filter by privacy level"),
22
+ privacy: z.enum(["open", "private", "payment", "closed", "secret"]).optional().describe("Filter by privacy level (closed/secret are legacy values on older spaces)"),
23
23
  },
24
24
  async ({ limit, cursor, q, state, type, privacy }) => {
25
25
  const data = await client.get("/spaces", { limit, cursor, q, state, type, privacy });
@@ -78,10 +78,11 @@ export function registerSpaceTools(server, client) {
78
78
  parent_id: z.string().optional().describe("Parent space ID (sp_...) to create as a child"),
79
79
  content_scaffold: z.enum(["none", "ui_default"]).optional().describe("Use none when document content will be written through document tools."),
80
80
  placement: placementSchema,
81
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
81
82
  },
82
- async ({ name, description, type, privacy, pod_type, parent_id, content_scaffold, placement }) => {
83
- const body = { name, description, type, privacy, pod_type, parent_id, content_scaffold, placement };
84
- const data = await client.post("/spaces", body);
83
+ async ({ idempotency_key, ...body }) => {
84
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
85
+ const data = await client.post("/spaces", body, headers);
85
86
  return json(data);
86
87
  }
87
88
  );
@@ -156,29 +157,49 @@ export function registerSpaceTools(server, client) {
156
157
 
157
158
  server.tool(
158
159
  "update_space",
159
- "Update an existing space's name, description, type, privacy, or state.",
160
+ "Update a space's General settings: identity (name, description, type), access (privacy, visibility, member limit, registration gates), behavior flags, shareable link, and state. Fields marked nullable are cleared by passing null. Archiving is a two-phase transition: descendants are ghosted asynchronously, shareable links cleared, and Stripe subscriptions in the tree cancelled; unarchiving restores descendants but NOT cleared links, redirects, or subscriptions.",
160
161
  {
161
162
  space_id: z.string().describe("Space ID (sp_...)"),
162
- name: z.string().optional().describe("New name"),
163
- description: z.string().optional().describe("New description"),
164
- type: z.string().optional().describe("New type"),
165
- privacy: z.string().optional().describe("New privacy level"),
166
- state: z.enum(["active", "archived"]).optional().describe("Set active or archived"),
163
+ name: z.string().optional().describe("New name (3-100 characters)"),
164
+ description: z.string().nullable().optional().describe("Description as HTML (sanitized server-side with the web app's safelist). Null clears it."),
165
+ type: z.enum(["discussion", "content", "list", "showcase", "events", "course", "section"]).optional().describe("Space format. Changing it is tier-gated (professional/organization) and applies the format's display defaults; reverting to content requires the space to have been a content space before."),
166
+ privacy: z.enum(["open", "private"]).optional().describe("Access level. private is tier-gated. To enable or disable paid access (privacy: payment), use update_space_payment instead."),
167
+ state: z.enum(["active", "archived"]).optional().describe("Set active or archived. See the archive warning in the tool description."),
168
+ visibility: z.enum(["public", ""]).nullable().optional().describe('"public" lists the space publicly, "" (or null) unlists it. Cascades to every descendant space. A private space cannot be public.'),
169
+ pod_type: z.enum(["standard", "readonly"]).optional().describe("readonly freezes member participation"),
170
+ size: z.number().int().min(1).nullable().optional().describe("Member limit (minimum 1). Null = unlimited."),
171
+ resource_editing: z.enum(["open", "closed"]).optional().describe("Whether members can edit shared resources"),
172
+ present_as: z.enum(["list", "grid"]).optional().describe("How child posts/spaces render"),
173
+ allow_reflections: z.boolean().optional().describe("Allow journal reflections"),
174
+ show_members: z.boolean().optional().describe("Show the members tab"),
175
+ block_until_registered: z.boolean().optional().describe("Block content until visitors register"),
176
+ block_until_approved: z.boolean().optional().describe("Block content until membership is approved"),
177
+ allow_members_to_invite: z.boolean().optional().describe("Let members invite others"),
178
+ use_legacy_registration_page: z.boolean().optional(),
179
+ hangout_link: z.string().nullable().optional().describe("Video call URL (http/https). Null clears it."),
180
+ abstract_text: z.string().nullable().optional().describe("Short summary text. Null clears it."),
181
+ emojicon: z.string().nullable().optional().describe("Emoji icon for the space. Null clears it."),
182
+ circle_creation: z.array(z.enum(["discussion", "subcircles", "resources"])).optional().describe("What members may create here. Empty array = nothing."),
183
+ url_handle: z.string().nullable().optional().describe("Shareable-link handle (3-25 letters/numbers, unique). Tier-gated to professional/organization. Null clears it."),
184
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
167
185
  },
168
- async ({ space_id, ...updates }) => {
169
- const data = await client.patch(`/spaces/${space_id}`, updates);
186
+ async ({ space_id, idempotency_key, ...updates }) => {
187
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
188
+ const data = await client.patch(`/spaces/${space_id}`, updates, headers);
170
189
  return json(data);
171
190
  }
172
191
  );
173
192
 
174
193
  server.tool(
175
194
  "delete_space",
176
- "Delete a space. This archives the space and queues cleanup.",
195
+ "Delete a space. Descendants are ghosted asynchronously and Stripe subscriptions in the tree are cancelled; cleanup runs in the background.",
177
196
  {
178
197
  space_id: z.string().describe("Space ID (sp_...)"),
198
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
179
199
  },
180
- async ({ space_id }) => {
181
- const data = await client.delete(`/spaces/${space_id}`);
200
+ async ({ space_id, idempotency_key }) => {
201
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
202
+ const data = await client.delete(`/spaces/${space_id}`, headers);
182
203
  return json(data);
183
204
  }
184
205
  );
@@ -189,9 +210,11 @@ export function registerSpaceTools(server, client) {
189
210
  {
190
211
  space_id: z.string().describe("Parent space ID (sp_...)"),
191
212
  space_ids: z.array(z.string()).describe("Ordered array of child space IDs (sp_...)"),
213
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
192
214
  },
193
- async ({ space_id, space_ids }) => {
194
- const data = await client.patch(`/spaces/${space_id}/children/reorder`, { space_ids });
215
+ async ({ space_id, space_ids, idempotency_key }) => {
216
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
217
+ const data = await client.patch(`/spaces/${space_id}/children/reorder`, { space_ids }, headers);
195
218
  return json(data);
196
219
  }
197
220
  );
@@ -0,0 +1,195 @@
1
+ import { z } from "zod";
2
+
3
+ // Website mode + header/footer authoring.
4
+ //
5
+ // Website mode is ROOT-scoped: pass any space_id you manage and the API
6
+ // resolves it to its website root. Enabling is tier-gated (Silver plan and
7
+ // above) and auto-creates a default header (a navbar) + footer; you then edit
8
+ // those configs. The header's nav (links/logo/title/alignment) lives in a
9
+ // NavbarConfig that the site_header share block references via a navbar node.
10
+ //
11
+ // Tiptap arrays (content/links) and style objects are TYPED (z.array/z.record),
12
+ // never bare z.any(), so the MCP client serializes them as structured JSON
13
+ // rather than stringifying them (which the API rejects).
14
+ export function registerWebsiteTools(server, client) {
15
+ server.tool(
16
+ "get_space_website",
17
+ "Get a space's website-mode state: enabled, show_spaces_in_page, and the header/footer share-block config IDs (shbk_...). Website mode is root-scoped — any managed space resolves to its website root.",
18
+ {
19
+ space_id: z.string().describe("Space ID (sp_...). Resolves to its website root."),
20
+ },
21
+ async ({ space_id }) => json(await client.get(`/spaces/${space_id}/website`))
22
+ );
23
+
24
+ server.tool(
25
+ "update_space_website",
26
+ "Enable/disable website mode and point the website at header/footer share blocks. enabled:true is tier-gated (Silver+) and, on first enable, auto-creates a default header (navbar) + footer; enabled:false preserves the configs so re-enabling is lossless. header_config_id/footer_config_id must reference a site_header/site_footer share block in the same space (null clears the pointer). Operates on the resolved website root. Typical flow: enable -> get_space_website to read the auto-created config IDs -> update_navbar_config / update_share_block_config to author them.",
27
+ {
28
+ space_id: z.string().describe("Space ID (sp_...). Resolves to its website root."),
29
+ enabled: z.boolean().optional().describe("true enables website mode (Silver+); false disables it (configs are preserved)."),
30
+ show_spaces_in_page: z.boolean().optional().describe("Show child spaces inline in the page while in website mode."),
31
+ header_config_id: z.string().nullable().optional().describe("shbk_ ID of a site_header share block to use as the header, or null to clear."),
32
+ footer_config_id: z.string().nullable().optional().describe("shbk_ ID of a site_footer share block to use as the footer, or null to clear."),
33
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
34
+ },
35
+ async ({ space_id, idempotency_key, ...updates }) => {
36
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
37
+ return json(await client.patch(`/spaces/${space_id}/website`, updates, headers));
38
+ }
39
+ );
40
+
41
+ // ----- Share block configs (site_header / site_footer / generic) ----------
42
+
43
+ server.tool(
44
+ "list_share_block_configs",
45
+ "List the share-block configs (kinds: site_header, site_footer, generic) for a space's website root.",
46
+ {
47
+ space_id: z.string().describe("Space ID (sp_...). Resolves to its website root."),
48
+ },
49
+ async ({ space_id }) => json(await client.get(`/spaces/${space_id}/share_block_configs`))
50
+ );
51
+
52
+ server.tool(
53
+ "get_share_block_config",
54
+ "Get one share-block config, including its full Tiptap content and style.",
55
+ {
56
+ space_id: z.string().describe("Space ID (sp_...)."),
57
+ config_id: z.string().describe("Share-block config ID (shbk_...)."),
58
+ },
59
+ async ({ space_id, config_id }) => json(await client.get(`/spaces/${space_id}/share_block_configs/${config_id}`))
60
+ );
61
+
62
+ server.tool(
63
+ "create_share_block_config",
64
+ "Create a share-block config. For a website header use kind 'site_header' with content = [{ type:'navbar', attrs:{ uid:'<uuid>', navbarConfigId:<int> } }] (create the navbar first with create_navbar_config); for a footer use kind 'site_footer' with freeform blocks. name must be unique within the space.",
65
+ {
66
+ space_id: z.string().describe("Space ID (sp_...). Resolves to its website root."),
67
+ name: z.string().describe("Config name (unique per space)."),
68
+ kind: z.enum(["generic", "site_header", "site_footer"]).optional().describe("Defaults to generic."),
69
+ content: z.array(z.any()).optional().describe("Tiptap node array (bare array, not a stringified doc)."),
70
+ style: z.record(z.any()).optional().describe("Style object (passthrough)."),
71
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
72
+ },
73
+ async ({ space_id, idempotency_key, ...body }) => {
74
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
75
+ return json(await client.post(`/spaces/${space_id}/share_block_configs`, body, headers));
76
+ }
77
+ );
78
+
79
+ server.tool(
80
+ "update_share_block_config",
81
+ "Update a share-block config's name, content, or style. Kind is immutable through this tool (changing a header's kind would break the website pointer).",
82
+ {
83
+ space_id: z.string().describe("Space ID (sp_...)."),
84
+ config_id: z.string().describe("Share-block config ID (shbk_...)."),
85
+ name: z.string().optional(),
86
+ content: z.array(z.any()).optional().describe("Tiptap node array (bare array)."),
87
+ style: z.record(z.any()).optional(),
88
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
89
+ },
90
+ async ({ space_id, config_id, idempotency_key, ...body }) => {
91
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
92
+ return json(await client.patch(`/spaces/${space_id}/share_block_configs/${config_id}`, body, headers));
93
+ }
94
+ );
95
+
96
+ server.tool(
97
+ "delete_share_block_config",
98
+ "Delete a share-block config. Returns 409 config_in_use if it is the active website header or footer — repoint or disable website mode first.",
99
+ {
100
+ space_id: z.string().describe("Space ID (sp_...)."),
101
+ config_id: z.string().describe("Share-block config ID (shbk_...)."),
102
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
103
+ },
104
+ async ({ space_id, config_id, idempotency_key }) => {
105
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
106
+ return json(await client.delete(`/spaces/${space_id}/share_block_configs/${config_id}`, headers));
107
+ }
108
+ );
109
+
110
+ // ----- Navbar configs (the website header's nav) --------------------------
111
+
112
+ server.tool(
113
+ "list_navbar_configs",
114
+ "List the navbar configs for a space's website root.",
115
+ {
116
+ space_id: z.string().describe("Space ID (sp_...). Resolves to its website root."),
117
+ },
118
+ async ({ space_id }) => json(await client.get(`/spaces/${space_id}/navbar_configs`))
119
+ );
120
+
121
+ server.tool(
122
+ "get_navbar_config",
123
+ "Get one navbar config, including its links and style.",
124
+ {
125
+ space_id: z.string().describe("Space ID (sp_...)."),
126
+ config_id: z.string().describe("Navbar config ID (nvbr_...)."),
127
+ },
128
+ async ({ space_id, config_id }) => json(await client.get(`/spaces/${space_id}/navbar_configs/${config_id}`))
129
+ );
130
+
131
+ server.tool(
132
+ "create_navbar_config",
133
+ "Create a navbar config (links + style) for a website header. Reference it from a site_header share block's navbar node via navbarConfigId. name must be unique within the space.",
134
+ {
135
+ space_id: z.string().describe("Space ID (sp_...). Resolves to its website root."),
136
+ name: z.string().describe("Config name (unique per space)."),
137
+ links: z.array(z.any()).optional().describe("Navbar link objects (bare array)."),
138
+ style: z.record(z.any()).optional().describe("Navbar style object (linkSource, alignment, showLogo, titleText, etc.)."),
139
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
140
+ },
141
+ async ({ space_id, idempotency_key, ...body }) => {
142
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
143
+ return json(await client.post(`/spaces/${space_id}/navbar_configs`, body, headers));
144
+ }
145
+ );
146
+
147
+ server.tool(
148
+ "update_navbar_config",
149
+ "Update a navbar config's name, links, or style.",
150
+ {
151
+ space_id: z.string().describe("Space ID (sp_...)."),
152
+ config_id: z.string().describe("Navbar config ID (nvbr_...)."),
153
+ name: z.string().optional(),
154
+ links: z.array(z.any()).optional().describe("Navbar link objects (bare array)."),
155
+ style: z.record(z.any()).optional(),
156
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
157
+ },
158
+ async ({ space_id, config_id, idempotency_key, ...body }) => {
159
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
160
+ return json(await client.patch(`/spaces/${space_id}/navbar_configs/${config_id}`, body, headers));
161
+ }
162
+ );
163
+
164
+ server.tool(
165
+ "delete_navbar_config",
166
+ "Delete a navbar config. A site_header still referencing it by navbarConfigId will render an empty nav until repointed.",
167
+ {
168
+ space_id: z.string().describe("Space ID (sp_...)."),
169
+ config_id: z.string().describe("Navbar config ID (nvbr_...)."),
170
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
171
+ },
172
+ async ({ space_id, config_id, idempotency_key }) => {
173
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
174
+ return json(await client.delete(`/spaces/${space_id}/navbar_configs/${config_id}`, headers));
175
+ }
176
+ );
177
+
178
+ server.tool(
179
+ "duplicate_navbar_config",
180
+ "Duplicate a navbar config (copies its links and style under a new unique name).",
181
+ {
182
+ space_id: z.string().describe("Space ID (sp_...)."),
183
+ config_id: z.string().describe("Navbar config ID (nvbr_...) to duplicate."),
184
+ idempotency_key: z.string().optional().describe("Idempotency-Key header for safe retries"),
185
+ },
186
+ async ({ space_id, config_id, idempotency_key }) => {
187
+ const headers = idempotency_key ? { "Idempotency-Key": idempotency_key } : undefined;
188
+ return json(await client.post(`/spaces/${space_id}/navbar_configs/${config_id}/duplicate`, {}, headers));
189
+ }
190
+ );
191
+ }
192
+
193
+ function json(data) {
194
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
195
+ }