@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
|
@@ -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 { registerNetlifyTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-netlify", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerNetlifyTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,22 +1,93 @@
|
|
|
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, searchToolInputSchema } 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 { filterNetlifySites } from "./search-filter.ts";
|
|
7
18
|
|
|
8
19
|
const BASE = "https://api.netlify.com";
|
|
9
20
|
|
|
10
|
-
const
|
|
21
|
+
const api: JsonApiConfig = {
|
|
11
22
|
base: BASE,
|
|
12
23
|
label: "Netlify",
|
|
13
24
|
headers: envAuthHeaders({ env: "NETLIFY_TOKEN" }),
|
|
14
|
-
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const netlifyGet = createJsonGetter(api);
|
|
28
|
+
// A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
|
|
29
|
+
const netlifySend = createJsonSender(api);
|
|
15
30
|
|
|
16
31
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
17
|
-
export const NETLIFY_TOOL_NAMES = [
|
|
32
|
+
export const NETLIFY_TOOL_NAMES = [
|
|
33
|
+
"netlify_list",
|
|
34
|
+
"netlify_get",
|
|
35
|
+
"netlify_search",
|
|
36
|
+
"netlify_deploy_list",
|
|
37
|
+
"netlify_build_trigger",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** A site id (a UUID) or name: never `.` or `..` (a URL parser collapses them out of the path). */
|
|
41
|
+
const siteIdArg = 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
|
+
interface TriggerArgs {
|
|
56
|
+
readonly siteId: string;
|
|
57
|
+
readonly branch?: string | undefined;
|
|
58
|
+
readonly production?: true | undefined;
|
|
59
|
+
readonly clearCache?: boolean | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Why a build trigger is refused before anything is sent, or `undefined`. An omitted `branch`
|
|
64
|
+
* publishes the PRODUCTION site, and the owner's preview shows the input only, so that choice must
|
|
65
|
+
* be written in the input: `production: true`. Shared by `refuseBefore` (standalone) and the
|
|
66
|
+
* handler (gateway mode registers the raw handler).
|
|
67
|
+
*/
|
|
68
|
+
function triggerProblem(p: TriggerArgs): string | undefined {
|
|
69
|
+
if (p.branch !== undefined && p.production === true) {
|
|
70
|
+
return "give a branch or production: true, not both; nothing was sent";
|
|
71
|
+
}
|
|
72
|
+
if (p.branch === undefined && p.production !== true) {
|
|
73
|
+
return "name a branch, or pass production: true to publish the production site; nothing was sent";
|
|
74
|
+
}
|
|
75
|
+
if (p.branch !== undefined && !isSafeGitRef(p.branch)) {
|
|
76
|
+
return "branch is not a safe git ref; nothing was sent";
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function registerNetlifyTools(
|
|
82
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
83
|
+
): void {
|
|
84
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
85
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
86
|
+
connector: "netlify",
|
|
87
|
+
scopeEnv: "NIMBUS_MCP_NETLIFY_WRITE_SCOPE",
|
|
88
|
+
scopeKinds: ["site"],
|
|
89
|
+
});
|
|
18
90
|
|
|
19
|
-
export function registerNetlifyTools(reg: ZodToolRegistrar): void {
|
|
20
91
|
reg(
|
|
21
92
|
"netlify_list",
|
|
22
93
|
"List Netlify sites (`GET /api/v1/sites`), capped at `per_page` (default 100). Returns a bare JSON array of site objects, each carrying the embedded `published_deploy` status and `build_settings`.",
|
|
@@ -31,7 +102,7 @@ export function registerNetlifyTools(reg: ZodToolRegistrar): void {
|
|
|
31
102
|
|
|
32
103
|
reg(
|
|
33
104
|
"netlify_get",
|
|
34
|
-
"Fetch one Netlify site by its id (`GET /api/v1/sites/{siteId}`). Returns the site object directly. Throws when no match is found.",
|
|
105
|
+
"Fetch one Netlify site by its id (`GET /api/v1/sites/{siteId}`). Returns the site object directly, including `published_deploy` (the live deploy). Throws when no match is found.",
|
|
35
106
|
z.object({
|
|
36
107
|
siteId: z.string().min(1),
|
|
37
108
|
}),
|
|
@@ -50,4 +121,83 @@ export function registerNetlifyTools(reg: ZodToolRegistrar): void {
|
|
|
50
121
|
return matchesResult(root, filterNetlifySites, p);
|
|
51
122
|
},
|
|
52
123
|
);
|
|
124
|
+
|
|
125
|
+
reg(
|
|
126
|
+
"netlify_deploy_list",
|
|
127
|
+
"List a Netlify site's most recent deploys (`GET /api/v1/sites/{siteId}/deploys`, newest first), at most `perPage` (default 5, max 10), each projected to `{ id, state, branch, context, commit_ref, created_at, published_at, error_message }`. `context` says whether it was a production deploy.",
|
|
128
|
+
z.object({
|
|
129
|
+
siteId: siteIdArg,
|
|
130
|
+
perPage: z.number().int().min(1).max(10).optional(),
|
|
131
|
+
}),
|
|
132
|
+
async (p) => {
|
|
133
|
+
const search = new URLSearchParams({ per_page: String(p.perPage ?? 5), page: "1" });
|
|
134
|
+
const root = await netlifyGet(
|
|
135
|
+
`/api/v1/sites/${encodeURIComponent(p.siteId)}/deploys?${search.toString()}`,
|
|
136
|
+
);
|
|
137
|
+
const deploys = Array.isArray(root) ? root : [];
|
|
138
|
+
return jsonResult(
|
|
139
|
+
deploys.map((raw) => {
|
|
140
|
+
const d = asObj(raw);
|
|
141
|
+
return {
|
|
142
|
+
id: capText(d["id"]),
|
|
143
|
+
state: capText(d["state"]),
|
|
144
|
+
branch: capText(d["branch"]),
|
|
145
|
+
context: capText(d["context"]),
|
|
146
|
+
commit_ref: capText(d["commit_ref"]),
|
|
147
|
+
created_at: capText(d["created_at"]),
|
|
148
|
+
published_at: capText(d["published_at"]),
|
|
149
|
+
error_message: capText(d["error_message"]),
|
|
150
|
+
};
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
registerWriteTool(
|
|
157
|
+
"netlify_build_trigger",
|
|
158
|
+
{
|
|
159
|
+
mutates: "netlify.build.trigger",
|
|
160
|
+
// A build adds a deploy; it does not delete one, and a production deploy is replaced by the next one.
|
|
161
|
+
recoverable: true,
|
|
162
|
+
scopeTargetOf: (p: { siteId: string }) => ({ kind: "site", value: p.siteId }),
|
|
163
|
+
refuseBefore: (p: TriggerArgs) => triggerProblem(p),
|
|
164
|
+
},
|
|
165
|
+
"Start a Netlify build of a site (`POST /api/v1/sites/{siteId}/builds`, query `branch` and `clear_cache`, NO body). IMPORTANT: Netlify treats a build with no branch as a PRODUCTION deploy and publishes it when it succeeds, so you MUST either name a `branch` (a branch that is not the site's production branch makes a branch deploy) or pass `production: true` to publish the production site on purpose; neither, or both, sends nothing. `clearCache: true` discards the build cache first. The reply is `accepted` with the build id: Netlify builds it afterwards, so check `netlify_deploy_list`.",
|
|
166
|
+
z.object({
|
|
167
|
+
siteId: siteIdArg,
|
|
168
|
+
branch: z.string().min(1).max(255).optional(),
|
|
169
|
+
production: z.literal(true).optional(),
|
|
170
|
+
clearCache: z.boolean().optional(),
|
|
171
|
+
}),
|
|
172
|
+
async (p) => {
|
|
173
|
+
const problem = triggerProblem(p);
|
|
174
|
+
if (problem !== undefined) throw new Error(problem);
|
|
175
|
+
// Netlify documents these as QUERY parameters, so nothing goes in a body.
|
|
176
|
+
const search = new URLSearchParams();
|
|
177
|
+
if (p.branch !== undefined) {
|
|
178
|
+
search.set("branch", p.branch);
|
|
179
|
+
}
|
|
180
|
+
if (p.clearCache === true) {
|
|
181
|
+
search.set("clear_cache", "true");
|
|
182
|
+
}
|
|
183
|
+
const qs = search.toString();
|
|
184
|
+
const reply = asObj(
|
|
185
|
+
await netlifySend(
|
|
186
|
+
"POST",
|
|
187
|
+
`/api/v1/sites/${encodeURIComponent(p.siteId)}/builds${qs === "" ? "" : `?${qs}`}`,
|
|
188
|
+
undefined,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
return jsonResult({
|
|
192
|
+
ok: true,
|
|
193
|
+
status: "accepted",
|
|
194
|
+
siteId: p.siteId,
|
|
195
|
+
// No branch = a production deploy (documented), and only `production: true` gets here without one.
|
|
196
|
+
branch: p.branch ?? null,
|
|
197
|
+
production: p.branch === undefined,
|
|
198
|
+
buildId: str(reply["id"]) ?? null,
|
|
199
|
+
deployId: str(reply["deploy_id"]) ?? null,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
);
|
|
53
203
|
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.testflight",
|
|
3
3
|
"displayName": "TestFlight",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "TestFlight connector via Apple's App Store Connect API
|
|
5
|
+
"description": "TestFlight connector via Apple's App Store Connect API. Surfaces apps and recent builds (version, processing state, uploaded date, min OS, encryption flag) so the local index can correlate iOS/macOS beta builds back to releases and incidents. Write tool (needs owner approval): `testflight.build.distribute`, which adds a build to beta groups.",
|
|
6
6
|
"author": "Nimbus",
|
|
7
7
|
"entrypoint": "dist/server.js",
|
|
8
8
|
"runtime": "bun",
|
|
9
9
|
"permissions": {
|
|
10
10
|
"network": ["api.appstoreconnect.apple.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 { registerTestflightTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-testflight", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerTestflightTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { signAppStoreConnectJwt } from "@nimbus-dev/sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { capText } from "../../../shared/cap-text.ts";
|
|
4
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
5
|
+
import {
|
|
6
|
+
createJsonGetter,
|
|
7
|
+
createJsonSender,
|
|
8
|
+
type JsonApiConfig,
|
|
9
|
+
} from "../../../shared/env-json-api.ts";
|
|
4
10
|
import { matchesResult } from "../../../shared/mcp-search-tool.ts";
|
|
5
|
-
import {
|
|
6
|
-
|
|
11
|
+
import {
|
|
12
|
+
createRegisterSimpleTool,
|
|
13
|
+
createZodToolRegistrar,
|
|
14
|
+
mcpJsonResult as jsonResult,
|
|
15
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
7
16
|
import { jwtParamsFromEnv } from "./jwt.ts";
|
|
8
17
|
import { filterTestflightBuilds } from "./search-filter.ts";
|
|
9
18
|
|
|
@@ -14,11 +23,15 @@ function authHeader(): Record<string, string> {
|
|
|
14
23
|
return { Authorization: `Bearer ${token}`, Accept: "application/json" };
|
|
15
24
|
}
|
|
16
25
|
|
|
17
|
-
const
|
|
26
|
+
const api: JsonApiConfig = {
|
|
18
27
|
base: APPSTORECONNECT_API,
|
|
19
28
|
label: "App Store Connect",
|
|
20
29
|
headers: authHeader,
|
|
21
|
-
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const appstoreGet = createJsonGetter(api);
|
|
33
|
+
// A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
|
|
34
|
+
const appstoreSend = createJsonSender(api);
|
|
22
35
|
|
|
23
36
|
function buildsPath(appId: string, limit: number): string {
|
|
24
37
|
return `/v1/builds?filter[app]=${encodeURIComponent(appId)}&sort=-uploadedDate&limit=${String(limit)}`;
|
|
@@ -29,9 +42,78 @@ export const TESTFLIGHT_TOOL_NAMES = [
|
|
|
29
42
|
"testflight_list",
|
|
30
43
|
"testflight_get",
|
|
31
44
|
"testflight_search",
|
|
45
|
+
"testflight_build_groups_get",
|
|
46
|
+
"testflight_beta_groups_list",
|
|
47
|
+
"testflight_build_distribute",
|
|
32
48
|
] as const;
|
|
33
49
|
|
|
34
|
-
|
|
50
|
+
/** App Store Connect resource ids (UUIDs and numeric app ids); never a dot segment. */
|
|
51
|
+
const idArg = z
|
|
52
|
+
.string()
|
|
53
|
+
.regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,64}$/, "letters, digits, '.', '_' and '-' only");
|
|
54
|
+
|
|
55
|
+
type Obj = Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
function asObj(v: unknown): Obj {
|
|
58
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function str(v: unknown): string | undefined {
|
|
62
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The beta groups a build is in, projected to what an approver needs. `publicLinkEnabled` is kept
|
|
67
|
+
* on purpose: a group with a public link is open to anyone who holds the link, whoever is in it.
|
|
68
|
+
*/
|
|
69
|
+
async function readBuildGroups(buildId: string): Promise<Obj[]> {
|
|
70
|
+
return projectGroups(
|
|
71
|
+
await appstoreGet(`/v1/builds/${encodeURIComponent(buildId)}/betaGroups?limit=200`),
|
|
72
|
+
200,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** At most `cap` groups of an App Store Connect list reply, as `{id, name, isInternalGroup, publicLinkEnabled}`. */
|
|
77
|
+
function projectGroups(reply: unknown, cap: number): Obj[] {
|
|
78
|
+
const data = Array.isArray(asObj(reply)["data"]) ? (asObj(reply)["data"] as unknown[]) : [];
|
|
79
|
+
return data.slice(0, cap).map((raw) => {
|
|
80
|
+
const g = asObj(raw);
|
|
81
|
+
const attrs = asObj(g["attributes"]);
|
|
82
|
+
return {
|
|
83
|
+
id: capText(g["id"]),
|
|
84
|
+
name: capText(attrs["name"]),
|
|
85
|
+
isInternalGroup: attrs["isInternalGroup"],
|
|
86
|
+
publicLinkEnabled: attrs["publicLinkEnabled"],
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The ids of the beta groups an app has, from `GET /v1/apps/{appId}/betaGroups`. */
|
|
92
|
+
async function appGroupIds(appId: string): Promise<Set<string>> {
|
|
93
|
+
const groups = projectGroups(
|
|
94
|
+
await appstoreGet(`/v1/apps/${encodeURIComponent(appId)}/betaGroups?limit=200`),
|
|
95
|
+
200,
|
|
96
|
+
);
|
|
97
|
+
return new Set(groups.map((g) => g["id"]).filter((id): id is string => typeof id === "string"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The app a build belongs to, from `GET /v1/builds/{id}?include=app`. */
|
|
101
|
+
async function appOfBuild(buildId: string): Promise<string | undefined> {
|
|
102
|
+
const root = asObj(await appstoreGet(`/v1/builds/${encodeURIComponent(buildId)}?include=app`));
|
|
103
|
+
const rel = asObj(asObj(asObj(root["data"])["relationships"])["app"]);
|
|
104
|
+
return str(asObj(rel["data"])["id"]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function registerTestflightTools(
|
|
108
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
109
|
+
): void {
|
|
110
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
111
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
112
|
+
connector: "testflight",
|
|
113
|
+
scopeEnv: "NIMBUS_MCP_TESTFLIGHT_WRITE_SCOPE",
|
|
114
|
+
scopeKinds: ["app"],
|
|
115
|
+
});
|
|
116
|
+
|
|
35
117
|
reg(
|
|
36
118
|
"testflight_list",
|
|
37
119
|
"List TestFlight resources via the App Store Connect API. When `appId` is omitted, returns the user's accessible apps via `GET /v1/apps`. When `appId` is provided, returns that app's most-recent builds via `GET /v1/builds?filter[app]=<id>&sort=-uploadedDate` (limit optional).",
|
|
@@ -76,4 +158,76 @@ export function registerTestflightTools(reg: ZodToolRegistrar): void {
|
|
|
76
158
|
return matchesResult(data, filterTestflightBuilds, p);
|
|
77
159
|
},
|
|
78
160
|
);
|
|
161
|
+
|
|
162
|
+
reg(
|
|
163
|
+
"testflight_build_groups_get",
|
|
164
|
+
"Read the beta groups a TestFlight build is already in (`GET /v1/builds/{buildId}/betaGroups`), projected to `{ groups: [{ id, name, isInternalGroup, publicLinkEnabled }] }`. A group with `publicLinkEnabled: true` is reachable by anyone who has its public link.",
|
|
165
|
+
z.object({ buildId: idArg }),
|
|
166
|
+
async (p) => jsonResult({ groups: await readBuildGroups(p.buildId) }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
reg(
|
|
170
|
+
"testflight_beta_groups_list",
|
|
171
|
+
"List an app's TestFlight beta groups (`GET /v1/apps/{appId}/betaGroups`), at most `limit` (default 100, max 200), projected to `{ groups: [{ id, name, isInternalGroup, publicLinkEnabled }] }`. Read it BEFORE `testflight_build_distribute` to see which target groups are public: a group with `publicLinkEnabled: true` is reachable by anyone who holds its link.",
|
|
172
|
+
z.object({ appId: idArg, limit: z.number().int().min(1).max(200).optional() }),
|
|
173
|
+
async (p) => {
|
|
174
|
+
const search = new URLSearchParams({ limit: String(p.limit ?? 100) });
|
|
175
|
+
const root = await appstoreGet(
|
|
176
|
+
`/v1/apps/${encodeURIComponent(p.appId)}/betaGroups?${search.toString()}`,
|
|
177
|
+
);
|
|
178
|
+
return jsonResult({ groups: projectGroups(root, p.limit ?? 100) });
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
registerWriteTool(
|
|
183
|
+
"testflight_build_distribute",
|
|
184
|
+
{
|
|
185
|
+
mutates: "testflight.build.distribute",
|
|
186
|
+
// Testers who were given the build cannot be un-invited from here; the groups the build was
|
|
187
|
+
// already in are recorded in the audit pre-state.
|
|
188
|
+
recoverable: false,
|
|
189
|
+
capturePreState: async (p: { buildId: string }) => ({
|
|
190
|
+
buildId: p.buildId,
|
|
191
|
+
groups: await readBuildGroups(p.buildId),
|
|
192
|
+
}),
|
|
193
|
+
scopeTargetOf: (p: { appId: string }) => ({ kind: "app", value: p.appId }),
|
|
194
|
+
},
|
|
195
|
+
'Add a TestFlight build to beta groups (`POST /v1/builds/{buildId}/relationships/betaGroups`, body `{ data: [{ type: "betaGroups", id }] }`). The build must belong to `appId`: it is read first (`GET /v1/builds/{buildId}?include=app`) and a build of another app sends nothing. Adding a build to a group makes it available to every tester in it, and a group with a public link reaches anyone holding the link (see `testflight_build_groups_get`). Apple may refuse (409/422) a build that is not VALID or a external group that has not passed beta review; the body is returned in the error. The reply is `accepted`.',
|
|
196
|
+
z.object({
|
|
197
|
+
appId: idArg,
|
|
198
|
+
buildId: idArg,
|
|
199
|
+
betaGroupIds: z.array(idArg).min(1).max(20),
|
|
200
|
+
}),
|
|
201
|
+
async (p) => {
|
|
202
|
+
if (new Set(p.betaGroupIds).size !== p.betaGroupIds.length) {
|
|
203
|
+
throw new Error("betaGroupIds has a duplicate; nothing was sent");
|
|
204
|
+
}
|
|
205
|
+
const owner = await appOfBuild(p.buildId);
|
|
206
|
+
if (owner !== p.appId) {
|
|
207
|
+
throw new Error(`build ${p.buildId} does not belong to app ${p.appId}; nothing was sent`);
|
|
208
|
+
}
|
|
209
|
+
// Every target group must be one of this app's: one GET, and nothing is sent on a mismatch.
|
|
210
|
+
const known = await appGroupIds(p.appId);
|
|
211
|
+
const stray = p.betaGroupIds.filter((id) => !known.has(id));
|
|
212
|
+
if (stray.length > 0) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`beta group(s) ${stray.join(", ")} do not belong to app ${p.appId}; nothing was sent`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
await appstoreSend(
|
|
218
|
+
"POST",
|
|
219
|
+
`/v1/builds/${encodeURIComponent(p.buildId)}/relationships/betaGroups`,
|
|
220
|
+
{
|
|
221
|
+
data: p.betaGroupIds.map((id) => ({ type: "betaGroups", id })),
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
return jsonResult({
|
|
225
|
+
ok: true,
|
|
226
|
+
status: "accepted",
|
|
227
|
+
appId: p.appId,
|
|
228
|
+
buildId: p.buildId,
|
|
229
|
+
betaGroupIds: p.betaGroupIds,
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
);
|
|
79
233
|
}
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"id": "com.nimbus.vercel",
|
|
3
3
|
"displayName": "Vercel",
|
|
4
4
|
"version": "0.1.0",
|
|
5
|
-
"description": "Vercel deployment connector
|
|
5
|
+
"description": "Vercel deployment connector. Surfaces deployment uid, project name, state, target (production/staging), the *.vercel.app host, inspector URL, git commit metadata (sha/message/ref/PR id), and creator into the local index as `vercel:deployment` items — useful for correlating deploys with PR / Slack history (\"which deploy shipped this commit?\"). Bearer-token auth. Optional `vercel.team_id` scopes requests to a team. The SaaS host (api.vercel.com) is fixed. Write tools (each needs owner approval): `vercel.deployment.redeploy` and `vercel.deployment.promote`. Projects / domains / env vars / aliases / logs 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.vercel.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 { registerVercelTools } from "./tools.ts";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
6
|
+
const server = new McpServer(
|
|
7
|
+
{ name: "nimbus-vercel", version: "0.1.0" },
|
|
8
|
+
{ capabilities: CONSENT_SERVER_CAPABILITIES },
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
registerVercelTools(server);
|
|
12
|
+
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|