@groundfloorcloud/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/index.js +868 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `@groundfloorcloud/mcp`
|
|
2
|
+
|
|
3
|
+
MCP for **inspect + guidance**. The **`gf` CLI** takes actions (login, deploy,
|
|
4
|
+
secrets, publish).
|
|
5
|
+
|
|
6
|
+
They are not replacements. Sign in with `gf`; the MCP reads that session and
|
|
7
|
+
docs so the agent knows *what is in the workspace* and *which `gf` command to
|
|
8
|
+
run*.
|
|
9
|
+
|
|
10
|
+
## How the MCP gets context
|
|
11
|
+
|
|
12
|
+
| Source | What it answers |
|
|
13
|
+
|--------|-----------------|
|
|
14
|
+
| `gf login` → `~/.groundfloor` | Who you are, which cell (prod/stage/dev), default workspace. Token refresh is the CLI’s job. |
|
|
15
|
+
| Control Plane (read-only tools) | What exists now: coderunners, apps, dataplane, logs. Tool: `workspace_context`. |
|
|
16
|
+
| [docs.groundfloor.cloud](https://docs.groundfloor.cloud) | How and when: `docs_list` / `docs_get`. |
|
|
17
|
+
| `suggest_cli` | Exact `gf …` command to run (MCP does not deploy). |
|
|
18
|
+
|
|
19
|
+
Switch workspace: `gf workspaces use <uuid>` (then MCP sees it). Expired session: `gf login` again.
|
|
20
|
+
|
|
21
|
+
## Cursor config
|
|
22
|
+
|
|
23
|
+
`gf` must be on `PATH` and you must have run `gf login` (and usually `gf workspaces use`).
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"groundfloor": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "@groundfloorcloud/mcp"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Optional: `"GROUNDFLOOR_API_URL"` only if you are not using the cell from `gf login`. Do not put a JWT in mcp.json.
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | Role |
|
|
41
|
+
|------|------|
|
|
42
|
+
| `whoami` / `workspace_context` | Session + live workspace snapshot |
|
|
43
|
+
| `workspaces_list` | List; switch with `gf workspaces use` |
|
|
44
|
+
| `coderunner_list` / `_get_status` / `_get_logs` | Inspect workloads |
|
|
45
|
+
| `apps_list` / `apps_get` | Inspect product Apps |
|
|
46
|
+
| `secrets_list` / `files_list` / `domains_list` / `dataplane_status` | Inspect (no secrets values) |
|
|
47
|
+
| `docs_list` / `docs_get` | Product docs |
|
|
48
|
+
| `suggest_cli` | Command to run: login, deploy, run, … |
|
|
49
|
+
|
|
50
|
+
Never create `app_kind=coderunner`. Deploy with `gf deploy`.
|
|
51
|
+
|
|
52
|
+
## Build (from this repo)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install
|
|
56
|
+
npm run build --workspace=@groundfloorcloud/mcp
|
|
57
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// ../cli/src/auth/oauth.ts
|
|
9
|
+
import crypto from "crypto";
|
|
10
|
+
import http from "http";
|
|
11
|
+
function endpoints(issuer) {
|
|
12
|
+
const base = issuer.replace(/\/+$/, "");
|
|
13
|
+
return {
|
|
14
|
+
authorization: `${base}/protocol/openid-connect/auth`,
|
|
15
|
+
token: `${base}/protocol/openid-connect/token`,
|
|
16
|
+
deviceAuthorization: `${base}/protocol/openid-connect/auth/device`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function postForm(url, form) {
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
23
|
+
body: new URLSearchParams(form).toString()
|
|
24
|
+
});
|
|
25
|
+
let body = {};
|
|
26
|
+
try {
|
|
27
|
+
body = await res.json();
|
|
28
|
+
} catch {
|
|
29
|
+
body = {};
|
|
30
|
+
}
|
|
31
|
+
return { ok: res.ok, status: res.status, body };
|
|
32
|
+
}
|
|
33
|
+
function tokenResponseFrom(body) {
|
|
34
|
+
return {
|
|
35
|
+
access_token: String(body.access_token ?? ""),
|
|
36
|
+
refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
|
|
37
|
+
id_token: typeof body.id_token === "string" ? body.id_token : void 0,
|
|
38
|
+
expires_in: typeof body.expires_in === "number" ? body.expires_in : void 0,
|
|
39
|
+
token_type: typeof body.token_type === "string" ? body.token_type : void 0
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function refreshTokens(opts) {
|
|
43
|
+
const ep = endpoints(opts.issuer);
|
|
44
|
+
const { ok, status, body } = await postForm(ep.token, {
|
|
45
|
+
grant_type: "refresh_token",
|
|
46
|
+
client_id: opts.clientId,
|
|
47
|
+
refresh_token: opts.refreshToken
|
|
48
|
+
});
|
|
49
|
+
if (!ok) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Token refresh failed (${status}): ${body.error_description ?? body.error ?? "session expired"}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return tokenResponseFrom(body);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ../cli/src/auth/store.ts
|
|
58
|
+
import { promises as fs } from "fs";
|
|
59
|
+
import os from "os";
|
|
60
|
+
import path from "path";
|
|
61
|
+
var CONFIG_DIR = path.join(os.homedir(), ".groundfloor");
|
|
62
|
+
var AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
|
|
63
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
64
|
+
async function ensureDir() {
|
|
65
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
66
|
+
}
|
|
67
|
+
async function readJson(file) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = await fs.readFile(file, "utf8");
|
|
70
|
+
return JSON.parse(raw);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code === "ENOENT") {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function readAuth() {
|
|
79
|
+
return readJson(AUTH_FILE);
|
|
80
|
+
}
|
|
81
|
+
async function writeAuth(session) {
|
|
82
|
+
await ensureDir();
|
|
83
|
+
await fs.writeFile(AUTH_FILE, `${JSON.stringify(session, null, 2)}
|
|
84
|
+
`, {
|
|
85
|
+
mode: 384
|
|
86
|
+
});
|
|
87
|
+
await fs.chmod(AUTH_FILE, 384);
|
|
88
|
+
}
|
|
89
|
+
async function readConfig() {
|
|
90
|
+
return await readJson(CONFIG_FILE) ?? {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ../cli/src/auth/session.ts
|
|
94
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
95
|
+
var NotLoggedInError = class extends Error {
|
|
96
|
+
constructor() {
|
|
97
|
+
super(
|
|
98
|
+
"Not logged in. Set GROUNDFLOOR_TOKEN, or run `gf login` first."
|
|
99
|
+
);
|
|
100
|
+
this.name = "NotLoggedInError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var accessTokenProvider = null;
|
|
104
|
+
function setAccessTokenProvider(provider) {
|
|
105
|
+
accessTokenProvider = provider;
|
|
106
|
+
}
|
|
107
|
+
function decodeJwt(token) {
|
|
108
|
+
const parts = token.split(".");
|
|
109
|
+
if (parts.length < 2) return null;
|
|
110
|
+
try {
|
|
111
|
+
const json = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
112
|
+
return JSON.parse(json);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function sessionFromTokens(tokens, ctx) {
|
|
118
|
+
const expiresInMs = (tokens.expires_in ?? 300) * 1e3;
|
|
119
|
+
return {
|
|
120
|
+
issuer: ctx.issuer,
|
|
121
|
+
clientId: ctx.clientId,
|
|
122
|
+
apiUrl: ctx.apiUrl,
|
|
123
|
+
accessToken: tokens.access_token,
|
|
124
|
+
refreshToken: tokens.refresh_token,
|
|
125
|
+
idToken: tokens.id_token,
|
|
126
|
+
expiresAt: Date.now() + expiresInMs
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function isFresh(session) {
|
|
130
|
+
return Date.now() < session.expiresAt - EXPIRY_SKEW_MS;
|
|
131
|
+
}
|
|
132
|
+
async function getValidAccessToken() {
|
|
133
|
+
if (accessTokenProvider) {
|
|
134
|
+
return accessTokenProvider();
|
|
135
|
+
}
|
|
136
|
+
const session = await readAuth();
|
|
137
|
+
if (!session || !session.accessToken) {
|
|
138
|
+
throw new NotLoggedInError();
|
|
139
|
+
}
|
|
140
|
+
if (isFresh(session)) {
|
|
141
|
+
return session.accessToken;
|
|
142
|
+
}
|
|
143
|
+
if (!session.refreshToken) {
|
|
144
|
+
throw new NotLoggedInError();
|
|
145
|
+
}
|
|
146
|
+
const refreshed = await refreshTokens({
|
|
147
|
+
issuer: session.issuer,
|
|
148
|
+
clientId: session.clientId,
|
|
149
|
+
refreshToken: session.refreshToken
|
|
150
|
+
});
|
|
151
|
+
const next = sessionFromTokens(refreshed, {
|
|
152
|
+
issuer: session.issuer,
|
|
153
|
+
clientId: session.clientId,
|
|
154
|
+
apiUrl: session.apiUrl
|
|
155
|
+
});
|
|
156
|
+
if (!next.refreshToken) {
|
|
157
|
+
next.refreshToken = session.refreshToken;
|
|
158
|
+
}
|
|
159
|
+
await writeAuth(next);
|
|
160
|
+
return next.accessToken;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ../cli/src/cp.ts
|
|
164
|
+
var CpError = class extends Error {
|
|
165
|
+
constructor(status, message) {
|
|
166
|
+
super(message);
|
|
167
|
+
this.status = status;
|
|
168
|
+
this.name = "CpError";
|
|
169
|
+
}
|
|
170
|
+
status;
|
|
171
|
+
};
|
|
172
|
+
async function parseError(res) {
|
|
173
|
+
let detail = res.statusText;
|
|
174
|
+
try {
|
|
175
|
+
const body = await res.json();
|
|
176
|
+
if (body?.detail) detail = String(body.detail);
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
return `${res.status} ${detail}`;
|
|
180
|
+
}
|
|
181
|
+
async function cpGet(apiUrl, path2) {
|
|
182
|
+
const token = await getValidAccessToken();
|
|
183
|
+
const res = await fetch(`${apiUrl}${path2}`, {
|
|
184
|
+
headers: {
|
|
185
|
+
Authorization: `Bearer ${token}`,
|
|
186
|
+
Accept: "application/json"
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
throw new CpError(res.status, await parseError(res));
|
|
191
|
+
}
|
|
192
|
+
return await res.json();
|
|
193
|
+
}
|
|
194
|
+
function queryString(params) {
|
|
195
|
+
const u = new URLSearchParams();
|
|
196
|
+
for (const [key, value] of Object.entries(params)) {
|
|
197
|
+
if (value) u.set(key, value);
|
|
198
|
+
}
|
|
199
|
+
const s = u.toString();
|
|
200
|
+
return s ? `?${s}` : "";
|
|
201
|
+
}
|
|
202
|
+
function listWorkspaces(apiUrl) {
|
|
203
|
+
return cpGet(apiUrl, "/v1/workspaces");
|
|
204
|
+
}
|
|
205
|
+
async function listCoderunners(apiUrl, workspaceId) {
|
|
206
|
+
const data = await cpGet(
|
|
207
|
+
apiUrl,
|
|
208
|
+
`/v1/workspaces/${encodeURIComponent(workspaceId)}/coderunners`
|
|
209
|
+
);
|
|
210
|
+
if (Array.isArray(data)) return data;
|
|
211
|
+
return data.coderunners ?? [];
|
|
212
|
+
}
|
|
213
|
+
async function getDeployment(apiUrl, workspaceId, coderunnerId) {
|
|
214
|
+
const token = await getValidAccessToken();
|
|
215
|
+
const res = await fetch(
|
|
216
|
+
`${apiUrl}/v1/workspaces/${encodeURIComponent(
|
|
217
|
+
workspaceId
|
|
218
|
+
)}/coderunners/${encodeURIComponent(coderunnerId)}/deployment`,
|
|
219
|
+
{
|
|
220
|
+
headers: {
|
|
221
|
+
Authorization: `Bearer ${token}`,
|
|
222
|
+
Accept: "application/json"
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
if (res.status === 204) return null;
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
throw new CpError(res.status, await parseError(res));
|
|
229
|
+
}
|
|
230
|
+
return await res.json();
|
|
231
|
+
}
|
|
232
|
+
function getCoderunnerStatus(apiUrl, workspaceId, coderunnerId) {
|
|
233
|
+
return cpGet(
|
|
234
|
+
apiUrl,
|
|
235
|
+
`/v1/workspaces/${encodeURIComponent(
|
|
236
|
+
workspaceId
|
|
237
|
+
)}/coderunners/${encodeURIComponent(coderunnerId)}/status`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
function getCoderunnerLogs(apiUrl, workspaceId, coderunnerId, jobId) {
|
|
241
|
+
return cpGet(
|
|
242
|
+
apiUrl,
|
|
243
|
+
`/v1/workspaces/${encodeURIComponent(
|
|
244
|
+
workspaceId
|
|
245
|
+
)}/coderunners/${encodeURIComponent(
|
|
246
|
+
coderunnerId
|
|
247
|
+
)}/logs/${encodeURIComponent(jobId)}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
function domainsBase(workspaceId, coderunnerId) {
|
|
251
|
+
return `/v1/workspaces/${encodeURIComponent(
|
|
252
|
+
workspaceId
|
|
253
|
+
)}/coderunners/${encodeURIComponent(coderunnerId)}/domains`;
|
|
254
|
+
}
|
|
255
|
+
function listDomains(apiUrl, workspaceId, coderunnerId) {
|
|
256
|
+
return cpGet(
|
|
257
|
+
apiUrl,
|
|
258
|
+
domainsBase(workspaceId, coderunnerId)
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
function wsPath(workspaceId, suffix) {
|
|
262
|
+
return `/v1/workspaces/${encodeURIComponent(workspaceId)}${suffix}`;
|
|
263
|
+
}
|
|
264
|
+
function listSecrets(apiUrl, workspaceId, environmentId) {
|
|
265
|
+
return cpGet(
|
|
266
|
+
apiUrl,
|
|
267
|
+
`${wsPath(workspaceId, "/secrets")}${queryString({
|
|
268
|
+
environment_id: environmentId
|
|
269
|
+
})}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
function listFiles(apiUrl, workspaceId) {
|
|
273
|
+
return cpGet(apiUrl, wsPath(workspaceId, "/files"));
|
|
274
|
+
}
|
|
275
|
+
function listApps(apiUrl, workspaceId) {
|
|
276
|
+
return cpGet(
|
|
277
|
+
apiUrl,
|
|
278
|
+
`${wsPath(workspaceId, "/apps")}${queryString({ kind: "product" })}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
function getApp(apiUrl, workspaceId, appId) {
|
|
282
|
+
return cpGet(
|
|
283
|
+
apiUrl,
|
|
284
|
+
wsPath(workspaceId, `/apps/${encodeURIComponent(appId)}`)
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
function getDataplaneStatus(apiUrl, workspaceId) {
|
|
288
|
+
return cpGet(
|
|
289
|
+
apiUrl,
|
|
290
|
+
wsPath(workspaceId, "/dataplane/status")
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ../cli/src/auth/config.ts
|
|
295
|
+
var CELLS = {
|
|
296
|
+
production: {
|
|
297
|
+
apiUrl: "https://platform.groundfloor.cloud",
|
|
298
|
+
issuer: "https://auth.groundfloor.cloud/realms/groundfloor",
|
|
299
|
+
clientId: "groundfloor-cli"
|
|
300
|
+
},
|
|
301
|
+
stage: {
|
|
302
|
+
apiUrl: "https://platform.stage.groundfloor.cloud",
|
|
303
|
+
issuer: "https://auth.stage.groundfloor.cloud/realms/groundfloor_pico_stage-realm",
|
|
304
|
+
clientId: "groundfloor-cli"
|
|
305
|
+
},
|
|
306
|
+
dev: {
|
|
307
|
+
apiUrl: "https://platform.dev.groundfloor.cloud",
|
|
308
|
+
issuer: "https://auth.dev.groundfloor.cloud/realms/groundfloor_dev",
|
|
309
|
+
clientId: "groundfloor-cli"
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
function inferCell(apiUrl, issuer) {
|
|
313
|
+
const api = apiUrl.replace(/\/+$/, "");
|
|
314
|
+
const iss = (issuer ?? "").replace(/\/+$/, "");
|
|
315
|
+
for (const [name, hosts] of Object.entries(CELLS)) {
|
|
316
|
+
if (api === hosts.apiUrl || iss === hosts.issuer) return name;
|
|
317
|
+
}
|
|
318
|
+
return void 0;
|
|
319
|
+
}
|
|
320
|
+
function trimTrailingSlash(value) {
|
|
321
|
+
return value.replace(/\/+$/, "");
|
|
322
|
+
}
|
|
323
|
+
async function resolveConfig(overrides = {}) {
|
|
324
|
+
const file = await readConfig();
|
|
325
|
+
const production = CELLS.production;
|
|
326
|
+
const issuer = overrides.issuer ?? process.env.GROUNDFLOOR_ISSUER ?? file.issuer ?? production.issuer;
|
|
327
|
+
const clientId = overrides.clientId ?? process.env.GROUNDFLOOR_CLIENT_ID ?? file.clientId ?? production.clientId;
|
|
328
|
+
const apiUrl = overrides.apiUrl ?? process.env.GROUNDFLOOR_API_URL ?? file.apiUrl ?? production.apiUrl;
|
|
329
|
+
const workspaceId = overrides.workspaceId ?? process.env.GROUNDFLOOR_WORKSPACE_ID ?? file.workspaceId;
|
|
330
|
+
const environment = overrides.environment ?? inferCell(apiUrl, issuer) ?? file.environment;
|
|
331
|
+
return {
|
|
332
|
+
issuer: trimTrailingSlash(issuer),
|
|
333
|
+
clientId,
|
|
334
|
+
apiUrl: trimTrailingSlash(apiUrl),
|
|
335
|
+
workspaceId: workspaceId || void 0,
|
|
336
|
+
environment
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
var defaults = CELLS.production;
|
|
340
|
+
|
|
341
|
+
// src/config.ts
|
|
342
|
+
function trimSlash(url) {
|
|
343
|
+
return url.replace(/\/+$/, "");
|
|
344
|
+
}
|
|
345
|
+
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.";
|
|
346
|
+
var MISSING_WORKSPACE = "No workspace selected. Run `gf workspaces` then `gf workspaces use <uuid>`, or pass workspace_id on this tool.";
|
|
347
|
+
async function resolveMcpConfig(overrides) {
|
|
348
|
+
const file = await resolveConfig();
|
|
349
|
+
const apiUrl = trimSlash(
|
|
350
|
+
process.env.GROUNDFLOOR_API_URL || process.env.CP_API || file.apiUrl || CELLS.production.apiUrl
|
|
351
|
+
);
|
|
352
|
+
const workspaceId = overrides?.workspaceId || process.env.GROUNDFLOOR_WORKSPACE_ID || process.env.WORKSPACE_ID || file.workspaceId || "";
|
|
353
|
+
const envToken = (process.env.GROUNDFLOOR_TOKEN || process.env.TOKEN || "").trim();
|
|
354
|
+
if (envToken) {
|
|
355
|
+
setAccessTokenProvider(async () => envToken);
|
|
356
|
+
} else {
|
|
357
|
+
setAccessTokenProvider(null);
|
|
358
|
+
}
|
|
359
|
+
let token = envToken;
|
|
360
|
+
if (!token) {
|
|
361
|
+
try {
|
|
362
|
+
token = await getValidAccessToken();
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if (err instanceof NotLoggedInError) {
|
|
365
|
+
throw new Error(MISSING_TOKEN);
|
|
366
|
+
}
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!token) {
|
|
371
|
+
throw new Error(MISSING_TOKEN);
|
|
372
|
+
}
|
|
373
|
+
const requireWorkspace = overrides?.requireWorkspace !== false;
|
|
374
|
+
if (requireWorkspace && !workspaceId) {
|
|
375
|
+
throw new Error(MISSING_WORKSPACE);
|
|
376
|
+
}
|
|
377
|
+
return { apiUrl, workspaceId, token };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/docs.ts
|
|
381
|
+
var DOCS_ORIGIN = "https://docs.groundfloor.cloud";
|
|
382
|
+
var DOC_CATALOG = [
|
|
383
|
+
{
|
|
384
|
+
id: "agents",
|
|
385
|
+
title: "Deploy with agents",
|
|
386
|
+
path: "/docs/customer-portal/agents",
|
|
387
|
+
summary: "How agents should deploy Coderunner workloads with gf / MCP."
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "developers",
|
|
391
|
+
title: "Developers (gf CLI)",
|
|
392
|
+
path: "/docs/customer-portal/developers",
|
|
393
|
+
summary: "gf login, workspaces, deploy, secrets, files, apps."
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
id: "coderunner",
|
|
397
|
+
title: "Coderunner",
|
|
398
|
+
path: "/docs/customer-portal/coderunner",
|
|
399
|
+
summary: "Functions, jobs, schedules, services \u2014 not Apps."
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
id: "authentication",
|
|
403
|
+
title: "API authentication",
|
|
404
|
+
path: "/docs/api/authentication",
|
|
405
|
+
summary: "Control Plane Bearer tokens and workspace identity."
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "shell",
|
|
409
|
+
title: "Shell federated apps",
|
|
410
|
+
path: "/docs/integrations/shell",
|
|
411
|
+
summary: "Product Apps vs Coderunner deploy."
|
|
412
|
+
}
|
|
413
|
+
];
|
|
414
|
+
function docUrl(entry) {
|
|
415
|
+
return `${DOCS_ORIGIN}${entry.path}`;
|
|
416
|
+
}
|
|
417
|
+
function findDoc(query) {
|
|
418
|
+
const q = query.trim().toLowerCase();
|
|
419
|
+
if (!q) return DOC_CATALOG;
|
|
420
|
+
return DOC_CATALOG.filter((d) => {
|
|
421
|
+
const hay = `${d.id} ${d.title} ${d.summary} ${d.path}`.toLowerCase();
|
|
422
|
+
return q.split(/\s+/).every((part) => hay.includes(part));
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function htmlToText(html) {
|
|
426
|
+
const article = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i)?.[1] ?? html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i)?.[1] ?? html;
|
|
427
|
+
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'/g, "'").replace(/"/g, '"').replace(/\s+/g, " ").trim();
|
|
428
|
+
}
|
|
429
|
+
var MAX_CHARS = 12e3;
|
|
430
|
+
async function fetchDocPage(entry) {
|
|
431
|
+
const url = docUrl(entry);
|
|
432
|
+
const res = await fetch(url, {
|
|
433
|
+
headers: { Accept: "text/html", "User-Agent": "groundfloor-mcp/0.1" },
|
|
434
|
+
redirect: "follow"
|
|
435
|
+
});
|
|
436
|
+
if (!res.ok) {
|
|
437
|
+
throw new Error(`Docs fetch failed: ${res.status} ${url}`);
|
|
438
|
+
}
|
|
439
|
+
const html = await res.text();
|
|
440
|
+
let body = htmlToText(html);
|
|
441
|
+
const truncated = body.length > MAX_CHARS;
|
|
442
|
+
if (truncated) body = `${body.slice(0, MAX_CHARS)}
|
|
443
|
+
\u2026`;
|
|
444
|
+
return { url, title: entry.title, text: body, truncated };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/playbook.ts
|
|
448
|
+
function suggestCli(intent, args) {
|
|
449
|
+
const ws = args?.workspace ? ` -w ${args.workspace}` : "";
|
|
450
|
+
const name = args?.name ?? args?.coderunner;
|
|
451
|
+
switch (intent) {
|
|
452
|
+
case "login":
|
|
453
|
+
return {
|
|
454
|
+
command: args?.cell === "stage" ? "gf login --stage" : args?.cell === "dev" ? "gf login --dev" : "gf login",
|
|
455
|
+
why: "Creates ~/.groundfloor session (auto-refresh). MCP reads this; do not paste JWTs into mcp.json.",
|
|
456
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/developers"
|
|
457
|
+
};
|
|
458
|
+
case "switch_workspace":
|
|
459
|
+
return {
|
|
460
|
+
command: args?.workspace ? `gf workspaces use ${args.workspace}` : "gf workspaces && gf workspaces use <uuid>",
|
|
461
|
+
why: "Sets the default workspace for gf and MCP.",
|
|
462
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/developers"
|
|
463
|
+
};
|
|
464
|
+
case "deploy":
|
|
465
|
+
return {
|
|
466
|
+
command: name ? `gf deploy -n ${name}${ws}` : `gf deploy${ws}`,
|
|
467
|
+
why: "Package the current (or --path) folder and deploy as a Coderunner. Do not create an App for this.",
|
|
468
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/coderunner"
|
|
469
|
+
};
|
|
470
|
+
case "run":
|
|
471
|
+
return {
|
|
472
|
+
command: name ? `gf coderunner run -c ${name}${ws}` : `gf coderunner run -c <id-or-slug>${ws}`,
|
|
473
|
+
why: "Smoke-invoke a deployed function or job.",
|
|
474
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/coderunner"
|
|
475
|
+
};
|
|
476
|
+
case "status":
|
|
477
|
+
return {
|
|
478
|
+
command: name ? `gf coderunner status -c ${name}${ws}` : `gf coderunner ls${ws}`,
|
|
479
|
+
why: "Inspect build/deploy state.",
|
|
480
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/coderunner"
|
|
481
|
+
};
|
|
482
|
+
case "publish_app":
|
|
483
|
+
return {
|
|
484
|
+
command: "gf apps publish --path release.zip",
|
|
485
|
+
why: "Ship a Shell federated product App (not a Coderunner workload).",
|
|
486
|
+
docs: "https://docs.groundfloor.cloud/docs/integrations/shell"
|
|
487
|
+
};
|
|
488
|
+
case "secrets":
|
|
489
|
+
return {
|
|
490
|
+
command: `gf secrets ls${ws}`,
|
|
491
|
+
why: "Mutations: gf secrets set|get|rm. MCP only lists keys.",
|
|
492
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/developers"
|
|
493
|
+
};
|
|
494
|
+
case "dataplane":
|
|
495
|
+
return {
|
|
496
|
+
command: `gf dataplane status${ws} # provision: gf dataplane provision`,
|
|
497
|
+
why: "Dataplane is required for vault, files, and secrets.",
|
|
498
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/developers"
|
|
499
|
+
};
|
|
500
|
+
default:
|
|
501
|
+
return {
|
|
502
|
+
command: "gf --help",
|
|
503
|
+
why: `Unknown intent "${intent}". Use login | switch_workspace | deploy | run | status | publish_app | secrets | dataplane.`,
|
|
504
|
+
docs: "https://docs.groundfloor.cloud/docs/customer-portal/developers"
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/respond.ts
|
|
510
|
+
function text(data) {
|
|
511
|
+
return {
|
|
512
|
+
content: [
|
|
513
|
+
{
|
|
514
|
+
type: "text",
|
|
515
|
+
text: typeof data === "string" ? data : JSON.stringify(data, null, 2)
|
|
516
|
+
}
|
|
517
|
+
]
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function errText(err) {
|
|
521
|
+
let message;
|
|
522
|
+
if (err instanceof NotLoggedInError) {
|
|
523
|
+
message = "Not signed in. Run `gf login` so MCP can use ~/.groundfloor. Tokens refresh with the CLI session.";
|
|
524
|
+
} else if (err instanceof CpError) {
|
|
525
|
+
message = `CP error: ${err.message}`;
|
|
526
|
+
} else if (err instanceof Error) {
|
|
527
|
+
message = err.message;
|
|
528
|
+
} else {
|
|
529
|
+
message = String(err);
|
|
530
|
+
}
|
|
531
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/index.ts
|
|
535
|
+
var wsId = z.string().optional().describe("Override the gf default workspace for this call only");
|
|
536
|
+
async function cfg(workspaceId, requireWorkspace = true) {
|
|
537
|
+
return resolveMcpConfig({ workspaceId, requireWorkspace });
|
|
538
|
+
}
|
|
539
|
+
async function resolveCoderunnerId(apiUrl, workspaceId, idOrSlug) {
|
|
540
|
+
const list = await listCoderunners(apiUrl, workspaceId);
|
|
541
|
+
const match = list.find((c) => c.id === idOrSlug || c.slug === idOrSlug);
|
|
542
|
+
if (!match) throw new Error(`Coderunner not found: ${idOrSlug}`);
|
|
543
|
+
return match.id;
|
|
544
|
+
}
|
|
545
|
+
var server = new McpServer({
|
|
546
|
+
name: "groundfloor",
|
|
547
|
+
version: "0.1.0"
|
|
548
|
+
});
|
|
549
|
+
server.tool(
|
|
550
|
+
"whoami",
|
|
551
|
+
"Show the gf CLI session (identity, cell, selected workspace). Run `gf login` if this fails.",
|
|
552
|
+
{},
|
|
553
|
+
async () => {
|
|
554
|
+
try {
|
|
555
|
+
const c = await cfg(void 0, false);
|
|
556
|
+
const claims = decodeJwt(c.token) ?? {};
|
|
557
|
+
const workspaces = await listWorkspaces(c.apiUrl);
|
|
558
|
+
return text({
|
|
559
|
+
email: claims.email || claims.preferred_username || null,
|
|
560
|
+
sub: claims.sub || null,
|
|
561
|
+
iss: claims.iss || null,
|
|
562
|
+
apiUrl: c.apiUrl,
|
|
563
|
+
workspaceId: c.workspaceId || null,
|
|
564
|
+
workspace_count: workspaces.length,
|
|
565
|
+
hint: c.workspaceId ? "Switch with: gf workspaces use <uuid>" : "Select a workspace: gf workspaces && gf workspaces use <uuid>"
|
|
566
|
+
});
|
|
567
|
+
} catch (e) {
|
|
568
|
+
return errText(e);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
);
|
|
572
|
+
server.tool(
|
|
573
|
+
"workspace_context",
|
|
574
|
+
"Live context for the selected workspace: session, coderunners, apps, dataplane, and suggested gf commands. This is how the agent knows *what is here*; use docs_get for *how to do it*.",
|
|
575
|
+
{ workspace_id: wsId },
|
|
576
|
+
async ({ workspace_id }) => {
|
|
577
|
+
try {
|
|
578
|
+
const c = await cfg(workspace_id);
|
|
579
|
+
const claims = decodeJwt(c.token) ?? {};
|
|
580
|
+
const [coderunners, apps, dataplane] = await Promise.all([
|
|
581
|
+
listCoderunners(c.apiUrl, c.workspaceId),
|
|
582
|
+
listApps(c.apiUrl, c.workspaceId).catch(() => ({ apps: [] })),
|
|
583
|
+
getDataplaneStatus(c.apiUrl, c.workspaceId).catch(() => null)
|
|
584
|
+
]);
|
|
585
|
+
return text({
|
|
586
|
+
session: {
|
|
587
|
+
email: claims.email || claims.preferred_username || null,
|
|
588
|
+
apiUrl: c.apiUrl,
|
|
589
|
+
workspaceId: c.workspaceId
|
|
590
|
+
},
|
|
591
|
+
coderunners: coderunners.map((cr) => ({
|
|
592
|
+
id: cr.id,
|
|
593
|
+
name: cr.name ?? cr.slug,
|
|
594
|
+
slug: cr.slug,
|
|
595
|
+
status: cr.status
|
|
596
|
+
})),
|
|
597
|
+
apps: apps.apps.map((app) => ({
|
|
598
|
+
id: app.id,
|
|
599
|
+
name: app.name,
|
|
600
|
+
slug: app.slug,
|
|
601
|
+
kind: app.app_kind,
|
|
602
|
+
status: app.status
|
|
603
|
+
})),
|
|
604
|
+
dataplane,
|
|
605
|
+
actions: {
|
|
606
|
+
deploy: suggestCli("deploy"),
|
|
607
|
+
switch_workspace: suggestCli("switch_workspace"),
|
|
608
|
+
run: suggestCli("run")
|
|
609
|
+
},
|
|
610
|
+
docs: DOC_CATALOG.map((d) => ({
|
|
611
|
+
id: d.id,
|
|
612
|
+
title: d.title,
|
|
613
|
+
url: `https://docs.groundfloor.cloud${d.path}`
|
|
614
|
+
}))
|
|
615
|
+
});
|
|
616
|
+
} catch (e) {
|
|
617
|
+
return errText(e);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
);
|
|
621
|
+
server.tool(
|
|
622
|
+
"workspaces_list",
|
|
623
|
+
"List workspaces visible to the current user. To change the default, tell the user to run `gf workspaces use <uuid>` (CLI owns that).",
|
|
624
|
+
{},
|
|
625
|
+
async () => {
|
|
626
|
+
try {
|
|
627
|
+
const c = await cfg(void 0, false);
|
|
628
|
+
const workspaces = await listWorkspaces(c.apiUrl);
|
|
629
|
+
return text({
|
|
630
|
+
workspaces,
|
|
631
|
+
selected: c.workspaceId || null,
|
|
632
|
+
count: workspaces.length,
|
|
633
|
+
next: suggestCli("switch_workspace", {
|
|
634
|
+
workspace: c.workspaceId ?? ""
|
|
635
|
+
})
|
|
636
|
+
});
|
|
637
|
+
} catch (e) {
|
|
638
|
+
return errText(e);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
server.tool(
|
|
643
|
+
"coderunner_list",
|
|
644
|
+
"List Coderunners in the workspace (read-only).",
|
|
645
|
+
{ workspace_id: wsId },
|
|
646
|
+
async ({ workspace_id }) => {
|
|
647
|
+
try {
|
|
648
|
+
const c = await cfg(workspace_id);
|
|
649
|
+
const list = await listCoderunners(c.apiUrl, c.workspaceId);
|
|
650
|
+
return text({ coderunners: list, count: list.length });
|
|
651
|
+
} catch (e) {
|
|
652
|
+
return errText(e);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
);
|
|
656
|
+
server.tool(
|
|
657
|
+
"coderunner_get_status",
|
|
658
|
+
"Coderunner lifecycle status + current deployment (read-only).",
|
|
659
|
+
{
|
|
660
|
+
workspace_id: wsId,
|
|
661
|
+
coderunner_id: z.string().min(1).describe("Coderunner id or slug")
|
|
662
|
+
},
|
|
663
|
+
async (args) => {
|
|
664
|
+
try {
|
|
665
|
+
const c = await cfg(args.workspace_id);
|
|
666
|
+
const id = await resolveCoderunnerId(
|
|
667
|
+
c.apiUrl,
|
|
668
|
+
c.workspaceId,
|
|
669
|
+
args.coderunner_id
|
|
670
|
+
);
|
|
671
|
+
const [status, deployment] = await Promise.all([
|
|
672
|
+
getCoderunnerStatus(c.apiUrl, c.workspaceId, id),
|
|
673
|
+
getDeployment(c.apiUrl, c.workspaceId, id)
|
|
674
|
+
]);
|
|
675
|
+
return text({ coderunner_id: id, status, deployment });
|
|
676
|
+
} catch (e) {
|
|
677
|
+
return errText(e);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
server.tool(
|
|
682
|
+
"coderunner_get_logs",
|
|
683
|
+
"Fetch logs for a job id from a previous `gf coderunner run`.",
|
|
684
|
+
{
|
|
685
|
+
workspace_id: wsId,
|
|
686
|
+
coderunner_id: z.string().min(1),
|
|
687
|
+
job_id: z.string().min(1)
|
|
688
|
+
},
|
|
689
|
+
async (args) => {
|
|
690
|
+
try {
|
|
691
|
+
const c = await cfg(args.workspace_id);
|
|
692
|
+
const id = await resolveCoderunnerId(
|
|
693
|
+
c.apiUrl,
|
|
694
|
+
c.workspaceId,
|
|
695
|
+
args.coderunner_id
|
|
696
|
+
);
|
|
697
|
+
return text(await getCoderunnerLogs(c.apiUrl, c.workspaceId, id, args.job_id));
|
|
698
|
+
} catch (e) {
|
|
699
|
+
return errText(e);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
server.tool(
|
|
704
|
+
"apps_list",
|
|
705
|
+
"List product Apps (Shell / standalone). Workloads are Coderunners, not Apps.",
|
|
706
|
+
{ workspace_id: wsId },
|
|
707
|
+
async ({ workspace_id }) => {
|
|
708
|
+
try {
|
|
709
|
+
const c = await cfg(workspace_id);
|
|
710
|
+
return text(await listApps(c.apiUrl, c.workspaceId));
|
|
711
|
+
} catch (e) {
|
|
712
|
+
return errText(e);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
server.tool(
|
|
717
|
+
"apps_get",
|
|
718
|
+
"Get one product App by id or slug.",
|
|
719
|
+
{ workspace_id: wsId, app_id: z.string().min(1) },
|
|
720
|
+
async (args) => {
|
|
721
|
+
try {
|
|
722
|
+
const c = await cfg(args.workspace_id);
|
|
723
|
+
const listed = await listApps(c.apiUrl, c.workspaceId);
|
|
724
|
+
const match = listed.apps.find(
|
|
725
|
+
(app) => app.id === args.app_id || app.slug === args.app_id
|
|
726
|
+
);
|
|
727
|
+
return text(match ?? await getApp(c.apiUrl, c.workspaceId, args.app_id));
|
|
728
|
+
} catch (e) {
|
|
729
|
+
return errText(e);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
);
|
|
733
|
+
server.tool(
|
|
734
|
+
"secrets_list",
|
|
735
|
+
"List secret keys (values are not shown). To set/delete, use `gf secrets`.",
|
|
736
|
+
{ workspace_id: wsId, environment_id: z.string().optional() },
|
|
737
|
+
async (args) => {
|
|
738
|
+
try {
|
|
739
|
+
const c = await cfg(args.workspace_id);
|
|
740
|
+
return text(
|
|
741
|
+
await listSecrets(c.apiUrl, c.workspaceId, args.environment_id)
|
|
742
|
+
);
|
|
743
|
+
} catch (e) {
|
|
744
|
+
return errText(e);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
);
|
|
748
|
+
server.tool(
|
|
749
|
+
"files_list",
|
|
750
|
+
"List workspace files (read-only). Upload/download with `gf files`.",
|
|
751
|
+
{ workspace_id: wsId },
|
|
752
|
+
async ({ workspace_id }) => {
|
|
753
|
+
try {
|
|
754
|
+
const c = await cfg(workspace_id);
|
|
755
|
+
return text(await listFiles(c.apiUrl, c.workspaceId));
|
|
756
|
+
} catch (e) {
|
|
757
|
+
return errText(e);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
);
|
|
761
|
+
server.tool(
|
|
762
|
+
"domains_list",
|
|
763
|
+
"List custom domains for a Coderunner service. Add/verify with `gf domains`.",
|
|
764
|
+
{ workspace_id: wsId, coderunner_id: z.string().min(1) },
|
|
765
|
+
async (args) => {
|
|
766
|
+
try {
|
|
767
|
+
const c = await cfg(args.workspace_id);
|
|
768
|
+
const id = await resolveCoderunnerId(
|
|
769
|
+
c.apiUrl,
|
|
770
|
+
c.workspaceId,
|
|
771
|
+
args.coderunner_id
|
|
772
|
+
);
|
|
773
|
+
return text(await listDomains(c.apiUrl, c.workspaceId, id));
|
|
774
|
+
} catch (e) {
|
|
775
|
+
return errText(e);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
);
|
|
779
|
+
server.tool(
|
|
780
|
+
"dataplane_status",
|
|
781
|
+
"Dataplane setup status. Provision with `gf dataplane provision`.",
|
|
782
|
+
{ workspace_id: wsId },
|
|
783
|
+
async ({ workspace_id }) => {
|
|
784
|
+
try {
|
|
785
|
+
const c = await cfg(workspace_id);
|
|
786
|
+
return text(await getDataplaneStatus(c.apiUrl, c.workspaceId));
|
|
787
|
+
} catch (e) {
|
|
788
|
+
return errText(e);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
);
|
|
792
|
+
server.tool(
|
|
793
|
+
"docs_list",
|
|
794
|
+
"Catalog of Groundfloor product docs (docs.groundfloor.cloud) the MCP can fetch.",
|
|
795
|
+
{ query: z.string().optional() },
|
|
796
|
+
async ({ query }) => {
|
|
797
|
+
const hits = findDoc(query ?? "");
|
|
798
|
+
return text({
|
|
799
|
+
origin: "https://docs.groundfloor.cloud",
|
|
800
|
+
docs: hits.map((d) => ({
|
|
801
|
+
id: d.id,
|
|
802
|
+
title: d.title,
|
|
803
|
+
url: `https://docs.groundfloor.cloud${d.path}`,
|
|
804
|
+
summary: d.summary
|
|
805
|
+
}))
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
);
|
|
809
|
+
server.tool(
|
|
810
|
+
"docs_get",
|
|
811
|
+
"Fetch a product doc page from docs.groundfloor.cloud (how/when to use CLI). Pass id from docs_list (agents, developers, coderunner, authentication, shell).",
|
|
812
|
+
{
|
|
813
|
+
id: z.string().min(1).describe("Catalog id, e.g. agents | developers | coderunner")
|
|
814
|
+
},
|
|
815
|
+
async ({ id }) => {
|
|
816
|
+
try {
|
|
817
|
+
const entry = DOC_CATALOG.find((d) => d.id === id);
|
|
818
|
+
if (!entry) {
|
|
819
|
+
return errText(
|
|
820
|
+
new Error(
|
|
821
|
+
`Unknown doc id "${id}". Use docs_list. Known: ${DOC_CATALOG.map((d) => d.id).join(", ")}`
|
|
822
|
+
)
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
return text(await fetchDocPage(entry));
|
|
826
|
+
} catch (e) {
|
|
827
|
+
return errText(e);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
);
|
|
831
|
+
server.tool(
|
|
832
|
+
"suggest_cli",
|
|
833
|
+
"Return the gf command the developer (or agent terminal) should run. MCP does not execute mutations.",
|
|
834
|
+
{
|
|
835
|
+
intent: z.enum([
|
|
836
|
+
"login",
|
|
837
|
+
"switch_workspace",
|
|
838
|
+
"deploy",
|
|
839
|
+
"run",
|
|
840
|
+
"status",
|
|
841
|
+
"publish_app",
|
|
842
|
+
"secrets",
|
|
843
|
+
"dataplane"
|
|
844
|
+
]),
|
|
845
|
+
workspace: z.string().optional(),
|
|
846
|
+
name: z.string().optional().describe("Coderunner name/id for deploy/run/status"),
|
|
847
|
+
cell: z.enum(["production", "stage", "dev"]).optional()
|
|
848
|
+
},
|
|
849
|
+
async (args) => {
|
|
850
|
+
return text(
|
|
851
|
+
suggestCli(args.intent, {
|
|
852
|
+
workspace: args.workspace ?? "",
|
|
853
|
+
name: args.name ?? "",
|
|
854
|
+
coderunner: args.name ?? "",
|
|
855
|
+
cell: args.cell ?? ""
|
|
856
|
+
})
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
);
|
|
860
|
+
async function main() {
|
|
861
|
+
const transport = new StdioServerTransport();
|
|
862
|
+
await server.connect(transport);
|
|
863
|
+
console.error("groundfloor MCP running on stdio (inspect + docs; actions via gf)");
|
|
864
|
+
}
|
|
865
|
+
main().catch((err) => {
|
|
866
|
+
console.error(err);
|
|
867
|
+
process.exit(1);
|
|
868
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@groundfloorcloud/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Groundfloor MCP — inspect workspace state and docs; take actions with the gf CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"groundfloor-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+ssh://git@bitbucket.org/codementeam/groundfloor-client-portal.git",
|
|
19
|
+
"directory": "packages/mcp"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://docs.groundfloor.cloud/docs/customer-portal/agents",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"groundfloor",
|
|
24
|
+
"mcp",
|
|
25
|
+
"coderunner"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"dev": "tsup --watch",
|
|
30
|
+
"typecheck": "tsup --silent",
|
|
31
|
+
"start": "node dist/index.js",
|
|
32
|
+
"clean": "rm -rf dist *.tsbuildinfo .turbo",
|
|
33
|
+
"prepublishOnly": "npm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
37
|
+
"zod": "^3.24.2"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^20",
|
|
41
|
+
"tsup": "^8.3.0",
|
|
42
|
+
"typescript": "^5"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|