@groundfloorcloud/mcp 0.1.16 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -8
  2. package/dist/index.js +468 -93
  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,85 @@ 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 reaches everything. A signed-in user with no recognised role
526
+ gets 403 on role-gated endpoints (Vault, feature flags).
527
+ - SSO (OIDC or SAML) can be connected per workspace; role names in the
528
+ provider's tokens must match the roles granted on Site access.
529
+
530
+ ## Multi-tenant (customer companies inside one workspace)
531
+
532
+ Turn on **Multi-tenant** in Settings to get a Tenants tab: one tenant per
533
+ customer company, invite-only onboarding, per-tenant SSO. The user's token
534
+ carries the tenant in the \`organization\` claim; the app must keep each tenant's
535
+ data apart using that claim. Site access, data, files and M2M tokens stay shared
536
+ across the workspace.
537
+
538
+ ## Turning it off
539
+
540
+ Switching to None or External keeps users, roles and settings (switch back and
541
+ they return). To remove them, turn sign-in off first, then **Delete Groundfloor
542
+ login**. Access grants and M2M tokens are kept.
543
+
544
+ ## In a Shell app
545
+
546
+ The signed-in user's JWT comes from \`window.__GF_GET_ACCESS_TOKEN__()\`. See
547
+ \`docs_get\` id \`shell\`. Unattended workloads use M2M instead (\`docs_get\` id
548
+ \`m2m\`), not an end-user login.
549
+ `.trim();
550
+
447
551
  // src/coderunner-runtime.ts
