@groundfloorcloud/mcp 0.2.4 → 0.2.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.
Files changed (2) hide show
  1. package/dist/index.js +126 -13
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -179,12 +179,14 @@ async function getValidAccessToken() {
179
179
 
180
180
  // ../cli/src/cp.ts
181
181
  var CpError = class extends Error {
182
- constructor(status, message) {
182
+ constructor(status, message, reason = null) {
183
183
  super(message);
184
184
  this.status = status;
185
+ this.reason = reason;
185
186
  this.name = "CpError";
186
187
  }
187
188
  status;
189
+ reason;
188
190
  };
189
191
  function formatCpErrorDetail(detail, fallback) {
190
192
  if (typeof detail === "string" && detail.trim()) return detail.trim();
@@ -204,6 +206,40 @@ function formatCpErrorDetail(detail, fallback) {
204
206
  }
205
207
  return fallback;
206
208
  }
209
+ function parseRetryAfter(value, now = Date.now()) {
210
+ if (!value) return null;
211
+ const trimmed = value.trim();
212
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
213
+ const at = Date.parse(trimmed);
214
+ if (Number.isNaN(at)) return null;
215
+ return Math.max(0, Math.ceil((at - now) / 1e3));
216
+ }
217
+ function formatWait(seconds) {
218
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
219
+ const minutes = Math.ceil(seconds / 60);
220
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"}`;
221
+ const hours = Math.ceil(minutes / 60);
222
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
223
+ }
224
+ var REASON_HEADER = "x-groundfloor-reason";
225
+ function formatCpErrorMessage(status, detail, retryAfterSeconds, reason = null) {
226
+ let message = `${status} ${detail}`;
227
+ const wait = retryAfterSeconds !== null ? `try again in ${formatWait(retryAfterSeconds)}` : "try again later";
228
+ if (status === 429 && reason === "sending_limited") {
229
+ message += ` (workspace sign-in email limit reached: ${wait}, or give access with a copy-link or temporary password instead)`;
230
+ } else if (status === 429) {
231
+ message += ` (rate limited: ${wait})`;
232
+ } else if (status === 423 && reason === "sending_paused") {
233
+ message += " (sign-in email is paused: only Groundfloor support can resume it; meanwhile give access with a copy-link or temporary password, or switch to your own email server)";
234
+ } else if (status === 423 && !/support/i.test(detail)) {
235
+ message += " (locked: Groundfloor has paused or frozen this; contact Groundfloor support)";
236
+ }
237
+ return message;
238
+ }
239
+ function reasonHeader(res) {
240
+ const value = res.headers?.get?.(REASON_HEADER);
241
+ return value ? value.trim() : null;
242
+ }
207
243
  async function parseError(res) {
208
244
  let detail = res.statusText;
209
245
  try {
@@ -213,7 +249,12 @@ async function parseError(res) {
213
249
  }
214
250
  } catch {
215
251
  }
216
- return `${res.status} ${detail}`;
252
+ return formatCpErrorMessage(
253
+ res.status,
254
+ detail,
255
+ parseRetryAfter(res.headers?.get?.("retry-after")),
256
+ reasonHeader(res)
257
+ );
217
258
  }
218
259
  async function cpGet(apiUrl, path2) {
219
260
  const token = await getValidAccessToken();
@@ -224,7 +265,7 @@ async function cpGet(apiUrl, path2) {
224
265
  }
225
266
  });
226
267
  if (!res.ok) {
227
- throw new CpError(res.status, await parseError(res));
268
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
228
269
  }
229
270
  return await res.json();
230
271
  }
@@ -235,7 +276,7 @@ async function cpDelete(apiUrl, path2) {
235
276
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
236
277
  });
237
278
  if (!res.ok) {
238
- throw new CpError(res.status, await parseError(res));
279
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
239
280
  }
240
281
  }
