@groundfloorcloud/mcp 0.1.16 → 0.2.1

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 (3) hide show
  1. package/README.md +22 -8
  2. package/dist/index.js +484 -94
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -63,6 +63,12 @@ which node
63
63
 
64
64
  The `command` must be your Node (from `which node`), not `npx` and not Cursor’s helper Node. Do not put a JWT in mcp.json.
65
65
 
66
+ ### Environment overrides
67
+
68
+ Use the prefixed names: `GROUNDFLOOR_API_URL`, `GROUNDFLOOR_WORKSPACE_ID`, `GROUNDFLOOR_TOKEN` (CI only; normally the `gf login` session is used).
69
+
70
+ **Deprecated:** the unprefixed `CP_API`, `WORKSPACE_ID` and `TOKEN` are still honoured as fallbacks, but they collide with unrelated variables in a shell. When one of them supplies a value the server writes a one-line notice to stderr (variable name only, never the value) and they will be removed in a later release.
71
+
66
72
  ## Tools
67
73
 
68
74
  | Tool | Role |
@@ -71,30 +77,38 @@ The `command` must be your Node (from `which node`), not `npx` and not Cursor’
71
77
  | `workspaces_list` | List; switch with `gf workspaces use` |
72
78
  | `coderunner_list` / `_get_status` / `_get_logs` / `_process_log` | Inspect workloads (Process Log does not need a job_id) |
73
79
  | `environments_list` | List workspace environments |
74
- | `coderunner_cancel_deployment` | Delete current (or given) deployment |
80
+ | `coderunner_cancel_deployment` | **Destructive.** Deletes one deployment (`deployment_id` is required); if it is the live one, the service goes down. Always the selected workspace (no per-call `workspace_id`) |
75
81
  | `apps_list` / `apps_get` | Inspect product Apps |
76
- | `secrets_list` / `files_list` / `domains_list` / `dataplane_status` | Inspect (no secrets values) |
77
- | `docs_list` / `docs_get` | Product docs (`coderunner`, `shell`, `design-standard`, `css-and-styling`, `databases` are embedded; `access` and `m2m` are live pages) |
78
- | `suggest_cli` | Command to run: login, deploy, init_app, publish_app, m2m_token, … |
82
+ | `secrets_list` / `files_list` / `dataplane_status` | Inspect (no secrets values) |
83
+ | `domains_list` | Custom domains of an App's Coderunner (`app_id` or `coderunner_id`). Domains exist on Apps, not standalone Coderunners |
84
+ | `quota_get` / `billing_summary` | Plan quota and billing for the workspace's **account** (account id is not the workspace id; billing needs Owner or Billing admin) |
85
+ | `docs_list` / `docs_get` | Product docs (`coderunner`, `coderunner-runtime`, `shell`, `design-standard`, `css-and-styling`, `databases`, `vault`, `feature-flags`, `authentication` are embedded; `access` and `m2m` are live pages, with an `offset` parameter to read past the 12,000-character page limit) |
86
+ | `suggest_cli` | Command to run: login, deploy, create_app, init_app, publish_app, m2m_token, … |
79
87
 
80
88
  Never create `app_kind=coderunner`. Deploy with `gf deploy`.
81
89
 
82
- **Shell apps:** `docs_get` id `shell`. Always `gf apps init --slug <portal-slug>`,
83
- then `npm run release` and `gf apps publish --path release.zip`. Do not invent
90
+ **Shell apps:** `docs_get` id `shell`. Always `gf apps create --name … --slug <portal-slug> --kind shell_federated`,
91
+ then `gf apps init --slug <portal-slug>`, `npm install`, `npm run release` and `gf apps publish --path release.zip`. Do not invent
84
92
  `App.tsx` / `vite.config`, zip `dist/`, or upload only `remoteEntry.js`.
85
93
  Own CSS framework: `docs_get` id `css-and-styling` (`FederatedStyleRoot`).
86
94
 
95
+ **App end users:** `docs_get` id `authentication`. They sign in only to the app via the workspace App Authentication realm; they have no Groundfloor console account or workspace membership.
96
+
97
+ **Tool annotations:** every tool except `coderunner_cancel_deployment` is marked `readOnlyHint`; the cancel tool is marked `destructiveHint`.
98
+
87
99
  **Databases:** `docs_get` id `databases`. Dataplane is not a database. Shared
88
100
  PostgreSQL is ready through Dataplane; a dedicated PostgreSQL is what you can
89
101
  deploy today. No other engines are offered.
90
102
 
91
- **Coderunner how-to** is embedded: `docs_get` id `coderunner` (also
92
- `coderunner-overview`). That page covers:
103
+ **Coderunner how-to** is embedded: `docs_get` id `coderunner`. The former
104
+ `coderunner-overview` id is no longer listed by `docs_list`, but `docs_get`
105
+ still accepts it as an alias and returns the same page. That page covers:
93
106
 
94
107
  - Types: `function`, `job`, `schedule`, `service` (**also called Deployment**)
95
108
  - App authentication is optional (end-user login); not required to create a coderunner
96
109
  - **Dockerfile is required** for `service` / Deployment
97
110
  - Runtime contract: Node `scripts.start`, listen on `PORT`/8080, bind `0.0.0.0` (`docs_get` id `coderunner-runtime`)
111
+ - Handler signature `(context, payload)` and the HTTP return shape, plus verified limits
98
112
  - Local development against live vault/files/secrets (`gf env`; as the workload use `gf m2m token`, not `gf token`)
99
113
 
100
114
  `workspace_context` includes a `coderunner_howto` summary so agents see this
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
8
  import { z } from "zod";
7
9
 
8
10
  // ../cli/src/auth/oauth.ts