448
552
  var CODERUNNER_RUNTIME_BODY = `
449
553
  The contract depends on the workload type.
@@ -469,9 +573,58 @@ The contract depends on the workload type.
469
573
  (\`Cannot find module './handler'\`, \`MODULE_NOT_FOUND\`) and the deployment
470
574
  ends **Failed (status 4)**. Check \`gf coderunner logs -c <id>\`.
471
575
 
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.
576
+ ### The handler signature
577
+
578
+ Arguments are **\`(context, payload)\`** in that order. Export a function named
579
+ \`handler\` (CommonJS \`module.exports = { handler }\`; an ES module
580
+ \`export async function handler(context, payload)\` also works):
581
+
582
+ \`\`\`js
583
+ const handler = async (context, payload) => {
584
+ // your code
585
+ };
586
+
587
+ module.exports = { handler };
588
+ \`\`\`
589
+
590
+ - \`payload\` is the JSON input: the request body for a function, the run's input
591
+ for a job or schedule. Guard for a missing body (\`payload?.name\`).
592
+ - \`context\` is reserved for runtime information. Do not rely on its contents.
593
+
594
+ ### Function return shape (HTTP)
595
+
596
+ Whatever the handler returns becomes the response; you may return anything.
597
+ The documented convention, used by every example, is:
598
+
599
+ \`\`\`js
600
+ const handler = async (context, payload) => {
601
+ if (!payload?.name) {
602
+ return {
603
+ statusCode: 400,
604
+ body: { error: "name is required" },
605
+ headers: { "content-type": "application/json" },
606
+ };
607
+ }
608
+ return {
609
+ statusCode: 200,
610
+ body: { greeting: \`Hello, \${payload.name}\` },
611
+ headers: { "content-type": "application/json" },
612
+ };
613
+ };
614
+
615
+ module.exports = { handler };
616
+ \`\`\`
617
+
618
+ Use \`200\`/\`201\` for success, \`400\` for bad input, \`401\`/\`403\` for auth,
619
+ \`404\` for not found, \`500\` for unexpected errors (no internals in the body).
620
+ Callers POST JSON to the Deployment URL with a bearer token, or use
621
+ \`gf coderunner run -c <slug> --payload '<json>'\`.
622
+
623
+ ### Jobs and schedules: return value and failure
624
+
625
+ A job or schedule needs no return value. If an error **escapes** the handler the
626
+ run is marked **Failed**; if you catch it, the run succeeds and handling it is
627
+ up to you.
475
628
 
476
629
  ## Node: service (or any Dockerfile that runs \`npm start\`)
477
630
 
@@ -480,7 +633,7 @@ A \`service\` **requires** a root Dockerfile; you own \`CMD\`. If it is
480
633
  and the process must follow the HTTP contract:
481
634
 
482
635
  \`\`\`dockerfile
483
- FROM node:18-alpine
636
+ FROM node:22-alpine
484
637
  WORKDIR /app
485
638
  COPY . .
486
639
  EXPOSE 8080
@@ -505,8 +658,14 @@ Offer GET \`/\` or \`/health\` for the probe. A Dockerfile whose \`CMD\` is not
505
658
 
506
659
  ## Python
507
660
 
508
- Bind \`0.0.0.0\` and \`PORT\` (default 8080), for example
509
- \`uvicorn main:app --host 0.0.0.0 --port \${PORT:-8080}\`.
661
+ Function, job and schedule: \`handler.py\` at the ZIP root (with
662
+ \`requirements.txt\`) defining \`def handler(context, payload):\`, same argument
663
+ order, same return convention (\`statusCode\`, \`body\`,
664
+ \`headers\`; an exception escaping the handler fails a job run). \`print\` goes
665
+ to the Process Log.
666
+
667
+ A Dockerfile-based service binds \`0.0.0.0\` and \`PORT\` (default 8080), for
668
+ example \`uvicorn main:app --host 0.0.0.0 --port \${PORT:-8080}\`.
510
669
 
511
670
  ## Jobs and schedules
512
671
 
@@ -580,8 +739,9 @@ Pick **one** type per coderunner. Changing type later means a new coderunner.
580
739
  \`/app/main.js\` that does \`require('./handler')\`; \`npm start\` is **not** run and
581
740
  \`scripts.start\` is not required. Missing \`handler.js\` \u2192 crash loop
582
741
  (\`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).
742
+ \`gf deploy\` fails fast on this. Handler arguments are \`(context, payload)\`; the
743
+ signature and the HTTP return shape (\`{ statusCode, body, headers }\`) are in the
744
+ Runtime contract section above.
585
745
  - Node with your own Dockerfile (\`CMD ["npm","start"]\`) instead: listen on
586
746
  \`process.env.PORT || 8080\`, bind **\`0.0.0.0\`** (health GET \`/\` or \`/health\`), and
587
747
  define \`scripts.start\`.
@@ -613,7 +773,7 @@ Pick **one** type per coderunner. Changing type later means a new coderunner.
613
773
  - Node example:
614
774
 
615
775
  \`\`\`dockerfile
616
- FROM node:18-alpine
776
+ FROM node:22-alpine
617
777
  WORKDIR /app
618
778
  COPY package*.json ./
619
779
  RUN npm ci --omit=dev
@@ -736,9 +896,28 @@ do the fetch yourself; keep using \`--secret\` injection for SDKs that only
736
896
  read a literal \`process.env\` value. Either way, cache the revealed value
737
897
  in your process rather than calling \`reveal\` on every request.
738
898
 
899
+ ## Limits an agent will hit
900
+
901
+ Verified against the control plane source; anything not listed here is not a
902
+ documented limit.
903
+
904
+ - **M2M token endpoint**: 20 requests per minute (per client and per source).
905
+ Cache the token until near expiry; do not mint per request.
906
+ - **Vault collection list/query**: \`limit\` 1-500 (default 50).
907
+ - **Feature flag string values**: at most 10,000 characters.
908
+ - **\`docs_get\` live pages**: truncated at 12,000 characters; use \`offset\` to
909
+ read the rest.
910
+
911
+ Plan quota and usage are account-scoped, not workspace-scoped. The **account
912
+ id is not the workspace id**: read \`account_id\` from \`workspaces_list\`. If a
913
+ deploy is refused or you need to know what is in use, call \`quota_get\`.
914
+ \`billing_summary\` shows spend, and only Owners and Billing admins can read it
915
+ (others get 403).
916
+
739
917
  ## Agent actions
740
918
 
741
- Inspect: \`workspace_context\`, \`docs_get\` id \`coderunner\` or \`coderunner-runtime\`, \`suggest_cli\`.
919
+
920
+ Inspect: \`workspace_context\`, \`quota_get\`, \`docs_get\` id \`coderunner\` or \`coderunner-runtime\`, \`suggest_cli\`.
742
921
  Act: \`gf deploy\`, \`gf coderunner run|status|logs\`, \`gf m2m token\`, \`gf identities ls|mint\`, \`gf api\`. MCP does not deploy.
743
922
  `.trim();
744
923
 
@@ -911,7 +1090,9 @@ One endpoint for every runtime:
911
1090
 
912
1091
  Weak \`ETag\` + \`Cache-Control: private, no-cache\`. Send \`If-None-Match\` \u2192 304.
913
1092
  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.
1093
+ Flag values are not secrets, but the endpoint is role-gated: only users with a
1094
+ recognised site role can read them (see Permission below); a signed-in user
1095
+ with no role gets 403.
915
1096
 
916
1097
  ### Shell federated app (browser)
917
1098
 
@@ -1026,8 +1207,9 @@ routes (omit it or use \`false\` otherwise \u2014 never \`null\`). \`visibility:
1026
1207
 
1027
1208
  ## suggest_cli
1028
1209
 
1210
+ - 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
1211
  - 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
1212
+ - publish_app \u2192 create + init + npm install + npm run release + gf apps publish --path release.zip
1031
1213
  `;
1032
1214
 
1033
1215
  // src/vault-guide.ts