241
282
  function queryString(params) {
@@ -272,7 +313,7 @@ async function getDeployment(apiUrl, workspaceId, coderunnerId) {
272
313
  );
273
314
  if (res.status === 204) return null;
274
315
  if (!res.ok) {
275
- throw new CpError(res.status, await parseError(res));
316
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
276
317
  }
277
318
  return await res.json();
278
319
  }
@@ -483,7 +524,16 @@ async function cpGetJson(apiUrl, token, path2) {
483
524
  }
484
525
  } catch {
485
526
  }
486
- throw new CpError(res.status, `${res.status} ${detail}`);
527
+ throw new CpError(
528
+ res.status,
529
+ formatCpErrorMessage(
530
+ res.status,
531
+ detail,
532
+ parseRetryAfter(res.headers?.get?.("retry-after")),
533
+ reasonHeader(res)
534
+ ),
535
+ reasonHeader(res)
536
+ );
487
537
  }
488
538
  return await res.json();
489
539
  }
@@ -552,7 +602,13 @@ the provider.
552
602
  port 25, 465, 587 or 2525), then **Send test email** (goes to their own
553
603
  verified address). API: \`PUT /v1/workspaces/{id}/auth\` with
554
604
  \`mail: {mode: "custom", ...}\`; test with
555
- \`POST /v1/workspaces/{id}/auth/mail/test\`.
605
+ \`POST /v1/workspaces/{id}/auth/mail/test\`. Over the sending limit it
606
+ returns 429 with \`Retry-After\`; while Groundfloor email is paused, 423. If
607
+ your own address is suppressed, or sending could not be checked, it returns
608
+ 200 with \`ok: false\` and a sentence in \`error\`. These answers carry an
609
+ \`X-Groundfloor-Reason\` header (\`sending_limited\`, \`sending_paused\`,
610
+ \`recipient_suppressed\`, \`send_failed\`); the per-user rate limit's 429
611
+ does not.
556
612
  - A failing custom server does not fall back to Groundfloor email; the send
557
613
  fails. Changing host or port needs the password again.
558
614
  - **Warning:** saving a \`mail\` block with no \`host\` (or
@@ -569,7 +625,12 @@ reset responses, and every user in \`GET /v1/workspaces/{id}/users\`, carry:
569
625
  - \`email_skipped_reason\`: \`smtp_not_configured\` (no email server set up for
570
626
  the workspace's sign-in email), \`send_failed\` (the server refused or
571
627
  failed), \`delivery_unconfirmed\` (timed out; it may still arrive),
572
- \`not_requested\` (sent with \`send_email: false\`), or \`null\`.
628
+ \`not_requested\` (sent with \`send_email: false\`), \`sending_limited\` (the
629
+ workspace or this address reached the sending limit; try later),
630
+ \`sending_paused\` (Groundfloor paused the workspace's sign-in email),
631
+ \`recipient_suppressed\` (the address bounced or reported spam), or \`null\`.
632
+ See "Email sending limits" below. Team invite responses carry the same
633
+ two fields.
573
634
  \`keycloak_smtp_missing\` is retired and no longer returned.
574
635
  - \`email_attempted_at\` (\`null\` = invited before delivery was recorded),
575
636
  \`invite_pending\`, \`password_change_required\`,
@@ -582,7 +643,10 @@ app-user tokens get 403):
582
643
 
583
644
  1. **Resend** (\`POST \u2026/users/{user_id}/resend-invite\`, pending users only):
584
645
  at most once per 60 s and 5 times per 24 h; re-inviting a pending user
585
- counts against the same limit (429 with \`Retry-After\`). If the reason is
646
+ counts against the same limit (429 with \`Retry-After\`). Over the sending
647
+ limit resend also returns 429 with \`Retry-After\` (seconds), told apart by
648
+ the header \`X-Groundfloor-Reason: sending_limited\`: wait that long. When sending is paused or the address is suppressed it returns 200
649
+ with \`email_sent: false\` and the reason. If the reason is
586
650
  \`send_failed\` or \`smtp_not_configured\`, fix email first (Branding \u2192
587
651
  Email sending, Send test email) or resending will fail the same way.
588
652
  2. **Temp password** (\`POST \u2026/users/{user_id}/temporary-password\`, pending
@@ -601,6 +665,40 @@ person who asked for them, tell them to share privately, and never write them
601
665
  to files, commits, logs or chat with anyone else. Once a user has set their
602
666
  own password, none of these apply (409): use reset-password.
603
667
 
668
+ ## Email sending limits
669
+
670
+ Workspace sign-in email (invite, resend, added-user and reset emails, the test
671
+ email) is limited per workspace to 200 per hour and 1,000 per 24 hours, and per
672
+ address to 5 per hour and 10 per 24 hours (rolling windows; Groundfloor can set
673
+ other workspace limits). Limits apply with Groundfloor email and your own
674
+ server. Portal Team invites are not limited.
675
+
676
+ - **Limited** (\`sending_limited\`, or 429 with \`Retry-After\` and
677
+ \`X-Groundfloor-Reason: sending_limited\`): invite and add
678
+ still create the user, without email. Wait and resend, or give access now
679
+ with **Copy link** or **Temp password**.
680
+ - **Paused** (\`sending_paused\`, test email 423): Groundfloor stopped the
681
+ workspace's Groundfloor email after too many bounces or spam reports. Only
682
+ Groundfloor support can resume it. Meanwhile use Copy link or Temp password,
683
+ or switch to **Your own email server** (pause applies to Groundfloor email
684
+ only).
685
+ - **Suppressed** (\`recipient_suppressed\`): the address bounced or reported
686
+ spam, so Groundfloor email will not send to it. Check the address with the
687
+ user. A workspace Admin or Owner can list suppressed addresses (masked) with
688
+ \`GET /v1/workspaces/{id}/mail/suppressions\` and clear one with
689
+ \`DELETE \u2026/mail/suppressions/{suppression_id}\` (optional body
690
+ \`{"email": "<full address>"}\` to clear it from the mail provider's bounce
691
+ and block lists too; the response's \`provider\` says what happened). An
692
+ address that reported spam (\`provider: spam_report_kept\`) stays on the
693
+ provider's spam list until Groundfloor support removes it, so mail to it may
694
+ still be dropped. If the test email says your own address is suppressed,
695
+ clear it the same way. Only clear an address the user confirms is correct
696
+ and wants mail.
697
+ - \`GET /v1/workspaces/{id}/mail/status\` (Admin or Owner) shows the mode,
698
+ \`state\` (\`active\` / \`paused\`), the limits, how many were sent in the
699
+ last hour and 24 hours, and the number of suppressed addresses.
700
+ \`workspace_context\` includes it as \`sign_in_email\`.
701
+
604
702
  ## Multi-tenant (customer companies inside one workspace)
605
703
 
606
704
  Turn on **Multi-tenant** in Settings to get a Tenants tab: one tenant per
@@ -1653,7 +1751,7 @@ var DOC_CATALOG = [
1653
1751
  id: "authentication",
1654
1752
  title: "Workspace Authentication",
1655
1753
  path: "/docs/access/app-authentication",
1656
- summary: "App end users sign in only to the app via the workspace App Authentication realm; no Groundfloor console account or workspace membership. Modes none / Groundfloor / external, users and roles, SSO, multi-tenant, sign-in email (Groundfloor email by default, or your own server). Invite delivery (email_sent, email_skipped_reason) and what to do when an invite isn't sent: resend, generated temporary password, one-time invite link; 409 for people who can already sign in. App users' tokens work only on the end-user routes. Optional; not required to create Apps or Coderunners. Separate from portal Team.",
1754
+ summary: "App end users sign in only to the app via the workspace App Authentication realm; no Groundfloor console account or workspace membership. Modes none / Groundfloor / external, users and roles, SSO, multi-tenant, sign-in email (Groundfloor email by default, or your own server). Invite delivery (email_sent, email_skipped_reason incl. sending_limited / sending_paused / recipient_suppressed) and what to do when an invite isn't sent: resend, generated temporary password, one-time invite link; 409 for people who can already sign in. Email sending limits (200/hour and 1,000/day per workspace, 5/hour and 10/day per address), 429 Retry-After and 423 paused (X-Groundfloor-Reason header), the test email's ok:false when suppressed, suppressed addresses (spam reports cleared at the provider only by support), mail status. App users' tokens work only on the end-user routes. Optional; not required to create Apps or Coderunners. Separate from portal Team.",
1657
1755
  embedded: APP_AUTHENTICATION_GUIDE,
1658
1756
  lastUpdated: "2026-10-14"
1659
1757
  },
@@ -1986,19 +2084,25 @@ server.tool(
1986
2084
  );
1987
2085
  server.tool(
1988
2086
  "workspace_context",
1989
- "Live context for the selected workspace: session, coderunners, apps, environments (with the default one), feature flags, dataplane (access layer, not a database), databases_howto, suggested gf commands. Use docs_get id coderunner before deploying; id databases for data work; id feature-flags for toggles/config; id css-and-styling for own CSS framework.",
2087
+ "Live context for the selected workspace: session, coderunners, apps, environments (with the default one), feature flags, dataplane (access layer, not a database), sign_in_email (App authentication email mode, paused or active, limits and use; null without workspace administer), databases_howto, suggested gf commands. Use docs_get id coderunner before deploying; id databases for data work; id feature-flags for toggles/config; id css-and-styling for own CSS framework.",
1990
2088
  { workspace_id: wsId },
1991
2089
  READ_ONLY,
1992
2090
  async ({ workspace_id }) => {
1993
2091
  try {
1994
2092
  const c = await cfg(workspace_id);
1995
2093
  const claims = decodeJwt(c.token) ?? {};
1996
- const [coderunners, apps, dataplane, environments, flags] = await Promise.all([
2094
+ const [coderunners, apps, dataplane, environments, flags, mail] = await Promise.all([
1997
2095
  listCoderunners(c.apiUrl, c.workspaceId),
1998
2096
  listApps(c.apiUrl, c.workspaceId).catch(() => ({ apps: [] })),
1999
2097
  getDataplaneStatus(c.apiUrl, c.workspaceId).catch(() => null),
2000
2098
  listWorkspaceEnvironments(c.apiUrl, c.workspaceId).catch(() => []),
2001
- listFlags(c.apiUrl, c.workspaceId).catch(() => null)
2099
+ listFlags(c.apiUrl, c.workspaceId).catch(() => null),
2100
+ // Workspace administer only; null for other callers or older cells.
2101
+ cpGetJson(
2102
+ c.apiUrl,
2103
+ c.token,
2104
+ `/v1/workspaces/${encodeURIComponent(c.workspaceId)}/mail/status`
2105
+ ).catch(() => null)
2002
2106
  ]);
2003
2107
  return text({
2004
2108
  session: {
@@ -2020,6 +2124,15 @@ server.tool(
2020
2124
  status: app.status
2021
2125
  })),
2022
2126
  dataplane,
2127
+ sign_in_email: mail ? {
2128
+ mode: mail.mode,
2129
+ state: mail.state,
2130
+ paused_reason: mail.paused_reason ?? null,
2131
+ limits: mail.limits,
2132
+ used: mail.used,
2133
+ suppressed_addresses: mail.suppressed_addresses,
2134
+ docs_get: "authentication"
2135
+ } : null,
2023
2136
  environments: environments.map((env) => ({
2024
2137
  id: env.id,
2025
2138
  name: env.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/mcp",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Groundfloor MCP — inspect workspace state and docs; take actions with the gf CLI.",
5
5
  "type": "module",
6
6
  "bin": {