@sutraspaces/mcp-server 1.2.0 → 1.3.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
@@ -86,12 +86,15 @@ Point your MCP client at `npx -y @sutraspaces/mcp-server`. The server communicat
86
86
  | Variable | Required | Description |
87
87
  |---|---|---|
88
88
  | `SUTRA_API_TOKEN` | No* | Your Sutra Admin API token (`sutra_live_sk_...`). *Required only if not using OAuth. When set, OAuth is skipped. |
89
- | `SUTRA_OAUTH_ISSUER` | No | OAuth issuer base URL (default: `https://sutra.co`). Must be `https://` (only loopback hosts may use `http://`). Separate from the Admin API URL. |
89
+ | `SUTRA_OAUTH_ISSUER` | No | OAuth issuer base URL (default: `https://api.sutra.co`). Must be `https://` (only loopback hosts may use `http://`). Separate from the Admin API URL. |
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
@@ -173,7 +182,7 @@ Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement
173
182
  - **get_survey_submissions** — Read survey responses
174
183
 
175
184
  ### Plans & Enrollments
176
- - **list_plans** / **get_plan** / **create_plan** / **update_plan** / **delete_plan** — Manage plans
185
+ - **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
186
  - **list_enrollments** / **get_enrollment** — Read enrollments
178
187
  - **list_payments** / **get_payment** — Read payment history
179
188
 
@@ -186,7 +195,7 @@ Use `sp_...` IDs, not slugs. For normal course and section pages, use `placement
186
195
  - **get_broadcast_delivery_status** — Check delivery progress
187
196
 
188
197
  ### Help Center
189
- These tools are available only when `SUTRA_HELP_CENTER_TOKEN` is set.
198
+ These tools are available when `SUTRA_LOTUS_TOKEN` is set with `help_center:read` / `help_center:write` scopes.
190
199
 
191
200
  - **help_list_articles** / **help_get_article** — Read Lotus Help Center articles, metadata, and version history
192
201
  - **help_create_article** / **help_update_article** — Human/admin-style create and update through Lotus
@@ -196,6 +205,18 @@ These tools are available only when `SUTRA_HELP_CENTER_TOKEN` is set.
196
205
  - **help_publish_article** — Explicitly publish an article version
197
206
  - **help_list_collections** / **help_create_collection** / **help_update_collection** / **help_delete_collection** — Manage Help Center collections
198
207
 
208
+ ### Blog
209
+ 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.
210
+
211
+ - **blog_list_posts** / **blog_get_post** — Read Lotus Blog posts, metadata, and version history
212
+ - **blog_create_post** / **blog_update_post** — Human/admin-style create and update through Lotus
213
+ - **blog_propose_post** — Submit an AI-authored draft as `ai_cobolt` and mark it pending human review
214
+ - **blog_propose_post_update** — Submit AI-authored changes to an existing post without publishing them
215
+ - **blog_review_post** — Approve or reject a pending post version
216
+ - **blog_publish_post** — Explicitly publish a post version
217
+ - **blog_list_categories** / **blog_create_category** / **blog_update_category** / **blog_delete_category** — Manage Blog categories
218
+ - **blog_list_tags** — List Blog tags
219
+
199
220
  ## Available Resources
200
221
 
201
222
  - **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.0",
3
+ "version": "1.3.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";
@@ -33,7 +34,7 @@ import { clear, load } from "./auth/store.js";
33
34
  const args = process.argv.slice(2);
34
35
 
35
36
  if (args.includes("login")) {
36
- const issuer = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
37
+ const issuer = process.env.SUTRA_OAUTH_ISSUER || "https://api.sutra.co";
37
38
  try {
38
39
  await login({ issuer });
39
40
  process.exit(0);
@@ -44,7 +45,7 @@ if (args.includes("login")) {
44
45
  }
45
46
 
46
47
  if (args.includes("logout")) {
47
- const issuer = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
48
+ const issuer = process.env.SUTRA_OAUTH_ISSUER || "https://api.sutra.co";
48
49
  clear(issuer);
49
50
  process.stderr.write("Logged out — cached credentials removed.\n");
50
51
  process.exit(0);
@@ -56,7 +57,7 @@ if (args.includes("logout")) {
56
57
 
57
58
  const SUTRA_API_TOKEN = process.env.SUTRA_API_TOKEN;
58
59
  const SUTRA_BASE_URL = process.env.SUTRA_BASE_URL;
59
- const SUTRA_OAUTH_ISSUER = process.env.SUTRA_OAUTH_ISSUER || "https://sutra.co";
60
+ const SUTRA_OAUTH_ISSUER = process.env.SUTRA_OAUTH_ISSUER || "https://api.sutra.co";
60
61
 
61
62
  let client;
62
63
 
@@ -119,12 +120,14 @@ if (SUTRA_API_TOKEN) {
119
120
 
120
121
  const server = new McpServer({
121
122
  name: "sutra",
122
- version: "1.2.0",
123
+ // Keep in lockstep with package.json.
124
+ version: "1.3.0",
123
125
  description:
124
126
  "Sutra Admin API — manage spaces, members, contacts, content, discussions, surveys, plans, broadcasts, and more.",
125
127
  });
126
128
 
127
129
  registerSpaceTools(server, client);
130
+ registerSpaceSettingsTools(server, client);
128
131
  registerMemberTools(server, client);
129
132
  registerContentTools(server, client);
130
133
  registerDocumentTools(server, client);
@@ -8,6 +8,14 @@ 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
+ - get_space returns the detail read with nested settings, appearance, and payment objects.
18
+
11
19
  Important IDs:
12
20
  - Spaces use sp_ IDs.
13
21
  - Members use mem_ IDs.
@@ -25,6 +33,8 @@ Scopes:
25
33
  - members:read reads members and contacts.
26
34
  - members:write writes member/contact-related values where supported.
27
35
  - spaces:read lists spaces where the token owner has admin access.
36
+ - spaces:write creates, updates, configures, and archives spaces — including settings, appearance, images, and the payment gate (update_space_payment).
37
+ - plans:read / plans:write manage payment plans; the payment gate itself needs spaces:write.
28
38
  - membership_spaces:read lists basic read-only inventory for spaces where the token owner is a member.
29
39
  - members.email:read is required to see or filter by email.
30
40
  - 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 ---
@@ -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
  );