@@ -1074,14 +1256,63 @@ Example:
1074
1256
  Or use \`gf api -X POST /v1/workspaces/{workspace_id}/vault/collections --body '{...}'\`.
1075
1257
 
1076
1258
  \`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).
1259
+ tables created by the first \`POST \u2026/vault/collections/{collection}\` appear when
1260
+ you pass \`include_implicit=true\` (or after a schema create).
1261
+
1262
+ ## Records: routes
1263
+
1264
+ All under \`/v1/workspaces/{workspace_id}/vault\`:
1079
1265
 
1080
- ## Query totals
1266
+ | Method and path | Purpose |
1267
+ |---|---|
1268
+ | \`POST /collections/{collection}/query\` | Filtered, paginated read |
1269
+ | \`GET /collections/{collection}/count\` | Count; optional \`filter\` query param (JSON object) |
1270
+ | \`POST /collections/{collection}\` | Create one record, body \`{"record": {...}}\`, 201, returns \`{"document": {...}}\` |
1271
+ | \`PUT /collections/{collection}/{document_id}\` | Update one record, body \`{"record": {...}}\`, returns \`{"document": {...}}\` |
1272
+ | \`DELETE /collections/{collection}/{document_id}\` | Delete one record, 204 |
1273
+ | \`GET /collections/{collection}/schema\` | Schema |
1274
+ | \`GET\`/\`PUT /collections/{collection}/acl\` | Collection ACL |
1081
1275
 
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.
1276
+ There is **no** \`/vault/{collection}\` route; always include \`/collections/\`.
1277
+ \`?connection=\` picks the database (default \`postgresql\`).
1278
+
1279
+ ## Query: request and response
1280
+
1281
+ Request body for \`POST \u2026/collections/{collection}/query\` (all optional):
1282
+ \`filter\`, \`sort\` (e.g. \`["-created_at", "name"]\`), \`projection\`,
1283
+ \`limit\` (1-500, default 50), \`offset\` (default 0), \`connection\`.
1284
+
1285
+ Response:
1286
+
1287
+ \`\`\`json
1288
+ { "items": [ {...} ], "total": 123, "has_more": true, "next_offset": 50 }
1289
+ \`\`\`
1290
+
1291
+ - The array key is **\`items\`** (not \`records\`).
1292
+ - \`total\` is the match count from Dataplane (see below for \`$text\`) and is
1293
+ **null** when upstream gave no count; it is never filled in from the page
1294
+ length. Do not treat \`total === items.length\` as the full table size.
1295
+ - Page with \`next_offset\` while \`has_more\` is true; \`next_offset\` is null
1296
+ on the last page.
1297
+
1298
+ ## Substring search (\`$text\` / \`$search\`)
1299
+
1300
+ \`{ "filter": { "$text": "alice" } }\` is a case-insensitive substring match
1301
+ over every field value. Control Plane runs it itself by scanning the caller's
1302
+ ACL-filtered view, at most \`VAULT_TEXT_SEARCH_MAX_SCAN_ROWS\` rows per request
1303
+ (default 10 000).
1304
+
1305
+ - Plain query: for \`$text\`, \`total\` is computed by Control Plane (not
1306
+ Dataplane). It is exact when the collection fits under the cap; past the cap
1307
+ it is **null** and the results cover only the scanned rows.
1308
+ - Large collections: send \`"chunked_scan": true\`, then
1309
+ \`"scan_cursor": <scan.next_cursor>\` with the same filter/sort until
1310
+ \`scan.complete\` is true. Each response has a \`scan\` block
1311
+ (\`complete\`, \`next_cursor\`, \`scanned_rows\`, \`total_rows\`); the exact
1312
+ match total is the sum of \`items\` across chunks. The cursor is not a
1313
+ snapshot \u2014 concurrent writes can skip or repeat rows.
1314
+ - \`GET \u2026/count\` with \`$text\` uses the plain bounded scan and returns 400
1315
+ past the cap; use the chunked query instead.
1085
1316
 
1086
1317
  ## Filters
1087
1318
 
@@ -1114,6 +1345,17 @@ schedules.
1114
1345
 
1115
1346
  Do not \`--env SENDGRID_SECRET=\u2026\`. That is **422**.
1116
1347
  \`gf secrets set SENDGRID_SECRET\` then \`gf deploy --secret SENDGRID_SECRET\`.
1348
+
1349
+ **Caveat:** \`--secret\` injection needs the platform-wide Control Plane setting
1350
+ \`CODERUNNER_INJECT_ENV_SECRETS\`, which is **off by default** and not
1351
+ configurable per workspace. When it is off the secret is not injected. Fallback:
1352
+ give the Coderunner's M2M client the \`secrets:read\` scope (Portal
1353
+ **Administer \u2192 M2M tokens**) and read the value at runtime with
1354
+ \`GET /v1/workspaces/{workspace_id}/secrets/{key}\` using the M2M bearer token.
1355
+ It is read-only, live (no redeploy on rotation) and workspace-wide. Details in
1356
+ \`docs_get\` id \`coderunner\`. The deployed value of the setting cannot be
1357
+ checked from the repo; if \`--secret\` fails or the variable is empty, use the
1358
+ fallback rather than plaintext \`--env\`.
1117
1359
  `.trim();
