@nimbus-dev/connectors 0.8.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/iac/nimbus.extension.json +1 -1
- package/connectors/iac/src/tools.ts +808 -105
- package/connectors/kubernetes/nimbus.extension.json +1 -1
- package/connectors/kubernetes/src/manifest-scan.ts +602 -0
- package/connectors/kubernetes/src/tools.ts +331 -2
- 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/consent-kit.ts +15 -0
- package/shared/env-json-api.ts +24 -6
- package/shared/prepare-tool-ids.ts +13 -4
- package/shared/private-kubeconfig.ts +10 -9
- package/shared/private-scratch.ts +68 -0
- package/shared/run-cli-json.ts +17 -1
|
@@ -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);
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
3
|
+
import {
|
|
4
|
+
createJsonGetter,
|
|
5
|
+
createJsonSender,
|
|
6
|
+
envAuthHeaders,
|
|
7
|
+
type JsonApiConfig,
|
|
8
|
+
} from "../../../shared/env-json-api.ts";
|
|
3
9
|
import { matchesResult, searchToolInputSchema } from "../../../shared/mcp-search-tool.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
10
|
+
import {
|
|
11
|
+
createRegisterSimpleTool,
|
|
12
|
+
createZodToolRegistrar,
|
|
13
|
+
mcpJsonResult as jsonResult,
|
|
14
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
6
15
|
import { filterVercelDeployments } from "./search-filter.ts";
|
|
7
16
|
|
|
8
17
|
const BASE = "https://api.vercel.com";
|
|
@@ -21,11 +30,15 @@ function withTeam(path: string): string {
|
|
|
21
30
|
return `${path}${sep}teamId=${encodeURIComponent(team)}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
const
|
|
33
|
+
const api: JsonApiConfig = {
|
|
25
34
|
base: BASE,
|
|
26
35
|
label: "Vercel",
|
|
27
36
|
headers: envAuthHeaders({ env: "VERCEL_TOKEN" }),
|
|
28
|
-
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getJson = createJsonGetter(api);
|
|
40
|
+
// A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
|
|
41
|
+
const sendJson = createJsonSender(api);
|
|
29
42
|
|
|
30
43
|
/** Every request is scoped to `VERCEL_TEAM_ID` when one is configured. */
|
|
31
44
|
async function vercelGet(path: string): Promise<unknown> {
|
|
@@ -33,9 +46,71 @@ async function vercelGet(path: string): Promise<unknown> {
|
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
36
|
-
export const VERCEL_TOOL_NAMES = [
|
|
49
|
+
export const VERCEL_TOOL_NAMES = [
|
|
50
|
+
"vercel_list",
|
|
51
|
+
"vercel_get",
|
|
52
|
+
"vercel_search",
|
|
53
|
+
"vercel_project_production_get",
|
|
54
|
+
"vercel_deployment_redeploy",
|
|
55
|
+
"vercel_deployment_promote",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
/** Deployment and project ids: never `.` or `..` (a URL parser collapses them out of the path). */
|
|
59
|
+
const idArg = z
|
|
60
|
+
.string()
|
|
61
|
+
.regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/, "letters, digits, '.', '_' and '-' only");
|
|
62
|
+
|
|
63
|
+
type Obj = Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
function asObj(v: unknown): Obj {
|
|
66
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function str(v: unknown): string | undefined {
|
|
70
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The facts of one deployment a redeploy needs and an approver wants to see. */
|
|
74
|
+
async function readDeployment(deploymentId: string): Promise<Obj & { name: string | undefined }> {
|
|
75
|
+
const d = asObj(await vercelGet(`/v13/deployments/${encodeURIComponent(deploymentId)}`));
|
|
76
|
+
return {
|
|
77
|
+
id: str(d["id"]) ?? str(d["uid"]) ?? deploymentId,
|
|
78
|
+
name: str(d["name"]),
|
|
79
|
+
target: d["target"] ?? null,
|
|
80
|
+
readyState: str(d["readyState"]) ?? str(d["state"]),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `{projectId, name, production}`: the deployment a promote makes stop being current. */
|
|
85
|
+
async function readProductionState(projectId: string): Promise<Obj> {
|
|
86
|
+
const project = asObj(await vercelGet(`/v9/projects/${encodeURIComponent(projectId)}`));
|
|
87
|
+
const prod = asObj(asObj(project["targets"])["production"]);
|
|
88
|
+
const id = str(prod["id"]) ?? str(prod["uid"]);
|
|
89
|
+
return {
|
|
90
|
+
projectId: str(project["id"]) ?? projectId,
|
|
91
|
+
name: str(project["name"]),
|
|
92
|
+
production:
|
|
93
|
+
id === undefined
|
|
94
|
+
? null
|
|
95
|
+
: {
|
|
96
|
+
id,
|
|
97
|
+
url: str(prod["url"]),
|
|
98
|
+
readyState: str(prod["readyState"]) ?? str(prod["state"]),
|
|
99
|
+
createdAt: prod["createdAt"],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function registerVercelTools(
|
|
105
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
106
|
+
): void {
|
|
107
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
108
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
109
|
+
connector: "vercel",
|
|
110
|
+
scopeEnv: "NIMBUS_MCP_VERCEL_WRITE_SCOPE",
|
|
111
|
+
scopeKinds: ["project", "deployment"],
|
|
112
|
+
});
|
|
37
113
|
|
|
38
|
-
export function registerVercelTools(reg: ZodToolRegistrar): void {
|
|
39
114
|
reg(
|
|
40
115
|
"vercel_list",
|
|
41
116
|
"List recent Vercel deployments (`GET /v6/deployments`), most-recent-first, capped at `limit` (default 100). Returns the `{ deployments, pagination }` envelope.",
|
|
@@ -70,4 +145,83 @@ export function registerVercelTools(reg: ZodToolRegistrar): void {
|
|
|
70
145
|
return matchesResult(deployments, filterVercelDeployments, p);
|
|
71
146
|
},
|
|
72
147
|
);
|
|
148
|
+
|
|
149
|
+
reg(
|
|
150
|
+
"vercel_project_production_get",
|
|
151
|
+
"Read which deployment is the CURRENT production deployment of a Vercel project (`GET /v9/projects/{projectId}`, `targets.production`), projected to `{ projectId, name, production: { id, url, readyState, createdAt } | null }`. `production` is null when the project has none. This is what `vercel_deployment_promote` replaces.",
|
|
152
|
+
z.object({ projectId: idArg }),
|
|
153
|
+
async (p) => jsonResult(await readProductionState(p.projectId)),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
registerWriteTool(
|
|
157
|
+
"vercel_deployment_redeploy",
|
|
158
|
+
{
|
|
159
|
+
mutates: "vercel.deployment.redeploy",
|
|
160
|
+
// A production redeploy can supersede the deployment that is live, and nothing here puts it
|
|
161
|
+
// back. The source deployment is recorded in the audit pre-state.
|
|
162
|
+
recoverable: false,
|
|
163
|
+
capturePreState: async (p: { deploymentId: string }) => {
|
|
164
|
+
const { id, target, readyState } = await readDeployment(p.deploymentId);
|
|
165
|
+
return { id, target, readyState };
|
|
166
|
+
},
|
|
167
|
+
scopeTargetOf: (p: { deploymentId: string }) => ({
|
|
168
|
+
kind: "deployment",
|
|
169
|
+
value: p.deploymentId,
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
"Redeploy an existing Vercel deployment as a NEW build (`POST /v13/deployments?forceNew=1`, body `{ name, deploymentId, target, withLatestCommit? }`). `target` is required and always sent: `production` replaces the live site once the new build is ready, `preview` does not (omitting it would make a preview). The project `name` is read from the source deployment first, and an unknown deployment sends nothing. The reply is `accepted` with the NEW deployment's id: Vercel builds it afterwards, so poll `vercel_get` until `readyState` is READY. Not undone by this tool.",
|
|
173
|
+
z.object({
|
|
174
|
+
deploymentId: idArg,
|
|
175
|
+
target: z.enum(["production", "preview"]),
|
|
176
|
+
withLatestCommit: z.boolean().optional(),
|
|
177
|
+
}),
|
|
178
|
+
async (p) => {
|
|
179
|
+
const source = await readDeployment(p.deploymentId);
|
|
180
|
+
if (source.name === undefined) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Vercel deployment ${p.deploymentId} has no project name; nothing was sent`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const body: Obj = { name: source.name, deploymentId: p.deploymentId, target: p.target };
|
|
186
|
+
if (p.withLatestCommit !== undefined) {
|
|
187
|
+
body["withLatestCommit"] = p.withLatestCommit;
|
|
188
|
+
}
|
|
189
|
+
const reply = asObj(await sendJson("POST", withTeam("/v13/deployments?forceNew=1"), body));
|
|
190
|
+
return jsonResult({
|
|
191
|
+
ok: true,
|
|
192
|
+
status: "accepted",
|
|
193
|
+
deploymentId: str(reply["id"]) ?? str(reply["uid"]),
|
|
194
|
+
url: str(reply["url"]),
|
|
195
|
+
readyState: str(reply["readyState"]) ?? str(reply["state"]),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
registerWriteTool(
|
|
201
|
+
"vercel_deployment_promote",
|
|
202
|
+
{
|
|
203
|
+
mutates: "vercel.deployment.promote",
|
|
204
|
+
// The previous production deployment is recorded; promoting it back is a second promote.
|
|
205
|
+
recoverable: false,
|
|
206
|
+
capturePreState: (p: { projectId: string }) => readProductionState(p.projectId),
|
|
207
|
+
scopeTargetOf: (p: { projectId: string }) => ({ kind: "project", value: p.projectId }),
|
|
208
|
+
},
|
|
209
|
+
"Promote an existing Vercel deployment to production WITHOUT rebuilding it (`POST /v10/projects/{projectId}/promote/{deploymentId}`, no body). The deployment that is production now stops being current: read it first with `vercel_project_production_get`. The reply is `accepted`; Vercel applies the promotion afterwards.",
|
|
210
|
+
z.object({ projectId: idArg, deploymentId: idArg }),
|
|
211
|
+
async (p) => {
|
|
212
|
+
await sendJson(
|
|
213
|
+
"POST",
|
|
214
|
+
withTeam(
|
|
215
|
+
`/v10/projects/${encodeURIComponent(p.projectId)}/promote/${encodeURIComponent(p.deploymentId)}`,
|
|
216
|
+
),
|
|
217
|
+
undefined,
|
|
218
|
+
);
|
|
219
|
+
return jsonResult({
|
|
220
|
+
ok: true,
|
|
221
|
+
status: "accepted",
|
|
222
|
+
projectId: p.projectId,
|
|
223
|
+
deploymentId: p.deploymentId,
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
);
|
|
73
227
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Longest vendor string a read projection returns, in UTF-16 code units. */
|
|
2
|
+
export const MAX_VENDOR_TEXT = 500;
|
|
3
|
+
|
|
4
|
+
/** Appended to a string that was cut, so a reader can tell it is not the whole value. */
|
|
5
|
+
export const TRUNCATION_MARKER = "...[truncated]";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A vendor string capped at `max` characters, with an explicit marker when it was cut; `undefined`
|
|
9
|
+
* for anything that is not a non-empty string. Read projections use it so one unbounded field
|
|
10
|
+
* (an error message, a commit message) cannot flood the model's context.
|
|
11
|
+
*/
|
|
12
|
+
export function capText(value: unknown, max: number = MAX_VENDOR_TEXT): string | undefined {
|
|
13
|
+
if (typeof value !== "string" || value === "") return undefined;
|
|
14
|
+
return value.length <= max ? value : `${value.slice(0, max)}${TRUNCATION_MARKER}`;
|
|
15
|
+
}
|
package/shared/consent-kit.ts
CHANGED
|
@@ -82,6 +82,14 @@ export type WriteToolConfig<T> = {
|
|
|
82
82
|
readonly capturePreState?: (args: T) => Promise<Record<string, unknown>>;
|
|
83
83
|
/** The scope target this invocation would touch, checked against the allow-list. */
|
|
84
84
|
readonly scopeTargetOf: (args: T) => { kind: string; value: string };
|
|
85
|
+
/**
|
|
86
|
+
* A refusal no consent can lift (an input the tool will reject, a tool that is refused outright),
|
|
87
|
+
* as its message, or `undefined` to proceed. Standalone only, and checked FIRST, before scope,
|
|
88
|
+
* budget and prompt: asking a human to approve a call that is certain to fail trains them to
|
|
89
|
+
* click through. It does NOT replace the handler's own check: in gateway mode the raw handler is
|
|
90
|
+
* registered and this is never called, so the handler must refuse the same input itself.
|
|
91
|
+
*/
|
|
92
|
+
readonly refuseBefore?: (args: T) => string | undefined;
|
|
85
93
|
};
|
|
86
94
|
|
|
87
95
|
export type WriteToolRegistrar = <T>(
|
|
@@ -348,6 +356,13 @@ export function createWriteToolRegistrar(
|
|
|
348
356
|
const guarded = async (args: T): Promise<McpListResult> => {
|
|
349
357
|
const target = toolCfg.scopeTargetOf(args);
|
|
350
358
|
|
|
359
|
+
// 0. A refusal no consent can lift comes before everything that could involve a human.
|
|
360
|
+
const early = toolCfg.refuseBefore?.(args);
|
|
361
|
+
if (early !== undefined) {
|
|
362
|
+
await record(name, "refused", { reason: early, target });
|
|
363
|
+
return refused(early);
|
|
364
|
+
}
|
|
365
|
+
|
|
351
366
|
// 1. SCOPE FIRST — before prompting. Asking a human to approve something the operator
|
|
352
367
|
// already forbade cannot change the outcome, and training people to click through
|
|
353
368
|
// prompts that do not matter is how you make the prompts that do matter ineffective.
|
package/shared/env-json-api.ts
CHANGED
|
@@ -222,17 +222,35 @@ export function createJsonSender(
|
|
|
222
222
|
return async (method, path, body, extraHeaders) => {
|
|
223
223
|
const text = await send(
|
|
224
224
|
path,
|
|
225
|
-
(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
body
|
|
229
|
-
|
|
225
|
+
// An `undefined` body is a bodiless request (Netlify's build trigger, Vercel's promote): no
|
|
226
|
+
// entity, so no Content-Type either, which a server may read as a body that must parse.
|
|
227
|
+
(headers) =>
|
|
228
|
+
body === undefined
|
|
229
|
+
? { method, headers: { ...headers, ...extraHeaders } }
|
|
230
|
+
: {
|
|
231
|
+
method,
|
|
232
|
+
headers: { ...headers, "Content-Type": "application/json", ...extraHeaders },
|
|
233
|
+
body: JSON.stringify(body),
|
|
234
|
+
},
|
|
230
235
|
errorPrefix(method, path),
|
|
231
236
|
);
|
|
232
|
-
return
|
|
237
|
+
return parsedOrEmpty(text);
|
|
233
238
|
};
|
|
234
239
|
}
|
|
235
240
|
|
|
241
|
+
/**
|
|
242
|
+
* A 2xx body as JSON, or `{}` when it is empty or not JSON. The write was applied either way, and a
|
|
243
|
+
* throw here would invite the caller to retry it (a duplicate build, a second invitation email).
|
|
244
|
+
*/
|
|
245
|
+
function parsedOrEmpty(text: string): unknown {
|
|
246
|
+
if (text === "") return {};
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(text) as unknown;
|
|
249
|
+
} catch {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
236
254
|
/**
|
|
237
255
|
* The mutating twin of {@link createJsonGetter}: a `(path, body) => Promise<unknown>`
|
|
238
256
|
* POST client that sends `body` as JSON (adding `Content-Type: application/json` to
|
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
* is here. A naming convention would not do: a suffix rule passes `*_files_get`-style names that
|
|
8
8
|
* the gateway treats as plain reads, and ids of the same class do not share a suffix.
|
|
9
9
|
*
|
|
10
|
-
* Grows per release:
|
|
11
|
-
*
|
|
12
|
-
* to be registered exactly once,
|
|
10
|
+
* Grows per release: 0.8.0 listed the two commit tools; the next release adds the Terraform plan,
|
|
11
|
+
* destroy-plan and show-json tools, the CloudFormation change-set create and the Kubernetes
|
|
12
|
+
* manifest plan. `prepare-tool-ids.test.ts` requires every id here to be registered exactly once,
|
|
13
|
+
* through `createPrepareToolRegistrar` and nothing else.
|
|
13
14
|
*/
|
|
14
|
-
export const PREPARE_TOOL_IDS = [
|
|
15
|
+
export const PREPARE_TOOL_IDS = [
|
|
16
|
+
"github_commit_prepare",
|
|
17
|
+
"gitlab_commit_prepare",
|
|
18
|
+
"iac_terraform_plan",
|
|
19
|
+
"iac_terraform_destroy_plan",
|
|
20
|
+
"iac_terraform_show_json",
|
|
21
|
+
"iac_cloudformation_changeset_create",
|
|
22
|
+
"k8s_manifest_plan",
|
|
23
|
+
] as const;
|
|
15
24
|
|
|
16
25
|
export type PrepareToolId = (typeof PREPARE_TOOL_IDS)[number];
|
|
17
26
|
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
import { constants } from "node:fs";
|
|
22
22
|
import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
23
|
import { tmpdir } from "node:os";
|
|
24
|
-
import { delimiter,
|
|
24
|
+
import { delimiter, join } from "node:path";
|
|
25
|
+
import { configuredScratchBase } from "./private-scratch.ts";
|
|
25
26
|
|
|
26
27
|
/** Names the directory private kubeconfigs are made in; an absolute path, else `os.tmpdir()`. */
|
|
27
|
-
export
|
|
28
|
+
export { SCRATCH_DIR_ENV } from "./private-scratch.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The one path `raw` (a `KUBECONFIG` value) names. A list of paths throws: a tool that cannot say
|
|
@@ -57,14 +58,14 @@ export function singleKubeconfigPath(
|
|
|
57
58
|
return only;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The base directory private kubeconfigs are made under: the configured scratch base, else
|
|
63
|
+
* `os.tmpdir()` on every platform. Unlike `withScratchDir` (private-scratch.ts) this does NOT
|
|
64
|
+
* refuse on Windows under the gateway: the before-state reads shipped in 0.8.0 run there today
|
|
65
|
+
* without the env var, and the gateway grants it with the Kubernetes write rows (D3).
|
|
66
|
+
*/
|
|
61
67
|
function scratchBase(): string {
|
|
62
|
-
|
|
63
|
-
if (configured === undefined || configured === "") return tmpdir();
|
|
64
|
-
if (!isAbsolute(configured)) {
|
|
65
|
-
throw new Error(`${SCRATCH_DIR_ENV} must be an absolute path`);
|
|
66
|
-
}
|
|
67
|
-
return configured;
|
|
68
|
+
return configuredScratchBase() ?? tmpdir();
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** Refuse a source that is not a plain file: a symlink, a directory, a device. */
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* private-scratch — a private directory for one tool call's files, removed when the call settles.
|
|
3
|
+
*
|
|
4
|
+
* The CLI-backed write tools hand their CLI a file rather than an argument: a CloudFormation
|
|
5
|
+
* template, a Kubernetes manifest. Each call gets its own 0700 directory under the configured
|
|
6
|
+
* scratch base, so concurrent calls never share a file and nothing outlives the call.
|
|
7
|
+
*
|
|
8
|
+
* The base is `NIMBUS_CONNECTOR_SCRATCH_DIR` (absolute) when set. When it is not, `os.tmpdir()`,
|
|
9
|
+
* with one exception: a Windows connector running under the gateway REFUSES. The gateway sandboxes
|
|
10
|
+
* a Windows connector in an AppContainer and grants it a per-action directory through that env
|
|
11
|
+
* var; the user temp directory is not that grant, so a file written there is either refused by
|
|
12
|
+
* the sandbox or, worse, shared with every other process of the user. Failing closed names the
|
|
13
|
+
* missing grant instead.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { isAbsolute, join } from "node:path";
|
|
19
|
+
import { getConnectorMode } from "./connector-mode.ts";
|
|
20
|
+
|
|
21
|
+
/** Names the directory scratch directories are made in; an absolute path. */
|
|
22
|
+
export const SCRATCH_DIR_ENV = "NIMBUS_CONNECTOR_SCRATCH_DIR";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The configured scratch base, or `undefined` when none is configured. Throws on a relative
|
|
26
|
+
* value: a relative base would resolve against whatever the connector's cwd happens to be.
|
|
27
|
+
*/
|
|
28
|
+
export function configuredScratchBase(): string | undefined {
|
|
29
|
+
const configured = process.env[SCRATCH_DIR_ENV]?.trim();
|
|
30
|
+
if (configured === undefined || configured === "") return undefined;
|
|
31
|
+
if (!isAbsolute(configured)) {
|
|
32
|
+
throw new Error(`${SCRATCH_DIR_ENV} must be an absolute path`);
|
|
33
|
+
}
|
|
34
|
+
return configured;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The base a {@link withScratchDir} directory is made under (see the module comment for the
|
|
39
|
+
* Windows-under-the-gateway refusal). `platform` is a parameter so both sides are testable.
|
|
40
|
+
*/
|
|
41
|
+
export function scratchDirBase(platform: NodeJS.Platform = process.platform): string {
|
|
42
|
+
const configured = configuredScratchBase();
|
|
43
|
+
if (configured !== undefined) return configured;
|
|
44
|
+
if (platform === "win32" && getConnectorMode() === "gateway") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`${SCRATCH_DIR_ENV} is not set: on Windows the gateway must grant this connector a scratch ` +
|
|
47
|
+
"directory, and the user temp directory is not one",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return tmpdir();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run `body` with a fresh 0700 directory of its own, removed when `body` settles, success or
|
|
55
|
+
* failure. `prefix` names the directory (`nimbus-cfn-`, `nimbus-k8s-`).
|
|
56
|
+
*/
|
|
57
|
+
export async function withScratchDir<T>(
|
|
58
|
+
prefix: string,
|
|
59
|
+
body: (dir: string) => Promise<T>,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const base = scratchDirBase();
|
|
62
|
+
const dir = await mkdtemp(join(base, prefix));
|
|
63
|
+
try {
|
|
64
|
+
return await body(dir);
|
|
65
|
+
} finally {
|
|
66
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
67
|
+
}
|
|
68
|
+
}
|