@seldonframe/mcp 1.0.3 → 1.0.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@seldonframe/mcp",
3
- "version": "1.0.3",
4
- "description": "MCP server for SeldonFrame — AI-native Business OS platform. One command creates a real hosted workspace with CRM, booking, intake, and Brain v2. v1.0.3: surfaces admin URL prominently in create_workspace response (no signup required to access dashboard).",
3
+ "version": "1.0.5",
4
+ "description": "MCP server for SeldonFrame — AI-native Business OS platform. One command creates a real hosted workspace with CRM, booking page, intake form, and AI agents. v1.0.5: collect_operator_email tool closes the onboarding loop (welcome email + lead capture); auto-injects x-org-id from workspace bearer for ALL tool calls (fixes list_contacts / list_deals / list_bookings / send_welcome_email); structured Soul-seed fields on create_workspace (phone, services, testimonials) so the landing page renders real data on the first GET; operator-facing labels replace internal slugs in responses.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "seldonframe-mcp": "src/index.js"
package/src/client.js CHANGED
@@ -139,6 +139,18 @@ export async function api(method, path, opts = {}) {
139
139
  };
140
140
  if (auth) headers.Authorization = auth;
141
141
 
142
+ // May 1, 2026 — inject x-org-id from the resolved workspace for
143
+ // EVERY tool call. Several CRM endpoints (contacts, deals,
144
+ // bookings, activities) use guardApiRequest which strictly
145
+ // requires x-org-id. Without this header, the bearer token alone
146
+ // returns 400 "Missing x-org-id" even though the bearer is
147
+ // workspace-scoped. Tools can opt out by passing
148
+ // `extra_headers: { "x-org-id": null }` if a route needs the
149
+ // header omitted, but the default is to send it.
150
+ if (workspace_id && !("x-org-id" in headers) && !("X-Org-ID" in headers)) {
151
+ headers["x-org-id"] = workspace_id;
152
+ }
153
+
142
154
  const res = await fetch(`${API_BASE}${path}`, {
143
155
  method,
144
156
  headers,
package/src/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  api,
3
+ API_INFO,
3
4
  fetchText,
4
5
  forgetWorkspace,
5
6
  htmlToText,
@@ -41,18 +42,61 @@ export const TOOLS = [
41
42
  {
42
43
  name: "create_workspace",
43
44
  description:
44
- "Create a real, hosted workspace on <slug>.app.seldonframe.com with CRM, Cal.diy booking, Formbricks intake, and Brain v2 pre-installed. The first workspace requires no API key. Example: create_workspace({ name: 'Dental Clinic Laval', source: 'https://mysite.com' })",
45
+ "Create a real, hosted workspace on <slug>.app.seldonframe.com with a CRM, booking page, intake form, and AI agents pre-installed. The first workspace requires no API key. When the user mentions a phone, email, address, list of services, or testimonials, pass them as structured fields so the landing page renders with their real content immediately. Example: create_workspace({ name: 'Desert Cool HVAC', phone: '(602) 555-0188', services: [{ name: 'AC Repair' }, { name: 'Heating' }] })",
45
46
  inputSchema: obj(
46
47
  {
47
48
  name: str("Human-readable workspace name."),
48
- source: str("Optional URL or description to seed the workspace's Soul from."),
49
+ source: str("Optional URL or description to seed the business profile from."),
50
+ phone: str("Operator's business phone (any format — we render as-is). Renders in nav, hero, and footer when set."),
51
+ email: str("Optional contact email surfaced in the landing footer."),
52
+ address: str("Optional business address. Comma-separated street, city, region, postal, country renders as-is."),
53
+ tagline: str("Short hero tagline (one line)."),
54
+ business_description: str("One paragraph about the business (used for hero subhead + about section)."),
55
+ services: {
56
+ type: "array",
57
+ description:
58
+ "List of services / products / offerings the business provides. Renders as the services grid on the landing page. Each item: { name, description? }.",
59
+ items: {
60
+ type: "object",
61
+ properties: {
62
+ name: { type: "string" },
63
+ description: { type: "string" },
64
+ },
65
+ required: ["name"],
66
+ },
67
+ },
68
+ testimonials: {
69
+ type: "array",
70
+ description:
71
+ "Optional customer testimonials to seed the landing page's testimonials section. Each item: { quote, name?, role?, company? }.",
72
+ items: {
73
+ type: "object",
74
+ properties: {
75
+ quote: { type: "string" },
76
+ name: { type: "string" },
77
+ role: { type: "string" },
78
+ company: { type: "string" },
79
+ },
80
+ required: ["quote"],
81
+ },
82
+ },
49
83
  },
50
84
  ["name"],
51
85
  ),
52
86
  handler: async (args) => {
53
87
  const firstEver = isFirstEverCall();
54
88
  const result = await api("POST", "/workspace/create", {
55
- body: { name: args.name, source: args.source ?? null },
89
+ body: {
90
+ name: args.name,
91
+ source: args.source ?? null,
92
+ phone: args.phone ?? null,
93
+ email: args.email ?? null,
94
+ address: args.address ?? null,
95
+ tagline: args.tagline ?? null,
96
+ business_description: args.business_description ?? null,
97
+ services: args.services ?? null,
98
+ testimonials: args.testimonials ?? null,
99
+ },
56
100
  allow_anonymous: true,
57
101
  });
58
102
  const ws = result.workspace ?? result;
@@ -67,6 +111,16 @@ export const TOOLS = [
67
111
  // in the response. Operators paste it into their browser and land
68
112
  // directly on the dashboard — no signup, no login, no OAuth.
69
113
  const adminUrl = result.admin_url ?? null;
114
+ // v1.0.4: strip the misleading `admin_*` keys out of the legacy
115
+ // flat `urls` object so old clients don't surface the
116
+ // /switch-workspace login-required URLs as if they were the
117
+ // intended path. Public-facing keys (home/book/intake) stay.
118
+ const rawUrls = result.urls ?? ws.urls ?? null;
119
+ const publicUrls = rawUrls
120
+ ? Object.fromEntries(
121
+ Object.entries(rawUrls).filter(([key]) => !key.startsWith("admin_")),
122
+ )
123
+ : null;
70
124
  const payload = {
71
125
  ok: true,
72
126
  workspace: {
@@ -82,15 +136,43 @@ export const TOOLS = [
82
136
  admin_url_message: adminUrl
83
137
  ? `⚡ Admin Dashboard (bookmark this!): ${adminUrl}\nClick to open the dashboard — no signup needed. Token expires in 7 days; re-mint with list_workspaces({}) when it does.`
84
138
  : null,
85
- urls: result.urls ?? ws.urls ?? null,
86
- public_urls: result.public_urls ?? null,
87
- installed: result.installed ?? ["crm", "caldiy-booking", "formbricks-intake", "brain-v2"],
139
+ urls: publicUrls,
140
+ public_urls: result.public_urls ?? publicUrls,
141
+ // May 1, 2026 — installed-block labels translated to operator
142
+ // copy. The MCP-internal slugs ("caldiy-booking",
143
+ // "formbricks-intake", "brain-v2") are deployment details no
144
+ // operator should need to learn — surfacing them on every
145
+ // create_workspace turn was friction without payoff.
146
+ installed: (result.installed ?? [
147
+ "crm",
148
+ "caldiy-booking",
149
+ "formbricks-intake",
150
+ "brain-v2",
151
+ ]).map((slug) => {
152
+ if (slug === "caldiy-booking") return "Booking page";
153
+ if (slug === "formbricks-intake") return "Intake form";
154
+ if (slug === "brain-v2") return "AI learning";
155
+ if (slug === "crm") return "CRM";
156
+ return slug;
157
+ }),
158
+ // May 1, 2026 — email collection promoted to the first
159
+ // suggested next step. Per the onboarding-loop spec, the
160
+ // operator gets a permanent inbox record (admin URLs,
161
+ // landing/booking/intake links) AND we earn a CRM lead in
162
+ // SeldonFrame's own workspace, so every workspace creation
163
+ // becomes a tracked relationship.
164
+ prompt_for_email: {
165
+ message:
166
+ "What's your email? I'll send you a summary with all the links above (so you don't lose them) and use it for your admin login when you upgrade. — calls collect_operator_email({ email, name? })",
167
+ tool: "collect_operator_email",
168
+ },
88
169
  next: [
89
170
  adminUrl
90
- ? `Open the admin dashboard: ${adminUrl} (paste into your browser; no signup needed)`
171
+ ? `⚡ Admin Dashboard (bookmark this!): ${adminUrl} (paste into your browser; no signup needed; token expires in 7 days)`
91
172
  : null,
92
- "install_vertical_pack({ pack: 'real-estate' }) // or 'dental', 'legal'",
93
- "fetch_source_for_soul({ url: 'https://yoursite.com' }) submit_soul({ soul })",
173
+ "Ask the user for their email, then call collect_operator_email({ email, name? }) sends the welcome email + creates a permanent record. Do this BEFORE any further customization.",
174
+ "install_vertical_pack({ pack: '<industry>' }) set up an industry template (real-estate, dental, legal, …)",
175
+ "fetch_source_for_soul({ url: 'https://yoursite.com' }) → submit_soul({ soul }) — pull the business profile from a website",
94
176
  "get_workspace_snapshot({}) — read workspace state to reason about next steps",
95
177
  ].filter(Boolean),
96
178
  };
@@ -197,6 +279,178 @@ export const TOOLS = [
197
279
  };
198
280
  },
199
281
  },
282
+ {
283
+ name: "send_welcome_email",
284
+ description:
285
+ "Email the active workspace's four key URLs (landing, booking, intake, admin dashboard) to a user. Use this AFTER create_workspace, only when the user has explicitly given their email — never auto-send. The admin URL is bearer-token-scoped and expires in 7 days. Example: send_welcome_email({ email: 'alice@example.com', name: 'Alice' }).",
286
+ inputSchema: obj(
287
+ {
288
+ email: str("Recipient email address."),
289
+ name: str("Optional recipient name (used in the greeting)."),
290
+ workspace_id: str("Optional workspace override. Defaults to active workspace."),
291
+ },
292
+ ["email"],
293
+ ),
294
+ handler: async (a) => {
295
+ const workspaceId = a.workspace_id ?? getDefaultWorkspace();
296
+ if (!workspaceId) {
297
+ throw new Error(
298
+ "No workspace selected. Run create_workspace({ name: '…' }) first, or pass workspace_id.",
299
+ );
300
+ }
301
+ const bearer = getWorkspaceBearer(workspaceId);
302
+ if (!bearer) {
303
+ throw new Error(
304
+ `No local bearer token for workspace ${workspaceId}. This device did not create it. Re-run create_workspace or switch to the device that did.`,
305
+ );
306
+ }
307
+
308
+ const snapshot = await api(
309
+ "GET",
310
+ `/workspace/${encodeURIComponent(workspaceId)}/snapshot`,
311
+ { workspace_id: workspaceId },
312
+ );
313
+ const publicUrls = snapshot?.public_urls ?? {};
314
+ if (!publicUrls.home || !publicUrls.book || !publicUrls.intake) {
315
+ throw new Error(
316
+ "Snapshot did not return public_urls (home/book/intake). Re-check the workspace.",
317
+ );
318
+ }
319
+
320
+ // Construct the bearer-scoped admin URL from the API base.
321
+ // API_INFO.base is `<host>/api/v1` — strip that suffix to get the app host.
322
+ const appHost = API_INFO.base.replace(/\/api\/v1\/?$/, "");
323
+ const adminUrl = `${appHost}/admin/${encodeURIComponent(workspaceId)}?token=${encodeURIComponent(bearer)}`;
324
+
325
+ await api("POST", "/email/send-welcome", {
326
+ body: {
327
+ email: a.email,
328
+ name: a.name ?? null,
329
+ workspace: {
330
+ landing_url: publicUrls.home,
331
+ booking_url: publicUrls.book,
332
+ intake_url: publicUrls.intake,
333
+ admin_url: adminUrl,
334
+ },
335
+ },
336
+ workspace_id: workspaceId,
337
+ });
338
+
339
+ return {
340
+ ok: true,
341
+ message: `Welcome email sent to ${a.email}`,
342
+ };
343
+ },
344
+ },
345
+ {
346
+ name: "collect_operator_email",
347
+ description:
348
+ "Onboarding-loop helper: ask the operator for their email post-create_workspace, then send the welcome email + record them as a lead in SeldonFrame's own CRM. Combines send_welcome_email + a lead-capture call so a single tool call closes the loop. Call this BEFORE further customization (configure_booking / customize_intake_form / install_vertical_pack) so the operator gets a permanent inbox record of their links. Example: collect_operator_email({ email: 'max@desertcool.com', name: 'Max' })",
349
+ inputSchema: obj(
350
+ {
351
+ email: str("Operator email — used as the welcome email recipient AND as the unique key for the SeldonFrame CRM lead."),
352
+ name: str("Optional operator name (used in the email greeting and on the CRM lead)."),
353
+ workspace_id: str("Optional workspace override. Defaults to the workspace just created."),
354
+ },
355
+ ["email"],
356
+ ),
357
+ handler: async (a) => {
358
+ const workspaceId = a.workspace_id ?? getDefaultWorkspace();
359
+ if (!workspaceId) {
360
+ throw new Error(
361
+ "No workspace selected. Run create_workspace({ name: '…' }) first, or pass workspace_id.",
362
+ );
363
+ }
364
+ const bearer = getWorkspaceBearer(workspaceId);
365
+ if (!bearer) {
366
+ throw new Error(
367
+ `No local bearer token for workspace ${workspaceId}. This device did not create it. Re-run create_workspace or switch to the device that did.`,
368
+ );
369
+ }
370
+
371
+ // Step 1: pull the workspace snapshot so we have the public URLs
372
+ // for the welcome email.
373
+ const snapshot = await api(
374
+ "GET",
375
+ `/workspace/${encodeURIComponent(workspaceId)}/snapshot`,
376
+ { workspace_id: workspaceId },
377
+ );
378
+ const publicUrls = snapshot?.public_urls ?? {};
379
+ const slug = snapshot?.workspace?.slug ?? null;
380
+ if (!publicUrls.home || !publicUrls.book || !publicUrls.intake) {
381
+ throw new Error(
382
+ "Snapshot did not return public_urls (home/book/intake). Re-check the workspace.",
383
+ );
384
+ }
385
+ const appHost = API_INFO.base.replace(/\/api\/v1\/?$/, "");
386
+ const adminUrl = `${appHost}/admin/${encodeURIComponent(workspaceId)}?token=${encodeURIComponent(bearer)}`;
387
+
388
+ // Step 2: send the welcome email. Failures here are surfaced
389
+ // (operator told us their email, we owe them the email) but
390
+ // don't block the lead-capture step below.
391
+ let emailSent = false;
392
+ let emailError = null;
393
+ try {
394
+ await api("POST", "/email/send-welcome", {
395
+ body: {
396
+ email: a.email,
397
+ name: a.name ?? null,
398
+ workspace: {
399
+ landing_url: publicUrls.home,
400
+ booking_url: publicUrls.book,
401
+ intake_url: publicUrls.intake,
402
+ admin_url: adminUrl,
403
+ },
404
+ },
405
+ workspace_id: workspaceId,
406
+ });
407
+ emailSent = true;
408
+ } catch (err) {
409
+ emailError = err?.message ?? String(err);
410
+ }
411
+
412
+ // Step 3: record the operator as a lead in SeldonFrame's own
413
+ // CRM workspace. Anonymous endpoint — no bearer required, ops
414
+ // workspace ID is server-side env only. Soft-failure: if the
415
+ // ops workspace isn't configured we still return ok.
416
+ let leadRecorded = false;
417
+ let leadId = null;
418
+ let leadError = null;
419
+ try {
420
+ const leadResp = await api("POST", "/leads/operator-signup", {
421
+ body: {
422
+ email: a.email,
423
+ name: a.name ?? null,
424
+ source_workspace_id: workspaceId,
425
+ source_workspace_slug: slug,
426
+ source: "mcp-onboarding",
427
+ },
428
+ allow_anonymous: true,
429
+ });
430
+ leadRecorded = Boolean(leadResp?.recorded);
431
+ leadId = leadResp?.lead_id ?? null;
432
+ } catch (err) {
433
+ leadError = err?.message ?? String(err);
434
+ }
435
+
436
+ return {
437
+ ok: emailSent || leadRecorded,
438
+ email_sent: emailSent,
439
+ email_error: emailError,
440
+ lead_recorded: leadRecorded,
441
+ lead_id: leadId,
442
+ lead_error: leadError,
443
+ message: emailSent
444
+ ? `Welcome email sent to ${a.email}. Check your inbox — the admin URL is in there too.`
445
+ : `Could not send welcome email${emailError ? `: ${emailError}` : ""}. Lead ${leadRecorded ? "recorded" : "not recorded"}.`,
446
+ next: [
447
+ "configure_booking({ title, duration_minutes, description }) — tune the booking page if you collected business hours",
448
+ "customize_intake_form({ ... }) — match your intake to the workspace's lead-qualification questions",
449
+ "install_vertical_pack({ pack: '<industry>' }) — add domain-specific objects, fields, and views",
450
+ ],
451
+ };
452
+ },
453
+ },
200
454
  {
201
455
  name: "revoke_bearer",
202
456
  description:
@@ -384,10 +638,10 @@ export const TOOLS = [
384
638
  {
385
639
  name: "install_vertical_pack",
386
640
  description:
387
- "Install a vertical pack (e.g. 'real-estate', 'dental', 'legal'). Adds domain-specific objects, fields, views.",
641
+ "Set up an industry template (real-estate, dental, legal, …). Adds industry-specific objects, fields, and views to the CRM and pre-fills the booking page + intake form for that line of work.",
388
642
  inputSchema: obj(
389
643
  {
390
- pack: str("Pack slug, e.g. 'real-estate', 'dental', 'legal'."),
644
+ pack: str("Industry template name, e.g. 'real-estate', 'dental', 'legal'."),
391
645
  workspace_id: str("Optional workspace override."),
392
646
  },
393
647
  ["pack"],
@@ -403,10 +657,10 @@ export const TOOLS = [
403
657
  {
404
658
  name: "install_caldiy_booking",
405
659
  description:
406
- "Install the Cal.diy booking block (event types, availability, bookings). Example: install_caldiy_booking({})",
660
+ "Install the booking page (event types, availability, scheduled bookings). Example: install_caldiy_booking({})",
407
661
  inputSchema: obj({
408
662
  workspace_id: str("Optional workspace override."),
409
- config: { type: "object", description: "Optional Cal.diy configuration overrides." },
663
+ config: { type: "object", description: "Optional booking-page configuration overrides." },
410
664
  }),
411
665
  handler: async (a) => {
412
666
  const ws = wsOrDefault(a.workspace_id);
@@ -419,10 +673,10 @@ export const TOOLS = [
419
673
  {
420
674
  name: "install_formbricks_intake",
421
675
  description:
422
- "Install a Formbricks intake form (surveys, conditional logic, contact sync). Example: install_formbricks_intake({})",
676
+ "Install an intake form (questions, conditional logic, automatic CRM sync). Example: install_formbricks_intake({})",
423
677
  inputSchema: obj({
424
678
  workspace_id: str("Optional workspace override."),
425
- form_id: str("Optional existing Formbricks form id to bind."),
679
+ form_id: str("Optional existing intake-form id to bind."),
426
680
  }),
427
681
  handler: async (a) => {
428
682
  const ws = wsOrDefault(a.workspace_id);
@@ -449,7 +703,7 @@ export const TOOLS = [
449
703
  {
450
704
  name: "fetch_source_for_soul",
451
705
  description:
452
- "Fetch a URL and return normalized text (headings + body, up to 256KB). Use this to gather raw content; then you (the caller) extract a structured Soul and submit it with submit_soul. Zero LLM cost to Seldon — extraction runs in this session.",
706
+ "Fetch a URL and return normalized text (headings + body, up to 256KB). Use this to gather raw content from the operator's existing website; then extract a structured business profile and save it with submit_soul. Zero LLM cost to SeldonFrame — extraction runs in this session.",
453
707
  inputSchema: obj(
454
708
  {
455
709
  url: str("Absolute URL to fetch."),
@@ -468,7 +722,7 @@ export const TOOLS = [
468
722
  truncated,
469
723
  text,
470
724
  next: [
471
- "Extract a Soul object: { mission, audience, tone, offerings[], differentiators[], faqs[] }",
725
+ "Extract a business profile: { mission, audience, tone, offerings[], differentiators[], faqs[] }",
472
726
  "submit_soul({ soul: <extracted> })",
473
727
  ],
474
728
  };
@@ -477,13 +731,13 @@ export const TOOLS = [
477
731
  {
478
732
  name: "submit_soul",
479
733
  description:
480
- "Submit a compiled Soul object to the active workspace. The caller is expected to have produced the structured Soul from fetch_source_for_soul output or user conversation.",
734
+ "Save a business profile to the active workspace. The profile drives the landing page, intake form copy, and AI-agent context. Call this after fetch_source_for_soul or after gathering details from the user. Triggers a re-render of the public landing page so changes are visible immediately.",
481
735
  inputSchema: obj(
482
736
  {
483
737
  soul: {
484
738
  type: "object",
485
739
  description:
486
- "Structured Soul. Expected keys: mission, audience, tone, offerings, differentiators, faqs. Additional keys allowed.",
740
+ "Business profile. Expected keys: business_name, tagline, soul_description, phone, email, address, offerings, faqs, testimonials. Additional keys allowed — they're preserved for future use.",
487
741
  },
488
742
  workspace_id: str("Optional workspace override."),
489
743
  },
package/src/welcome.js CHANGED
@@ -104,9 +104,9 @@ Soul compilation runs HERE, not on the backend:
104
104
 
105
105
  ## When you'll need \`SELDONFRAME_API_KEY\`
106
106
 
107
- The first workspace is free forever. Paid tiers (Starter $49/mo, Operator
108
- $99/mo, Agency $149/mo) unlock additional workspaces, custom domains,
109
- white-label, and advanced Brain capabilities. A key is required for:
107
+ The first workspace is free forever. Paid tiers (Growth $29/mo, Scale
108
+ $99/mo, both with metered usage) unlock additional workspaces, custom
109
+ domains, white-label, and advanced Brain capabilities. A key is required for:
110
110
 
111
111
  - Adding a **second workspace**
112
112
  - Connecting a **custom domain**