1118
1360
 
1119
1361
  // src/version.ts
@@ -1149,7 +1391,7 @@ var DOC_CATALOG = [
1149
1391
  path: "/docs/deploy",
1150
1392
  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
1393
  embedded: CODERUNNER_GUIDE,
1152
- lastUpdated: "2026-10-02"
1394
+ lastUpdated: "2026-10-08"
1153
1395
  },
1154
1396
  {
1155
1397
  id: "coderunner-runtime",
@@ -1157,21 +1399,15 @@ var DOC_CATALOG = [
1157
1399
  path: "/docs/deploy",
1158
1400
  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
1401
  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"
1402
+ lastUpdated: "2026-10-08"
1169
1403
  },
1170
1404
  {
1171
1405
  id: "authentication",
1172
1406
  title: "Workspace Authentication",
1173
1407
  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."
1408
+ 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.",
1409
+ embedded: APP_AUTHENTICATION_GUIDE,
1410
+ lastUpdated: "2026-10-08"
1175
1411
  },
1176
1412
  {
1177
1413
  id: "access",
@@ -1197,7 +1433,7 @@ var DOC_CATALOG = [
1197
1433
  path: "/docs/apps/shell",
1198
1434
  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
1435
  embedded: SHELL_APP_GUIDE,
1200
- lastUpdated: "2026-09-26"
1436
+ lastUpdated: "2026-10-08"
1201
1437
  },
1202
1438
  {
1203
1439
  id: "design-standard",
@@ -1227,9 +1463,9 @@ var DOC_CATALOG = [
1227
1463
  id: "vault",
1228
1464
  title: "Data Vault",
1229
1465
  path: "/docs/api/vault",
1230
- summary: "Create collections with JSON Schema, query totals vs limit, ACLs, implicit tables, nested filters, identities vs M2M.",
1466
+ 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
1467
  embedded: VAULT_GUIDE,
1232
- lastUpdated: "2026-09-26"
1468
+ lastUpdated: "2026-10-09"
1233
1469
  },
1234
1470
  {
1235
1471
  id: "feature-flags",
@@ -1237,7 +1473,7 @@ var DOC_CATALOG = [
1237
1473
  path: "/docs/configure/feature-flags",
1238
1474
  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
1475
  embedded: FLAGS_GUIDE,
1240
- lastUpdated: "2026-09-26"
1476
+ lastUpdated: "2026-10-08"
1241
1477
  },
1242
1478
  {
1243
1479
  id: "flags-api",
@@ -1245,7 +1481,7 @@ var DOC_CATALOG = [
1245
1481
  path: "/docs/api/flags",
1246
1482
  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
1483
  embedded: FLAGS_GUIDE,
1248
- lastUpdated: "2026-09-26"
1484
+ lastUpdated: "2026-10-08"
1249
1485
  },
1250
1486
  {
1251
1487
  id: "scheduled-jobs",
@@ -1253,12 +1489,19 @@ var DOC_CATALOG = [
1253
1489
  path: "/docs/deploy/schedules",
1254
1490
  summary: "workload_type=schedule plus cron. gf coderunner status shows type/schedule/last_job. Use Process Log, not a public URL.",
1255
1491
  embedded: CODERUNNER_GUIDE,
1256
- lastUpdated: "2026-10-02"
1492
+ lastUpdated: "2026-10-08"
1257
1493
  }
1258
1494
  ];
1259
1495
  function docUrl(entry) {
1260
1496
  return `${DOCS_ORIGIN}${entry.path}`;
1261
1497
  }
1498
+ var DOC_ALIASES = {
1499
+ "coderunner-overview": "coderunner"
1500
+ };
1501
+ function getDocById(id) {
1502
+ const key = DOC_ALIASES[id] ?? id;
1503
+ return DOC_CATALOG.find((d) => d.id === key);
1504
+ }
1262
1505
  function findDoc(query) {
1263
1506
  const q = query.trim().toLowerCase();
1264
1507
  if (!q) return DOC_CATALOG;
@@ -1272,7 +1515,7 @@ function htmlToText(html) {
1272
1515
  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
1516
  }
1274
1517
  var MAX_CHARS = 12e3;
1275
- async function fetchDocPage(entry) {
1518
+ async function fetchDocPage(entry, offset = 0) {
1276
1519
  const url = docUrl(entry);
1277
1520
  if (entry.embedded?.trim()) {
1278
1521
  return {
@@ -1293,15 +1536,18 @@ async function fetchDocPage(entry) {
1293
1536
  throw new Error(`Docs fetch failed: ${res.status} ${url}`);
1294
1537
  }
1295
1538
  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`;
1539
+ const full = htmlToText(html);
1540
+ const start = Math.max(0, Math.min(offset, full.length));
1541
+ const end = start + MAX_CHARS;
1542
+ const truncated = full.length > end;
1543
+ const body = full.slice(start, end) + (truncated ? "\n\u2026" : "");
1300
1544
  return {
1301
1545
  url,
1302
1546
  title: entry.title,
1303
1547
  text: body,
1304
1548
  truncated,
1549
+ totalChars: full.length,
1550
+ nextOffset: truncated ? end : null,
1305
1551
  source: "docs.groundfloor.cloud",
1306
1552
  lastUpdated: null,
1307
1553
  mcpVersion: GF_MCP_VERSION
@@ -1309,6 +1555,10 @@ async function fetchDocPage(entry) {
1309
1555
  }
1310
1556
 
1311
1557
  // src/playbook.ts
1558
+ function createAppCommand(slug, name) {
1559
+ const displayName = name?.trim() || "<App Name>";
1560
+ return `gf apps create --name ${JSON.stringify(displayName)} --slug ${slug || "<portal-slug>"} --kind shell_federated`;
1561
+ }
1312
1562
  function suggestCli(intent, args) {
1313
1563
  const ws = args?.workspace ? ` -w ${args.workspace}` : "";
1314
1564
  const name = args?.name ?? args?.coderunner;
@@ -1349,6 +1599,14 @@ function suggestCli(intent, args) {
1349
1599
  why: "Process Log (stdout/stderr). gf deploy prints the last lines on failure; this command fetches more. runner_id accepts a coderunner id.",
1350
1600
  docs: "https://docs.groundfloor.cloud/docs/deploy"
1351
1601
  };
1602
+ case "create_app": {
1603
+ const slug = args?.slug?.trim();
1604
+ return {
1605
+ command: createAppCommand(slug, args?.name),
1606
+ 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).',
1607
+ docs: "https://docs.groundfloor.cloud/docs/apps/shell/getting-started"
1608
+ };
1609
+ }
1352
1610
  case "init_app": {
1353
1611
  const slug = args?.slug?.trim();
1354
1612
  return {
@@ -1357,12 +1615,15 @@ function suggestCli(intent, args) {
1357
1615
  docs: "https://docs.groundfloor.cloud/docs/apps/shell"
1358
1616
  };
1359
1617
  }
1360
- case "publish_app":
1618
+ case "publish_app": {
1619
+ const slug = args?.slug?.trim();
1620
+ const dir = slug || "<slug>";
1361
1621
  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.",
1622
+ 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`,
1623
+ 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
1624
  docs: "https://docs.groundfloor.cloud/docs/apps/shell"
1365
1625
  };
1626
+ }
1366
1627
  case "environments":
1367
1628
  return {
1368
1629
  command: `gf environments ls${ws}`,
@@ -1371,8 +1632,8 @@ function suggestCli(intent, args) {
1371
1632
  };
1372
1633
  case "cancel_deployment":
1373
1634
  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.",
1635
+ command: name ? `gf coderunner cancel-deployment -c ${name} --yes${ws}` : `gf coderunner cancel-deployment -c <id-or-slug> --yes${ws}`,
1636
+ 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
1637
  docs: "https://docs.groundfloor.cloud/docs/deploy"
1377
1638
  };
1378
1639
  case "secrets":
@@ -1403,7 +1664,7 @@ function suggestCli(intent, args) {
1403
1664
  default:
1404
1665
  return {
1405
1666
  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.`,
1667
+ 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
1668
  docs: "https://docs.groundfloor.cloud/docs/developer-tools/cli"
1408
1669
  };
1409
1670
  }
@@ -1434,7 +1695,9 @@ function errText(err) {
1434
1695
  return { content: [{ type: "text", text: message }], isError: true };
1435
1696
  }
1436
1697
 
1437
- // src/index.ts
1698
+ // src/server.ts
1699
+ var READ_ONLY = { readOnlyHint: true };
1700
+ var DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true };
1438
1701
  var wsId = z.string().optional().describe("Override the gf default workspace for this call only");
1439
1702
  async function cfg(workspaceId, requireWorkspace = true) {
1440
1703
  return resolveMcpConfig({ workspaceId, requireWorkspace });
@@ -1453,6 +1716,7 @@ server.tool(
1453
1716
  "whoami",
1454
1717
  "Show the gf CLI session (identity, cell, selected workspace). Run `gf login` if this fails.",
1455
1718
  {},
1719
+ READ_ONLY,
1456
1720
  async () => {
1457
1721
  try {
1458
1722
  const c = await cfg(void 0, false);
@@ -1476,6 +1740,7 @@ server.tool(
1476
1740
  "workspace_context",
1477
1741
  "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
1742
  { workspace_id: wsId },
1743
+ READ_ONLY,
1479
1744
  async ({ workspace_id }) => {
1480
1745
  try {
1481
1746
  const c = await cfg(workspace_id);
@@ -1535,12 +1800,16 @@ server.tool(
1535
1800
  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
1801
  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
1802
  },
1803
+ app_end_users: {
1804
+ docs_get: "authentication",
1805
+ 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."
1806
+ },
1538
1807
  shell_app_howto: {
1539
1808
  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.",
1809
+ 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
1810
  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
1811
  design: "docs_get id design-standard",
1543
- suggest_cli: "init_app | publish_app"
1812
+ suggest_cli: "create_app | init_app | publish_app"
1544
1813
  },
1545
1814
  databases_howto: {
1546
1815
  docs_get: "databases",
@@ -1559,6 +1828,7 @@ server.tool(
1559
1828
  flags: suggestCli("flags"),
1560
1829
  cancel_deployment: suggestCli("cancel_deployment"),
1561
1830
  vault_token: suggestCli("m2m_token"),
1831
+ create_app: suggestCli("create_app"),
1562
1832
  init_app: suggestCli("init_app"),
1563
1833
  publish_app: suggestCli("publish_app")
1564
1834
  },
@@ -1578,6 +1848,7 @@ server.tool(
1578
1848
  "workspaces_list",
1579
1849
  "List workspaces visible to the current user. To change the default, tell the user to run `gf workspaces use <uuid>` (CLI owns that).",
1580
1850
  {},
1851
+ READ_ONLY,
1581
1852
  async () => {
1582
1853
  try {
1583
1854
  const c = await cfg(void 0, false);
@@ -1599,6 +1870,7 @@ server.tool(
1599
1870
  "coderunner_list",
1600
1871
  "List Coderunners in the workspace (read-only).",
1601
1872
  { workspace_id: wsId },
1873
+ READ_ONLY,
1602
1874
  async ({ workspace_id }) => {
1603
1875
  try {
1604
1876
  const c = await cfg(workspace_id);
@@ -1616,6 +1888,7 @@ server.tool(
1616
1888
  workspace_id: wsId,
1617
1889
  coderunner_id: z.string().min(1).describe("Coderunner id or slug")
1618
1890
  },
1891
+ READ_ONLY,
1619
1892
  async (args) => {
1620
1893
  try {
1621
1894
  const c = await cfg(args.workspace_id);
@@ -1645,6 +1918,7 @@ server.tool(
1645
1918
  search: z.string().optional(),
1646
1919
  deployment_id: z.string().optional()
1647
1920
  },
1921
+ READ_ONLY,
1648
1922
  async (args) => {
1649
1923
  try {
1650
1924
  const c = await cfg(args.workspace_id);
@@ -1671,6 +1945,7 @@ server.tool(
1671
1945
  "environments_list",
1672
1946
  "List environments in the workspace (id, name, status).",
1673
1947
  { workspace_id: wsId },
1948
+ READ_ONLY,
1674
1949
  async ({ workspace_id }) => {
1675
1950
  try {
1676
1951
  const c = await cfg(workspace_id);
@@ -1686,30 +1961,23 @@ server.tool(
1686
1961
  );
1687
1962
  server.tool(
1688
1963
  "coderunner_cancel_deployment",
1689
- "Delete a coderunner deployment (current deployment if deployment_id is omitted). Same as `gf coderunner cancel-deployment`.",
1964
+ "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
1965
  {
1691
- workspace_id: wsId,
1692
1966
  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")
1967
+ deployment_id: z.string().trim().min(1).describe(
1968
+ "Id of the deployment to delete (coderunner_get_status \u2192 deployment.id). Required: there is no default."
1969
+ )
1694
1970
  },
1971
+ DESTRUCTIVE,
1695
1972
  async (args) => {
1696
1973
  try {
1697
- const c = await cfg(args.workspace_id);
1974
+ const c = await cfg();
1698
1975
  const id = await resolveCoderunnerId(
1699
1976
  c.apiUrl,
1700
1977
  c.workspaceId,
1701
1978
  args.coderunner_id
1702
1979
  );
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
- }
1980
+ const deploymentId = args.deployment_id;
1713
1981
  await deleteCoderunnerDeployment(
1714
1982
  c.apiUrl,
1715
1983
  c.workspaceId,
@@ -1717,6 +1985,7 @@ server.tool(
1717
1985
  deploymentId
1718
1986
  );
1719
1987
  return text({
1988
+ workspace_id: c.workspaceId,
1720
1989
  coderunner_id: id,
1721
1990
  deployment_id: deploymentId,
1722
1991
  deleted: true,
@@ -1735,6 +2004,7 @@ server.tool(
1735
2004
  coderunner_id: z.string().min(1),
1736
2005
  job_id: z.string().min(1)
1737
2006
  },
2007
+ READ_ONLY,
1738
2008
  async (args) => {
1739
2009
  try {
1740
2010
  const c = await cfg(args.workspace_id);
@@ -1753,6 +2023,7 @@ server.tool(
1753
2023
  "apps_list",
1754
2024
  "List product Apps (Shell / standalone). Workloads are Coderunners, not Apps.",
1755
2025
  { workspace_id: wsId },
2026
+ READ_ONLY,
1756
2027
  async ({ workspace_id }) => {
1757
2028
  try {
1758
2029
  const c = await cfg(workspace_id);
@@ -1766,6 +2037,7 @@ server.tool(
1766
2037
  "apps_get",
1767
2038
  "Get one product App by id or slug.",
1768
2039
  { workspace_id: wsId, app_id: z.string().min(1) },
2040
+ READ_ONLY,
1769
2041
  async (args) => {
1770
2042
  try {
1771
2043
  const c = await cfg(args.workspace_id);
@@ -1783,6 +2055,7 @@ server.tool(
1783
2055
  "secrets_list",
1784
2056
  "List secret keys (values are not shown). To set/delete, use `gf secrets`.",
1785
2057
  { workspace_id: wsId, environment_id: z.string().optional() },
2058
+ READ_ONLY,
1786
2059
  async (args) => {
1787
2060
  try {
1788
2061
  const c = await cfg(args.workspace_id);
@@ -1798,6 +2071,7 @@ server.tool(
1798
2071
  "flags_list",
1799
2072
  "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
2073
  { workspace_id: wsId, environment_id: z.string().optional() },
2074
+ READ_ONLY,
1801
2075
  async (args) => {
1802
2076
  try {
1803
2077
  const c = await cfg(args.workspace_id);
@@ -1813,6 +2087,7 @@ server.tool(
1813
2087
  "files_list",
1814
2088
  "List workspace files (read-only). Upload/download with `gf files`.",
1815
2089
  { workspace_id: wsId },
2090
+ READ_ONLY,
1816
2091
  async ({ workspace_id }) => {
1817
2092
  try {
1818
2093
  const c = await cfg(workspace_id);
@@ -1824,17 +2099,45 @@ server.tool(
1824
2099
  );
1825
2100
  server.tool(
1826
2101
  "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) },
2102
+ '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.',
2103
+ {
2104
+ workspace_id: wsId,
2105
+ app_id: z.string().min(1).optional().describe("App id or slug"),
2106
+ coderunner_id: z.string().min(1).optional().describe("Coderunner id or slug; resolves to the App whose primary Coderunner it is")
2107
+ },
2108
+ READ_ONLY,
1829
2109
  async (args) => {
1830
2110
  try {
2111
+ if (!args.app_id && !args.coderunner_id) {
2112
+ throw new Error("Pass app_id (id or slug) or coderunner_id.");
2113
+ }
1831
2114
  const c = await cfg(args.workspace_id);
1832
- const id = await resolveCoderunnerId(
2115
+ const listed = await listApps(c.apiUrl, c.workspaceId);
2116
+ let match;
2117
+ if (args.app_id) {
2118
+ match = listed.apps.find(
2119
+ (app) => app.id === args.app_id || app.slug === args.app_id
2120
+ );
2121
+ if (!match) throw new Error(`App not found: ${args.app_id}`);
2122
+ } else {
2123
+ const crId = await resolveCoderunnerId(
2124
+ c.apiUrl,
2125
+ c.workspaceId,
2126
+ args.coderunner_id
2127
+ );
2128
+ match = listed.apps.find((app) => app.primary_coderunner_id === crId);
2129
+ if (!match) {
2130
+ throw new Error(
2131
+ `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>).`
2132
+ );
2133
+ }
2134
+ }
2135
+ const domains = await cpGetJson(
1833
2136
  c.apiUrl,
1834
- c.workspaceId,
1835
- args.coderunner_id
2137
+ c.token,
2138
+ `/v1/workspaces/${encodeURIComponent(c.workspaceId)}/apps/${encodeURIComponent(match.id)}/coderunner/domains`
1836
2139
  );
1837
- return text(await listDomains(c.apiUrl, c.workspaceId, id));
2140
+ return text({ app_id: match.id, app_slug: match.slug, ...domains });
1838
2141
  } catch (e) {
1839
2142
  return errText(e);
1840
2143
  }
@@ -1842,8 +2145,9 @@ server.tool(
1842
2145
  );
1843
2146
  server.tool(
1844
2147
  "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`.",
2148
+ "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
2149
  { workspace_id: wsId },
2150
+ READ_ONLY,
1847
2151
  async ({ workspace_id }) => {
1848
2152
  try {
1849
2153
  const c = await cfg(workspace_id);
@@ -1853,10 +2157,73 @@ server.tool(
1853
2157
  }
1854
2158
  }
1855
2159
  );
2160
+ var accountId = z.string().optional().describe(
2161
+ "Organization/account id (NOT the workspace id). Defaults to the account that owns the selected workspace."
2162
+ );
2163
+ async function resolveAccountId(c, explicit) {
2164
+ if (explicit?.trim()) return explicit.trim();
2165
+ const workspaces = await listWorkspaces(c.apiUrl);
2166
+ const ws = workspaces.find((w) => w.id === c.workspaceId);
2167
+ const id = ws?.account_id || ws?.organization_id;
2168
+ if (!id) {
2169
+ throw new Error(
2170
+ "Could not determine the account for the selected workspace. Pass account_id (organization id)."
2171
+ );
2172
+ }
2173
+ return id;
2174
+ }
2175
+ server.tool(
2176
+ "quota_get",
2177
+ "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.",
2178
+ { workspace_id: wsId, account_id: accountId },
2179
+ READ_ONLY,
2180
+ async (args) => {
2181
+ try {
2182
+ const c = await cfg(args.workspace_id);
2183
+ const id = await resolveAccountId(c, args.account_id);
2184
+ return text(
2185
+ await cpGetJson(
2186
+ c.apiUrl,
2187
+ c.token,
2188
+ `/v1/accounts/${encodeURIComponent(id)}/quota`
2189
+ )
2190
+ );
2191
+ } catch (e) {
2192
+ return errText(e);
2193
+ }
2194
+ }
2195
+ );
2196
+ server.tool(
2197
+ "billing_summary",
2198
+ "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.",
2199
+ {
2200
+ workspace_id: wsId,
2201
+ account_id: accountId,
2202
+ 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)")
2203
+ },
2204
+ READ_ONLY,
2205
+ async (args) => {
2206
+ try {
2207
+ const c = await cfg(args.workspace_id);
2208
+ const id = await resolveAccountId(c, args.account_id);
2209
+ const qs = args.since_seconds ? `?since_seconds=${args.since_seconds}` : "";
2210
+ return text(
2211
+ await cpGetJson(
2212
+ c.apiUrl,
2213
+ c.token,
2214
+ `/v1/accounts/${encodeURIComponent(id)}/billing/summary${qs}`
2215
+ )
2216
+ );
2217
+ } catch (e) {
2218
+ return errText(e);
2219
+ }
2220
+ }
2221
+ );
1856
2222
  server.tool(
1857
2223
  "docs_list",
1858
2224
  "Catalog of Groundfloor product docs (docs.groundfloor.cloud) the MCP can fetch.",
1859
2225
  { query: z.string().optional() },
2226
+ READ_ONLY,
1860
2227
  async ({ query }) => {
1861
2228
  const hits = findDoc(query ?? "");
1862
2229
  return text({
@@ -1874,15 +2241,19 @@ server.tool(
1874
2241
  );
1875
2242
  server.tool(
1876
2243
  "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.",
2244
+ "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
2245
  {
1879
2246
  id: z.string().min(1).describe(
1880
2247
  "Catalog id, e.g. coderunner | coderunner-runtime | shell | design-standard | css-and-styling | databases | agents | developers | authentication | access | m2m"
2248
+ ),
2249
+ offset: z.number().int().min(0).optional().describe(
2250
+ "Live (non-embedded) pages only: character offset to resume from. Pass nextOffset from the previous result when truncated is true."
1881
2251
  )
1882
2252
  },
1883
- async ({ id }) => {
2253
+ READ_ONLY,
2254
+ async ({ id, offset }) => {
1884
2255
  try {
1885
- const entry = DOC_CATALOG.find((d) => d.id === id);
2256
+ const entry = getDocById(id);
1886
2257
  if (!entry) {
1887
2258
  return errText(
1888
2259
  new Error(
@@ -1890,7 +2261,7 @@ server.tool(
1890
2261
  )
1891
2262
  );
1892
2263
  }
1893
- return text(await fetchDocPage(entry));
2264
+ return text(await fetchDocPage(entry, offset ?? 0));
1894
2265
  } catch (e) {
1895
2266
  return errText(e);
1896
2267
  }
@@ -1898,7 +2269,7 @@ server.tool(
1898
2269
  );
1899
2270
  server.tool(
1900
2271
  "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.",
2272
+ "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
2273
  {
1903
2274
  intent: z.enum([
1904
2275
  "login",
@@ -1909,6 +2280,7 @@ server.tool(
1909
2280
  "logs",
1910
2281
  "environments",
1911
2282
  "cancel_deployment",
2283
+ "create_app",
1912
2284
  "init_app",
1913
2285
  "publish_app",
1914
2286
  "secrets",
@@ -1918,10 +2290,11 @@ server.tool(
1918
2290
  "m2m_token"
1919
2291
  ]),
1920
2292
  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"),
2293
+ 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"),
2294
+ slug: z.string().optional().describe("Portal app slug for create_app / init_app / publish_app"),
1923
2295
  cell: z.enum(["production", "stage", "dev"]).optional()
1924
2296
  },
2297
+ READ_ONLY,
1925
2298
  async (args) => {
1926
2299
  return text(
1927
2300
  suggestCli(args.intent, {
@@ -1934,6 +2307,8 @@ server.tool(
1934
2307
  );
1935
2308
  }
1936
2309
  );
2310
+
2311
+ // src/index.ts
1937
2312
  async function main() {
1938
2313
  const transport = new StdioServerTransport();
1939
2314
  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.0",
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",