@@ -95,7 +97,7 @@ var EXPIRY_SKEW_MS = 3e4;
95
97
  var NotLoggedInError = class extends Error {
96
98
  constructor() {
97
99
  super(
98
- "Not logged in. Set GROUNDFLOOR_TOKEN, or run `gf login` first."
100
+ "Not logged in. Set GROUNDFLOOR_TOKEN to a Groundfloor access token (e.g. from `gf m2m token`), or run `gf login` first."
99
101
  );
100
102
  this.name = "NotLoggedInError";
101
103
  }
@@ -129,11 +131,26 @@ function sessionFromTokens(tokens, ctx) {
129
131
  function isFresh(session) {
130
132
  return Date.now() < session.expiresAt - EXPIRY_SKEW_MS;
131
133
  }
134
+ function isExpiredJwt(token) {
135
+ const exp = decodeJwt(token)?.exp;
136
+ return typeof exp === "number" && Date.now() >= exp * 1e3 - EXPIRY_SKEW_MS;
137
+ }
132
138
  async function getValidAccessToken() {
133
139
  if (accessTokenProvider) {
134
140
  return accessTokenProvider();
135
141
  }
142
+ const envToken = process.env.GROUNDFLOOR_TOKEN?.trim();
143
+ let envTokenExpired = false;
144
+ if (envToken) {
145
+ if (!isExpiredJwt(envToken)) return envToken;
146
+ envTokenExpired = true;
147
+ }
136
148
  const session = await readAuth();
149
+ if (envTokenExpired && (!session || !session.accessToken)) {
150
+ throw new Error(
151
+ "GROUNDFLOOR_TOKEN has expired and there is no saved `gf login` session. Mint a fresh token (e.g. `gf m2m token`) or run `gf login`."
152
+ );
153
+ }
137
154
  if (!session || !session.accessToken) {
138
155
  throw new NotLoggedInError();
139
156
  }
@@ -305,17 +322,6 @@ function getProcessLogs(apiUrl, workspaceId, query = {}) {
305
322
  `/v1/workspaces/${encodeURIComponent(workspaceId)}/logs${qs ? `?${qs}` : ""}`
306
323
  );
307
324
  }
308
- function domainsBase(workspaceId, coderunnerId) {
309
- return `/v1/workspaces/${encodeURIComponent(
310
- workspaceId
311
- )}/coderunners/${encodeURIComponent(coderunnerId)}/domains`;
312
- }
313
- function listDomains(apiUrl, workspaceId, coderunnerId) {
314
- return cpGet(
315
- apiUrl,
316
- domainsBase(workspaceId, coderunnerId)
317
- );
318
- }
319
325
  function wsPath(workspaceId, suffix) {
320
326
  return `/v1/workspaces/${encodeURIComponent(workspaceId)}${suffix}`;
321
327
  }
@@ -411,13 +417,32 @@ function trimSlash(url) {
411
417
  }
412
418
  var MISSING_TOKEN = "Not signed in. Run `gf login` (or `gf login --stage`) so MCP can use ~/.groundfloor. Do not paste JWTs into Cursor MCP config.";
413
419
  var MISSING_WORKSPACE = "No workspace selected. Run `gf workspaces` then `gf workspaces use <uuid>`, or pass workspace_id on this tool.";
420
+ var reportedSources = /* @__PURE__ */ new Set();
421
+ function noteDeprecatedEnv(setting, used, preferred) {
422
+ const key = `${setting}:${used}`;
423
+ if (reportedSources.has(key)) return;
424
+ reportedSources.add(key);
425
+ console.error(
426
+ `groundfloor MCP: ${setting} taken from deprecated env var ${used}; set ${preferred} instead (unprefixed names will be removed).`
427
+ );
428
+ }
429
+ function pick(setting, preferred, deprecated) {
430
+ const a = process.env[preferred];
431
+ if (a) return a;
432
+ const b = process.env[deprecated];
433
+ if (b) {
434
+ noteDeprecatedEnv(setting, deprecated, preferred);
435
+ return b;
436
+ }
437
+ return "";
438
+ }
414
439
  async function resolveMcpConfig(overrides) {
415
440
  const file = await resolveConfig();
416
441
  const apiUrl = trimSlash(
417
- process.env.GROUNDFLOOR_API_URL || process.env.CP_API || file.apiUrl || CELLS.production.apiUrl
442
+ pick("API URL", "GROUNDFLOOR_API_URL", "CP_API") || file.apiUrl || CELLS.production.apiUrl
418
443
  );
419
- const workspaceId = overrides?.workspaceId || process.env.GROUNDFLOOR_WORKSPACE_ID || process.env.WORKSPACE_ID || file.workspaceId || "";
420
- const envToken = (process.env.GROUNDFLOOR_TOKEN || process.env.TOKEN || "").trim();
444
+ const workspaceId = overrides?.workspaceId || pick("workspace id", "GROUNDFLOOR_WORKSPACE_ID", "WORKSPACE_ID") || file.workspaceId || "";
445
+ const envToken = pick("access token", "GROUNDFLOOR_TOKEN", "TOKEN").trim();
421
446
  if (envToken) {
422
447
  setAccessTokenProvider(async () => envToken);
423
448
  } else {
@@ -444,6 +469,88 @@ async function resolveMcpConfig(overrides) {
444
469
  return { apiUrl, workspaceId, token };
445
470
  }
446
471
 
472
+ // src/cp-get.ts
473
+ async function cpGetJson(apiUrl, token, path2) {
474
+ const res = await fetch(`${apiUrl}${path2}`, {
475
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
476
+ });
477
+ if (!res.ok) {
478
+ let detail = res.statusText;
479
+ try {
480
+ const body = await res.json();
481
+ if (body?.detail !== void 0) {
482
+ detail = formatCpErrorDetail(body.detail, res.statusText);
483
+ }
484
+ } catch {
485
+ }
486
+ throw new CpError(res.status, `${res.status} ${detail}`);
487
+ }
488
+ return await res.json();
489
+ }
490
+
491
+ // src/app-authentication-guide.ts
492
+ var APP_AUTHENTICATION_GUIDE = `
493
+ # App authentication (end users of a customer's app)
494
+
495
+ ## The rule
496
+
497
+ People who use an app built on Groundfloor sign in **only to that app**,
498
+ through the workspace's **App authentication** realm. They do **not** get a
499
+ Groundfloor console (Client Portal) account and are **not** workspace members.
500
+ Portal team roles (Owner, Admin, Writer) are a separate system and never apply
501
+ to app end users. Do not build, suggest or provision Portal logins for them.
502
+
503
+ App authentication is **optional**. It is not needed to create Apps or deploy
504
+ Coderunners, and it never changes how the team signs in to the Portal.
505
+
506
+ ## Sign-in modes (Portal: Administer \u2192 App authentication, Admin or Owner)
507
+
508
+ | Mode | End users sign in with | Use when |
509
+ |---|---|---|
510
+ | None | Nothing | Apps are public or handle access themselves |
511
+ | Groundfloor | A hosted sign-in page; users, roles, SSO and branding managed in the Portal | You want sign-in without running an identity provider |
512
+ | External | Your own OpenID Connect provider (Okta, Entra ID, Auth0) | Users already have accounts there |
513
+
514
+ For External, register \`https://<your-shell-site>/*\` as a redirect address with
515
+ the provider.
516
+
517
+ ## Users and roles (Groundfloor mode)
518
+
519
+ - **Invite user**: they set their own password (email needs Branding set up).
520
+ - **Add user**: you set a password now; no email is sent.
521
+ - Each user has a role: built-in \`viewer\`, \`user\`, \`admin\`, or a custom role
522
+ created on the **Roles** tab. Roles here are unrelated to Portal team roles.
523
+ - The role decides what the user reaches once granted on **Site access**
524
+ (Data Vault collections, workspace-wide Files, App launch). The built-in
525
+ \`admin\` role is granted like any other role: new collections start with
526
+ an \`admin\` grant and new workspaces give it Files write, and removing a
527
+ grant removes admins' access. Keep \`admin\` in every Access PUT unless the
528
+ user means to remove it. A signed-in user with no recognised role
529
+ gets 403 on role-gated endpoints (Vault, feature flags).
530
+ - SSO (OIDC or SAML) can be connected per workspace; role names in the
531
+ provider's tokens must match the roles granted on Site access.
532
+
533
+ ## Multi-tenant (customer companies inside one workspace)
534
+
535
+ Turn on **Multi-tenant** in Settings to get a Tenants tab: one tenant per
536
+ customer company, invite-only onboarding, per-tenant SSO. The user's token
537
+ carries the tenant in the \`organization\` claim; the app must keep each tenant's
538
+ data apart using that claim. Site access, data, files and M2M tokens stay shared
539
+ across the workspace.
540
+
541
+ ## Turning it off
542
+
543
+ Switching to None or External keeps users, roles and settings (switch back and
544
+ they return). To remove them, turn sign-in off first, then **Delete Groundfloor
545
+ login**. Access grants and M2M tokens are kept.
546
+
547
+ ## In a Shell app
548
+
549
+ The signed-in user's JWT comes from \`window.__GF_GET_ACCESS_TOKEN__()\`. See
550
+ \`docs_get\` id \`shell\`. Unattended workloads use M2M instead (\`docs_get\` id
551
+ \`m2m\`), not an end-user login.
552
+ `.trim();
553
+
447
554
  // src/coderunner-runtime.ts
448
555
  var CODERUNNER_RUNTIME_BODY = `
449
556
  The contract depends on the workload type.
@@ -469,9 +576,58 @@ The contract depends on the workload type.
469
576
  (\`Cannot find module './handler'\`, \`MODULE_NOT_FOUND\`) and the deployment
470
577
  ends **Failed (status 4)**. Check \`gf coderunner logs -c <id>\`.
471
578
 
472
- For the handler's arguments and, for functions, the HTTP event/return shape,
473
- see the runtime docs (https://docs.groundfloor.cloud/docs/deploy). Do not guess
474
- the argument order.
579
+ ### The handler signature
580
+
581
+ Arguments are **\`(context, payload)\`** in that order. Export a function named
582
+ \`handler\` (CommonJS \`module.exports = { handler }\`; an ES module
583
+ \`export async function handler(context, payload)\` also works):
584
+
585
+ \`\`\`js
586
+ const handler = async (context, payload) => {
587
+ // your code
588
+ };
589
+
590
+ module.exports = { handler };
591
+ \`\`\`
592
+
593
+ - \`payload\` is the JSON input: the request body for a function, the run's input
594
+ for a job or schedule. Guard for a missing body (\`payload?.name\`).
595
+ - \`context\` is reserved for runtime information. Do not rely on its contents.
596
+
597
+ ### Function return shape (HTTP)
598
+
599
+ Whatever the handler returns becomes the response; you may return anything.
600
+ The documented convention, used by every example, is:
601
+
602
+ \`\`\`js
603
+ const handler = async (context, payload) => {
604
+ if (!payload?.name) {
605
+ return {
606
+ statusCode: 400,
607
+ body: { error: "name is required" },
608
+ headers: { "content-type": "application/json" },
609
+ };
610
+ }
611
+ return {
612
+ statusCode: 200,
613
+ body: { greeting: \`Hello, \${payload.name}\` },
614
+ headers: { "content-type": "application/json" },
615
+ };
616
+ };
617
+
618
+ module.exports = { handler };
619
+ \`\`\`
620
+
621
+ Use \`200\`/\`201\` for success, \`400\` for bad input, \`401\`/\`403\` for auth,
622
+ \`404\` for not found, \`500\` for unexpected errors (no internals in the body).
623
+ Callers POST JSON to the Deployment URL with a bearer token, or use
624
+ \`gf coderunner run -c <slug> --payload '<json>'\`.
625
+
626
+ ### Jobs and schedules: return value and failure
627
+
628
+ A job or schedule needs no return value. If an error **escapes** the handler the
629
+ run is marked **Failed**; if you catch it, the run succeeds and handling it is
630
+ up to you.
475
631
 
476
632
  ## Node: service (or any Dockerfile that runs \`npm start\`)
477
633
 
@@ -480,7 +636,7 @@ A \`service\` **requires** a root Dockerfile; you own \`CMD\`. If it is
480
636
  and the process must follow the HTTP contract:
481
637
 
482
638
  \`\`\`dockerfile
483
- FROM node:18-alpine
639
+ FROM node:22-alpine
484
640
  WORKDIR /app
485
641
  COPY . .
486
642
  EXPOSE 8080
@@ -505,8 +661,14 @@ Offer GET \`/\` or \`/health\` for the probe. A Dockerfile whose \`CMD\` is not
505
661
 
506
662
  ## Python
507
663
 
508
- Bind \`0.0.0.0\` and \`PORT\` (default 8080), for example
509
- \`uvicorn main:app --host 0.0.0.0 --port \${PORT:-8080}\`.
664
+ Function, job and schedule: \`handler.py\` at the ZIP root (with
665
+ \`requirements.txt\`) defining \`def handler(context, payload):\`, same argument
666
+ order, same return convention (\`statusCode\`, \`body\`,
667
+ \`headers\`; an exception escaping the handler fails a job run). \`print\` goes
668
+ to the Process Log.
669
+
670
+ A Dockerfile-based service binds \`0.0.0.0\` and \`PORT\` (default 8080), for
671
+ example \`uvicorn main:app --host 0.0.0.0 --port \${PORT:-8080}\`.
510
672
 
511
673
  ## Jobs and schedules
512
674
 
@@ -580,8 +742,9 @@ Pick **one** type per coderunner. Changing type later means a new coderunner.
580
742
  \`/app/main.js\` that does \`require('./handler')\`; \`npm start\` is **not** run and
581
743
  \`scripts.start\` is not required. Missing \`handler.js\` \u2192 crash loop
582
744
  (\`Cannot find module './handler'\`), deployment **Failed (status 4)**.
583
- \`gf deploy\` fails fast on this. Handler arguments and the HTTP event/return
584
- shape: see the runtime docs (\`docs_get\` id \`coderunner-runtime\`, docs.groundfloor.cloud/docs/deploy).
745
+ \`gf deploy\` fails fast on this. Handler arguments are \`(context, payload)\`; the
746
+ signature and the HTTP return shape (\`{ statusCode, body, headers }\`) are in the
747
+ Runtime contract section above.
585
748
  - Node with your own Dockerfile (\`CMD ["npm","start"]\`) instead: listen on
586
749
  \`process.env.PORT || 8080\`, bind **\`0.0.0.0\`** (health GET \`/\` or \`/health\`), and
587
750
  define \`scripts.start\`.
@@ -613,7 +776,7 @@ Pick **one** type per coderunner. Changing type later means a new coderunner.
613
776
  - Node example:
614
777
 
615
778
  \`\`\`dockerfile
616
- FROM node:18-alpine
779
+ FROM node:22-alpine
617
780
  WORKDIR /app
618
781
  COPY package*.json ./
619
782
  RUN npm ci --omit=dev
@@ -736,9 +899,28 @@ do the fetch yourself; keep using \`--secret\` injection for SDKs that only
736
899
  read a literal \`process.env\` value. Either way, cache the revealed value
737
900
  in your process rather than calling \`reveal\` on every request.
738
901
 
902
+ ## Limits an agent will hit
903
+
904
+ Verified against the control plane source; anything not listed here is not a
905
+ documented limit.
906
+
907
+ - **M2M token endpoint**: 20 requests per minute (per client and per source).
908
+ Cache the token until near expiry; do not mint per request.
909
+ - **Vault collection list/query**: \`limit\` 1-500 (default 50).
910
+ - **Feature flag string values**: at most 10,000 characters.
911
+ - **\`docs_get\` live pages**: truncated at 12,000 characters; use \`offset\` to
912
+ read the rest.
913
+
914
+ Plan quota and usage are account-scoped, not workspace-scoped. The **account
915
+ id is not the workspace id**: read \`account_id\` from \`workspaces_list\`. If a
916
+ deploy is refused or you need to know what is in use, call \`quota_get\`.
917
+ \`billing_summary\` shows spend, and only Owners and Billing admins can read it
918
+ (others get 403).
919
+
739
920
  ## Agent actions
740
921
 
741
- Inspect: \`workspace_context\`, \`docs_get\` id \`coderunner\` or \`coderunner-runtime\`, \`suggest_cli\`.
922
+
923
+ Inspect: \`workspace_context\`, \`quota_get\`, \`docs_get\` id \`coderunner\` or \`coderunner-runtime\`, \`suggest_cli\`.
742
924
  Act: \`gf deploy\`, \`gf coderunner run|status|logs\`, \`gf m2m token\`, \`gf identities ls|mint\`, \`gf api\`. MCP does not deploy.
743
925
  `.trim();
744
926
 
@@ -911,7 +1093,9 @@ One endpoint for every runtime:
911
1093
 
912
1094
  Weak \`ETag\` + \`Cache-Control: private, no-cache\`. Send \`If-None-Match\` \u2192 304.
913
1095
  Poll no faster than every 30s (the kit uses 60s). No push channel yet.
914
- Flag values are not secrets \u2014 every signed-in end user can read them.
1096
+ Flag values are not secrets, but the endpoint is role-gated: only users with a
1097
+ recognised site role can read them (see Permission below); a signed-in user
1098
+ with no role gets 403.
915
1099
 
916
1100
  ### Shell federated app (browser)
917
1101
 
@@ -1026,8 +1210,9 @@ routes (omit it or use \`false\` otherwise \u2014 never \`null\`). \`visibility:
1026
1210
 
1027
1211
  ## suggest_cli
1028
1212
 
1213
+ - create_app \u2192 \`gf apps create --name "<App Name>" --slug <portal-slug> --kind shell_federated\` (register the App first; publish fails with App not found otherwise)
1029
1214
  - init_app \u2192 \`gf apps init --slug <portal-slug>\`
1030
- - publish_app \u2192 init + npm install + npm run release + gf apps publish --path release.zip
1215
+ - publish_app \u2192 create + init + npm install + npm run release + gf apps publish --path release.zip
1031
1216
  `;
1032
1217
 
1033
1218
  // src/vault-guide.ts
@@ -1074,14 +1259,63 @@ Example:
1074
1259
  Or use \`gf api -X POST /v1/workspaces/{workspace_id}/vault/collections --body '{...}'\`.
1075
1260
 
1076
1261
  \`GET \u2026/vault/collections\` lists schema-registered collections. Implicit
1077
- tables created by the first \`POST \u2026/vault/{collection}\` appear when you pass
1078
- \`include_implicit=true\` (or after a schema create).
1262
+ tables created by the first \`POST \u2026/vault/collections/{collection}\` appear when
1263
+ you pass \`include_implicit=true\` (or after a schema create).
1264
+
1265
+ ## Records: routes
1266
+
1267
+ All under \`/v1/workspaces/{workspace_id}/vault\`:
1268
+
1269
+ | Method and path | Purpose |
1270
+ |---|---|
1271
+ | \`POST /collections/{collection}/query\` | Filtered, paginated read |
1272
+ | \`GET /collections/{collection}/count\` | Count; optional \`filter\` query param (JSON object) |
1273
+ | \`POST /collections/{collection}\` | Create one record, body \`{"record": {...}}\`, 201, returns \`{"document": {...}}\` |
1274
+ | \`PUT /collections/{collection}/{document_id}\` | Update one record, body \`{"record": {...}}\`, returns \`{"document": {...}}\` |
1275
+ | \`DELETE /collections/{collection}/{document_id}\` | Delete one record, 204 |
1276
+ | \`GET /collections/{collection}/schema\` | Schema |
1277
+ | \`GET\`/\`PUT /collections/{collection}/acl\` | Collection ACL |
1079
1278
 
1080
- ## Query totals
1279
+ There is **no** \`/vault/{collection}\` route; always include \`/collections/\`.
1280
+ \`?connection=\` picks the database (default \`postgresql\`).
1081
1281
 
1082
- \`POST \u2026/vault/{collection}/query\` \`total\` is the **unpaged** match count.
1083
- \`limit\` only sizes \`records\`. Do not treat \`total === records.length\` as
1084
- the full table size.
1282
+ ## Query: request and response
1283
+
1284
+ Request body for \`POST \u2026/collections/{collection}/query\` (all optional):
1285
+ \`filter\`, \`sort\` (e.g. \`["-created_at", "name"]\`), \`projection\`,
1286
+ \`limit\` (1-500, default 50), \`offset\` (default 0), \`connection\`.
1287
+
1288
+ Response:
1289
+
1290
+ \`\`\`json
1291
+ { "items": [ {...} ], "total": 123, "has_more": true, "next_offset": 50 }
1292
+ \`\`\`
1293
+
1294
+ - The array key is **\`items\`** (not \`records\`).
1295
+ - \`total\` is the match count from Dataplane (see below for \`$text\`) and is
1296
+ **null** when upstream gave no count; it is never filled in from the page
1297
+ length. Do not treat \`total === items.length\` as the full table size.
1298
+ - Page with \`next_offset\` while \`has_more\` is true; \`next_offset\` is null
1299
+ on the last page.
1300
+
1301
+ ## Substring search (\`$text\` / \`$search\`)
1302
+
1303
+ \`{ "filter": { "$text": "alice" } }\` is a case-insensitive substring match
1304
+ over every field value. Control Plane runs it itself by scanning the caller's
1305
+ ACL-filtered view, at most \`VAULT_TEXT_SEARCH_MAX_SCAN_ROWS\` rows per request
1306
+ (default 10 000).
1307
+
1308
+ - Plain query: for \`$text\`, \`total\` is computed by Control Plane (not
1309
+ Dataplane). It is exact when the collection fits under the cap; past the cap
1310
+ it is **null** and the results cover only the scanned rows.
1311
+ - Large collections: send \`"chunked_scan": true\`, then
1312
+ \`"scan_cursor": <scan.next_cursor>\` with the same filter/sort until
1313
+ \`scan.complete\` is true. Each response has a \`scan\` block
1314
+ (\`complete\`, \`next_cursor\`, \`scanned_rows\`, \`total_rows\`); the exact
1315
+ match total is the sum of \`items\` across chunks. The cursor is not a
1316
+ snapshot \u2014 concurrent writes can skip or repeat rows.
1317
+ - \`GET \u2026/count\` with \`$text\` uses the plain bounded scan and returns 400
1318
+ past the cap; use the chunked query instead.
1085
1319
 
1086
1320
  ## Filters
1087
1321
 
@@ -1095,6 +1329,18 @@ Grant site roles on **Administer \u2192 Access**. Workload identities get
1095
1329
  collection grants via \`PUT \u2026/vault/collections/{name}/acl\`. M2M is Team
1096
1330
  Writer (workspace-wide), not an Access subject.
1097
1331
 
1332
+ - \`PUT \u2026/acl\` is a **full replace**, saved exactly as sent. \`GET\` the
1333
+ current grants first and send them all back with your change: every site
1334
+ role grant, \`admin\` included, and every workload identity grant. Anything
1335
+ you leave out is removed.
1336
+ - The built-in site role \`admin\` is granted like any other role. New
1337
+ collections start with \`admin\` \u2192 \`delete\`; if a PUT leaves \`admin\`
1338
+ out, admins lose access to that collection. Never drop it unless the user
1339
+ asked for that.
1340
+ - Only builders (team members with \`ddl\`, or the Client Portal) read or
1341
+ change ACLs. A site (app end user) token gets 403 on every ACL route,
1342
+ even with the \`admin\` role.
1343
+
1098
1344
  ## Identities vs M2M
1099
1345
 
1100
1346
  - Unattended Coderunners: attach an M2M client, \`gf deploy\`, then
@@ -1114,6 +1360,17 @@ schedules.
1114
1360
 
1115
1361
  Do not \`--env SENDGRID_SECRET=\u2026\`. That is **422**.
1116
1362
  \`gf secrets set SENDGRID_SECRET\` then \`gf deploy --secret SENDGRID_SECRET\`.
1363
+
1364
+ **Caveat:** \`--secret\` injection needs the platform-wide Control Plane setting
1365
+ \`CODERUNNER_INJECT_ENV_SECRETS\`, which is **off by default** and not
1366
+ configurable per workspace. When it is off the secret is not injected. Fallback:
1367
+ give the Coderunner's M2M client the \`secrets:read\` scope (Portal
1368
+ **Administer \u2192 M2M tokens**) and read the value at runtime with
1369
+ \`GET /v1/workspaces/{workspace_id}/secrets/{key}\` using the M2M bearer token.
1370
+ It is read-only, live (no redeploy on rotation) and workspace-wide. Details in
1371
+ \`docs_get\` id \`coderunner\`. The deployed value of the setting cannot be
1372
+ checked from the repo; if \`--secret\` fails or the variable is empty, use the
1373
+ fallback rather than plaintext \`--env\`.
1117
1374
  `.trim();
1118
1375
 
1119
1376
  // src/version.ts
@@ -1149,7 +1406,7 @@ var DOC_CATALOG = [
1149
1406
  path: "/docs/deploy",
1150
1407
  summary: "Workload types (function, job, schedule, service/Deployment), runtime contract (handler.js for function/job/schedule; npm start, PORT 8080 for service), Dockerfile for services, Coderunner identities for Vault, App authentication optional, local live data, gf deploy.",
1151
1408
  embedded: CODERUNNER_GUIDE,
1152
- lastUpdated: "2026-10-02"
1409
+ lastUpdated: "2026-10-08"
1153
1410
  },
1154
1411
  {
1155
1412
  id: "coderunner-runtime",
@@ -1157,27 +1414,21 @@ var DOC_CATALOG = [
1157
1414
  path: "/docs/deploy",
1158
1415
  summary: "What the platform expects your code to do: Node handler.js for function/job/schedule, npm start + PORT/8080 + bind 0.0.0.0 for service/Dockerfile, Python bind.",
1159
1416
  embedded: CODERUNNER_RUNTIME_CONTRACT,
1160
- lastUpdated: "2026-10-02"
1161
- },
1162
- {
1163
- id: "coderunner-overview",
1164
- title: "Coderunner overview",
1165
- path: "/docs/deploy",
1166
- summary: "Architecture and lifecycle. Prefer docs_get id coderunner for type usage, Dockerfile, and create prerequisites.",
1167
- embedded: CODERUNNER_GUIDE,
1168
- lastUpdated: "2026-10-02"
1417
+ lastUpdated: "2026-10-08"
1169
1418
  },
1170
1419
  {
1171
1420
  id: "authentication",
1172
1421
  title: "Workspace Authentication",
1173
1422
  path: "/docs/access/app-authentication",
1174
- summary: "Workspace site auth (Groundfloor or external) is optional end-user login. Leaving Groundfloor keeps the realm (disabled) until Delete Groundfloor login. Separate from portal Team. Not required to create Apps or Coderunners."
1423
+ 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. Optional; not required to create Apps or Coderunners. Separate from portal Team.",
1424
+ embedded: APP_AUTHENTICATION_GUIDE,
1425
+ lastUpdated: "2026-10-12"
1175
1426
  },
1176
1427
  {
1177
1428
  id: "access",
1178
1429
  title: "Access",
1179
1430
  path: "/docs/access/site-access",
1180
- summary: "Grant site roles Data Vault collections, workspace-wide Files, and Apps launch. Admin is implicit. M2M is not an Access subject. Team membership is unchanged."
1431
+ summary: "Grant site roles Data Vault collections, workspace-wide Files, and Apps launch. The built-in admin role is granted like any other role; every Access PUT is a full replace, so include admin and the existing grants or they are removed. Only builders manage Access; app end users get 403. M2M is not an Access subject. Team membership is unchanged."
1181
1432
  },
1182
1433
  {
1183
1434
  id: "m2m",
@@ -1197,7 +1448,7 @@ var DOC_CATALOG = [
1197
1448
  path: "/docs/apps/shell",
1198
1449
  summary: "Product Apps vs Coderunner. Always gf apps init --slug <portal-slug>, then npm run release and gf apps publish --path release.zip. Never zip dist/ or upload only remoteEntry.js.",
1199
1450
  embedded: SHELL_APP_GUIDE,
1200
- lastUpdated: "2026-09-26"
1451
+ lastUpdated: "2026-10-08"
1201
1452
  },
1202
1453
  {
1203
1454
  id: "design-standard",
@@ -1227,9 +1478,9 @@ var DOC_CATALOG = [
1227
1478
  id: "vault",
1228
1479
  title: "Data Vault",
1229
1480
  path: "/docs/api/vault",
1230
- summary: "Create collections with JSON Schema, query totals vs limit, ACLs, implicit tables, nested filters, identities vs M2M.",
1481
+ summary: "Create collections with JSON Schema, record routes (/vault/collections/{collection}/\u2026), query request/response (items, total, has_more, next_offset, limit 1-500), $text substring search (bounded scan, chunked_scan/scan_cursor), ACLs, implicit tables, nested filters, identities vs M2M, secrets fallback.",
1231
1482
  embedded: VAULT_GUIDE,
1232
- lastUpdated: "2026-09-26"
1483
+ lastUpdated: "2026-10-12"
1233
1484
  },
1234
1485
  {
1235
1486
  id: "feature-flags",
@@ -1237,7 +1488,7 @@ var DOC_CATALOG = [
1237
1488
  path: "/docs/configure/feature-flags",
1238
1489
  summary: "Built-in per-environment feature flags (toggles / config values) \u2014 use these, not a Vault collection. gf flags ls|set|compare|promote. Apps read GET \u2026/flags/runtime: Shell apps with the end-user JWT + window.__GF_ENVIRONMENT_ID__ (starter-kit useFlag), Coderunners with gf m2m token + GROUNDFLOOR_ENVIRONMENT_ID. Default environment is dev.",
1239
1490
  embedded: FLAGS_GUIDE,
1240
- lastUpdated: "2026-09-26"
1491
+ lastUpdated: "2026-10-08"
1241
1492
  },
1242
1493
  {
1243
1494
  id: "flags-api",
@@ -1245,7 +1496,7 @@ var DOC_CATALOG = [
1245
1496
  path: "/docs/api/flags",
1246
1497
  summary: "REST reference for flags: list/get/put/delete per environment_id (omitted \u2192 workspace default, dev), compare, promote, runtime read. Key format, value_type validation, auth per method (M2M read-only), error codes.",
1247
1498
  embedded: FLAGS_GUIDE,
1248
- lastUpdated: "2026-09-26"
1499
+ lastUpdated: "2026-10-08"
1249
1500
  },
1250
1501
  {
1251
1502
  id: "scheduled-jobs",
@@ -1253,12 +1504,19 @@ var DOC_CATALOG = [
1253
1504
  path: "/docs/deploy/schedules",
1254
1505
  summary: "workload_type=schedule plus cron. gf coderunner status shows type/schedule/last_job. Use Process Log, not a public URL.",
1255
1506
  embedded: CODERUNNER_GUIDE,
1256
- lastUpdated: "2026-10-02"
1507
+ lastUpdated: "2026-10-08"
1257
1508
  }
1258
1509
  ];
1259
1510
  function docUrl(entry) {
1260
1511
  return `${DOCS_ORIGIN}${entry.path}`;
1261
1512
  }
1513
+ var DOC_ALIASES = {
1514
+ "coderunner-overview": "coderunner"
1515
+ };
1516
+ function getDocById(id) {
1517
+ const key = DOC_ALIASES[id] ?? id;
1518
+ return DOC_CATALOG.find((d) => d.id === key);
1519
+ }
1262
1520
  function findDoc(query) {
1263
1521
  const q = query.trim().toLowerCase();
1264
1522
  if (!q) return DOC_CATALOG;
@@ -1272,7 +1530,7 @@ function htmlToText(html) {
1272
1530
  return article.replace(/<script\b[\s\S]*?<\/script>/gi, " ").replace(/<style\b[\s\S]*?<\/style>/gi, " ").replace(/<nav\b[\s\S]*?<\/nav>/gi, " ").replace(/<footer\b[\s\S]*?<\/footer>/gi, " ").replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/\s+/g, " ").trim();
1273
1531
  }
1274
1532
  var MAX_CHARS = 12e3;
1275
- async function fetchDocPage(entry) {
1533
+ async function fetchDocPage(entry, offset = 0) {
1276
1534
  const url = docUrl(entry);
1277
1535
  if (entry.embedded?.trim()) {
1278
1536
  return {
@@ -1293,15 +1551,18 @@ async function fetchDocPage(entry) {
1293
1551
  throw new Error(`Docs fetch failed: ${res.status} ${url}`);
1294
1552
  }
1295
1553
  const html = await res.text();
1296
- let body = htmlToText(html);
1297
- const truncated = body.length > MAX_CHARS;
1298
- if (truncated) body = `${body.slice(0, MAX_CHARS)}
1299
- \u2026`;
1554
+ const full = htmlToText(html);
1555
+ const start = Math.max(0, Math.min(offset, full.length));
1556
+ const end = start + MAX_CHARS;
1557
+ const truncated = full.length > end;
1558
+ const body = full.slice(start, end) + (truncated ? "\n\u2026" : "");
1300
1559
  return {
1301
1560
  url,
1302
1561
  title: entry.title,
1303
1562
  text: body,
1304
1563
  truncated,
1564
+ totalChars: full.length,
1565
+ nextOffset: truncated ? end : null,
1305
1566
  source: "docs.groundfloor.cloud",
1306
1567
  lastUpdated: null,
1307
1568
  mcpVersion: GF_MCP_VERSION
@@ -1309,6 +1570,10 @@ async function fetchDocPage(entry) {
1309
1570
  }
1310
1571
 
1311
1572
  // src/playbook.ts
1573
+ function createAppCommand(slug, name) {
1574
+ const displayName = name?.trim() || "<App Name>";
1575
+ return `gf apps create --name ${JSON.stringify(displayName)} --slug ${slug || "<portal-slug>"} --kind shell_federated`;
1576
+ }
1312
1577
  function suggestCli(intent, args) {
1313
1578
  const ws = args?.workspace ? ` -w ${args.workspace}` : "";
1314
1579
  const name = args?.name ?? args?.coderunner;
@@ -1349,6 +1614,14 @@ function suggestCli(intent, args) {
1349
1614
  why: "Process Log (stdout/stderr). gf deploy prints the last lines on failure; this command fetches more. runner_id accepts a coderunner id.",
1350
1615
  docs: "https://docs.groundfloor.cloud/docs/deploy"
1351
1616
  };
1617
+ case "create_app": {
1618
+ const slug = args?.slug?.trim();
1619
+ return {
1620
+ command: createAppCommand(slug, args?.name),
1621
+ why: 'Register the App in the workspace. gf apps publish resolves the App by slug and fails with "App not found" if this was never run. A 409 means the slug already exists: skip to init/publish. This is a Shell federated App, not a Coderunner (never --kind coderunner).',
1622
+ docs: "https://docs.groundfloor.cloud/docs/apps/shell/getting-started"
1623
+ };
1624
+ }
1352
1625
  case "init_app": {
1353
1626
  const slug = args?.slug?.trim();
1354
1627
  return {
@@ -1357,12 +1630,15 @@ function suggestCli(intent, args) {
1357
1630
  docs: "https://docs.groundfloor.cloud/docs/apps/shell"
1358
1631
  };
1359
1632
  }
1360
- case "publish_app":
1633
+ case "publish_app": {
1634
+ const slug = args?.slug?.trim();
1635
+ const dir = slug || "<slug>";
1361
1636
  return {
1362
- command: args?.slug ? `gf apps init --slug ${args.slug} && cd ${args.slug} && npm install && npm run release && gf apps publish --path release.zip` : "gf apps init --slug <portal-slug> && cd <slug> && npm install && npm run release && gf apps publish --path release.zip",
1363
- why: "Ship a Shell federated App. Always init the kit first. npm run release (not zip dist/, not a lone remoteEntry.js). Do not gf deploy. Do not invent App.tsx.",
1637
+ command: `${createAppCommand(slug, args?.name)} && gf apps init --slug ${slug || "<portal-slug>"} && cd ${dir} && npm install && npm run release && gf apps publish --path release.zip`,
1638
+ why: "Ship a Shell federated App. Register it (gf apps create), then init the kit. Skip create if the App already exists (create returns 409). npm run release (not zip dist/, not a lone remoteEntry.js). Do not gf deploy. Do not invent App.tsx.",
1364
1639
  docs: "https://docs.groundfloor.cloud/docs/apps/shell"
1365
1640
  };
1641
+ }
1366
1642
  case "environments":
1367
1643
  return {
1368
1644
  command: `gf environments ls${ws}`,
@@ -1371,8 +1647,8 @@ function suggestCli(intent, args) {
1371
1647
  };
1372
1648
  case "cancel_deployment":
1373
1649
  return {
1374
- command: name ? `gf coderunner cancel-deployment -c ${name}${ws}` : `gf coderunner cancel-deployment -c <id-or-slug>${ws}`,
1375
- why: "Delete the current deployment (or --deployment-id). Use this for a deploy stuck in deploying.",
1650
+ command: name ? `gf coderunner cancel-deployment -c ${name} --yes${ws}` : `gf coderunner cancel-deployment -c <id-or-slug> --yes${ws}`,
1651
+ why: "Delete the current deployment (or --deployment-id). Use this for a deploy stuck in deploying. --yes cancels without a confirmation prompt (required without a TTY).",
1376
1652
  docs: "https://docs.groundfloor.cloud/docs/deploy"
1377
1653
  };
1378
1654
  case "secrets":
@@ -1403,7 +1679,7 @@ function suggestCli(intent, args) {
1403
1679
  default:
1404
1680
  return {
1405
1681
  command: "gf --help",
1406
- why: `Unknown intent "${intent}". Use login | switch_workspace | deploy | run | status | logs | environments | cancel_deployment | init_app | publish_app | secrets | flags | dataplane | vault_token.`,
1682
+ why: `Unknown intent "${intent}". Use login | switch_workspace | deploy | run | status | logs | environments | cancel_deployment | create_app | init_app | publish_app | secrets | flags | dataplane | vault_token.`,
1407
1683
  docs: "https://docs.groundfloor.cloud/docs/developer-tools/cli"
1408
1684
  };
1409
1685
  }
@@ -1434,7 +1710,9 @@ function errText(err) {
1434
1710
  return { content: [{ type: "text", text: message }], isError: true };
1435
1711
  }
1436
1712
 
1437
- // src/index.ts
1713
+ // src/server.ts
1714
+ var READ_ONLY = { readOnlyHint: true };
1715
+ var DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
1438
1716
  var wsId = z.string().optional().describe("Override the gf default workspace for this call only");
1439
1717
  async function cfg(workspaceId, requireWorkspace = true) {
1440
1718
  return resolveMcpConfig({ workspaceId, requireWorkspace });
@@ -1453,6 +1731,7 @@ server.tool(
1453
1731
  "whoami",
1454
1732
  "Show the gf CLI session (identity, cell, selected workspace). Run `gf login` if this fails.",
1455
1733
  {},
1734
+ READ_ONLY,
1456
1735
  async () => {
1457
1736
  try {
1458
1737
  const c = await cfg(void 0, false);
@@ -1476,6 +1755,7 @@ server.tool(
1476
1755
  "workspace_context",
1477
1756
  "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.",
1478
1757
  { workspace_id: wsId },
1758
+ READ_ONLY,
1479
1759
  async ({ workspace_id }) => {
1480
1760
  try {
1481
1761
  const c = await cfg(workspace_id);
@@ -1535,12 +1815,16 @@ server.tool(
1535
1815
  node: "function/job/schedule without a Dockerfile run a generated /app/main.js that requires ./handler: ship a root handler.js exporting handler (scripts.start not needed). service/Dockerfile running npm start: scripts.start, listen PORT||8080, bind 0.0.0.0. gf deploy fails fast on either missing.",
1536
1816
  local_live_data: 'eval "$(gf env)". For files/secrets as a builder use Bearer $(gf token). For Vault/Files as the deployed workload, attach an M2M client and use Bearer $(gf m2m token). Never DATAPLANE_SERVICE_API_KEY. Do not zip .env files.'
1537
1817
  },
1818
+ app_end_users: {
1819
+ docs_get: "authentication",
1820
+ rule: "End users of a customer's app sign in only to that app, through the workspace's App Authentication realm (Administer \u2192 App authentication). They have no Groundfloor console account and no workspace membership; their site role (viewer/user/admin or custom) decides what they reach via Site access. Do not build or suggest Portal logins for them."
1821
+ },
1538
1822
  shell_app_howto: {
1539
1823
  docs_get: "shell",
1540
- rule: "Never invent a Vite + Module Federation app. gf apps init --slug <portal-slug>, npm install, npm run release, gf apps publish --path release.zip. Do not zip dist/ or upload only remoteEntry.js.",
1824
+ rule: "Never invent a Vite + Module Federation app. gf apps create --name <name> --slug <portal-slug> --kind shell_federated (registers the App; publish fails with App not found without it), gf apps init --slug <portal-slug>, npm install, npm run release, gf apps publish --path release.zip. Do not zip dist/ or upload only remoteEntry.js.",
1541
1825
  css: "Tailwind is the default (docs_get id css-and-styling). Own framework: FederatedStyleRoot + ?inline CSS in Shadow DOM. Do not load a second CSS framework onto the Shell page.",
1542
1826
  design: "docs_get id design-standard",
1543
- suggest_cli: "init_app | publish_app"
1827
+ suggest_cli: "create_app | init_app | publish_app"
1544
1828
  },
1545
1829
  databases_howto: {
1546
1830
  docs_get: "databases",
@@ -1559,6 +1843,7 @@ server.tool(
1559
1843
  flags: suggestCli("flags"),
1560
1844
  cancel_deployment: suggestCli("cancel_deployment"),
1561
1845
  vault_token: suggestCli("m2m_token"),
1846
+ create_app: suggestCli("create_app"),
1562
1847
  init_app: suggestCli("init_app"),
1563
1848
  publish_app: suggestCli("publish_app")
1564
1849
  },
@@ -1578,6 +1863,7 @@ server.tool(
1578
1863
  "workspaces_list",
1579
1864
  "List workspaces visible to the current user. To change the default, tell the user to run `gf workspaces use <uuid>` (CLI owns that).",
1580
1865
  {},
1866
+ READ_ONLY,
1581
1867
  async () => {
1582
1868
  try {
1583
1869
  const c = await cfg(void 0, false);
@@ -1599,6 +1885,7 @@ server.tool(
1599
1885
  "coderunner_list",
1600
1886
  "List Coderunners in the workspace (read-only).",
1601
1887
  { workspace_id: wsId },
1888
+ READ_ONLY,
1602
1889
  async ({ workspace_id }) => {
1603
1890
  try {
1604
1891
  const c = await cfg(workspace_id);
@@ -1616,6 +1903,7 @@ server.tool(
1616
1903
  workspace_id: wsId,
1617
1904
  coderunner_id: z.string().min(1).describe("Coderunner id or slug")
1618
1905
  },
1906
+ READ_ONLY,
1619
1907
  async (args) => {
1620
1908
  try {
1621
1909
  const c = await cfg(args.workspace_id);
@@ -1645,6 +1933,7 @@ server.tool(
1645
1933
  search: z.string().optional(),
1646
1934
  deployment_id: z.string().optional()
1647
1935
  },
1936
+ READ_ONLY,
1648
1937
  async (args) => {
1649
1938
  try {
1650
1939
  const c = await cfg(args.workspace_id);
@@ -1671,6 +1960,7 @@ server.tool(
1671
1960
  "environments_list",
1672
1961
  "List environments in the workspace (id, name, status).",
1673
1962
  { workspace_id: wsId },
1963
+ READ_ONLY,
1674
1964
  async ({ workspace_id }) => {
1675
1965
  try {
1676
1966
  const c = await cfg(workspace_id);
@@ -1686,30 +1976,23 @@ server.tool(
1686
1976
  );
1687
1977
  server.tool(
1688
1978
  "coderunner_cancel_deployment",
1689
- "Delete a coderunner deployment (current deployment if deployment_id is omitted). Same as `gf coderunner cancel-deployment`.",
1979
+ "DESTRUCTIVE. Deletes one deployment of a Coderunner; if it is the live deployment the service goes down. deployment_id is required (find it with coderunner_get_status). Acts only on the configured workspace (gf workspaces use). Same as `gf coderunner cancel-deployment --deployment-id`. Confirm with the user before calling.",
1690
1980
  {
1691
- workspace_id: wsId,
1692
1981
  coderunner_id: z.string().min(1).describe("Coderunner id or slug"),
1693
- deployment_id: z.string().optional().describe("Deployment id; defaults to the current deployment")
1982
+ deployment_id: z.string().trim().min(1).describe(
1983
+ "Id of the deployment to delete (coderunner_get_status \u2192 deployment.id). Required: there is no default."
1984
+ )
1694
1985
  },
1986
+ DESTRUCTIVE,
1695
1987
  async (args) => {
1696
1988
  try {
1697
- const c = await cfg(args.workspace_id);
1989
+ const c = await cfg();
1698
1990
  const id = await resolveCoderunnerId(
1699
1991
  c.apiUrl,
1700
1992
  c.workspaceId,
1701
1993
  args.coderunner_id
1702
1994
  );
1703
- let deploymentId = args.deployment_id?.trim();
1704
- if (!deploymentId) {
1705
- const current = await getDeployment(c.apiUrl, c.workspaceId, id);
1706
- deploymentId = current?.id?.trim();
1707
- }
1708
- if (!deploymentId) {
1709
- throw new Error(
1710
- `No current deployment for coderunner ${id}. Pass deployment_id.`
1711
- );
1712
- }
1995
+ const deploymentId = args.deployment_id;
1713
1996
  await deleteCoderunnerDeployment(
1714
1997
  c.apiUrl,
1715
1998
  c.workspaceId,
@@ -1717,6 +2000,7 @@ server.tool(
1717
2000
  deploymentId
1718
2001
  );
1719
2002
  return text({
2003
+ workspace_id: c.workspaceId,
1720
2004
  coderunner_id: id,
1721
2005
  deployment_id: deploymentId,
1722
2006
  deleted: true,
@@ -1735,6 +2019,7 @@ server.tool(
1735
2019
  coderunner_id: z.string().min(1),
1736
2020
  job_id: z.string().min(1)
1737
2021
  },
2022
+ READ_ONLY,
1738
2023
  async (args) => {
1739
2024
  try {
1740
2025
  const c = await cfg(args.workspace_id);
@@ -1753,6 +2038,7 @@ server.tool(
1753
2038
  "apps_list",
1754
2039
  "List product Apps (Shell / standalone). Workloads are Coderunners, not Apps.",
1755
2040
  { workspace_id: wsId },
2041
+ READ_ONLY,
1756
2042
  async ({ workspace_id }) => {
1757
2043
  try {
1758
2044
  const c = await cfg(workspace_id);
@@ -1766,6 +2052,7 @@ server.tool(
1766
2052
  "apps_get",
1767
2053
  "Get one product App by id or slug.",
1768
2054
  { workspace_id: wsId, app_id: z.string().min(1) },
2055
+ READ_ONLY,
1769
2056
  async (args) => {
1770
2057
  try {
1771
2058
  const c = await cfg(args.workspace_id);
@@ -1783,6 +2070,7 @@ server.tool(
1783
2070
  "secrets_list",
1784
2071
  "List secret keys (values are not shown). To set/delete, use `gf secrets`.",
1785
2072
  { workspace_id: wsId, environment_id: z.string().optional() },
2073
+ READ_ONLY,
1786
2074
  async (args) => {
1787
2075
  try {
1788
2076
  const c = await cfg(args.workspace_id);
@@ -1798,6 +2086,7 @@ server.tool(
1798
2086
  "flags_list",
1799
2087
  "List feature flags and values in one environment (omit environment_id \u2192 workspace default, dev; response echoes the resolved id). To set/delete/promote, use `gf flags`. docs_get id feature-flags for runtime use.",
1800
2088
  { workspace_id: wsId, environment_id: z.string().optional() },
2089
+ READ_ONLY,
1801
2090
  async (args) => {
1802
2091
  try {
1803
2092
  const c = await cfg(args.workspace_id);
@@ -1813,6 +2102,7 @@ server.tool(
1813
2102
  "files_list",
1814
2103
  "List workspace files (read-only). Upload/download with `gf files`.",
1815
2104
  { workspace_id: wsId },
2105
+ READ_ONLY,
1816
2106
  async ({ workspace_id }) => {
1817
2107
  try {
1818
2108
  const c = await cfg(workspace_id);
@@ -1824,17 +2114,45 @@ server.tool(
1824
2114
  );
1825
2115
  server.tool(
1826
2116
  "domains_list",
1827
- "List custom domains for a Coderunner service. Add/verify with `gf domains`.",
1828
- { workspace_id: wsId, coderunner_id: z.string().min(1) },
2117
+ 'List custom domains of a standalone App (the service-backed kind). Pass app_id (id or slug) or coderunner_id (the App\'s primary Coderunner). Standalone Coderunners have no domains of their own: the Coderunner domain routes always return 400 "Custom domains are available on Apps" and that server message is surfaced if hit. Shell apps have none. Add/verify with `gf domains` only for an App-backed service.',
2118
+ {
2119
+ workspace_id: wsId,
2120
+ app_id: z.string().min(1).optional().describe("App id or slug"),
2121
+ coderunner_id: z.string().min(1).optional().describe("Coderunner id or slug; resolves to the App whose primary Coderunner it is")
2122
+ },
2123
+ READ_ONLY,
1829
2124
  async (args) => {
1830
2125
  try {
2126
+ if (!args.app_id && !args.coderunner_id) {
2127
+ throw new Error("Pass app_id (id or slug) or coderunner_id.");
2128
+ }
1831
2129
  const c = await cfg(args.workspace_id);
1832
- const id = await resolveCoderunnerId(
2130
+ const listed = await listApps(c.apiUrl, c.workspaceId);
2131
+ let match;
2132
+ if (args.app_id) {
2133
+ match = listed.apps.find(
2134
+ (app) => app.id === args.app_id || app.slug === args.app_id
2135
+ );
2136
+ if (!match) throw new Error(`App not found: ${args.app_id}`);
2137
+ } else {
2138
+ const crId = await resolveCoderunnerId(
2139
+ c.apiUrl,
2140
+ c.workspaceId,
2141
+ args.coderunner_id
2142
+ );
2143
+ match = listed.apps.find((app) => app.primary_coderunner_id === crId);
2144
+ if (!match) {
2145
+ throw new Error(
2146
+ `Coderunner ${crId} is not the primary Coderunner of any App, and standalone Coderunners cannot have custom domains. Package it as an App first (gf apps create --kind standalone --primary-coderunner <id>).`
2147
+ );
2148
+ }
2149
+ }
2150
+ const domains = await cpGetJson(
1833
2151
  c.apiUrl,
1834
- c.workspaceId,
1835
- args.coderunner_id
2152
+ c.token,
2153
+ `/v1/workspaces/${encodeURIComponent(c.workspaceId)}/apps/${encodeURIComponent(match.id)}/coderunner/domains`
1836
2154
  );
1837
- return text(await listDomains(c.apiUrl, c.workspaceId, id));
2155
+ return text({ app_id: match.id, app_slug: match.slug, ...domains });
1838
2156
  } catch (e) {
1839
2157
  return errText(e);
1840
2158
  }
@@ -1842,8 +2160,9 @@ server.tool(
1842
2160
  );
1843
2161
  server.tool(
1844
2162
  "dataplane_status",
1845
- "Dataplane setup status (Dataplane is how Groundfloor talks to databases, not a database itself). PostgreSQL (shared, platform-managed) is ready through it by default. Provision with `gf dataplane provision`.",
2163
+ "Dataplane setup status for the workspace, as reported by Control Plane. Provision with `gf dataplane provision`. See docs_get id databases for what Dataplane is.",
1846
2164
  { workspace_id: wsId },
2165
+ READ_ONLY,
1847
2166
  async ({ workspace_id }) => {
1848
2167
  try {
1849
2168
  const c = await cfg(workspace_id);
@@ -1853,10 +2172,73 @@ server.tool(
1853
2172
  }
1854
2173
  }
1855
2174
  );
2175
+ var accountId = z.string().optional().describe(
2176
+ "Organization/account id (NOT the workspace id). Defaults to the account that owns the selected workspace."
2177
+ );
2178
+ async function resolveAccountId(c, explicit) {
2179
+ if (explicit?.trim()) return explicit.trim();
2180
+ const workspaces = await listWorkspaces(c.apiUrl);
2181
+ const ws = workspaces.find((w) => w.id === c.workspaceId);
2182
+ const id = ws?.account_id || ws?.organization_id;
2183
+ if (!id) {
2184
+ throw new Error(
2185
+ "Could not determine the account for the selected workspace. Pass account_id (organization id)."
2186
+ );
2187
+ }
2188
+ return id;
2189
+ }
2190
+ server.tool(
2191
+ "quota_get",
2192
+ "Plan tier and usage against plan limits (workspaces, file storage, activity-log events, \u2026) for the organization that owns the workspace. Account-scoped, not workspace-scoped; readable by organization members. Use when a create/deploy is refused for plan reasons.",
2193
+ { workspace_id: wsId, account_id: accountId },
2194
+ READ_ONLY,
2195
+ async (args) => {
2196
+ try {
2197
+ const c = await cfg(args.workspace_id);
2198
+ const id = await resolveAccountId(c, args.account_id);
2199
+ return text(
2200
+ await cpGetJson(
2201
+ c.apiUrl,
2202
+ c.token,
2203
+ `/v1/accounts/${encodeURIComponent(id)}/quota`
2204
+ )
2205
+ );
2206
+ } catch (e) {
2207
+ return errText(e);
2208
+ }
2209
+ }
2210
+ );
2211
+ server.tool(
2212
+ "billing_summary",
2213
+ "Compute-unit usage and billing estimate for the organization that owns the workspace (account-scoped, not workspace-scoped). Only Owners and Billing admins may read it; others get 403. The response notes whether it is an estimate.",
2214
+ {
2215
+ workspace_id: wsId,
2216
+ account_id: accountId,
2217
+ since_seconds: z.number().int().min(3600).max(400 * 86400).optional().describe("Lookback window in seconds (server default 30 days; min 1 hour, max ~13 months)")
2218
+ },
2219
+ READ_ONLY,
2220
+ async (args) => {
2221
+ try {
2222
+ const c = await cfg(args.workspace_id);
2223
+ const id = await resolveAccountId(c, args.account_id);
2224
+ const qs = args.since_seconds ? `?since_seconds=${args.since_seconds}` : "";
2225
+ return text(
2226
+ await cpGetJson(
2227
+ c.apiUrl,
2228
+ c.token,
2229
+ `/v1/accounts/${encodeURIComponent(id)}/billing/summary${qs}`
2230
+ )
2231
+ );
2232
+ } catch (e) {
2233
+ return errText(e);
2234
+ }
2235
+ }
2236
+ );
1856
2237
  server.tool(
1857
2238
  "docs_list",
1858
2239
  "Catalog of Groundfloor product docs (docs.groundfloor.cloud) the MCP can fetch.",
1859
2240
  { query: z.string().optional() },
2241
+ READ_ONLY,
1860
2242
  async ({ query }) => {
1861
2243
  const hits = findDoc(query ?? "");
1862
2244
  return text({
@@ -1874,15 +2256,19 @@ server.tool(
1874
2256
  );
1875
2257
  server.tool(
1876
2258
  "docs_get",
1877
- "How-to for Coderunner, Shell, design-standard, css-and-styling, databases, access, m2m. Pass id from docs_list. Prefer id coderunner or coderunner-runtime before deploying. For site grants use id access. For Coderunner Vault/Files use id m2m.",
2259
+ "How-to for Coderunner, Shell, design-standard, css-and-styling, databases, access, m2m, authentication (app end users). Pass id from docs_list. Prefer id coderunner or coderunner-runtime before deploying. For site grants use id access. For Coderunner Vault/Files use id m2m.",
1878
2260
  {
1879
2261
  id: z.string().min(1).describe(
1880
2262
  "Catalog id, e.g. coderunner | coderunner-runtime | shell | design-standard | css-and-styling | databases | agents | developers | authentication | access | m2m"
2263
+ ),
2264
+ offset: z.number().int().min(0).optional().describe(
2265
+ "Live (non-embedded) pages only: character offset to resume from. Pass nextOffset from the previous result when truncated is true."
1881
2266
  )
1882
2267
  },
1883
- async ({ id }) => {
2268
+ READ_ONLY,
2269
+ async ({ id, offset }) => {
1884
2270
  try {
1885
- const entry = DOC_CATALOG.find((d) => d.id === id);
2271
+ const entry = getDocById(id);
1886
2272
  if (!entry) {
1887
2273
  return errText(
1888
2274
  new Error(
@@ -1890,7 +2276,7 @@ server.tool(
1890
2276
  )
1891
2277
  );
1892
2278
  }
1893
- return text(await fetchDocPage(entry));
2279
+ return text(await fetchDocPage(entry, offset ?? 0));
1894
2280
  } catch (e) {
1895
2281
  return errText(e);
1896
2282
  }
@@ -1898,7 +2284,7 @@ server.tool(
1898
2284
  );
1899
2285
  server.tool(
1900
2286
  "suggest_cli",
1901
- "Return the gf command to run. For a Shell App use intent init_app or publish_app (never scaffold App.tsx; never gf deploy). MCP does not execute mutations.",
2287
+ "Return the gf command to run. For a Shell App use intent create_app, init_app or publish_app (publish_app is the full create + init + release + publish chain; never scaffold App.tsx; never gf deploy). MCP does not execute mutations.",
1902
2288
  {
1903
2289
  intent: z.enum([
1904
2290
  "login",
@@ -1909,6 +2295,7 @@ server.tool(
1909
2295
  "logs",
1910
2296
  "environments",
1911
2297
  "cancel_deployment",
2298
+ "create_app",
1912
2299
  "init_app",
1913
2300
  "publish_app",
1914
2301
  "secrets",
@@ -1918,10 +2305,11 @@ server.tool(
1918
2305
  "m2m_token"
1919
2306
  ]),
1920
2307
  workspace: z.string().optional(),
1921
- name: z.string().optional().describe("Coderunner name/id for deploy/run/status/logs/cancel_deployment/vault_token"),
1922
- slug: z.string().optional().describe("Portal app slug for init_app / publish_app"),
2308
+ name: z.string().optional().describe("Coderunner name/id for deploy/run/status/logs/cancel_deployment/vault_token; App display name for create_app / publish_app"),
2309
+ slug: z.string().optional().describe("Portal app slug for create_app / init_app / publish_app"),
1923
2310
  cell: z.enum(["production", "stage", "dev"]).optional()
1924
2311
  },
2312
+ READ_ONLY,
1925
2313
  async (args) => {
1926
2314
  return text(
1927
2315
  suggestCli(args.intent, {
@@ -1934,6 +2322,8 @@ server.tool(
1934
2322
  );
1935
2323
  }
1936
2324
  );
2325
+
2326
+ // src/index.ts
1937
2327
  async function main() {
1938
2328
  const transport = new StdioServerTransport();
1939
2329
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/mcp",
3
- "version": "0.1.16",
3
+ "version": "0.2.1",
4
4
  "description": "Groundfloor MCP — inspect workspace state and docs; take actions with the gf CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,14 +27,14 @@
27
27
  "scripts": {
28
28
  "build": "tsup",
29
29
  "dev": "tsup --watch",
30
- "typecheck": "tsup --silent",
30
+ "typecheck": "tsc --noEmit",
31
31
  "start": "node dist/index.js",
32
32
  "clean": "rm -rf dist *.tsbuildinfo .turbo",
33
33
  "prepublishOnly": "npm run build"
34
34
  },
35
35
  "dependencies": {
36
- "@modelcontextprotocol/sdk": "^1.12.1",
37
- "zod": "^3.24.2"
36
+ "@modelcontextprotocol/sdk": "^1.23.0",
37
+ "zod": "^4.4.3"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^20",