@nimbus-dev/connectors 0.9.0 → 0.11.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,22 +1,93 @@
1
1
  import { z } from "zod";
2
- import { createJsonGetter, envAuthHeaders } from "../../../shared/env-json-api.ts";
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 { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
5
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
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 netlifyGet = createJsonGetter({
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 = ["netlify_get", "netlify_list", "netlify_search"] as const;
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 (read-only). 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.",
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 { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
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
- await runReadOnlyMcpConnector("nimbus-testflight", (reg) => {
5
- registerTestflightTools(reg);
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 { createJsonGetter } from "../../../shared/env-json-api.ts";
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 { mcpJsonResult as jsonResult } from "../../../shared/mcp-tool-kit.ts";
6
- import type { ZodToolRegistrar } from "../../../shared/run-read-only-mcp-connector.ts";
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 appstoreGet = createJsonGetter({
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,92 @@ 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
- export function registerTestflightTools(reg: ZodToolRegistrar): void {
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
+ /**
101
+ * The ids, among `ids`, of beta groups that have a public link right now: one
102
+ * `GET /v1/betaGroups/{id}` each, read at dispatch so the answer is the group's state at the moment
103
+ * of the send, not at the time anyone was shown it.
104
+ */
105
+ async function publicLinkGroupIds(ids: readonly string[]): Promise<string[]> {
106
+ const open: string[] = [];
107
+ for (const id of ids) {
108
+ const root = asObj(await appstoreGet(`/v1/betaGroups/${encodeURIComponent(id)}`));
109
+ if (asObj(asObj(root["data"])["attributes"])["publicLinkEnabled"] === true) open.push(id);
110
+ }
111
+ return open;
112
+ }
113
+
114
+ /** The app a build belongs to, from `GET /v1/builds/{id}?include=app`. */
115
+ async function appOfBuild(buildId: string): Promise<string | undefined> {
116
+ const root = asObj(await appstoreGet(`/v1/builds/${encodeURIComponent(buildId)}?include=app`));
117
+ const rel = asObj(asObj(asObj(root["data"])["relationships"])["app"]);
118
+ return str(asObj(rel["data"])["id"]);
119
+ }
120
+
121
+ export function registerTestflightTools(
122
+ server: ConsentServer & { tool: (...args: never) => unknown },
123
+ ): void {
124
+ const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
125
+ const registerWriteTool = createWriteToolRegistrar(server, {
126
+ connector: "testflight",
127
+ scopeEnv: "NIMBUS_MCP_TESTFLIGHT_WRITE_SCOPE",
128
+ scopeKinds: ["app"],
129
+ });
130
+
35
131
  reg(
36
132
  "testflight_list",
37
133
  "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 +172,87 @@ export function registerTestflightTools(reg: ZodToolRegistrar): void {
76
172
  return matchesResult(data, filterTestflightBuilds, p);
77
173
  },
78
174
  );
175
+
176
+ reg(
177
+ "testflight_build_groups_get",
178
+ "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.",
179
+ z.object({ buildId: idArg }),
180
+ async (p) => jsonResult({ groups: await readBuildGroups(p.buildId) }),
181
+ );
182
+
183
+ reg(
184
+ "testflight_beta_groups_list",
185
+ "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.",
186
+ z.object({ appId: idArg, limit: z.number().int().min(1).max(200).optional() }),
187
+ async (p) => {
188
+ const search = new URLSearchParams({ limit: String(p.limit ?? 100) });
189
+ const root = await appstoreGet(
190
+ `/v1/apps/${encodeURIComponent(p.appId)}/betaGroups?${search.toString()}`,
191
+ );
192
+ return jsonResult({ groups: projectGroups(root, p.limit ?? 100) });
193
+ },
194
+ );
195
+
196
+ registerWriteTool(
197
+ "testflight_build_distribute",
198
+ {
199
+ mutates: "testflight.build.distribute",
200
+ // Testers who were given the build cannot be un-invited from here; the groups the build was
201
+ // already in are recorded in the audit pre-state.
202
+ recoverable: false,
203
+ capturePreState: async (p: { buildId: string }) => ({
204
+ buildId: p.buildId,
205
+ groups: await readBuildGroups(p.buildId),
206
+ }),
207
+ scopeTargetOf: (p: { appId: string }) => ({ kind: "app", value: p.appId }),
208
+ },
209
+ '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`): each target group is read (`GET /v1/betaGroups/{id}`) right before the send, and one with `publicLinkEnabled: true` is refused with `testflight_public_link_forbidden` unless `allowPublicLink` is `true` (default false). 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`.',
210
+ z.object({
211
+ appId: idArg,
212
+ buildId: idArg,
213
+ betaGroupIds: z.array(idArg).min(1).max(20),
214
+ allowPublicLink: z.boolean().optional(),
215
+ }),
216
+ async (p) => {
217
+ if (new Set(p.betaGroupIds).size !== p.betaGroupIds.length) {
218
+ throw new Error("betaGroupIds has a duplicate; nothing was sent");
219
+ }
220
+ const owner = await appOfBuild(p.buildId);
221
+ if (owner !== p.appId) {
222
+ throw new Error(`build ${p.buildId} does not belong to app ${p.appId}; nothing was sent`);
223
+ }
224
+ // Every target group must be one of this app's: one GET, and nothing is sent on a mismatch.
225
+ const known = await appGroupIds(p.appId);
226
+ const stray = p.betaGroupIds.filter((id) => !known.has(id));
227
+ if (stray.length > 0) {
228
+ throw new Error(
229
+ `beta group(s) ${stray.join(", ")} do not belong to app ${p.appId}; nothing was sent`,
230
+ );
231
+ }
232
+ // A group with a public link reaches anyone who holds the link, so it is distributed to only
233
+ // when the caller said so. Read now, at dispatch, from each group itself.
234
+ if (p.allowPublicLink !== true) {
235
+ const open = await publicLinkGroupIds(p.betaGroupIds);
236
+ if (open.length > 0) {
237
+ throw new Error(
238
+ `testflight_public_link_forbidden: beta group(s) ${open.join(", ")} have a public link; pass allowPublicLink: true to distribute to them; nothing was sent`,
239
+ );
240
+ }
241
+ }
242
+ await appstoreSend(
243
+ "POST",
244
+ `/v1/builds/${encodeURIComponent(p.buildId)}/relationships/betaGroups`,
245
+ {
246
+ data: p.betaGroupIds.map((id) => ({ type: "betaGroups", id })),
247
+ },
248
+ );
249
+ return jsonResult({
250
+ ok: true,
251
+ status: "accepted",
252
+ appId: p.appId,
253
+ buildId: p.buildId,
254
+ betaGroupIds: p.betaGroupIds,
255
+ });
256
+ },
257
+ );
79
258
  }
@@ -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 (read-only). 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. Projects / domains / env vars / aliases / logs are deferred follow-ups.",
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 { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
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
- await runReadOnlyMcpConnector("nimbus-vercel", (reg) => {
5
- registerVercelTools(reg);
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);