@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.
@@ -1,8 +1,17 @@
1
1
  import { z } from "zod";
2
- import { createJsonGetter, envAuthHeaders } from "../../../shared/env-json-api.ts";
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 { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
5
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
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 getJson = createJsonGetter({
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 = ["vercel_get", "vercel_list", "vercel_search"] as const;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-dev/connectors",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
+ }
@@ -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
- (headers) => ({
226
- method,
227
- headers: { ...headers, "Content-Type": "application/json", ...extraHeaders },
228
- body: JSON.stringify(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 text === "" ? {} : (JSON.parse(text) as unknown);
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