@nimbus-dev/connectors 0.9.0 → 0.10.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/connectors/bitrise/nimbus.extension.json +2 -2
- package/connectors/bitrise/src/server.ts +12 -4
- package/connectors/bitrise/src/tools.ts +138 -7
- package/connectors/codemagic/nimbus.extension.json +2 -2
- package/connectors/codemagic/src/server.ts +12 -4
- package/connectors/codemagic/src/tools.ts +130 -6
- package/connectors/firebase/nimbus.extension.json +2 -2
- package/connectors/firebase/src/server.ts +12 -4
- package/connectors/firebase/src/tools.ts +155 -9
- package/connectors/netlify/nimbus.extension.json +2 -2
- package/connectors/netlify/src/server.ts +12 -4
- package/connectors/netlify/src/tools.ts +158 -8
- package/connectors/testflight/nimbus.extension.json +2 -2
- package/connectors/testflight/src/server.ts +12 -4
- package/connectors/testflight/src/tools.ts +160 -6
- package/connectors/vercel/nimbus.extension.json +2 -2
- package/connectors/vercel/src/server.ts +12 -4
- package/connectors/vercel/src/tools.ts +161 -7
- package/package.json +1 -1
- package/shared/cap-text.ts +15 -0
- package/shared/env-json-api.ts +24 -6
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.bitrise",
|
|
3
3
|
"displayName": "Bitrise",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Bitrise REST connector (mobile CI builds + apps).
|
|
5
|
+
"description": "Bitrise REST connector (mobile CI builds + apps). Surfaces build status, workflow id, branch and commit so the local index can correlate mobile CI runs back to PRs and incidents. Write tool (needs owner approval): `bitrise.build.trigger`.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["api.bitrise.io"]
|
|
11
11
|
},
|
|
12
|
-
"hitlRequired": [],
|
|
12
|
+
"hitlRequired": ["write"],
|
|
13
13
|
"syncInterval": 600,
|
|
14
14
|
"minNimbusVersion": "0.2.0"
|
|
15
15
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
|
|
2
4
|
import { registerBitriseTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-bitrise", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerBitriseTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,22 +1,80 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { capText } from "../../../shared/cap-text.ts";
|
|
3
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
4
|
+
import {
|
|
5
|
+
createJsonGetter,
|
|
6
|
+
createJsonSender,
|
|
7
|
+
envAuthHeaders,
|
|
8
|
+
type JsonApiConfig,
|
|
9
|
+
} from "../../../shared/env-json-api.ts";
|
|
10
|
+
import { isSafeGitRef } from "../../../shared/github-rest.ts";
|
|
3
11
|
import { matchesResult } from "../../../shared/mcp-search-tool.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
12
|
+
import {
|
|
13
|
+
createRegisterSimpleTool,
|
|
14
|
+
createZodToolRegistrar,
|
|
15
|
+
mcpJsonResult as jsonResult,
|
|
16
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
6
17
|
import { filterBitriseBuilds } from "./search-filter.ts";
|
|
7
18
|
|
|
8
19
|
const BITRISE_API = "https://api.bitrise.io";
|
|
9
20
|
|
|
10
|
-
const
|
|
21
|
+
const api: JsonApiConfig = {
|
|
11
22
|
base: BITRISE_API,
|
|
12
23
|
label: "Bitrise",
|
|
13
24
|
headers: envAuthHeaders({ env: "BITRISE_TOKEN", scheme: "" }),
|
|
14
|
-
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const bitriseGet = createJsonGetter(api);
|
|
28
|
+
// A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
|
|
29
|
+
const bitriseSend = createJsonSender(api);
|
|
15
30
|
|
|
16
31
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
17
|
-
export const BITRISE_TOOL_NAMES = [
|
|
32
|
+
export const BITRISE_TOOL_NAMES = [
|
|
33
|
+
"bitrise_list",
|
|
34
|
+
"bitrise_get",
|
|
35
|
+
"bitrise_search",
|
|
36
|
+
"bitrise_build_latest",
|
|
37
|
+
"bitrise_build_trigger",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** An app slug: never `.` or `..` (a URL parser collapses them out of the path). */
|
|
41
|
+
const appSlugArg = z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/, "letters, digits, '.', '_' and '-' only");
|
|
44
|
+
|
|
45
|
+
const workflowArg = z
|
|
46
|
+
.string()
|
|
47
|
+
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/, "letters, digits, '.', '_' and '-' only");
|
|
48
|
+
|
|
49
|
+
type Obj = Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
function asObj(v: unknown): Obj {
|
|
52
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function str(v: unknown): string | undefined {
|
|
56
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The slug of the build a trigger started. The reply's `build_slug` is deprecated beside `slug`;
|
|
61
|
+
* read the root `build_slug`, then `slug`, then `results[0].build_slug`. Never `build_url`.
|
|
62
|
+
*/
|
|
63
|
+
function builtSlug(reply: Obj): string | null {
|
|
64
|
+
const first = Array.isArray(reply["results"]) ? asObj(reply["results"][0]) : {};
|
|
65
|
+
return str(reply["build_slug"]) ?? str(reply["slug"]) ?? str(first["build_slug"]) ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function registerBitriseTools(
|
|
69
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
70
|
+
): void {
|
|
71
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
72
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
73
|
+
connector: "bitrise",
|
|
74
|
+
scopeEnv: "NIMBUS_MCP_BITRISE_WRITE_SCOPE",
|
|
75
|
+
scopeKinds: ["app"],
|
|
76
|
+
});
|
|
18
77
|
|
|
19
|
-
export function registerBitriseTools(reg: ZodToolRegistrar): void {
|
|
20
78
|
reg(
|
|
21
79
|
"bitrise_list",
|
|
22
80
|
"List Bitrise resources. When `appSlug` is omitted, returns the user's accessible apps via `/v0.1/me/apps`. When `appSlug` is provided, returns the app's recent builds via `/v0.1/apps/<slug>/builds` (status filter and limit optional).",
|
|
@@ -73,4 +131,77 @@ export function registerBitriseTools(reg: ZodToolRegistrar): void {
|
|
|
73
131
|
return matchesResult(data, filterBitriseBuilds, p);
|
|
74
132
|
},
|
|
75
133
|
);
|
|
134
|
+
|
|
135
|
+
reg(
|
|
136
|
+
"bitrise_build_latest",
|
|
137
|
+
"Read an app's most recent Bitrise build (`GET /v0.1/apps/{appSlug}/builds?limit=1`), optionally of one `branch` and/or `workflow`, projected to `{ build: { slug, status, status_text, branch, triggered_workflow, triggered_at, finished_at } | null }`. `bitrise_list` has no branch or workflow filter.",
|
|
138
|
+
z.object({
|
|
139
|
+
appSlug: appSlugArg,
|
|
140
|
+
branch: z.string().min(1).max(255).optional(),
|
|
141
|
+
workflow: workflowArg.optional(),
|
|
142
|
+
}),
|
|
143
|
+
async (p) => {
|
|
144
|
+
const search = new URLSearchParams({ limit: "1" });
|
|
145
|
+
if (p.branch !== undefined) {
|
|
146
|
+
search.set("branch", p.branch);
|
|
147
|
+
}
|
|
148
|
+
if (p.workflow !== undefined) {
|
|
149
|
+
search.set("workflow", p.workflow);
|
|
150
|
+
}
|
|
151
|
+
const root = asObj(
|
|
152
|
+
await bitriseGet(`/v0.1/apps/${encodeURIComponent(p.appSlug)}/builds?${search.toString()}`),
|
|
153
|
+
);
|
|
154
|
+
const first = Array.isArray(root["data"]) ? asObj(root["data"][0]) : {};
|
|
155
|
+
const slug = str(first["slug"]);
|
|
156
|
+
return jsonResult({
|
|
157
|
+
build:
|
|
158
|
+
slug === undefined
|
|
159
|
+
? null
|
|
160
|
+
: {
|
|
161
|
+
slug,
|
|
162
|
+
status: first["status"],
|
|
163
|
+
status_text: capText(first["status_text"]),
|
|
164
|
+
branch: capText(first["branch"]),
|
|
165
|
+
triggered_workflow: capText(first["triggered_workflow"]),
|
|
166
|
+
triggered_at: capText(first["triggered_at"]),
|
|
167
|
+
finished_at: capText(first["finished_at"]),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
registerWriteTool(
|
|
174
|
+
"bitrise_build_trigger",
|
|
175
|
+
{
|
|
176
|
+
mutates: "bitrise.build.trigger",
|
|
177
|
+
// A build can be aborted in Bitrise; it deletes nothing.
|
|
178
|
+
recoverable: true,
|
|
179
|
+
scopeTargetOf: (p: { appSlug: string }) => ({ kind: "app", value: p.appSlug }),
|
|
180
|
+
},
|
|
181
|
+
'Start a Bitrise build of one workflow on one branch (`POST /v0.1/apps/{appSlug}/builds`, body `{ hook_info: { type: "bitrise" }, build_params: { branch, workflow_id } }`). The reply is `accepted` with the new build\'s slug (read from `build_slug`, else `slug`, else `results[0].build_slug`): Bitrise runs it afterwards, so poll `bitrise_get` with that slug.',
|
|
182
|
+
z.object({
|
|
183
|
+
appSlug: appSlugArg,
|
|
184
|
+
branch: z.string().min(1).max(255),
|
|
185
|
+
workflow: workflowArg,
|
|
186
|
+
}),
|
|
187
|
+
async (p) => {
|
|
188
|
+
if (!isSafeGitRef(p.branch)) {
|
|
189
|
+
throw new Error("branch is not a safe git ref; nothing was sent");
|
|
190
|
+
}
|
|
191
|
+
const reply = asObj(
|
|
192
|
+
await bitriseSend("POST", `/v0.1/apps/${encodeURIComponent(p.appSlug)}/builds`, {
|
|
193
|
+
hook_info: { type: "bitrise" },
|
|
194
|
+
build_params: { branch: p.branch, workflow_id: p.workflow },
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
return jsonResult({
|
|
198
|
+
ok: true,
|
|
199
|
+
status: "accepted",
|
|
200
|
+
appSlug: p.appSlug,
|
|
201
|
+
branch: p.branch,
|
|
202
|
+
workflow: p.workflow,
|
|
203
|
+
buildSlug: builtSlug(reply),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
);
|
|
76
207
|
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.codemagic",
|
|
3
3
|
"displayName": "Codemagic",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Codemagic REST connector (mobile CI/CD builds + apps).
|
|
5
|
+
"description": "Codemagic REST connector (mobile CI/CD builds + apps). Surfaces build status, workflow id, branch and commit so the local index can correlate mobile CI runs back to PRs and incidents. Write tool (needs owner approval): `codemagic.build.trigger`.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["api.codemagic.io"]
|
|
11
11
|
},
|
|
12
|
-
"hitlRequired": [],
|
|
12
|
+
"hitlRequired": ["write"],
|
|
13
13
|
"syncInterval": 600,
|
|
14
14
|
"minNimbusVersion": "0.2.0"
|
|
15
15
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
|
|
2
4
|
import { registerCodemagicTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-codemagic", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerCodemagicTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,26 +1,87 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { capText } from "../../../shared/cap-text.ts";
|
|
3
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
4
|
+
import {
|
|
5
|
+
createJsonGetter,
|
|
6
|
+
createJsonSender,
|
|
7
|
+
envAuthHeaders,
|
|
8
|
+
type JsonApiConfig,
|
|
9
|
+
} from "../../../shared/env-json-api.ts";
|
|
10
|
+
import { isSafeGitRef } from "../../../shared/github-rest.ts";
|
|
3
11
|
import { matchesResult } from "../../../shared/mcp-search-tool.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
12
|
+
import {
|
|
13
|
+
createRegisterSimpleTool,
|
|
14
|
+
createZodToolRegistrar,
|
|
15
|
+
mcpJsonResult as jsonResult,
|
|
16
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
6
17
|
import { filterCodemagicBuilds } from "./search-filter.ts";
|
|
7
18
|
|
|
8
19
|
const CODEMAGIC_API = "https://api.codemagic.io";
|
|
9
20
|
|
|
10
|
-
const
|
|
21
|
+
const api: JsonApiConfig = {
|
|
11
22
|
base: CODEMAGIC_API,
|
|
12
23
|
label: "Codemagic",
|
|
13
24
|
headers: envAuthHeaders({ env: "CODEMAGIC_TOKEN", scheme: "", header: "x-auth-token" }),
|
|
14
|
-
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const codemagicGet = createJsonGetter(api);
|
|
28
|
+
// A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
|
|
29
|
+
const codemagicSend = createJsonSender(api);
|
|
15
30
|
|
|
16
31
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
17
32
|
export const CODEMAGIC_TOOL_NAMES = [
|
|
18
33
|
"codemagic_list",
|
|
19
34
|
"codemagic_get",
|
|
20
35
|
"codemagic_search",
|
|
36
|
+
"codemagic_build_latest",
|
|
37
|
+
"codemagic_build_trigger",
|
|
21
38
|
] as const;
|
|
22
39
|
|
|
23
|
-
|
|
40
|
+
/** Codemagic app and workflow ids: letters, digits, `.`, `_`, `-`; never a dot segment. */
|
|
41
|
+
const idArg = z
|
|
42
|
+
.string()
|
|
43
|
+
.regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/, "letters, digits, '.', '_' and '-' only");
|
|
44
|
+
|
|
45
|
+
type Obj = Record<string, unknown>;
|
|
46
|
+
|
|
47
|
+
function asObj(v: unknown): Obj {
|
|
48
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function str(v: unknown): string | undefined {
|
|
52
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A build reduced to a fixed field set. Everything else Codemagic returns (artefacts, config,
|
|
57
|
+
* commit and workflow settings) is dropped, and so is any unknown field. The field names are
|
|
58
|
+
* Codemagic's and are not verified against a live account.
|
|
59
|
+
*/
|
|
60
|
+
function projectBuild(b: Obj): Obj {
|
|
61
|
+
const scalar = (v: unknown): string | number | undefined =>
|
|
62
|
+
typeof v === "number" ? v : capText(v);
|
|
63
|
+
return {
|
|
64
|
+
id: capText(b["_id"]) ?? capText(b["id"]),
|
|
65
|
+
status: capText(b["status"]),
|
|
66
|
+
branch: capText(b["branch"]),
|
|
67
|
+
workflowId: capText(b["workflowId"]),
|
|
68
|
+
index: scalar(b["index"]),
|
|
69
|
+
createdAt: scalar(b["createdAt"]),
|
|
70
|
+
startedAt: scalar(b["startedAt"]),
|
|
71
|
+
finishedAt: scalar(b["finishedAt"]),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function registerCodemagicTools(
|
|
76
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
77
|
+
): void {
|
|
78
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
79
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
80
|
+
connector: "codemagic",
|
|
81
|
+
scopeEnv: "NIMBUS_MCP_CODEMAGIC_WRITE_SCOPE",
|
|
82
|
+
scopeKinds: ["app"],
|
|
83
|
+
});
|
|
84
|
+
|
|
24
85
|
reg(
|
|
25
86
|
"codemagic_list",
|
|
26
87
|
"List Codemagic resources. When `appId` is omitted, returns the user's accessible apps via `/apps`. When `appId` is provided, returns the app's recent builds via `/builds?appId=<id>` (limit optional).",
|
|
@@ -68,4 +129,67 @@ export function registerCodemagicTools(reg: ZodToolRegistrar): void {
|
|
|
68
129
|
return matchesResult(builds, filterCodemagicBuilds, p);
|
|
69
130
|
},
|
|
70
131
|
);
|
|
132
|
+
|
|
133
|
+
reg(
|
|
134
|
+
"codemagic_build_latest",
|
|
135
|
+
"Read an app's most recent Codemagic build, optionally of one `workflowId` and/or `branch`: the app's last 50 builds (`GET /builds?appId=<id>&limit=50`) filtered here, newest first. Returns `{ build: { id, status, branch, workflowId, index, createdAt, startedAt, finishedAt } | null, scanned }`; every other field is dropped. The field names are Codemagic's own and unverified.",
|
|
136
|
+
z.object({
|
|
137
|
+
appId: idArg,
|
|
138
|
+
workflowId: idArg.optional(),
|
|
139
|
+
branch: z.string().min(1).max(255).optional(),
|
|
140
|
+
}),
|
|
141
|
+
async (p) => {
|
|
142
|
+
const root = asObj(
|
|
143
|
+
await codemagicGet(`/builds?appId=${encodeURIComponent(p.appId)}&limit=50`),
|
|
144
|
+
);
|
|
145
|
+
const builds = Array.isArray(root["builds"]) ? root["builds"] : [];
|
|
146
|
+
const match = builds.find((raw) => {
|
|
147
|
+
const b = asObj(raw);
|
|
148
|
+
return (
|
|
149
|
+
(p.workflowId === undefined || b["workflowId"] === p.workflowId) &&
|
|
150
|
+
(p.branch === undefined || b["branch"] === p.branch)
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
return jsonResult({
|
|
154
|
+
build: match === undefined ? null : projectBuild(asObj(match)),
|
|
155
|
+
scanned: builds.length,
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
registerWriteTool(
|
|
161
|
+
"codemagic_build_trigger",
|
|
162
|
+
{
|
|
163
|
+
mutates: "codemagic.build.trigger",
|
|
164
|
+
// A build can be cancelled in Codemagic; it deletes nothing.
|
|
165
|
+
recoverable: true,
|
|
166
|
+
scopeTargetOf: (p: { appId: string }) => ({ kind: "app", value: p.appId }),
|
|
167
|
+
},
|
|
168
|
+
"Start a Codemagic build of one workflow on one branch (`POST /builds`, body `{ appId, workflowId, branch }`). Triggers and branch settings in codemagic.yaml are not applied to an API build. The reply is `accepted` with the build id: Codemagic runs it afterwards, so poll `codemagic_get` with that id.",
|
|
169
|
+
z.object({
|
|
170
|
+
appId: idArg,
|
|
171
|
+
workflowId: idArg,
|
|
172
|
+
branch: z.string().min(1).max(255),
|
|
173
|
+
}),
|
|
174
|
+
async (p) => {
|
|
175
|
+
if (!isSafeGitRef(p.branch)) {
|
|
176
|
+
throw new Error("branch is not a safe git ref; nothing was sent");
|
|
177
|
+
}
|
|
178
|
+
const reply = asObj(
|
|
179
|
+
await codemagicSend("POST", "/builds", {
|
|
180
|
+
appId: p.appId,
|
|
181
|
+
workflowId: p.workflowId,
|
|
182
|
+
branch: p.branch,
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
return jsonResult({
|
|
186
|
+
ok: true,
|
|
187
|
+
status: "accepted",
|
|
188
|
+
appId: p.appId,
|
|
189
|
+
workflowId: p.workflowId,
|
|
190
|
+
branch: p.branch,
|
|
191
|
+
buildId: str(reply["buildId"]) ?? null,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
);
|
|
71
195
|
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.firebase",
|
|
3
3
|
"displayName": "Firebase App Distribution",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Firebase App Distribution connector
|
|
5
|
+
"description": "Firebase App Distribution connector. Surfaces each configured app's recent releases (display/build version, release notes, create time, console + testing URIs) so the local index can correlate mobile beta releases back to builds and incidents. Binary download links are stored as metadata but never fetched. Write tool (needs owner approval): `firebase.release.distribute`, which invites testers by email.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["firebaseappdistribution.googleapis.com", "oauth2.googleapis.com"]
|
|
11
11
|
},
|
|
12
|
-
"hitlRequired": [],
|
|
12
|
+
"hitlRequired": ["write"],
|
|
13
13
|
"syncInterval": 600,
|
|
14
14
|
"minNimbusVersion": "0.2.0"
|
|
15
15
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
|
|
2
4
|
import { registerFirebaseTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-firebase", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerFirebaseTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { mintGoogleAccessToken } from "@nimbus-dev/sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
4
|
+
import { guardedFetch } from "../../../shared/guarded-fetch.ts";
|
|
3
5
|
import { matchesResult } from "../../../shared/mcp-search-tool.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
+
import {
|
|
7
|
+
createRegisterSimpleTool,
|
|
8
|
+
createZodToolRegistrar,
|
|
9
|
+
mcpJsonResult as jsonResult,
|
|
10
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
6
11
|
import { filterFirebaseReleases } from "./search-filter.ts";
|
|
7
12
|
import { configuredAppIds, projectNumberFromAppId, serviceAccountFromEnv } from "./token.ts";
|
|
8
13
|
|
|
9
14
|
const APP_DISTRIBUTION_API = "https://firebaseappdistribution.googleapis.com";
|
|
15
|
+
const LABEL = "Firebase App Distribution";
|
|
10
16
|
|
|
11
17
|
// App Distribution OAuth2 tokens last ~1h; cache and refresh conservatively.
|
|
12
18
|
let cachedToken: { token: string; expiresAt: number } | null = null;
|
|
@@ -33,15 +39,43 @@ async function accessToken(): Promise<string> {
|
|
|
33
39
|
return token;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
/**
|
|
43
|
+
* One App Distribution request. It goes through `guardedFetch`, so the bearer token is never sent
|
|
44
|
+
* to a redirect target: a GET follows only a same-origin Location, and a redirect on a POST is
|
|
45
|
+
* refused (the write may or may not have been applied) with no second request.
|
|
46
|
+
*/
|
|
47
|
+
async function appDistRequest(
|
|
48
|
+
method: "GET" | "POST",
|
|
49
|
+
path: string,
|
|
50
|
+
body?: unknown,
|
|
51
|
+
): Promise<unknown> {
|
|
52
|
+
// The credential is read BEFORE the request, so a missing one sends nothing.
|
|
53
|
+
const token = await accessToken();
|
|
54
|
+
const res = await guardedFetch(LABEL, `${APP_DISTRIBUTION_API}${path}`, {
|
|
55
|
+
method,
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${token}`,
|
|
58
|
+
Accept: "application/json",
|
|
59
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
60
|
+
},
|
|
61
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
39
62
|
});
|
|
40
63
|
const text = await res.text();
|
|
41
64
|
if (!res.ok) {
|
|
42
|
-
throw new Error(
|
|
65
|
+
throw new Error(`${LABEL} ${String(res.status)}: ${text.slice(0, 400)}`);
|
|
66
|
+
}
|
|
67
|
+
if (text === "") return {};
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(text) as unknown;
|
|
70
|
+
} catch {
|
|
71
|
+
// A 2xx means the call was applied: an unparseable body must not read as a failure, or the
|
|
72
|
+
// caller retries and invites every tester twice.
|
|
73
|
+
return {};
|
|
43
74
|
}
|
|
44
|
-
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function appDistGet(path: string): Promise<unknown> {
|
|
78
|
+
return appDistRequest("GET", path);
|
|
45
79
|
}
|
|
46
80
|
|
|
47
81
|
function appBasePath(appId: string): string {
|
|
@@ -60,10 +94,72 @@ function releasePath(appId: string, releaseId: string): string {
|
|
|
60
94
|
return `${appBasePath(appId)}/releases/${encodeURIComponent(releaseId)}`;
|
|
61
95
|
}
|
|
62
96
|
|
|
97
|
+
/** Firebase accepts at most 999 testers and groups together in one distribute call. */
|
|
98
|
+
const MAX_RECIPIENTS = 999;
|
|
99
|
+
|
|
100
|
+
interface DistributeArgs {
|
|
101
|
+
readonly appId: string;
|
|
102
|
+
readonly releaseId: string;
|
|
103
|
+
readonly groupAliases?: readonly string[] | undefined;
|
|
104
|
+
readonly testerEmails?: readonly string[] | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The recipients as sent: each list de-duplicated (Firebase would mail a repeated tester twice). */
|
|
108
|
+
function distributeRecipients(p: DistributeArgs): {
|
|
109
|
+
groupAliases: string[];
|
|
110
|
+
testerEmails: string[];
|
|
111
|
+
} {
|
|
112
|
+
return {
|
|
113
|
+
groupAliases: [...new Set(p.groupAliases ?? [])],
|
|
114
|
+
// Email addresses are case-insensitive here: fold before de-duplicating, send the folded form.
|
|
115
|
+
testerEmails: [...new Set((p.testerEmails ?? []).map((e) => e.toLowerCase()))],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Why a distribute call is refused before anything is read or sent, or `undefined`. Shared by
|
|
121
|
+
* `refuseBefore` (standalone: before scope, consent and the pre-state read) and the handler
|
|
122
|
+
* (gateway mode registers the raw handler, so it must refuse the same input itself).
|
|
123
|
+
*/
|
|
124
|
+
function distributeProblem(p: DistributeArgs): string | undefined {
|
|
125
|
+
const { groupAliases, testerEmails } = distributeRecipients(p);
|
|
126
|
+
if (!configuredAppIds().includes(p.appId)) {
|
|
127
|
+
return `app ${p.appId} is not in FIREBASE_APP_IDS; nothing was sent`;
|
|
128
|
+
}
|
|
129
|
+
if (groupAliases.length + testerEmails.length === 0) {
|
|
130
|
+
return "give at least one tester email or group alias; nothing was sent";
|
|
131
|
+
}
|
|
132
|
+
if (groupAliases.length + testerEmails.length > MAX_RECIPIENTS) {
|
|
133
|
+
return `at most ${String(MAX_RECIPIENTS)} testers and groups together; nothing was sent`;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const releaseIdArg = z
|
|
139
|
+
.string()
|
|
140
|
+
.regex(/^[A-Za-z0-9_-]{1,128}$/, "letters, digits, '_' and '-' only");
|
|
141
|
+
const groupAliasArg = z
|
|
142
|
+
.string()
|
|
143
|
+
.regex(/^[A-Za-z0-9_-]{1,128}$/, "letters, digits, '_' and '-' only");
|
|
144
|
+
|
|
63
145
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
64
|
-
export const FIREBASE_TOOL_NAMES = [
|
|
146
|
+
export const FIREBASE_TOOL_NAMES = [
|
|
147
|
+
"firebase_list",
|
|
148
|
+
"firebase_get",
|
|
149
|
+
"firebase_search",
|
|
150
|
+
"firebase_app_distribution_distribute",
|
|
151
|
+
] as const;
|
|
152
|
+
|
|
153
|
+
export function registerFirebaseTools(
|
|
154
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
155
|
+
): void {
|
|
156
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
157
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
158
|
+
connector: "firebase",
|
|
159
|
+
scopeEnv: "NIMBUS_MCP_FIREBASE_WRITE_SCOPE",
|
|
160
|
+
scopeKinds: ["app"],
|
|
161
|
+
});
|
|
65
162
|
|
|
66
|
-
export function registerFirebaseTools(reg: ZodToolRegistrar): void {
|
|
67
163
|
reg(
|
|
68
164
|
"firebase_list",
|
|
69
165
|
"List Firebase App Distribution resources. When `appId` is omitted, returns the connector's configured app ids. When `appId` is provided, returns that app's most-recent releases via GET /v1/projects/<n>/apps/<appId>/releases (pageSize optional, default 50).",
|
|
@@ -103,4 +199,54 @@ export function registerFirebaseTools(reg: ZodToolRegistrar): void {
|
|
|
103
199
|
return matchesResult(releases, filterFirebaseReleases, p);
|
|
104
200
|
},
|
|
105
201
|
);
|
|
202
|
+
|
|
203
|
+
registerWriteTool(
|
|
204
|
+
"firebase_app_distribution_distribute",
|
|
205
|
+
{
|
|
206
|
+
mutates: "firebase.release.distribute",
|
|
207
|
+
// Invitation and release emails cannot be unsent, and new testers stay created.
|
|
208
|
+
recoverable: false,
|
|
209
|
+
capturePreState: async (p: { appId: string; releaseId: string }) => {
|
|
210
|
+
// The API has no read of a release's current groups or testers, so the release itself is
|
|
211
|
+
// the before-state.
|
|
212
|
+
const r = (await appDistGet(releasePath(p.appId, p.releaseId))) as Record<string, unknown>;
|
|
213
|
+
return {
|
|
214
|
+
displayVersion: r["displayVersion"],
|
|
215
|
+
buildVersion: r["buildVersion"],
|
|
216
|
+
testState: r["testState"],
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
scopeTargetOf: (p: { appId: string }) => ({ kind: "app", value: p.appId }),
|
|
220
|
+
refuseBefore: (p: DistributeArgs) => distributeProblem(p),
|
|
221
|
+
},
|
|
222
|
+
"Send an existing Firebase App Distribution release to testers and groups (`POST /v1/projects/<n>/apps/<appId>/releases/<releaseId>:distribute`, body `{ testerEmails, groupAliases }`, empty arrays omitted). WARNING, vendor behaviour: an email that is not yet a tester BECOMES a new tester and is SENT an invitation email, and testers get a release email; none of that can be unsent. At least one of `testerEmails` and `groupAliases` is required, at most 999 together; a missing group makes Firebase answer INVALID_ARGUMENT and the app must be one of the connector's FIREBASE_APP_IDS. The API cannot say who already has the release, so every group and tester sent is listed in the result. The reply is `accepted`.",
|
|
223
|
+
z.object({
|
|
224
|
+
appId: z.string().min(1),
|
|
225
|
+
releaseId: releaseIdArg,
|
|
226
|
+
groupAliases: z.array(groupAliasArg).max(MAX_RECIPIENTS).optional(),
|
|
227
|
+
testerEmails: z.array(z.email().max(254)).max(MAX_RECIPIENTS).optional(),
|
|
228
|
+
}),
|
|
229
|
+
async (p) => {
|
|
230
|
+
// Validated here and in `refuseBefore`, not with `.refine` (a refined schema loses `.shape`).
|
|
231
|
+
const problem = distributeProblem(p);
|
|
232
|
+
if (problem !== undefined) throw new Error(problem);
|
|
233
|
+
const { groupAliases, testerEmails } = distributeRecipients(p);
|
|
234
|
+
const body: Record<string, string[]> = {};
|
|
235
|
+
if (testerEmails.length > 0) {
|
|
236
|
+
body["testerEmails"] = testerEmails;
|
|
237
|
+
}
|
|
238
|
+
if (groupAliases.length > 0) {
|
|
239
|
+
body["groupAliases"] = groupAliases;
|
|
240
|
+
}
|
|
241
|
+
await appDistRequest("POST", `${releasePath(p.appId, p.releaseId)}:distribute`, body);
|
|
242
|
+
return jsonResult({
|
|
243
|
+
ok: true,
|
|
244
|
+
status: "accepted",
|
|
245
|
+
appId: p.appId,
|
|
246
|
+
releaseId: p.releaseId,
|
|
247
|
+
groupAliases,
|
|
248
|
+
testerEmails,
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
);
|
|
106
252
|
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.netlify",
|
|
3
3
|
"displayName": "Netlify",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Netlify site connector
|
|
5
|
+
"description": "Netlify site connector. Surfaces site id, name, the production URL + SSL URL, the admin dashboard URL, the linked git repo + branch, and the embedded published-deploy status (state / branch / commit ref / commit URL / deploy preview URL) into the local index as `netlify:site` items — useful for answering \"is the latest deploy live?\" and \"which site shipped this commit?\". Bearer-token (PAT) auth. The SaaS host (api.netlify.com) is fixed. Write tool (needs owner approval): `netlify.build.trigger`; a build with no branch is a PRODUCTION deploy. Forms, functions, env vars, and DNS are deferred follow-ups.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["api.netlify.com"]
|
|
11
11
|
},
|
|
12
|
-
"hitlRequired": [],
|
|
12
|
+
"hitlRequired": ["write"],
|
|
13
13
|
"syncInterval": 600,
|
|
14
14
|
"minNimbusVersion": "0.2.0"
|
|
15
15
|
}
|