diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5fd7a7e..07578b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,22 @@
All notable changes to this package are documented here.
+## 0.22.1 — 2026-08-31
+
+This patch adds a maintained Vercel connection over the public REST API. It is
+a new opt-in provider subpath: existing imports, connector declarations,
+credentials, and deployment behavior do not change. Deployments that do not
+import the Vercel provider can ignore this release.
+
+### Added
+
+- **Maintained Vercel REST connection.** `@zackbart/connecta/providers/vercel`
+ ships 18 projected project, deployment, log, domain, environment-variable,
+ and lifecycle tools plus separate GET, JSON-mutation, and upload hatches. It
+ uses one operator-managed access token, defaults account calls to an optional
+ team, never decrypts environment values in named reads, and adds no Vercel
+ SDK dependency.
+
## 0.22.0 — 2026-08-31
This release lets one deployment serve several authenticated people without
diff --git a/README.md b/README.md
index 687f30b..92c24c5 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ flowchart TB
Explicit["call_destructive_tool
one visible call per write
your client can ask you first"]
end
- Integrations["The integrations you chose
Linear · Stripe · Notion · your HTTP API · any MCP server"]
+ Integrations["The integrations you chose
Linear · Stripe · Notion · Vercel · your HTTP API · any MCP server"]
Client -->|"one connection"| Sandbox
Client --> Explicit
@@ -63,8 +63,8 @@ Fifty issues in, one small object out. Your context window notices.
- **Wrap any HTTP API by hand.** A few lines per tool. No OpenAPI conversion —
generated tool sprawl is the problem, not the fix.
- **Use maintained connections** for Cloudflare, Linear, Mixpanel, Notion,
- RevenueCat, and Stripe — known endpoints, auth defaults, and vetted read/write
- classifications, imported one at a time.
+ RevenueCat, Stripe, and Vercel — known endpoints, auth defaults, and vetted
+ read/write classifications, imported one at a time.
- **Let the agent work in code.** Search, chain, filter, join, and reduce
inside the sandbox instead of round-tripping every call through the model.
- **Teach undeclared result shapes by using them.** Successful read-only calls
diff --git a/documentation/connector-guides.md b/documentation/connector-guides.md
index ff3e2a1..1f8708d 100644
--- a/documentation/connector-guides.md
+++ b/documentation/connector-guides.md
@@ -136,11 +136,11 @@ few things both shapes fully own:
- [Hand-written HTTP providers](./provider-conventions.md#hand-written-http-providers)
(H1–H14) — `api()` surfaces where Connecta owns every name, schema,
- projection, and error. Cloudflare and Notion.
+ projection, and error. Cloudflare, Notion, and Vercel.
- [Hosted-MCP proxies](./provider-conventions.md#hosted-mcp-proxies) (P1–P13) —
`remoteMcp()` wrappers where the downstream owns the catalog and Connecta
owns the endpoint, credential, classification, guide, and budget. Linear,
- Stripe, and Mixpanel.
+ Stripe, Mixpanel, and RevenueCat.
Both sets are judged by one measure: what the convention saves the model that
interacts with connecta, priced in discovery tokens, wrong-tool selection,
diff --git a/documentation/connectors.md b/documentation/connectors.md
index f1d00c6..71acf90 100644
--- a/documentation/connectors.md
+++ b/documentation/connectors.md
@@ -137,6 +137,7 @@ Maintained provider guides:
- [Notion](./notion.md)
- [RevenueCat](./revenuecat.md)
- [Stripe](./stripe.md)
+- [Vercel](./vercel.md)
## The `api()` construction contract
@@ -226,12 +227,13 @@ into a result or a typed failure. That split is not fastidiousness. Notion's
cannot fix it — while Cloudflare's means a token scope, and the two want
opposite next moves. A helper that guessed would be wrong for one of them.
-Cloudflare and Notion both run on it. Their existing suites carried over
-unchanged, which proves the migration kept the behavior those suites cover —
-not that nothing changed. Three things did, and the changelog names them: a
-3xx is refused where both providers used to follow it, both now fail past
-their byte ceiling, and `cloudflare()`'s `baseUrl` is validated at
-construction. Each suite gained one test for the ceiling, because the one
+Cloudflare, Notion, and Vercel run on it. The first two existing suites carried
+over unchanged when the helper was extracted, which proves that migration kept
+the behavior those suites cover — not that nothing changed. Three things did,
+and the changelog names them: a 3xx is refused where both providers used to
+follow it, both now fail past their byte ceiling, and `cloudflare()`'s
+`baseUrl` is validated at construction. Each suite gained one test for the
+ceiling, because the one
guard the helper was written to add is the one a provider's own mapper can
most easily disarm: a bare `catch` around `response.json()` swallows the
transport's refusal along with a parse error, and turns a response nobody was
diff --git a/documentation/operations.md b/documentation/operations.md
index b771537..88bfde3 100644
--- a/documentation/operations.md
+++ b/documentation/operations.md
@@ -258,7 +258,7 @@ in.
| `operator-boundary.test.ts` | the operator row of the decisions table, after every mutation route: authentication material managed without moving a declared structure, and the one honest exception — a credential write making a remote catalog appear, which is discovery arriving, not an operator editing the deployment |
| `operator-store.test.ts` | `src/operator-ui/app/store.ts` against a fake browser: the Clerk listener, ambient Access requests without a browser-readable token, `gate()`, the generation fence, and the request path |
| `provider-conventions.test.ts` | the conventions a test can hold: hand-written providers refusing schemas they cannot enforce (H5), their compact discovery schemas staying complete (H7), Cloudflare stating its second pagination convention in the schema (H10), and Notion saying it has no escape hatch (H14) |
-| `provider-registry.test.ts` | all six maintained providers inside real deployments: boot, description, address, catalog, storage, credential, admission, and activity isolation; plus provider-specific discovery and guide contracts |
+| `provider-registry.test.ts` | all seven maintained providers inside real deployments: boot, description, address, catalog, storage, credential, admission, and activity isolation; plus provider-specific discovery and guide contracts |
| `registry.test.ts` | construction and id validation, startup warnings, address resolution, version 2 catalog TTL/persistence/completeness, agent-only stale-while-revalidate with cross-request single-flight shared with blocking reads in both start orders, owned teardown, invalidation/fingerprint guards, blocking diagnostics, and broken-connector isolation |
| `remote-mcp.test.ts` | `remoteMcp()` against an in-process server through the `_transportFactory` seam: passthrough, downstream `isError`, Workers-safe output-schema validation, request-scoped client reuse and at-most-once scope close; plus the real transport's manual redirect policy, destination guard, credential containment, and downstream session termination |
| `remote-mcp-credential.test.ts` | `remoteMcp()` drawing a static key from `/credentials`: the declared slot and its refusal of named fields and bad header names, header framing (bearer, bare, and the two `Basic` forms) observed on the wire, an empty slot failing as `auth_required` rather than reaching the downstream, a value carrying a control character refused before framing and absent from every surface — `call_tool`, `status`, the Test result, the payload-free activity event, and the thrown error — rotation replacing the cached client and a connect already in flight while a wiped value fails the next call, the Test action's catalog probe and scope close, the cleartext-destination warning, and the vault and `authorize_connector` handoff end to end |
@@ -274,6 +274,7 @@ in.
| `ui-credentials.test.ts` | credential-management routes: save, test, delete, validation, authentication, same-origin checks, and multi-field credential shapes |
| `ui.test.ts` | the server shell and remaining `/ui/*` routes: gated `/ui/data` with broken-connector isolation and registry-owned catalog-observation containment, plus the URL safety gates |
| `validate.test.ts` | `validateToolInput()` — a returned (not thrown) `invalid_args` naming the path, `additionalProperties: false` enforcement, per-schema validator caching, and an unusable schema passed through with one warning |
+| `vercel-provider.test.ts` | `vercel()` construction, team scoping, project and deployment projections, finite build and runtime logs, value-safe environment variables, domains, lifecycle writes, REST hatches, typed failures, and credential test |
### Node-bound (`NODE_ONLY_SUITES`)
diff --git a/documentation/provider-conventions.md b/documentation/provider-conventions.md
index 45ebe32..c22db2a 100644
--- a/documentation/provider-conventions.md
+++ b/documentation/provider-conventions.md
@@ -1,6 +1,6 @@
# Provider conventions
-The six maintained prebuilt connections grew one at a time, and until now
+The seven maintained prebuilt connections grew one at a time, and until now
"excellent provider" meant whatever the last author thought. This document
writes the judgment down so it can be argued with, audited, and reused.
@@ -8,7 +8,7 @@ There are two genuinely different provider shapes, and one convention set
cannot honestly cover both:
- **Hand-written HTTP providers** — `api()` surfaces where Connecta owns every
- tool name, schema, projection, and error. Today: Cloudflare, Notion.
+ tool name, schema, projection, and error. Today: Cloudflare, Notion, Vercel.
- **Hosted-MCP proxies** — `remoteMcp()` wrappers around a server somebody else
operates, where the names, schemas, results, and error prose arrive as they
are. Today: Linear, Stripe, Mixpanel, RevenueCat.
@@ -85,7 +85,7 @@ other source with no description or no `inputSchema`.
## Hand-written HTTP providers
Connecta owns the whole surface here, which means every miss is ours. These
-apply to `api()`-based prebuilt connections (Cloudflare, Notion) and are the
+apply to `api()`-based prebuilt connections (Cloudflare, Notion, Vercel) and are the
bar any future one is written to.
None of them asks an author to re-derive transport safety. URL confinement,
@@ -646,11 +646,11 @@ evidence and nothing else: no tool is generated from one, which is the
## What the audit checks
The provider audit ([#342](https://github.com/zackbart/connecta/issues/342))
-runs this document against each of the six providers and returns a verdict per
+runs this document against each of the seven providers and returns a verdict per
convention: **meets**, **misses** (with the fix), or **not applicable** (with
the reason). A convention is never quietly skipped, and an accepted miss is
recorded as a provider-specific exception with its argument, not left blank.
-Its six reports live in [provider-audit.md](https://github.com/zackbart/connecta/blob/main/records/provider-audit.md), and the
+Its seven reports live in [provider-audit.md](https://github.com/zackbart/connecta/blob/main/records/provider-audit.md), and the
mechanically checkable half of the hand-written bar runs on every test run in
[`test/provider-conventions.test.ts`](https://github.com/zackbart/connecta/blob/main/test/provider-conventions.test.ts) —
so a convention that was met once stays met, or fails loudly.
diff --git a/documentation/upgrading.md b/documentation/upgrading.md
index 7e4974c..5b96501 100644
--- a/documentation/upgrading.md
+++ b/documentation/upgrading.md
@@ -57,7 +57,7 @@ exist so far:
| --- | --- | --- |
| **pre-template** | before 0.10.2 | no `connecta init` existed; hand-written, or copied from the retired `examples/node` |
| **A** | 0.10.2 – 0.15.1 | `.env.example`, `.gitignore`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `package.json`, `src/index.ts`, `tsconfig.json` |
-| **B** | 0.16.0 – 0.22.0 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
+| **B** | 0.16.0 – 0.22.1 | adds `.dockerignore`, `Dockerfile`, `docker-compose.yml`, and `src/file-activity.ts`; `src/index.ts` grows the four commented operator blocks; `.env.example` ships `CONNECTA_TOKEN=` empty |
Generation A is a decade in template years and identifying it precisely does
not matter, because you are about to reconstruct it exactly rather than guess
@@ -106,7 +106,7 @@ know what to preserve, once to know what to re-verify at the end.
### Bump the pin and install
```sh
-npm pkg set dependencies.@zackbart/connecta=0.22.0
+npm pkg set dependencies.@zackbart/connecta=0.22.1
npm install
```
@@ -130,7 +130,7 @@ Generate the *current* template beside the base you already made, into the same
`$SCRATCH`:
```sh
-(cd "$SCRATCH" && npx @zackbart/connecta@0.22.0 init current)
+(cd "$SCRATCH" && npx @zackbart/connecta@0.22.1 init current)
```
You now have a three-way merge with a real base: `$SCRATCH/base` is what this
@@ -186,7 +186,7 @@ A deployment older than 0.10.2 has no base to diff against. Do not try to
manufacture one. Instead:
1. `SCRATCH=$(mktemp -d)`, then
- `(cd "$SCRATCH" && npx @zackbart/connecta@0.22.0 init current)` — there is no
+ `(cd "$SCRATCH" && npx @zackbart/connecta@0.22.1 init current)` — there is no
`base` leg here, only the current template to read from.
2. Copy `$SCRATCH/current` into the deployment file by file, **skipping
`src/index.ts`**.
@@ -207,7 +207,7 @@ first, so cross them bottom-up: start at the oldest one still above this
deployment's pin and work back up the page, because each boundary assumes the
older ones are already done.
-### 0.21.2 → 0.22.0
+### 0.21.2 → 0.22.1
Connector and user policy remain config-as-code. If `identity.connectorAccess`
is configured, every interactive human may now manage the authentication of
diff --git a/documentation/vercel.md b/documentation/vercel.md
new file mode 100644
index 0000000..6980e48
--- /dev/null
+++ b/documentation/vercel.md
@@ -0,0 +1,194 @@
+# Vercel
+
+Import `vercel()` independently from `@zackbart/connecta/providers/vercel`.
+It is a hand-written `api()` connection over Vercel's public REST API. The
+connection owns 18 named operations and three provider-relative REST hatches.
+It adds no provider dependency, imports no `node:` builtin, and is not reachable
+from Connecta's root entry.
+
+```ts
+import { vercel } from "@zackbart/connecta/providers/vercel";
+
+const hosting = vercel("hosting", {
+ purpose: "Production web applications for the product team",
+ teamId: "team_1a2b3c4d5e6f7g8h9i0j1k2l",
+});
+```
+
+The deployment stores one Vercel access token in Connecta's credential vault.
+Create the token in Vercel Account Settings under Tokens. Scope it to the
+personal account or team this connection needs and give it an expiration date.
+The operator UI's Test action calls `GET /v2/user` and reports the authenticated
+username, email, name, or id. Connecta never probes it in the background.
+
+## Why this uses REST instead of Vercel MCP
+
+Vercel MCP provides useful project, deployment, and log reads, but it does not
+cover the public API. This connection keeps those common reads and adds project
+domains, value-safe environment-variable management, deployment promotion and
+deletion, and direct access to versioned REST endpoints. The three hatches mean
+a newly published Vercel endpoint does not require a Connecta release before an
+agent can use it.
+
+This is still authored rather than generated. No OpenAPI document creates tools
+at runtime. The named operations are reviewed, projected, classified, and
+tested by hand. The published OpenAPI document is used only by
+`npm run drift:check` to compare the 19 named endpoints this connection calls.
+
+## Configuration
+
+```ts
+vercel("hosting", {
+ title: "Production hosting",
+ authScope: "shared",
+ purpose: "Customer-facing sites owned by Platform",
+ teamId: "team_...",
+ defaultPageSize: 20,
+ instructions: "Never promote the docs project from this connection.",
+ maxResultBytes: 512_000,
+ callAdmission: {
+ rules: [{ maxConcurrency: 6 }],
+ },
+});
+```
+
+`purpose` is required and blank text throws at construction. `teamId` is a
+default, not a hidden lock. Named account-scoped tools accept a `teamId`
+override; pass `null` to target the token owner's personal account explicitly.
+Without a configured default or an override, Vercel uses that personal account.
+`list_teams` returns the ids needed to reach a team.
+
+`defaultPageSize` defaults to 20 and must be a whole number from 1 through 100.
+Vercel meters endpoints separately, so the connection invents no global request
+budget. A deployment may supply `callAdmission` when it has its own concurrency
+or call-rate requirement.
+
+`baseUrl` exists for a test double or an HTTPS proxy. The guarded transport
+confines every path under that base, refuses redirects, prevents request headers
+from replacing `Authorization`, passes `ctx.signal`, and stops reading at 8 MiB.
+The runtime-log read also returns at most 500 rows and stops a stream that stays
+open past 10 seconds. HTTP is accepted only for a loopback test double.
+
+## Named tools
+
+| Tool | What it does |
+| --- | --- |
+| `list_teams` | Lists teams the token can reach. |
+| `list_projects` | Searches or lists lean project summaries. |
+| `get_project` | Reads build settings, Git identity, and the production deployment. |
+| `list_deployments` | Filters deployments by project, target, state, branch, or SHA. |
+| `get_deployment` | Reads one deployment by id or hostname. |
+| `get_build_logs` | Reads at most 1,000 existing build events with live following disabled. |
+| `get_runtime_logs` | Reads a 1–500 row runtime-log snapshot and stops a stream open past 10 seconds. |
+| `list_project_domains` | Lists verification, redirect, branch, and custom-environment state. |
+| `add_project_domain` | Adds a regular, redirect, branch, or custom-environment domain. |
+| `verify_project_domain` | Rechecks a pending domain after its DNS challenge is complete. |
+| `remove_project_domain` | Removes a project domain, optionally with domains redirecting to it. |
+| `list_project_env_vars` | Lists metadata without asking Vercel to decrypt values. |
+| `upsert_project_env_var` | Creates or replaces one variable. |
+| `update_project_env_var` | Patches one variable by its id. |
+| `delete_project_env_var` | Removes one variable from future deployments. |
+| `promote_deployment` | Promotes an existing build to production without rebuilding. |
+| `cancel_deployment` | Cancels work that is queued, initializing, or building. |
+| `delete_deployment` | Permanently removes a deployment and its URL. |
+
+Every read returns a lean projection by default. Projects drop security,
+billing, and presentation settings. Deployments keep state, target, timestamps,
+creator, and Git identity. Domain reads keep the verification challenge because
+dropping it would make an unverified result unusable. The project, deployment,
+domain, and build-log reads accept `raw: true` when a Vercel field omitted by
+the projection matters.
+
+`raw: true` preserves unprojected list items while keeping the named tool's
+declared envelope: list calls still return their item key and `page`, and build
+logs still return `{ events }`.
+
+## Environment values are write-only here
+
+`list_project_env_vars` sends `decrypt=false`, then drops `value` even if
+Vercel returns one anyway. It returns the key, id, storage type, visibility,
+targets, branch, custom-environment ids, comment, and timestamps. The create and
+update tools accept a value as input, but their result projection drops it too.
+
+That boundary is deliberate. An agent can audit placement and make a requested
+change without filling its context with database URLs or API keys. Sensitive
+values cannot be read back from Vercel in any case. The generic
+`vercel_api_get` hatch returns the endpoint's untouched response, so a caller
+that deliberately requests an endpoint capable of decrypting a non-sensitive
+value has asked to cross the named tool's safer boundary.
+
+Environment changes affect future deployments. They do not rewrite a value
+already embedded in an existing deployment, and none of the environment tools
+triggers a deployment on its own.
+
+## REST hatches
+
+Vercel's API is too large and changes too often for every operation to deserve
+a permanent named tool.
+
+- `vercel_api_get` accepts only GET and is explicitly read-only.
+- `vercel_api_mutate` accepts JSON POST, PUT, PATCH, and DELETE. It always
+ crosses `call_destructive_tool`.
+- `vercel_api_upload` accepts POST or PUT with exactly one explicit UTF-8 or
+ base64 body. It also crosses `call_destructive_tool`.
+
+All three take a path beginning with `/` and including Vercel's version, such
+as `/v1/edge-config`. Query parameters are name/value rows rather than a string
+to parse. They use the configured default team unless the caller passes
+`personalAccount: true`; that flag cannot be combined with a `teamId` or `slug`
+query row. The upload hatch accepts endpoint-specific headers such as a digest,
+but refuses credential, cookie, host, content-type, content-length, and
+transfer-encoding headers. It reads no local file. The caller supplies the
+bytes, content type, and any checksum the endpoint requires.
+
+Use a named tool when one exists. A named tool wins on argument validation,
+result size, or safety routing. The hatch is for products such as Edge Config,
+feature flags, drains, checks, security, and team settings that are not worth a
+large permanent catalog.
+
+## Pagination
+
+The four list families expose one connector-wide contract:
+
+```ts
+{
+ items: [],
+ page: { hasMore: true, nextCursor: "opaque" }
+}
+```
+
+The item key is `teams`, `projects`, `deployments`, or `domains`. Pass
+`nextCursor` back as `cursor` unchanged. Vercel uses different parameter names
+and cursor types behind the four endpoints. The connector owns that mapping so
+programs do not parse timestamps or branch on provider-specific pagination.
+
+Environment-variable listing has no pagination in Vercel's published contract
+and returns `{ variables }` without a false page object.
+
+## Typed failures
+
+- HTTP 401 and 403 become `auth_required`. Vercel uses 403 both for a bad token
+ and for a token outside the requested team or operation scope.
+- HTTP 404 becomes `not_found`. Re-list the owning project, deployment, domain,
+ or environment variable before using the id again.
+- HTTP 400, 409, and 422 become `invalid_args`.
+- HTTP 429 becomes `rate_limited`. `Retry-After` wins; otherwise the connector
+ derives the delay from `X-RateLimit-Reset`.
+- HTTP 5xx becomes `unavailable`.
+
+The error text keeps Vercel's error code and message. It never parses prose to
+invent a class.
+
+## No SDK on purpose
+
+The connection imports only Connecta modules and Web APIs. `@vercel/sdk` is not
+a dependency or optional peer. Direct fetch keeps the root Workers-safe, avoids
+shipping the generated model graph, and lets the reviewed named operations and
+the REST hatches share one guarded transport.
+
+The trade is API drift, handled explicitly. `scripts/drift/vercel-endpoints.json`
+records the method, versioned path, specification revision, and request/response
+digest for every fixed endpoint. Before a release, `npm run drift:check` compares
+those rows with Vercel's published OpenAPI document at
+`https://openapi.vercel.sh/`. The hatches are intentionally absent from that
+list because their endpoint is chosen by deployment code at call time.
diff --git a/knip.jsonc b/knip.jsonc
index 59d8c9a..366ac0c 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -13,6 +13,7 @@
"src/providers/notion.ts",
"src/providers/revenuecat.ts",
"src/providers/stripe.ts",
+ "src/providers/vercel.ts",
"src/operator-ui/app/main.tsx",
"examples/*/src/index.ts",
"scripts/*.mjs",
diff --git a/package-lock.json b/package-lock.json
index 0219214..e2dc1ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@zackbart/connecta",
- "version": "0.22.0",
+ "version": "0.22.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zackbart/connecta",
- "version": "0.22.0",
+ "version": "0.22.1",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
diff --git a/package.json b/package.json
index a7aa977..35ab5a8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@zackbart/connecta",
- "version": "0.22.0",
+ "version": "0.22.1",
"type": "module",
"sideEffects": false,
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of seven meta-tools.",
@@ -90,6 +90,10 @@
"./providers/stripe": {
"types": "./dist/providers/stripe.d.ts",
"import": "./dist/providers/stripe.js"
+ },
+ "./providers/vercel": {
+ "types": "./dist/providers/vercel.d.ts",
+ "import": "./dist/providers/vercel.js"
}
},
"scripts": {
diff --git a/records/provider-audit.md b/records/provider-audit.md
index a1dffc8..1edf5c4 100644
--- a/records/provider-audit.md
+++ b/records/provider-audit.md
@@ -1,7 +1,7 @@
# Provider audit
[`provider-conventions.md`](../documentation/provider-conventions.md) wrote the bar down. This
-document runs it against the six maintained prebuilt connections and returns a
+document runs it against the seven maintained prebuilt connections and returns a
verdict for every applicable convention: **meets**, **misses** (with the fix),
or **n/a** (with the reason). A convention is never quietly skipped, and an
accepted miss is recorded as a provider-specific exception with its argument
@@ -15,7 +15,7 @@ Every miss below is fixed in the same change that recorded it
([#342](https://github.com/zackbart/connecta/issues/342)), except where the row
says otherwise. The mechanically checkable half of the hand-written bar is now
a test — [`test/provider-conventions.test.ts`](https://github.com/zackbart/connecta/blob/main/test/provider-conventions.test.ts)
-walks the shipped surface of both `api()` providers on every run, so these
+walks the shipped surface of all three `api()` providers on every run, so these
verdicts cannot rot quietly back into prose. The proxies' mechanical rows live
in their own suites, because what they assert is the wrapper's identity,
classification, and budget rather than tool shapes the wrapper does not own.
@@ -89,6 +89,37 @@ deletion workflows rather than missing fields on the five existing writes
([#408](https://github.com/zackbart/connecta/issues/408),
[#409](https://github.com/zackbart/connecta/issues/409)).
+## Vercel — hand-written HTTP
+
+Twenty-one tools over the public REST API: eighteen named, two guarded request
+hatches, and one guarded upload hatch. The named tools deliberately go past
+Vercel MCP on project domains, environment variables, and deployment lifecycle.
+
+| Convention | Verdict | Notes |
+| --- | --- | --- |
+| H1 identity | meets | `id`, required `purpose` (blank throws), optional `title`, default `teamId`, and `instructions` appended under `## Account instructions` |
+| H2 names | meets | every name is `snake_case`, opens with its safety verb, and the three generic tools sort together as `vercel_api_*` |
+| H3 budgets | meets | every selection sentence fits 160 characters and every full description fits 240, asserted with the other hand-written providers |
+| H4 disqualifiers | meets | project and deployment reads state what their logs and actions do not include; the mutate hatch says JSON and no uploads; the upload hatch says explicit bytes and no local files |
+| H5 schemas | meets | all 21 tools use closed plain-object input schemas with explicit required lists, described nested fields, local bounds, and construction-time validator compilation |
+| H6 whose bound | meets | list sizes name the connector's 1–100 cap and configured default; build logs name the 1,000-event cap; runtime logs name the 500-row and 10-second connector bounds; environment values name Vercel's 64 KB total |
+| H7 compact fit | meets | every input and output remains complete under the 1,024-byte compact renderer budget |
+| H8 output schemas | meets | 21 of 21; the endpoint-generic hatches alone keep an open `result` |
+| H9 projection | meets | project, deployment, domain, and build-log reads project and offer `raw: true`; environment reads never decrypt or return values, and writes also strip values from their result |
+| H10 pagination | meets | teams, projects, deployments, and domains expose the same `page.hasMore` and opaque `page.nextCursor`; environment listing honestly has no page in Vercel's contract |
+| H11 errors | meets | 401/403 auth, 404 absence, 400/409/422 arguments, 429 rate limit with either reset header, and 5xx availability are asserted by code and retryability |
+| H12 credential | meets | one labeled access-token field; `testCredential` calls `/v2/user` and reports the identity the token authenticated |
+| H13 guide | meets | structured, declared summary, `required: true` because personal-versus-team scope and build-versus-runtime log selection are cross-tool rules no one schema can carry |
+| H14 hatch | meets | split GET, JSON mutation, and raw upload; only GET is read-only, all paths are provider-relative and confined, personal-account scope is explicit, and upload bytes and transport-owned headers stay guarded |
+
+The initial drift review used Vercel's live OpenAPI document rather than the
+generated SDK as authority. It caught two version changes before this provider
+shipped: deployment listing is `/v7/deployments`, and environment-variable
+list/create are `/v10/projects/{idOrName}/env` while update/delete remain on
+v9. The reviewed 19 fixed operations now live in
+`scripts/drift/vercel-endpoints.json`; the dynamic hatches do not pretend to
+have a fixed endpoint manifest.
+
## Linear — hosted-MCP proxy
| Convention | Verdict | Notes |
@@ -179,6 +210,7 @@ documented tools, ninety-five classified, one deliberately not.
| Stripe | 10 | 3 | — | — |
| Mixpanel | 7 | 5 | P10 half n/a | — |
| RevenueCat | 12 | 0 | P4 n/a (one endpoint); P3 met with a purpose-bearing summary | — |
+| Vercel | 14 | 0 | — | — |
Nineteen misses, nineteen fixes, six recorded exceptions, one judgment left to
the issue that owns it. The pattern in the misses is worth naming: sixteen of
@@ -190,8 +222,8 @@ The conventions are mostly not asking for different behavior. They are asking
for the behavior to reach the agent, which is a different problem and, on this
evidence, the one the providers were losing.
-RevenueCat is the first connection written *after* the conventions and adds no
-misses to those nineteen, which is the least interesting thing about its row.
+RevenueCat and Vercel were written *after* the conventions and add no misses to
+those nineteen, which is the least interesting thing about their rows.
The interesting part is that two conventions came out somewhere other than
their obvious reading — P4 has no endpoint to select and P12 declines a number
the provider actually publishes — and both had to be argued rather than
diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs
index 64fc68a..e0d701b 100644
--- a/scripts/check-package.mjs
+++ b/scripts/check-package.mjs
@@ -264,6 +264,8 @@ try {
"dist/providers/stripe.d.ts",
"dist/providers/cloudflare.js",
"dist/providers/cloudflare.d.ts",
+ "dist/providers/vercel.js",
+ "dist/providers/vercel.d.ts",
]) {
if (!paths.has(required)) {
throw new Error(`Packed artifact is missing ${required}`);
@@ -470,6 +472,19 @@ const cloudflareConnection = cloudflareProvider.cloudflare("edge", {
if (cloudflareConnection.id !== "edge") {
throw new Error("Cloudflare provider did not return a connector");
}
+const vercelProvider = await import("@zackbart/connecta/providers/vercel");
+if (typeof vercelProvider.vercel !== "function") {
+ throw new Error("missing Vercel provider constructor");
+}
+const vercelConnection = vercelProvider.vercel("hosting", {
+ purpose: "package smoke",
+});
+if (vercelConnection.id !== "hosting" || vercelConnection.kind !== "api") {
+ throw new Error("Vercel provider did not return an api() connector");
+}
+if (!vercelConnection.staticTools?.length) {
+ throw new Error("Vercel provider published no tools");
+}
for (const name of [
"clerkAuth",
"cloudflareApi",
@@ -490,6 +505,8 @@ for (const name of [
"cloudflare",
"CLOUDFLARE_API_BASE",
"CLOUDFLARE_DNS_RECORD_TYPES",
+ "vercel",
+ "VERCEL_API_BASE_URL",
]) {
if (name in core) throw new Error(name + " leaked into the core entry");
}
@@ -897,6 +914,9 @@ try {
if (existsSync(join(work, "node_modules", "cloudflare"))) {
throw new Error("Cloudflare SDK was installed with the package");
}
+ if (existsSync(join(work, "node_modules", "@vercel", "sdk"))) {
+ throw new Error("Vercel SDK was installed with the package");
+ }
run(process.execPath, ["smoke.mjs"], work);
run(
diff --git a/scripts/drift-check.mjs b/scripts/drift-check.mjs
index ae3ada1..1846890 100644
--- a/scripts/drift-check.mjs
+++ b/scripts/drift-check.mjs
@@ -37,7 +37,7 @@ const defaultManifestDirectory = resolvePath(repositoryRoot, "scripts/drift");
/** Hosted-MCP proxies: a live catalog, read with the maintainer's own key. */
const HOSTED_PROVIDERS = ["linear", "stripe", "mixpanel", "revenuecat"];
/** Hand-written HTTP providers: a published specification, read as evidence. */
-const SPEC_PROVIDERS = ["cloudflare", "notion"];
+const SPEC_PROVIDERS = ["cloudflare", "notion", "vercel"];
/** Where each hosted provider's credential comes from, and what it is. */
const HOSTED_CREDENTIALS = {
diff --git a/scripts/drift/vercel-endpoints.json b/scripts/drift/vercel-endpoints.json
new file mode 100644
index 0000000..f494524
--- /dev/null
+++ b/scripts/drift/vercel-endpoints.json
@@ -0,0 +1,122 @@
+{
+ "provider": "vercel",
+ "specification": {
+ "url": "https://openapi.vercel.sh/"
+ },
+ "endpoints": [
+ {
+ "method": "GET",
+ "path": "/v2/teams",
+ "specRevision": "0.0.1",
+ "contract": "sha256:8fb7afa7c425fdfd85ec0bb1e679c0b94e59708543ccd88b3c89fdeaa0ad3981"
+ },
+ {
+ "method": "GET",
+ "path": "/v2/user",
+ "specRevision": "0.0.1",
+ "contract": "sha256:02edb7d895128ab8f701af0c83a1daa1cc3cc3b8b773286a344312171e2205cf"
+ },
+ {
+ "method": "GET",
+ "path": "/v10/projects",
+ "specRevision": "0.0.1",
+ "contract": "sha256:4c247451cb5bde9cc660cecedec94c9ddd8711b3cfd9925062f8eb403b39d756"
+ },
+ {
+ "method": "GET",
+ "path": "/v9/projects/{idOrName}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:755ef20b9d393b17888336e0e6753b6e508b5dd769cf3f5b8e64c8b3dd3fb228"
+ },
+ {
+ "method": "GET",
+ "path": "/v7/deployments",
+ "specRevision": "0.0.1",
+ "contract": "sha256:1923d5b2ceb2efe93bdf31db7c1e5cdbaa6e6af859e46557ee95a6b7d43fe2cd"
+ },
+ {
+ "method": "GET",
+ "path": "/v13/deployments/{idOrUrl}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:613490ff5f551d9943abcbaaab02c7aa1a49a447587d7ae7ce34a2b0eddd77ef"
+ },
+ {
+ "method": "GET",
+ "path": "/v3/deployments/{idOrUrl}/events",
+ "specRevision": "0.0.1",
+ "contract": "sha256:62dfec8f83e91c31b548a0a34dbf1b5d92a30c6931b366cd2828786c8c95fdde"
+ },
+ {
+ "method": "GET",
+ "path": "/v1/projects/{projectId}/deployments/{deploymentId}/runtime-logs",
+ "specRevision": "0.0.1",
+ "contract": "sha256:277157a1c7de04d7f8e062d90a6925bb83293cbd761babb9bdd498e614bc96cd"
+ },
+ {
+ "method": "GET",
+ "path": "/v9/projects/{idOrName}/domains",
+ "specRevision": "0.0.1",
+ "contract": "sha256:a9d34690d44f4a2614a5711886a29f23c03c24e769fe79c1c9569c0701440930"
+ },
+ {
+ "method": "POST",
+ "path": "/v10/projects/{idOrName}/domains",
+ "specRevision": "0.0.1",
+ "contract": "sha256:a41fb79cbd0ddd7ba52d9b041ca6c636aa60a1065210ca6019449685c2bb9633"
+ },
+ {
+ "method": "POST",
+ "path": "/v9/projects/{idOrName}/domains/{domain}/verify",
+ "specRevision": "0.0.1",
+ "contract": "sha256:366900558cfa65f3145c4824935a6834bf21daece06e72a4d36c4b6646353578"
+ },
+ {
+ "method": "DELETE",
+ "path": "/v9/projects/{idOrName}/domains/{domain}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:cf73839ee932602c00a1b1882c9bb2a2a2b4c4901e597cd517abb893ce6f85f9"
+ },
+ {
+ "method": "GET",
+ "path": "/v10/projects/{idOrName}/env",
+ "specRevision": "0.0.1",
+ "contract": "sha256:6ed625cd296bcbc3375e80317b7b4bb2f18a8f9bb973f0928c7220e1b324e2bc"
+ },
+ {
+ "method": "POST",
+ "path": "/v10/projects/{idOrName}/env",
+ "specRevision": "0.0.1",
+ "contract": "sha256:369c0569a468cc2bc9d15d66c1be8b0b242dbd132b253ff0f5f50fbf1b3cdd20"
+ },
+ {
+ "method": "PATCH",
+ "path": "/v9/projects/{idOrName}/env/{id}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:4b3f2750b254069abc5be22580135ad335d581dc075c0d70e718fe7e359959bd"
+ },
+ {
+ "method": "DELETE",
+ "path": "/v9/projects/{idOrName}/env/{id}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:d6588fd053b7d376ba70042b94503ade24a869ed316afa38cc11c245915a315b"
+ },
+ {
+ "method": "POST",
+ "path": "/v10/projects/{projectId}/promote/{deploymentId}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:087cb47af9f3500691b55680414f9664a60e4eafa27842cf4324dcf7518fbfa2"
+ },
+ {
+ "method": "PATCH",
+ "path": "/v12/deployments/{id}/cancel",
+ "specRevision": "0.0.1",
+ "contract": "sha256:4994c3bb309b363a4614c36f82ada2f42192d2bc2dfd5a79fa305852872030b2"
+ },
+ {
+ "method": "DELETE",
+ "path": "/v13/deployments/{id}",
+ "specRevision": "0.0.1",
+ "contract": "sha256:41d4413d7865ed00540aabddb79130b658362e59d480a9a0cbf2e2f3719c0c5e"
+ }
+ ]
+}
diff --git a/src/providers/vercel.ts b/src/providers/vercel.ts
new file mode 100644
index 0000000..3b8efe7
--- /dev/null
+++ b/src/providers/vercel.ts
@@ -0,0 +1,1457 @@
+/** See documentation/vercel.md#no-sdk-on-purpose. */
+import { api, type ApiTool } from "../connectors/api.js";
+import {
+ guardedFetch,
+ retryAfterMs,
+ type GuardedRequest,
+ type GuardedTransport,
+} from "../connectors/guarded-fetch.js";
+import { ConnectorCallError } from "../errors.js";
+import { withDeadline } from "../timeout.js";
+import type {
+ Connector,
+ ConnectorCallAdmissionPolicy,
+ ConnectorContext,
+ JsonSchema,
+} from "../types.js";
+
+/** Vercel's public REST origin. Override only for a proxy or test double. */
+export const VERCEL_API_BASE_URL = "https://api.vercel.com";
+
+const MAX_PAGE_SIZE = 100;
+const DEFAULT_PAGE_SIZE = 20;
+const VERCEL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
+const MAX_RUNTIME_LOG_ROWS = 500;
+const DEFAULT_RUNTIME_LOG_ROWS = 100;
+const RUNTIME_LOG_TIMEOUT_MS = 10_000;
+
+export interface VercelOptions {
+ /** Human-readable display name; defaults to "Vercel". */
+ title?: string;
+ /** Downstream auth ownership. Defaults to one shared deployment grant. */
+ authScope?: "shared" | "personal";
+ /** Which Vercel account or team this connection operates, and for whom. */
+ purpose: string;
+ /** Default team id for scoped calls. Omit to use the token's personal account. */
+ teamId?: string;
+ /** Account-specific conventions appended to the maintained provider guide. */
+ instructions?: string;
+ /** API base override for a proxy or test double. */
+ baseUrl?: string;
+ /** Default page size for list tools. Defaults to 20; Vercel's local cap is 100. */
+ defaultPageSize?: number;
+ /** Optional per-runtime downstream call-admission policy. */
+ callAdmission?: ConnectorCallAdmissionPolicy;
+ /** Connector-specific inline result limit; omit to inherit the deployment. */
+ maxResultBytes?: number;
+}
+
+type JsonRecord = Record;
+
+function asRecord(value: unknown): JsonRecord {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as JsonRecord)
+ : {};
+}
+
+function asArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+function compact(value: T): T {
+ return Object.fromEntries(
+ Object.entries(value).filter(([, entry]) => entry !== undefined),
+ ) as T;
+}
+
+function detailFor(payload: unknown, status: number): string {
+ const root = asRecord(payload);
+ const error = asRecord(root["error"]);
+ const code = typeof error["code"] === "string" ? error["code"] : undefined;
+ const message =
+ typeof error["message"] === "string" && error["message"].trim()
+ ? error["message"].trim()
+ : typeof root["message"] === "string" && root["message"].trim()
+ ? root["message"].trim()
+ : `Vercel returned HTTP ${status}.`;
+ return code ? `Vercel ${code}: ${message}` : message;
+}
+
+function resetAfterMs(headers: Headers): number | undefined {
+ const retryAfter = retryAfterMs(headers);
+ if (retryAfter !== undefined) return retryAfter;
+ const raw = headers.get("x-ratelimit-reset");
+ if (!raw) return undefined;
+ const seconds = Number(raw);
+ if (!Number.isFinite(seconds)) return undefined;
+ return Math.max(0, Math.trunc(seconds * 1_000 - Date.now()));
+}
+
+/** Map Vercel failures by the caller's useful next move. */
+function vercelFailure(
+ status: number,
+ headers: Headers,
+ payload: unknown,
+): ConnectorCallError {
+ const detail = detailFor(payload, status);
+ if (status === 429) {
+ const wait = resetAfterMs(headers);
+ return new ConnectorCallError(
+ "rate_limited",
+ `${detail} Vercel meters endpoints separately; wait for the reported reset before retrying this operation.`,
+ wait === undefined ? {} : { retryAfterMs: wait },
+ );
+ }
+ if (status === 401 || status === 403) {
+ return new ConnectorCallError(
+ "auth_required",
+ `${detail} The configured access token is invalid, expired, outside this team, or lacks the required scope. An operator must replace it or widen its Vercel scope.`,
+ );
+ }
+ if (status === 404) {
+ return new ConnectorCallError(
+ "not_found",
+ `${detail} Confirm the project, deployment, domain, or environment-variable id with its list tool.`,
+ );
+ }
+ if (status === 400 || status === 409 || status === 422) {
+ return new ConnectorCallError("invalid_args", detail);
+ }
+ if (status >= 500) {
+ const wait = resetAfterMs(headers);
+ return new ConnectorCallError(
+ "unavailable",
+ `${detail} Vercel is failing upstream.`,
+ wait === undefined ? {} : { retryAfterMs: wait },
+ );
+ }
+ return new ConnectorCallError("connector_call_failed", detail, {
+ retryable: false,
+ });
+}
+
+function parseBody(text: string, contentType: string | null): unknown {
+ if (!text) return undefined;
+ try {
+ return JSON.parse(text);
+ } catch {
+ if (contentType?.includes("stream+json") || contentType?.includes("ndjson")) {
+ const rows: unknown[] = [];
+ for (const line of text.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ rows.push(JSON.parse(line));
+ } catch {
+ rows.push({ message: line });
+ }
+ }
+ return rows;
+ }
+ return text;
+ }
+}
+
+function parseStreamRows(text: string): unknown[] {
+ if (!text.trim()) return [];
+ try {
+ const payload = JSON.parse(text);
+ return Array.isArray(payload) ? payload : [payload];
+ } catch {
+ const rows: unknown[] = [];
+ for (const line of text.split("\n")) {
+ if (!line.trim()) continue;
+ try {
+ rows.push(JSON.parse(line));
+ } catch {
+ throw new ConnectorCallError(
+ "connector_call_failed",
+ "Vercel returned a malformed runtime-log stream.",
+ { retryable: false },
+ );
+ }
+ }
+ return rows;
+ }
+}
+
+function vercelTransport(baseUrl: string): GuardedTransport {
+ return guardedFetch({
+ provider: "Vercel",
+ baseUrl,
+ headers: { Accept: "application/json" },
+ maxResponseBytes: VERCEL_MAX_RESPONSE_BYTES,
+ authenticate: async (ctx) => {
+ const token = (await ctx.credential?.get())?.trim();
+ if (!token) {
+ throw new ConnectorCallError(
+ "auth_required",
+ "No Vercel access token is configured for this connector. An operator must add one on /credentials before any Vercel call can run.",
+ );
+ }
+ return { Authorization: `Bearer ${token}` };
+ },
+ });
+}
+
+async function callVercel(
+ send: GuardedTransport,
+ request: GuardedRequest,
+ ctx: ConnectorContext,
+ parseSuccess?: (text: string) => unknown,
+): Promise {
+ return await send(request, ctx, async (response) => {
+ const text = await response.text();
+ const payload = parseBody(text, response.headers.get("content-type"));
+ if (!response.ok) {
+ throw vercelFailure(response.status, response.headers, payload);
+ }
+ return parseSuccess ? parseSuccess(text) : payload;
+ });
+}
+
+function teamQuery(
+ args: JsonRecord,
+ defaultTeamId: string | undefined,
+): Record {
+ return {
+ teamId: args["teamId"] === null
+ ? undefined
+ : args["teamId"] ?? defaultTeamId,
+ };
+}
+
+function nextCursor(payload: unknown): string | null {
+ const pagination = asRecord(asRecord(payload)["pagination"]);
+ const next = pagination["next"];
+ return next === undefined || next === null || next === "" ? null : String(next);
+}
+
+function page(payload: unknown): { hasMore: boolean; nextCursor: string | null } {
+ const cursor = nextCursor(payload);
+ return { hasMore: cursor !== null, nextCursor: cursor };
+}
+
+function projectTeam(value: unknown): JsonRecord {
+ const team = asRecord(value);
+ return compact({
+ id: team["id"],
+ slug: team["slug"],
+ name: team["name"],
+ avatar: team["avatar"],
+ createdAt: team["createdAt"],
+ membership: asRecord(team["membership"])["role"],
+ });
+}
+
+function projectProject(value: unknown): JsonRecord {
+ const project = asRecord(value);
+ const link = asRecord(project["link"]);
+ const targets = asRecord(project["targets"]);
+ const production = asRecord(targets["production"]);
+ return compact({
+ id: project["id"],
+ name: project["name"],
+ accountId: project["accountId"],
+ framework: project["framework"],
+ createdAt: project["createdAt"],
+ updatedAt: project["updatedAt"],
+ paused: project["paused"] === true,
+ productionBranch: project["productionBranch"] ?? link["productionBranch"],
+ rootDirectory: project["rootDirectory"],
+ nodeVersion: project["nodeVersion"],
+ buildCommand: project["buildCommand"],
+ installCommand: project["installCommand"],
+ devCommand: project["devCommand"],
+ outputDirectory: project["outputDirectory"],
+ repository:
+ Object.keys(link).length === 0
+ ? undefined
+ : compact({
+ type: link["type"],
+ org: link["org"],
+ repo: link["repo"],
+ repoId: link["repoId"],
+ }),
+ productionDeployment:
+ Object.keys(production).length === 0
+ ? undefined
+ : compact({
+ id: production["id"] ?? production["uid"],
+ url: production["url"],
+ state: production["readyState"] ?? production["state"],
+ createdAt: production["createdAt"] ?? production["created"],
+ }),
+ });
+}
+
+function projectDeployment(value: unknown): JsonRecord {
+ const deployment = asRecord(value);
+ const creator = asRecord(deployment["creator"]);
+ const meta = asRecord(deployment["meta"]);
+ return compact({
+ id: deployment["uid"] ?? deployment["id"],
+ name: deployment["name"],
+ url: deployment["url"],
+ state: deployment["readyState"] ?? deployment["state"],
+ target: deployment["target"],
+ source: deployment["source"],
+ createdAt: deployment["createdAt"] ?? deployment["created"],
+ buildingAt: deployment["buildingAt"],
+ readyAt: deployment["ready"] ?? deployment["readyAt"],
+ projectId: deployment["projectId"],
+ creator:
+ Object.keys(creator).length === 0
+ ? undefined
+ : compact({
+ id: creator["uid"] ?? creator["id"],
+ username: creator["username"],
+ email: creator["email"],
+ }),
+ git:
+ meta["githubCommitRef"] || meta["gitlabCommitRef"] || meta["bitbucketCommitRef"]
+ ? compact({
+ branch:
+ meta["githubCommitRef"] ??
+ meta["gitlabCommitRef"] ??
+ meta["bitbucketCommitRef"],
+ sha:
+ meta["githubCommitSha"] ??
+ meta["gitlabCommitSha"] ??
+ meta["bitbucketCommitSha"],
+ message:
+ meta["githubCommitMessage"] ??
+ meta["gitlabCommitMessage"] ??
+ meta["bitbucketCommitMessage"],
+ })
+ : undefined,
+ });
+}
+
+function projectDomain(value: unknown): JsonRecord {
+ const domain = asRecord(value);
+ return compact({
+ name: domain["name"],
+ apexName: domain["apexName"],
+ projectId: domain["projectId"],
+ verified: domain["verified"] === true,
+ verification: domain["verification"],
+ redirect: domain["redirect"],
+ redirectStatusCode: domain["redirectStatusCode"],
+ gitBranch: domain["gitBranch"],
+ customEnvironmentId: domain["customEnvironmentId"],
+ createdAt: domain["createdAt"],
+ updatedAt: domain["updatedAt"],
+ });
+}
+
+/** Deliberately omits `value`, even if a raw Vercel response happens to carry it. */
+function projectEnvironmentVariable(value: unknown): JsonRecord {
+ const variable = asRecord(value);
+ return compact({
+ id: variable["id"],
+ key: variable["key"],
+ type: variable["type"],
+ visibility: variable["visibility"],
+ target: variable["target"],
+ gitBranch: variable["gitBranch"],
+ customEnvironmentIds: variable["customEnvironmentIds"],
+ comment: variable["comment"],
+ createdAt: variable["createdAt"],
+ updatedAt: variable["updatedAt"],
+ });
+}
+
+const RAW_PROPERTY: JsonSchema = {
+ type: "boolean",
+ description: "Return Vercel's untouched response instead of the lean projection.",
+};
+
+const TEAM_ID_PROPERTY: JsonSchema = {
+ type: ["string", "null"],
+ minLength: 1,
+ description: "Vercel team id. Omit for the configured default; pass null for the token owner's personal account.",
+};
+
+const PROJECT_ID_PROPERTY: JsonSchema = {
+ type: "string",
+ minLength: 1,
+ description: "Project id or project name from list_projects.",
+};
+
+const DEPLOYMENT_ID_PROPERTY: JsonSchema = {
+ type: "string",
+ minLength: 1,
+ description: "Deployment id from list_deployments.",
+};
+
+const CURSOR_PROPERTY: JsonSchema = {
+ type: "string",
+ minLength: 1,
+ description: "Opaque nextCursor returned by the previous page. Pass it back unchanged.",
+};
+
+function limitProperty(defaultPageSize: number): JsonSchema {
+ return {
+ type: "integer",
+ minimum: 1,
+ maximum: MAX_PAGE_SIZE,
+ description: `Rows per request, 1 to ${MAX_PAGE_SIZE}. Defaults to this connector's ${defaultPageSize}.`,
+ };
+}
+
+const PAGE_SCHEMA: JsonSchema = {
+ type: "object",
+ properties: {
+ hasMore: { type: "boolean" },
+ nextCursor: {
+ type: ["string", "null"],
+ description: "Pass back unchanged as cursor when hasMore is true.",
+ },
+ },
+ required: ["hasMore", "nextCursor"],
+};
+
+function listSchema(key: string, item: JsonSchema): JsonSchema {
+ return {
+ type: "object",
+ properties: {
+ [key]: { type: "array", items: item },
+ page: PAGE_SCHEMA,
+ },
+ required: [key, "page"],
+ };
+}
+
+const TEAM_SCHEMA: JsonSchema = {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ slug: { type: "string" },
+ name: { type: "string" },
+ avatar: { type: ["string", "null"] },
+ createdAt: { type: "number" },
+ membership: { type: "string" },
+ },
+ required: ["id", "slug", "name"],
+};
+
+const PROJECT_SCHEMA: JsonSchema = {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" },
+ accountId: { type: "string" },
+ framework: { type: ["string", "null"] },
+ createdAt: { type: "number" },
+ updatedAt: { type: "number" },
+ paused: { type: "boolean" },
+ productionBranch: { type: "string" },
+ rootDirectory: { type: ["string", "null"] },
+ nodeVersion: { type: "string" },
+ buildCommand: { type: ["string", "null"] },
+ installCommand: { type: ["string", "null"] },
+ devCommand: { type: ["string", "null"] },
+ outputDirectory: { type: ["string", "null"] },
+ repository: { type: "object" },
+ productionDeployment: { type: "object" },
+ },
+ required: ["id", "name"],
+};
+
+const DEPLOYMENT_SCHEMA: JsonSchema = {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" },
+ url: { type: ["string", "null"] },
+ state: { type: "string" },
+ target: { type: ["string", "null"] },
+ source: { type: "string" },
+ createdAt: { type: "number" },
+ buildingAt: { type: "number" },
+ readyAt: { type: "number" },
+ projectId: { type: "string" },
+ creator: { type: "object" },
+ git: { type: "object" },
+ },
+ required: ["id", "name", "state"],
+};
+
+const DOMAIN_SCHEMA: JsonSchema = {
+ type: "object",
+ properties: {
+ name: { type: "string" },
+ apexName: { type: "string" },
+ projectId: { type: "string" },
+ verified: { type: "boolean" },
+ verification: { type: "array" },
+ redirect: { type: ["string", "null"] },
+ redirectStatusCode: { type: ["integer", "null"] },
+ gitBranch: { type: ["string", "null"] },
+ customEnvironmentId: { type: ["string", "null"] },
+ createdAt: { type: "number" },
+ updatedAt: { type: "number" },
+ },
+ required: ["name", "projectId", "verified"],
+};
+
+const ENV_SCHEMA: JsonSchema = {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ key: { type: "string" },
+ type: { type: "string" },
+ visibility: { type: "string" },
+ target: { type: ["array", "string"], items: { type: "string" } },
+ gitBranch: { type: ["string", "null"] },
+ customEnvironmentIds: { type: "array", items: { type: "string" } },
+ comment: { type: "string" },
+ createdAt: { type: "number" },
+ updatedAt: { type: "number" },
+ },
+ required: ["id", "key", "type"],
+};
+
+function namedInput(
+ properties: Record,
+ required: string[],
+): JsonSchema {
+ return { type: "object", properties, required, additionalProperties: false };
+}
+
+function queryPairs(value: unknown): Record {
+ const query: Record = {};
+ for (const row of asArray(value)) {
+ const pair = asRecord(row);
+ if (typeof pair["name"] !== "string") continue;
+ const item = pair["value"];
+ if (
+ typeof item === "string" ||
+ typeof item === "number" ||
+ typeof item === "boolean"
+ ) {
+ query[pair["name"]] = item;
+ }
+ }
+ return query;
+}
+
+const QUERY_PROPERTY: JsonSchema = {
+ type: "array",
+ description: "Provider query parameters as name/value pairs.",
+ items: {
+ type: "object",
+ properties: {
+ name: { type: "string", minLength: 1, description: "Query parameter name." },
+ value: {
+ type: ["string", "number", "boolean"],
+ description: "Query parameter value; guarded transport stringifies it once.",
+ },
+ },
+ required: ["name", "value"],
+ additionalProperties: false,
+ },
+};
+
+const HEADERS_PROPERTY: JsonSchema = {
+ type: "array",
+ description: "Endpoint-specific request headers. Credential, cookie, host, framing, and content-type headers are connector-owned.",
+ items: {
+ type: "object",
+ properties: {
+ name: { type: "string", minLength: 1, description: "HTTP header name." },
+ value: { type: "string", description: "HTTP header value." },
+ },
+ required: ["name", "value"],
+ additionalProperties: false,
+ },
+};
+
+const FORBIDDEN_HATCH_HEADERS = new Set([
+ "authorization",
+ "content-length",
+ "content-type",
+ "cookie",
+ "host",
+ "transfer-encoding",
+]);
+
+function headerPairs(value: unknown): Record {
+ const headers: Record = {};
+ for (const row of asArray(value)) {
+ const pair = asRecord(row);
+ if (typeof pair["name"] === "string" && typeof pair["value"] === "string") {
+ const normalized = pair["name"].trim().toLowerCase();
+ if (FORBIDDEN_HATCH_HEADERS.has(normalized)) {
+ throw new ConnectorCallError(
+ "invalid_args",
+ `A Vercel upload may not set the ${normalized} header; the connector owns credentials, cookies, origin, framing, and content type.`,
+ );
+ }
+ headers[pair["name"]] = pair["value"];
+ }
+ }
+ return headers;
+}
+
+function uploadBody(args: JsonRecord): Uint8Array | string {
+ const hasText = typeof args["textBody"] === "string";
+ const hasBase64 = typeof args["base64Body"] === "string";
+ if (hasText === hasBase64) {
+ throw new ConnectorCallError(
+ "invalid_args",
+ "Provide exactly one of textBody or base64Body for a Vercel upload.",
+ );
+ }
+ if (hasText) return args["textBody"];
+ try {
+ return Uint8Array.from(atob(args["base64Body"]), (character) =>
+ character.charCodeAt(0),
+ );
+ } catch {
+ throw new ConnectorCallError(
+ "invalid_args",
+ "base64Body is not valid base64.",
+ );
+ }
+}
+
+function rawRequest(
+ args: JsonRecord,
+ defaultTeamId: string | undefined,
+): Pick {
+ const query = queryPairs(args["query"]);
+ if (
+ args["personalAccount"] === true &&
+ (query["teamId"] !== undefined || query["slug"] !== undefined)
+ ) {
+ throw new ConnectorCallError(
+ "invalid_args",
+ "personalAccount cannot be combined with a teamId or slug query parameter.",
+ );
+ }
+ if (
+ args["personalAccount"] !== true &&
+ defaultTeamId &&
+ query["teamId"] === undefined &&
+ query["slug"] === undefined
+ ) {
+ query["teamId"] = defaultTeamId;
+ }
+ return { path: String(args["path"]), query };
+}
+
+const PERSONAL_ACCOUNT_PROPERTY: JsonSchema = {
+ type: "boolean",
+ description: "True omits the configured default team. Do not combine with a teamId or slug query parameter.",
+};
+
+function tools(
+ send: GuardedTransport,
+ defaultPageSize: number,
+ defaultTeamId: string | undefined,
+): ApiTool[] {
+ const readOnly = { readOnlyHint: true } as const;
+ const destructive = { readOnlyHint: false, destructiveHint: true } as const;
+ const team = (args: JsonRecord) => teamQuery(args, defaultTeamId);
+ const limit = (args: JsonRecord) => args["limit"] ?? defaultPageSize;
+ return [
+ {
+ name: "vercel_api_get",
+ description:
+ "Call any Vercel REST GET endpoint and return its untouched response. Use named reads first for smaller results and stable projections.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ path: {
+ type: "string",
+ minLength: 1,
+ description: "Path below api.vercel.com beginning with '/', including its API version. No query string.",
+ },
+ query: QUERY_PROPERTY,
+ personalAccount: PERSONAL_ACCOUNT_PROPERTY,
+ },
+ ["path"],
+ ),
+ outputSchema: {
+ type: "object",
+ properties: { result: { description: "Vercel's untouched response body." } },
+ required: ["result"],
+ },
+ handler: async (args, ctx) => ({
+ result:
+ (await callVercel(
+ send,
+ { method: "GET", ...rawRequest(args, defaultTeamId) },
+ ctx,
+ )) ?? null,
+ }),
+ },
+ {
+ name: "vercel_api_mutate",
+ description:
+ "Call any JSON Vercel REST mutation endpoint. The approval-gated hatch for API operations the named tools do not cover; no file uploads.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ {
+ method: {
+ type: "string",
+ enum: ["POST", "PUT", "PATCH", "DELETE"],
+ description: "HTTP mutation method required by the Vercel endpoint.",
+ },
+ path: {
+ type: "string",
+ minLength: 1,
+ description: "Path below api.vercel.com beginning with '/', including its API version. No query string.",
+ },
+ query: QUERY_PROPERTY,
+ personalAccount: PERSONAL_ACCOUNT_PROPERTY,
+ body: {
+ type: ["object", "array", "string", "number", "boolean", "null"],
+ description: "JSON body exactly as documented by Vercel. Omit when the endpoint has no body.",
+ },
+ },
+ ["method", "path"],
+ ),
+ outputSchema: {
+ type: "object",
+ properties: { result: { description: "Vercel's untouched response body, or null for an empty response." } },
+ required: ["result"],
+ },
+ handler: async (args, ctx) => ({
+ result:
+ (await callVercel(
+ send,
+ {
+ method: args["method"],
+ ...rawRequest(args, defaultTeamId),
+ ...(args["body"] !== undefined ? { body: args["body"] } : {}),
+ },
+ ctx,
+ )) ?? null,
+ }),
+ },
+ {
+ name: "vercel_api_upload",
+ description:
+ "Upload explicit text or base64 bytes to a Vercel POST or PUT endpoint. Covers deployment files and other raw-body APIs; reads no local files.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ {
+ method: {
+ type: "string",
+ enum: ["POST", "PUT"],
+ description: "Upload method required by the Vercel endpoint.",
+ },
+ path: {
+ type: "string",
+ minLength: 1,
+ description: "Path below api.vercel.com beginning with '/', including its API version. No query string.",
+ },
+ query: QUERY_PROPERTY,
+ personalAccount: PERSONAL_ACCOUNT_PROPERTY,
+ headers: HEADERS_PROPERTY,
+ contentType: {
+ type: "string",
+ minLength: 1,
+ description: "Content-Type for the raw body, such as application/octet-stream.",
+ },
+ textBody: {
+ type: "string",
+ description: "Raw UTF-8 body. Exclusive with base64Body.",
+ },
+ base64Body: {
+ type: "string",
+ description: "Base64-encoded bytes. Exclusive with textBody.",
+ },
+ },
+ ["method", "path", "contentType"],
+ ),
+ outputSchema: {
+ type: "object",
+ properties: {
+ result: {
+ description: "Vercel's untouched upload response body, or null for an empty response.",
+ },
+ },
+ required: ["result"],
+ },
+ handler: async (args, ctx) => ({
+ result:
+ (await callVercel(
+ send,
+ {
+ method: args["method"],
+ ...rawRequest(args, defaultTeamId),
+ headers: {
+ ...headerPairs(args["headers"]),
+ "Content-Type": args["contentType"],
+ },
+ rawBody: uploadBody(args),
+ },
+ ctx,
+ )) ?? null,
+ }),
+ },
+ {
+ name: "list_teams",
+ description:
+ "List teams the access token can reach. Supplies teamId for project, deployment, domain, environment, and raw API calls.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ { limit: limitProperty(defaultPageSize), cursor: CURSOR_PROPERTY },
+ [],
+ ),
+ outputSchema: listSchema("teams", TEAM_SCHEMA),
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ { method: "GET", path: "/v2/teams", query: { limit: limit(args), until: args["cursor"] } },
+ ctx,
+ );
+ return { teams: asArray(asRecord(payload)["teams"]).map(projectTeam), page: page(payload) };
+ },
+ },
+ {
+ name: "list_projects",
+ description:
+ "List or search Vercel projects with repository, framework, and production-deployment identity. Returns lean project summaries.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ teamId: TEAM_ID_PROPERTY,
+ search: { type: "string", description: "Case-insensitive project-name search." },
+ limit: limitProperty(defaultPageSize),
+ cursor: CURSOR_PROPERTY,
+ raw: RAW_PROPERTY,
+ },
+ [],
+ ),
+ outputSchema: listSchema("projects", PROJECT_SCHEMA),
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ {
+ method: "GET",
+ path: "/v10/projects",
+ query: { ...team(args), search: args["search"], limit: limit(args), from: args["cursor"] },
+ },
+ ctx,
+ );
+ const projects = asArray(asRecord(payload)["projects"]);
+ return {
+ projects: args["raw"] === true ? projects : projects.map(projectProject),
+ page: page(payload),
+ };
+ },
+ },
+ {
+ name: "get_project",
+ description:
+ "Get one Vercel project by id or name, including build settings, Git identity, and current production deployment.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ { projectId: PROJECT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY, raw: RAW_PROPERTY },
+ ["projectId"],
+ ),
+ outputSchema: PROJECT_SCHEMA,
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ { method: "GET", path: `/v9/projects/${encodeURIComponent(args["projectId"])}`, query: team(args) },
+ ctx,
+ );
+ return args["raw"] === true ? payload : projectProject(payload);
+ },
+ },
+ {
+ name: "list_deployments",
+ description:
+ "List Vercel deployments, filtered by project, target, state, branch, or commit SHA. Returns ids needed by log and lifecycle tools.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ teamId: TEAM_ID_PROPERTY,
+ projectId: { ...PROJECT_ID_PROPERTY, description: "Project id or name. Omit to list the whole account or team." },
+ target: { type: "string", description: "Deployment target, usually production or preview." },
+ state: {
+ type: "string",
+ enum: ["BUILDING", "ERROR", "INITIALIZING", "QUEUED", "READY", "CANCELED", "BLOCKED"],
+ description: "Exact Vercel deployment state.",
+ },
+ branch: { type: "string", description: "Git branch name." },
+ sha: { type: "string", description: "Git commit SHA." },
+ limit: limitProperty(defaultPageSize),
+ cursor: CURSOR_PROPERTY,
+ raw: RAW_PROPERTY,
+ },
+ [],
+ ),
+ outputSchema: listSchema("deployments", DEPLOYMENT_SCHEMA),
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ {
+ method: "GET",
+ path: "/v7/deployments",
+ query: {
+ ...team(args), projectId: args["projectId"], target: args["target"],
+ state: args["state"], branch: args["branch"], sha: args["sha"],
+ limit: limit(args), until: args["cursor"],
+ },
+ },
+ ctx,
+ );
+ const deployments = asArray(asRecord(payload)["deployments"]);
+ return {
+ deployments: args["raw"] === true
+ ? deployments
+ : deployments.map(projectDeployment),
+ page: page(payload),
+ };
+ },
+ },
+ {
+ name: "get_deployment",
+ description:
+ "Get one Vercel deployment by id or hostname, including its state, target, creator, Git commit, and timestamps.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ deploymentId: { ...DEPLOYMENT_ID_PROPERTY, description: "Deployment id or deployment hostname." },
+ teamId: TEAM_ID_PROPERTY,
+ raw: RAW_PROPERTY,
+ },
+ ["deploymentId"],
+ ),
+ outputSchema: DEPLOYMENT_SCHEMA,
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ { method: "GET", path: `/v13/deployments/${encodeURIComponent(args["deploymentId"])}`, query: team(args) },
+ ctx,
+ );
+ return args["raw"] === true ? payload : projectDeployment(payload);
+ },
+ },
+ {
+ name: "get_build_logs",
+ description:
+ "Get bounded build events for one deployment, including stdout, stderr, command, exit, and deployment-state records. Does not follow live output.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ deploymentId: DEPLOYMENT_ID_PROPERTY,
+ teamId: TEAM_ID_PROPERTY,
+ direction: { type: "string", enum: ["forward", "backward"], description: "Chronological direction. Defaults to forward." },
+ limit: { type: "integer", minimum: 1, maximum: 1000, description: "Events per request, 1 to this connector's 1,000-event cap. Defaults to 100." },
+ since: { type: "number", description: "Only events at or after this JavaScript timestamp." },
+ until: { type: "number", description: "Only events at or before this JavaScript timestamp." },
+ raw: RAW_PROPERTY,
+ },
+ ["deploymentId"],
+ ),
+ outputSchema: {
+ type: "object",
+ properties: {
+ events: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ type: { type: "string" }, createdAt: { type: "number" },
+ message: { type: "string" }, payload: { type: "object" },
+ },
+ required: ["type", "createdAt"],
+ },
+ },
+ },
+ required: ["events"],
+ },
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ {
+ method: "GET",
+ path: `/v3/deployments/${encodeURIComponent(args["deploymentId"])}/events`,
+ query: {
+ ...team(args), direction: args["direction"] ?? "forward", follow: 0,
+ builds: 1, limit: args["limit"] ?? 100, since: args["since"], until: args["until"],
+ },
+ },
+ ctx,
+ );
+ if (args["raw"] === true) return { events: asArray(payload) };
+ const events = asArray(payload).map((value) => {
+ const event = asRecord(value);
+ const eventPayload = asRecord(event["payload"]);
+ return compact({
+ type: event["type"], createdAt: event["created"] ?? event["date"],
+ message: eventPayload["text"] ?? eventPayload["message"],
+ payload: Object.keys(eventPayload).length === 0 ? undefined : eventPayload,
+ });
+ });
+ return { events };
+ },
+ },
+ {
+ name: "get_runtime_logs",
+ description:
+ "Get a bounded runtime-log snapshot for one deployment. Returns at most 500 rows and stops a stream that stays open past 10 seconds.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY,
+ deploymentId: DEPLOYMENT_ID_PROPERTY,
+ teamId: TEAM_ID_PROPERTY,
+ limit: {
+ type: "integer",
+ minimum: 1,
+ maximum: MAX_RUNTIME_LOG_ROWS,
+ description: `Rows returned, 1 to ${MAX_RUNTIME_LOG_ROWS}. Defaults to ${DEFAULT_RUNTIME_LOG_ROWS}.`,
+ },
+ },
+ ["projectId", "deploymentId"],
+ ),
+ outputSchema: {
+ type: "object",
+ properties: {
+ logs: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ level: { type: "string" }, message: { type: "string" },
+ timestampInMs: { type: "number" }, source: { type: "string" },
+ domain: { type: "string" }, requestMethod: { type: "string" },
+ requestPath: { type: "string" }, responseStatusCode: { type: "number" },
+ messageTruncated: { type: "boolean" },
+ },
+ required: ["level", "message", "timestampInMs", "source"],
+ },
+ },
+ },
+ required: ["logs"],
+ },
+ handler: async (args, ctx) => {
+ const payload = await withDeadline(
+ (signal) => callVercel(
+ send,
+ {
+ method: "GET",
+ path: `/v1/projects/${encodeURIComponent(args["projectId"])}/deployments/${encodeURIComponent(args["deploymentId"])}/runtime-logs`,
+ query: team(args), headers: { Accept: "application/stream+json" },
+ },
+ { ...ctx, signal },
+ parseStreamRows,
+ ),
+ {
+ timeoutMs: RUNTIME_LOG_TIMEOUT_MS,
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
+ timeoutError: new ConnectorCallError(
+ "unavailable",
+ `Vercel's runtime-log stream stayed open past this connector's ${RUNTIME_LOG_TIMEOUT_MS / 1_000}-second bound. Retry for a fresh snapshot.`,
+ ),
+ },
+ );
+ const requested = args["limit"] ?? DEFAULT_RUNTIME_LOG_ROWS;
+ return { logs: asArray(payload).slice(0, requested) };
+ },
+ },
+ {
+ name: "list_project_domains",
+ description:
+ "List domains assigned to one Vercel project, including verification challenges, redirects, branch bindings, and custom-environment bindings.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY,
+ verified: { type: "boolean", description: "Filter by verification state." },
+ limit: limitProperty(defaultPageSize), cursor: CURSOR_PROPERTY, raw: RAW_PROPERTY,
+ },
+ ["projectId"],
+ ),
+ outputSchema: listSchema("domains", DOMAIN_SCHEMA),
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ {
+ method: "GET", path: `/v9/projects/${encodeURIComponent(args["projectId"])}/domains`,
+ query: { ...team(args), verified: args["verified"], limit: limit(args), until: args["cursor"] },
+ },
+ ctx,
+ );
+ const domains = asArray(asRecord(payload)["domains"]);
+ return {
+ domains: args["raw"] === true ? domains : domains.map(projectDomain),
+ page: page(payload),
+ };
+ },
+ },
+ {
+ name: "add_project_domain",
+ description:
+ "Add a domain, redirect, Git-branch domain, or custom-environment domain to a Vercel project. An unverified result includes its DNS challenge.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY,
+ domain: { type: "string", minLength: 1, description: "Domain name to add." },
+ gitBranch: { type: "string", description: "Bind this domain to one Git branch." },
+ customEnvironmentId: { type: "string", description: "Bind this domain to one custom environment." },
+ redirect: { type: "string", description: "Target domain for a redirect." },
+ redirectStatusCode: { type: "integer", enum: [301, 302, 307, 308], description: "Redirect status; only valid with redirect." },
+ },
+ ["projectId", "domain"],
+ ),
+ outputSchema: DOMAIN_SCHEMA,
+ handler: async (args, ctx) => projectDomain(await callVercel(
+ send,
+ {
+ method: "POST", path: `/v10/projects/${encodeURIComponent(args["projectId"])}/domains`, query: team(args),
+ body: compact({ name: args["domain"], gitBranch: args["gitBranch"], customEnvironmentId: args["customEnvironmentId"], redirect: args["redirect"], redirectStatusCode: args["redirectStatusCode"] }),
+ },
+ ctx,
+ )),
+ },
+ {
+ name: "verify_project_domain",
+ description:
+ "Ask Vercel to verify a project's pending domain after its DNS challenge has been completed. Returns the current domain state.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ { projectId: PROJECT_ID_PROPERTY, domain: { type: "string", minLength: 1, description: "Pending domain name from list_project_domains." }, teamId: TEAM_ID_PROPERTY },
+ ["projectId", "domain"],
+ ),
+ outputSchema: DOMAIN_SCHEMA,
+ handler: async (args, ctx) => projectDomain(await callVercel(
+ send,
+ { method: "POST", path: `/v9/projects/${encodeURIComponent(args["projectId"])}/domains/${encodeURIComponent(args["domain"])}/verify`, query: team(args) },
+ ctx,
+ )),
+ },
+ {
+ name: "remove_project_domain",
+ description:
+ "Remove a domain from one Vercel project. Optionally remove project domains that redirect to it; this does not delete the account-level domain.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY,
+ domain: { type: "string", minLength: 1, description: "Project domain name from list_project_domains." },
+ removeRedirects: { type: "boolean", description: "Also remove project domains that redirect to this one." },
+ teamId: TEAM_ID_PROPERTY,
+ },
+ ["projectId", "domain"],
+ ),
+ outputSchema: {
+ type: "object", properties: { removed: { type: "boolean" }, domain: { type: "string" } }, required: ["removed", "domain"],
+ },
+ handler: async (args, ctx) => {
+ await callVercel(
+ send,
+ {
+ method: "DELETE", path: `/v9/projects/${encodeURIComponent(args["projectId"])}/domains/${encodeURIComponent(args["domain"])}`,
+ query: team(args), body: args["removeRedirects"] === undefined ? undefined : { removeRedirects: args["removeRedirects"] },
+ },
+ ctx,
+ );
+ return { removed: true, domain: args["domain"] };
+ },
+ },
+ {
+ name: "list_project_env_vars",
+ description:
+ "List a project's environment-variable metadata without decrypting or returning values. Includes targets, visibility, branches, and custom environments.",
+ annotations: readOnly,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY,
+ gitBranch: { type: "string", description: "Preview branch filter." },
+ customEnvironmentId: { type: "string", description: "Custom environment filter." },
+ },
+ ["projectId"],
+ ),
+ outputSchema: {
+ type: "object", properties: { variables: { type: "array", items: ENV_SCHEMA } }, required: ["variables"],
+ },
+ handler: async (args, ctx) => {
+ const payload = await callVercel(
+ send,
+ {
+ method: "GET", path: `/v10/projects/${encodeURIComponent(args["projectId"])}/env`,
+ query: { ...team(args), gitBranch: args["gitBranch"], customEnvironmentId: args["customEnvironmentId"], decrypt: "false" },
+ },
+ ctx,
+ );
+ return { variables: asArray(asRecord(payload)["envs"]).map(projectEnvironmentVariable) };
+ },
+ },
+ {
+ name: "upsert_project_env_var",
+ description:
+ "Create or replace one Vercel project environment variable. Changes affect only future deployments; trigger a new deployment separately.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY,
+ key: { type: "string", minLength: 1, maxLength: 256, description: "Environment variable name." },
+ value: { type: "string", maxLength: 65536, description: "New value. Vercel's total project-environment payload is capped at 64 KB." },
+ type: { type: "string", enum: ["plain", "encrypted", "sensitive"], description: "Storage type. Sensitive values cannot be read back." },
+ targets: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", enum: ["production", "preview", "development"] }, description: "Default Vercel environments that receive this value." },
+ gitBranch: { type: "string", description: "Optional preview-only Git branch." },
+ customEnvironmentIds: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 }, description: "Custom environment ids that receive this value." },
+ comment: { type: "string", maxLength: 500, description: "Operator-facing note explaining the variable." },
+ },
+ ["projectId", "key", "value", "type", "targets"],
+ ),
+ outputSchema: ENV_SCHEMA,
+ handler: async (args, ctx) => {
+ const payload = asRecord(await callVercel(
+ send,
+ {
+ method: "POST", path: `/v10/projects/${encodeURIComponent(args["projectId"])}/env`,
+ query: { ...team(args), upsert: "true" },
+ body: compact({ key: args["key"], value: args["value"], type: args["type"], target: args["targets"], gitBranch: args["gitBranch"], customEnvironmentIds: args["customEnvironmentIds"], comment: args["comment"] }),
+ },
+ ctx,
+ ));
+ const failed = asArray(payload["failed"]);
+ if (failed.length > 0) {
+ const error = asRecord(asRecord(failed[0])["error"]);
+ const code = typeof error["code"] === "string" ? `${error["code"]}: ` : "";
+ const message = typeof error["message"] === "string"
+ ? error["message"]
+ : "Vercel rejected the environment-variable write.";
+ throw new ConnectorCallError("invalid_args", `Vercel ${code}${message}`);
+ }
+ const created = Array.isArray(payload["created"])
+ ? payload["created"][0]
+ : payload["created"];
+ const result = projectEnvironmentVariable(created ?? payload);
+ if (!result["id"] || !result["key"] || !result["type"]) {
+ throw new ConnectorCallError(
+ "connector_call_failed",
+ "Vercel accepted the environment-variable write without returning the created variable.",
+ { retryable: false },
+ );
+ }
+ return result;
+ },
+ },
+ {
+ name: "update_project_env_var",
+ description:
+ "Update one Vercel project environment variable by id. Send only fields that should change; deployments keep their previous values.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ {
+ projectId: PROJECT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY,
+ envVarId: { type: "string", minLength: 1, description: "Environment-variable id from list_project_env_vars." },
+ key: { type: "string", minLength: 1, maxLength: 256, description: "Replacement variable name." },
+ value: { type: "string", maxLength: 65536, description: "Replacement value." },
+ type: { type: "string", enum: ["plain", "encrypted", "sensitive"], description: "Replacement storage type." },
+ targets: { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", enum: ["production", "preview", "development"] }, description: "Replacement default environments." },
+ gitBranch: { type: ["string", "null"], description: "Replacement preview branch, or null to clear it." },
+ customEnvironmentIds: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 }, description: "Replacement custom environment ids." },
+ comment: { type: "string", maxLength: 500, description: "Replacement operator-facing note." },
+ },
+ ["projectId", "envVarId"],
+ ),
+ outputSchema: ENV_SCHEMA,
+ handler: async (args, ctx) => {
+ const body = compact({ key: args["key"], value: args["value"], type: args["type"], target: args["targets"], gitBranch: args["gitBranch"], customEnvironmentIds: args["customEnvironmentIds"], comment: args["comment"] });
+ if (Object.keys(body).length === 0) {
+ throw new ConnectorCallError("invalid_args", "Nothing to update: provide key, value, type, targets, gitBranch, customEnvironmentIds, or comment.");
+ }
+ return projectEnvironmentVariable(await callVercel(
+ send,
+ { method: "PATCH", path: `/v9/projects/${encodeURIComponent(args["projectId"])}/env/${encodeURIComponent(args["envVarId"])}`, query: team(args), body },
+ ctx,
+ ));
+ },
+ },
+ {
+ name: "delete_project_env_var",
+ description:
+ "Delete one environment variable from a Vercel project by id. Existing deployments keep their embedded value; future deployments do not.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ { projectId: PROJECT_ID_PROPERTY, envVarId: { type: "string", minLength: 1, description: "Environment-variable id from list_project_env_vars." }, teamId: TEAM_ID_PROPERTY },
+ ["projectId", "envVarId"],
+ ),
+ outputSchema: { type: "object", properties: { deleted: { type: "boolean" }, envVarId: { type: "string" } }, required: ["deleted", "envVarId"] },
+ handler: async (args, ctx) => {
+ await callVercel(send, { method: "DELETE", path: `/v9/projects/${encodeURIComponent(args["projectId"])}/env/${encodeURIComponent(args["envVarId"])}`, query: team(args) }, ctx);
+ return { deleted: true, envVarId: args["envVarId"] };
+ },
+ },
+ {
+ name: "promote_deployment",
+ description:
+ "Promote an existing Vercel deployment to production without rebuilding it. The deployment must belong to the named project.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ { projectId: PROJECT_ID_PROPERTY, deploymentId: DEPLOYMENT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY },
+ ["projectId", "deploymentId"],
+ ),
+ outputSchema: { type: "object", properties: { promoted: { type: "boolean" }, deploymentId: { type: "string" } }, required: ["promoted", "deploymentId"] },
+ handler: async (args, ctx) => {
+ await callVercel(send, { method: "POST", path: `/v10/projects/${encodeURIComponent(args["projectId"])}/promote/${encodeURIComponent(args["deploymentId"])}`, query: team(args) }, ctx);
+ return { promoted: true, deploymentId: args["deploymentId"] };
+ },
+ },
+ {
+ name: "cancel_deployment",
+ description:
+ "Cancel a queued, initializing, or building Vercel deployment. A deployment that is already ready, failed, canceled, or deleted cannot be canceled.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ { deploymentId: DEPLOYMENT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY },
+ ["deploymentId"],
+ ),
+ outputSchema: DEPLOYMENT_SCHEMA,
+ handler: async (args, ctx) => projectDeployment(await callVercel(
+ send,
+ { method: "PATCH", path: `/v12/deployments/${encodeURIComponent(args["deploymentId"])}/cancel`, query: team(args) },
+ ctx,
+ )),
+ },
+ {
+ name: "delete_deployment",
+ description:
+ "Permanently delete one Vercel deployment and its deployment URL. This cannot be undone; use cancel_deployment for work still running.",
+ annotations: destructive,
+ inputSchema: namedInput(
+ { deploymentId: DEPLOYMENT_ID_PROPERTY, teamId: TEAM_ID_PROPERTY },
+ ["deploymentId"],
+ ),
+ outputSchema: { type: "object", properties: { deleted: { type: "boolean" }, deploymentId: { type: "string" } }, required: ["deleted", "deploymentId"] },
+ handler: async (args, ctx) => {
+ await callVercel(send, { method: "DELETE", path: `/v13/deployments/${encodeURIComponent(args["deploymentId"])}`, query: team(args) }, ctx);
+ return { deleted: true, deploymentId: args["deploymentId"] };
+ },
+ },
+ ];
+}
+
+function usageGuide(
+ purpose: string,
+ teamId: string | undefined,
+ instructions: string | undefined,
+): string {
+ const accountInstructions = instructions?.trim();
+ return `# Vercel usage
+
+Account purpose: ${purpose}
+
+## Scope before action
+
+${
+ teamId
+ ? `This connection defaults to team \`${teamId}\`. Every named account-scoped tool accepts a \`teamId\` override; pass \`null\` to target the token owner's personal account.`
+ : "This connection defaults to the token owner's personal account. Call `list_teams`, then pass `teamId`, for team-owned resources."
+}
+
+Project names are accepted where Vercel accepts an id or name, but deployment,
+environment-variable, and team ids are opaque. Read them from their list tool
+and pass them back unchanged.
+
+## Diagnose deployments in order
+
+- Read \`get_deployment\` first. Its state says whether logs can still change.
+- Use \`get_build_logs\` for install, build, and framework output.
+- Use \`get_runtime_logs\` for application requests after a deployment runs.
+- \`promote_deployment\` moves an existing build to production. It does not
+ rebuild it. A rebuild or Git deployment belongs in \`vercel_api_mutate\`.
+
+## Environment values
+
+\`list_project_env_vars\` never decrypts or returns values. It reports names,
+targets, visibility, branch bindings, and ids. The create and update tools take
+values only as write input, and their projected results omit them. Environment
+changes apply to future deployments, not deployments that already exist.
+
+## Named tools and the REST hatches
+
+Use named tools when one exists. They validate arguments and return smaller,
+stable objects. \`vercel_api_get\` reaches every other GET endpoint and
+\`vercel_api_mutate\` reaches JSON POST, PUT, PATCH, and DELETE endpoints.
+\`vercel_api_upload\` sends explicit text or base64 bytes and never reads a
+local file. Paths include Vercel's API version, such as \`/v1/edge-config\`,
+and query parameters are name/value pairs. Pass \`personalAccount: true\` to
+omit this connection's default team. No hatch accepts an absolute URL.
+
+## Pagination and rate limits
+
+List tools return \`page.hasMore\` and \`page.nextCursor\`. Pass the cursor back
+unchanged. Vercel meters endpoints separately and returns the reset in response
+headers. A rate-limit failure carries that delay when Vercel supplies it.
+${
+ accountInstructions
+ ? `\n## Account instructions\n\n${accountInstructions}\n`
+ : ""
+ }`;
+}
+
+/** A maintained Vercel connection over the public REST API. */
+export function vercel(id: string, options: VercelOptions): Connector {
+ const purpose = options.purpose.trim();
+ if (!purpose) {
+ throw new Error("vercel() requires a non-empty account purpose.");
+ }
+ const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
+ if (
+ !Number.isInteger(defaultPageSize) ||
+ defaultPageSize < 1 ||
+ defaultPageSize > MAX_PAGE_SIZE
+ ) {
+ throw new Error(
+ `vercel() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`,
+ );
+ }
+ const teamId = options.teamId?.trim() || undefined;
+ const send = vercelTransport(options.baseUrl ?? VERCEL_API_BASE_URL);
+
+ return api(id, {
+ ...(options.authScope ? { authScope: options.authScope } : {}),
+ title: options.title ?? "Vercel",
+ description: `Vercel account and deployments: ${purpose}`,
+ credential: {
+ label: "Vercel access token",
+ description:
+ "Access token from Vercel Account Settings → Tokens. Choose the personal account or team scope this deployment needs and set an expiration date. The connector never sends it anywhere except api.vercel.com or the configured baseUrl proxy.",
+ placeholder: "Paste Vercel access token",
+ },
+ testCredential: async (value, ctx) => {
+ try {
+ const payload = asRecord(await callVercel(
+ send,
+ { method: "GET", path: "/v2/user" },
+ { ...ctx, credential: { get: async () => value, getAll: async () => ({ value }) } },
+ ));
+ const user = asRecord(payload["user"] ?? payload);
+ const identity = user["username"] ?? user["email"] ?? user["name"] ?? user["id"] ?? "Vercel user";
+ return { ok: true, message: `Authenticated as ${identity}.` };
+ } catch (error) {
+ return {
+ ok: false,
+ message: error instanceof ConnectorCallError ? error.message : "Vercel rejected the token.",
+ };
+ }
+ },
+ usageGuide: {
+ content: usageGuide(purpose, teamId, options.instructions),
+ summary:
+ "Team scoping, deployment diagnosis, value-safe environment variables, REST hatches, and cursor pagination.",
+ required: true,
+ },
+ ...(options.callAdmission
+ ? { callAdmission: options.callAdmission }
+ : {}),
+ tools: tools(send, defaultPageSize, teamId),
+ ...(options.maxResultBytes !== undefined
+ ? { maxResultBytes: options.maxResultBytes }
+ : {}),
+ });
+}
diff --git a/src/version.ts b/src/version.ts
index 4400dc3..de20c29 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -4,4 +4,4 @@
* a bump that forgets this file fails the build rather than shipping a stale
* version to `/health` and to downstream MCP handshakes.
*/
-export const CONNECTA_VERSION = "0.22.0";
+export const CONNECTA_VERSION = "0.22.1";
diff --git a/templates/node/package.json b/templates/node/package.json
index e72722e..ca29919 100644
--- a/templates/node/package.json
+++ b/templates/node/package.json
@@ -15,7 +15,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@zackbart/connecta": "0.22.0",
+ "@zackbart/connecta": "0.22.1",
"quickjs-emscripten": "0.32.0"
},
"devDependencies": {
diff --git a/test/guarded-fetch.test.ts b/test/guarded-fetch.test.ts
index a7babb6..a6d83c3 100644
--- a/test/guarded-fetch.test.ts
+++ b/test/guarded-fetch.test.ts
@@ -1,6 +1,6 @@
// The guarded transport hand-written connectors send every request through.
-// Cloudflare and Notion prove the shape end to end in their own suites; this
-// one pins the mechanics they both depend on and neither exercises directly —
+// Cloudflare, Notion, and Vercel prove the shape in their own suites; this one
+// pins the mechanics they all depend on and do not each exercise directly —
// the confinement that only fails on a hostile path, the ceiling that only
// fires on an absurd response, and the redirect nobody's provider sends.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
diff --git a/test/package-surface.test.ts b/test/package-surface.test.ts
index 2f0d60d..2da26aa 100644
--- a/test/package-surface.test.ts
+++ b/test/package-surface.test.ts
@@ -307,4 +307,17 @@ describe("public package boundary", () => {
expect(match[1], `${match[1]} is not a relative import`).toMatch(/^\./);
}
});
+
+ it("keeps the Vercel provider dependency-free and out of the root entry", () => {
+ expect(packageJson.dependencies).not.toHaveProperty("@vercel/sdk");
+ expect(packageJson.peerDependencies).not.toHaveProperty("@vercel/sdk");
+ expect(packageJson.devDependencies).not.toHaveProperty("@vercel/sdk");
+ const source = readFileSync(
+ join(ROOT, "src", "providers", "vercel.ts"),
+ "utf8",
+ );
+ for (const match of source.matchAll(/from\s+"([^"]+)"/g)) {
+ expect(match[1], `${match[1]} is not a relative import`).toMatch(/^\./);
+ }
+ });
});
diff --git a/test/provider-conventions.test.ts b/test/provider-conventions.test.ts
index 399e539..e91c3c0 100644
--- a/test/provider-conventions.test.ts
+++ b/test/provider-conventions.test.ts
@@ -3,7 +3,7 @@
//
// `documentation/provider-conventions.md` names H1–H14 and P1–P13, and its
// table marks which of them a machine can decide. This suite decides those for
-// the two `api()` providers — the ones where connecta owns every name, schema,
+// the three `api()` providers — the ones where connecta owns every name, schema,
// description, and budget, and where a miss is therefore ours. The proxies'
// mechanical bar lives in their own suites, because it is about the wrapper's
// identity and classification rather than about tool shapes it does not own.
@@ -18,6 +18,7 @@ import {
} from "../src/catalog.js";
import { cloudflare } from "../src/providers/cloudflare.js";
import { notion } from "../src/providers/notion.js";
+import { vercel } from "../src/providers/vercel.js";
import { memoryStorage } from "../src/storage/memory.js";
import { validateToolInput } from "../src/validate.js";
import { silentLogger } from "./helpers.js";
@@ -76,6 +77,19 @@ const VERBS: Readonly> = {
"query",
"trash",
],
+ vercel: [
+ "list",
+ "get",
+ "add",
+ "verify",
+ "remove",
+ "upsert",
+ "update",
+ "delete",
+ "promote",
+ "cancel",
+ "vercel",
+ ],
};
/**
@@ -124,6 +138,7 @@ const NESTED_DESCRIPTION_EXCEPTIONS: Readonly<
"cloudflare_api_upload.files[].base64",
],
notion: [],
+ vercel: [],
};
interface SchemaNode {
@@ -194,6 +209,10 @@ const providers = await Promise.all([
"notion",
notion("nt", { purpose: "Engineering wiki and roadmap questions" }),
),
+ surface(
+ "vercel",
+ vercel("vc", { purpose: "Production web applications" }),
+ ),
]);
describe.each(providers)(
diff --git a/test/provider-registry.test.ts b/test/provider-registry.test.ts
index 0b60b7b..2730130 100644
--- a/test/provider-registry.test.ts
+++ b/test/provider-registry.test.ts
@@ -8,6 +8,7 @@ import { mixpanel } from "../src/providers/mixpanel.js";
import { notion } from "../src/providers/notion.js";
import { revenuecat } from "../src/providers/revenuecat.js";
import { stripe } from "../src/providers/stripe.js";
+import { vercel } from "../src/providers/vercel.js";
import { connectorGuideSummary } from "../src/skills.js";
import { memoryStorage } from "../src/storage/memory.js";
import { activityFor, activitySink, invokeTestCall, seedCatalog, silentLogger } from "./helpers.js";
@@ -129,6 +130,20 @@ const providers: ProviderCase[] = [
notion("notion_ops", { purpose: "Operations handbook", title: "Ops wiki", defaultPageSize: 50 }),
], true),
},
+ {
+ name: "vercel",
+ ids: ["vercel_prod", "vercel_preview"] as const,
+ toolName: "list_projects",
+ secondToolName: "list_deployments",
+ descriptionMarks: ["Production applications", "Preview applications"],
+ admissionIds: ["vercel_prod", "vercel_preview"],
+ meteredId: "vercel_prod",
+ staticCatalog: true,
+ factory: (storage: KVStorage) => deployment(storage, [
+ vercel("vercel_prod", { purpose: "Production applications", teamId: "team_prod", callAdmission: budget }),
+ vercel("vercel_preview", { purpose: "Preview applications", teamId: "team_preview", callAdmission: budget }),
+ ], true),
+ },
];
describe.each(providers)("$name() inside a real deployment", ({ factory, ids, toolName, secondToolName, descriptionMarks, admissionIds, meteredId, staticCatalog }) => {
diff --git a/test/vercel-provider.test.ts b/test/vercel-provider.test.ts
new file mode 100644
index 0000000..e86868a
--- /dev/null
+++ b/test/vercel-provider.test.ts
@@ -0,0 +1,761 @@
+// The Vercel connection is hand-written fetch. Tests stub the network and pin
+// the requests, projections, secret handling, and typed failures we own.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ConnectorCallError } from "../src/errors.js";
+import {
+ VERCEL_API_BASE_URL,
+ vercel,
+} from "../src/providers/vercel.js";
+import { memoryStorage } from "../src/storage/memory.js";
+import { isExplicitlyReadOnly } from "../src/tool-safety.js";
+import { silentLogger } from "./helpers.js";
+import type {
+ Connector,
+ ConnectorContext,
+ ConnectorUsageGuide,
+} from "../src/types.js";
+
+interface StubResponse {
+ status?: number;
+ body?: unknown;
+ text?: string;
+ headers?: Record;
+}
+
+interface StubCall {
+ url: string;
+ method: string;
+ headers: Record;
+ body: unknown;
+}
+
+let responses: StubResponse[] = [];
+const calls: StubCall[] = [];
+const realFetch = globalThis.fetch;
+
+function queue(...items: StubResponse[]): void {
+ responses.push(...items);
+}
+
+beforeEach(() => {
+ responses = [];
+ calls.length = 0;
+ globalThis.fetch = vi.fn(async (input: unknown, init: RequestInit = {}) => {
+ const text =
+ responses[0]?.text ?? JSON.stringify(responses[0]?.body ?? {});
+ const next = responses.shift() ?? {};
+ calls.push({
+ url: String(input),
+ method: init.method ?? "GET",
+ headers: Object.fromEntries(new Headers(init.headers).entries()),
+ body:
+ typeof init.body === "string" && init.body
+ ? JSON.parse(init.body)
+ : undefined,
+ });
+ return new Response(text, {
+ status: next.status ?? 200,
+ ...(next.headers ? { headers: next.headers } : {}),
+ });
+ }) as unknown as typeof fetch;
+});
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function context(token: string | null = "vercel-token"): ConnectorContext {
+ return {
+ storage: memoryStorage(),
+ logger: silentLogger,
+ baseUrl: "https://connecta.example",
+ credential: {
+ get: async () => token,
+ getAll: async () => (token ? { value: token } : null),
+ },
+ };
+}
+
+function connection(overrides: Record = {}): Connector {
+ return vercel("hosting", {
+ purpose: "Production web applications",
+ teamId: "team_default",
+ ...overrides,
+ } as Parameters[1]);
+}
+
+function call(
+ connector: Connector,
+ name: string,
+ args: Record = {},
+ ctx: ConnectorContext = context(),
+): Promise {
+ return connector.callTool(name, args, ctx) as Promise;
+}
+
+function url(index = 0): URL {
+ return new URL(calls[index]!.url);
+}
+
+function guide(connector: Connector): ConnectorUsageGuide {
+ if (typeof connector.usageGuide !== "object" || !connector.usageGuide) {
+ throw new Error("expected a structured guide");
+ }
+ return connector.usageGuide;
+}
+
+describe("vercel() construction", () => {
+ it("rejects blank purpose and invalid page defaults", () => {
+ expect(() => vercel("hosting", { purpose: " " })).toThrow(
+ "vercel() requires a non-empty account purpose.",
+ );
+ expect(() =>
+ vercel("hosting", { purpose: "apps", defaultPageSize: 101 }),
+ ).toThrow("between 1 and 100");
+ });
+
+ it("ships a dependency-free static API surface with split safety", async () => {
+ const connector = connection();
+ const tools = await connector.listTools(context());
+ expect(connector.kind).toBe("api");
+ expect(connector.title).toBe("Vercel");
+ expect(connector.credential?.label).toBe("Vercel access token");
+ expect(tools).toHaveLength(21);
+ expect(tools.every((tool) => tool.inputSchema && tool.outputSchema)).toBe(
+ true,
+ );
+ expect(
+ isExplicitlyReadOnly(
+ tools.find((tool) => tool.name === "vercel_api_get")!,
+ ),
+ ).toBe(true);
+ expect(
+ isExplicitlyReadOnly(
+ tools.find((tool) => tool.name === "vercel_api_mutate")!,
+ ),
+ ).toBe(false);
+ expect(
+ tools.find((tool) => tool.name === "delete_deployment")?.annotations,
+ ).toMatchObject({ readOnlyHint: false, destructiveHint: true });
+ });
+
+ it("carries account scope and the raw-hatch boundary in its guide", () => {
+ const content = guide(
+ connection({ instructions: "Never promote the docs project." }),
+ ).content;
+ expect(content).toContain("Production web applications");
+ expect(content).toContain("team_default");
+ expect(content).toContain("vercel_api_get");
+ expect(content).toContain("never reads a");
+ expect(content).toContain("Never promote the docs project.");
+ });
+
+ it("constructs without touching the network", () => {
+ connection();
+ expect(globalThis.fetch).not.toHaveBeenCalled();
+ });
+});
+
+describe("Vercel reads and projections", () => {
+ it("lists teams with opaque cursor pagination", async () => {
+ queue({
+ body: {
+ teams: [
+ {
+ id: "team_1",
+ slug: "acme",
+ name: "Acme",
+ createdAt: 10,
+ membership: { role: "OWNER", confirmed: true },
+ billing: { plan: "enterprise" },
+ },
+ ],
+ pagination: { next: 12345 },
+ },
+ });
+ const result = await call(connection(), "list_teams", {
+ limit: 5,
+ cursor: "67890",
+ });
+ expect(url().pathname).toBe("/v2/teams");
+ expect(url().searchParams.get("until")).toBe("67890");
+ expect(result).toEqual({
+ teams: [
+ {
+ id: "team_1",
+ slug: "acme",
+ name: "Acme",
+ createdAt: 10,
+ membership: "OWNER",
+ },
+ ],
+ page: { hasMore: true, nextCursor: "12345" },
+ });
+ });
+
+ it("searches projects under the default team and removes settings noise", async () => {
+ queue({
+ body: {
+ projects: [
+ {
+ id: "prj_1",
+ name: "site",
+ framework: "nextjs",
+ accountId: "team_default",
+ createdAt: 1,
+ updatedAt: 2,
+ link: {
+ type: "github",
+ org: "acme",
+ repo: "site",
+ repoId: 99,
+ productionBranch: "main",
+ gitCredentialId: "secret-noise",
+ },
+ targets: {
+ production: {
+ id: "dpl_prod",
+ url: "site.example",
+ readyState: "READY",
+ createdAt: 3,
+ alias: ["large", "array"],
+ },
+ },
+ security: { passwordProtection: "noise" },
+ },
+ ],
+ pagination: { next: "next-project" },
+ },
+ });
+ const result = await call(connection(), "list_projects", {
+ search: "site",
+ cursor: "current-project",
+ });
+ expect(url().pathname).toBe("/v10/projects");
+ expect(url().searchParams.get("teamId")).toBe("team_default");
+ expect(url().searchParams.get("search")).toBe("site");
+ expect(url().searchParams.get("from")).toBe("current-project");
+ expect(calls[0]?.headers["authorization"]).toBe("Bearer vercel-token");
+ expect(result.projects[0]).toEqual({
+ id: "prj_1",
+ name: "site",
+ accountId: "team_default",
+ framework: "nextjs",
+ createdAt: 1,
+ updatedAt: 2,
+ paused: false,
+ productionBranch: "main",
+ repository: {
+ type: "github",
+ org: "acme",
+ repo: "site",
+ repoId: 99,
+ },
+ productionDeployment: {
+ id: "dpl_prod",
+ url: "site.example",
+ state: "READY",
+ createdAt: 3,
+ },
+ });
+ expect(result.page).toEqual({
+ hasMore: true,
+ nextCursor: "next-project",
+ });
+ });
+
+ it("keeps raw list items inside the declared pagination envelope", async () => {
+ queue({
+ body: {
+ projects: [
+ { id: "prj_raw", name: "raw", security: { extra: true } },
+ ],
+ pagination: { next: "next-raw" },
+ },
+ });
+ const result = await call(connection(), "list_projects", { raw: true });
+ expect(result).toEqual({
+ projects: [
+ { id: "prj_raw", name: "raw", security: { extra: true } },
+ ],
+ page: { hasMore: true, nextCursor: "next-raw" },
+ });
+ });
+
+ it("overrides the default team and returns raw project responses on request", async () => {
+ queue({ body: { id: "prj_raw", name: "raw", security: { extra: true } } });
+ const result = await call(connection(), "get_project", {
+ projectId: "raw",
+ teamId: "team_other",
+ raw: true,
+ });
+ expect(url().pathname).toBe("/v9/projects/raw");
+ expect(url().searchParams.get("teamId")).toBe("team_other");
+ expect(result.security).toEqual({ extra: true });
+ });
+
+ it("lists deployments with stable Git projection and timestamp cursor", async () => {
+ queue({
+ body: {
+ deployments: [
+ {
+ uid: "dpl_1",
+ name: "site",
+ url: "site-abc.vercel.app",
+ readyState: "READY",
+ target: "production",
+ created: 100,
+ creator: { uid: "usr_1", username: "zack", extra: "drop" },
+ meta: {
+ githubCommitRef: "main",
+ githubCommitSha: "abc123",
+ githubCommitMessage: "Ship",
+ githubRepoVisibility: "private",
+ },
+ },
+ ],
+ pagination: { next: 99 },
+ },
+ });
+ const result = await call(connection(), "list_deployments", {
+ projectId: "prj_1",
+ state: "READY",
+ cursor: "120",
+ });
+ expect(url().pathname).toBe("/v7/deployments");
+ expect(url().searchParams.get("until")).toBe("120");
+ expect(result.deployments[0]).toMatchObject({
+ id: "dpl_1",
+ state: "READY",
+ creator: { id: "usr_1", username: "zack" },
+ git: { branch: "main", sha: "abc123", message: "Ship" },
+ });
+ expect(result.page.nextCursor).toBe("99");
+ });
+
+ it("gets finite build events and never enables follow mode", async () => {
+ queue({
+ body: [
+ {
+ type: "stdout",
+ created: 100,
+ payload: { text: "Build complete", deploymentId: "dpl_1" },
+ },
+ ],
+ });
+ const result = await call(connection(), "get_build_logs", {
+ deploymentId: "dpl_1",
+ direction: "backward",
+ limit: 10,
+ });
+ expect(url().pathname).toBe("/v3/deployments/dpl_1/events");
+ expect(url().searchParams.get("follow")).toBe("0");
+ expect(url().searchParams.get("builds")).toBe("1");
+ expect(result.events[0]).toEqual({
+ type: "stdout",
+ createdAt: 100,
+ message: "Build complete",
+ payload: { text: "Build complete", deploymentId: "dpl_1" },
+ });
+ });
+
+ it("wraps raw build events in the declared output envelope", async () => {
+ queue({
+ body: [
+ { type: "stdout", created: 100, payload: { text: "raw" }, extra: true },
+ ],
+ });
+ const result = await call(connection(), "get_build_logs", {
+ deploymentId: "dpl_1",
+ raw: true,
+ });
+ expect(result).toEqual({
+ events: [
+ { type: "stdout", created: 100, payload: { text: "raw" }, extra: true },
+ ],
+ });
+ });
+
+ it("parses runtime stream JSON under either content type and caps rows", async () => {
+ queue({
+ text:
+ '{"level":"info","message":"ok","timestampInMs":1,"source":"serverless"}\n' +
+ '{"level":"error","message":"bad","timestampInMs":2,"source":"edge-function"}\n',
+ headers: { "content-type": "application/json" },
+ });
+ const result = await call(connection(), "get_runtime_logs", {
+ projectId: "prj_1",
+ deploymentId: "dpl_1",
+ limit: 1,
+ });
+ expect(url().pathname).toBe(
+ "/v1/projects/prj_1/deployments/dpl_1/runtime-logs",
+ );
+ expect(calls[0]?.headers["accept"]).toBe("application/stream+json");
+ expect(result.logs).toEqual([
+ { level: "info", message: "ok", timestampInMs: 1, source: "serverless" },
+ ]);
+ });
+});
+
+describe("Vercel domains and environment variables", () => {
+ it("projects domain verification state and cursor", async () => {
+ queue({
+ body: {
+ domains: [
+ {
+ name: "app.example.com",
+ apexName: "example.com",
+ projectId: "prj_1",
+ verified: false,
+ verification: [
+ {
+ type: "TXT",
+ domain: "_vercel.example.com",
+ value: "challenge",
+ reason: "pending",
+ },
+ ],
+ },
+ ],
+ pagination: { next: 7 },
+ },
+ });
+ const result = await call(connection(), "list_project_domains", {
+ projectId: "prj_1",
+ verified: false,
+ });
+ expect(url().searchParams.get("verified")).toBe("false");
+ expect(result.domains[0]).toMatchObject({
+ name: "app.example.com",
+ verified: false,
+ verification: [{ type: "TXT", value: "challenge" }],
+ });
+ expect(result.page.hasMore).toBe(true);
+ });
+
+ it("builds domain add, verify, and removal requests", async () => {
+ queue(
+ { body: { name: "preview.example.com", projectId: "prj_1", verified: true } },
+ { body: { name: "preview.example.com", projectId: "prj_1", verified: true } },
+ { body: {} },
+ );
+ const connector = connection();
+ await call(connector, "add_project_domain", {
+ projectId: "prj_1",
+ domain: "preview.example.com",
+ gitBranch: "feature",
+ });
+ await call(connector, "verify_project_domain", {
+ projectId: "prj_1",
+ domain: "preview.example.com",
+ });
+ const removed = await call(connector, "remove_project_domain", {
+ projectId: "prj_1",
+ domain: "preview.example.com",
+ removeRedirects: true,
+ });
+ expect(calls[0]).toMatchObject({
+ method: "POST",
+ body: { name: "preview.example.com", gitBranch: "feature" },
+ });
+ expect(new URL(calls[1]!.url).pathname.endsWith(
+ "/preview.example.com/verify",
+ )).toBe(true);
+ expect(calls[2]).toMatchObject({
+ method: "DELETE",
+ body: { removeRedirects: true },
+ });
+ expect(removed).toEqual({
+ removed: true,
+ domain: "preview.example.com",
+ });
+ });
+
+ it("never asks Vercel to decrypt environment values and never returns one", async () => {
+ queue({
+ body: {
+ envs: [
+ {
+ id: "env_1",
+ key: "DATABASE_URL",
+ value: "postgres://must-not-leak",
+ decrypted: true,
+ type: "sensitive",
+ visibility: "secret",
+ target: ["production"],
+ },
+ ],
+ },
+ });
+ const result = await call(connection(), "list_project_env_vars", {
+ projectId: "prj_1",
+ });
+ expect(url().pathname).toBe("/v10/projects/prj_1/env");
+ expect(url().searchParams.get("decrypt")).toBe("false");
+ expect(result.variables).toEqual([
+ {
+ id: "env_1",
+ key: "DATABASE_URL",
+ type: "sensitive",
+ visibility: "secret",
+ target: ["production"],
+ },
+ ]);
+ expect(JSON.stringify(result)).not.toContain("must-not-leak");
+ });
+
+ it("upserts a value but strips it from the response", async () => {
+ queue({
+ body: {
+ created: {
+ id: "env_1",
+ key: "API_KEY",
+ value: "must-not-return",
+ type: "sensitive",
+ target: ["production", "preview"],
+ },
+ failed: [],
+ },
+ });
+ const result = await call(connection(), "upsert_project_env_var", {
+ projectId: "prj_1",
+ key: "API_KEY",
+ value: "write-only",
+ type: "sensitive",
+ targets: ["production", "preview"],
+ });
+ expect(calls[0]).toMatchObject({
+ method: "POST",
+ body: {
+ key: "API_KEY",
+ value: "write-only",
+ type: "sensitive",
+ target: ["production", "preview"],
+ },
+ });
+ expect(url().searchParams.get("upsert")).toBe("true");
+ expect(url().pathname).toBe("/v10/projects/prj_1/env");
+ expect(result).toEqual({
+ id: "env_1",
+ key: "API_KEY",
+ type: "sensitive",
+ target: ["production", "preview"],
+ });
+ });
+
+ it("turns a successful HTTP env-write rejection into invalid_args", async () => {
+ queue({
+ status: 201,
+ body: {
+ created: null,
+ failed: [
+ {
+ error: {
+ code: "ENV_ALREADY_EXISTS",
+ message: "The variable already exists.",
+ value: "must-not-leak",
+ },
+ },
+ ],
+ },
+ });
+ await expect(
+ call(connection(), "upsert_project_env_var", {
+ projectId: "prj_1",
+ key: "API_KEY",
+ value: "write-only",
+ type: "sensitive",
+ targets: ["production"],
+ }),
+ ).rejects.toMatchObject({
+ code: "invalid_args",
+ message: "Vercel ENV_ALREADY_EXISTS: The variable already exists.",
+ });
+ });
+
+ it("refuses an empty update before touching Vercel", async () => {
+ await expect(
+ call(connection(), "update_project_env_var", {
+ projectId: "prj_1",
+ envVarId: "env_1",
+ }),
+ ).rejects.toMatchObject({ code: "invalid_args" });
+ expect(globalThis.fetch).not.toHaveBeenCalled();
+ });
+});
+
+describe("Vercel raw API hatches and lifecycle calls", () => {
+ it("adds the default team to arbitrary GET requests", async () => {
+ queue({ body: { items: [1, 2] } });
+ const result = await call(connection(), "vercel_api_get", {
+ path: "/v1/edge-config",
+ query: [{ name: "limit", value: 2 }],
+ });
+ expect(url().pathname).toBe("/v1/edge-config");
+ expect(url().searchParams.get("teamId")).toBe("team_default");
+ expect(url().searchParams.get("limit")).toBe("2");
+ expect(result).toEqual({ result: { items: [1, 2] } });
+ });
+
+ it("can opt named and arbitrary calls into the personal account", async () => {
+ queue(
+ { body: { id: "prj_personal", name: "personal" } },
+ { body: { user: { id: "usr_1" } } },
+ );
+ await call(connection(), "get_project", {
+ projectId: "prj_personal",
+ teamId: null,
+ });
+ await call(connection(), "vercel_api_get", {
+ path: "/v2/user",
+ personalAccount: true,
+ });
+ expect(new URL(calls[0]!.url).searchParams.has("teamId")).toBe(false);
+ expect(new URL(calls[1]!.url).searchParams.has("teamId")).toBe(false);
+ });
+
+ it("sends JSON mutations and never permits GET through the write hatch", async () => {
+ queue({ body: { id: "rule_1" } });
+ const result = await call(connection(), "vercel_api_mutate", {
+ method: "PATCH",
+ path: "/v1/example/rule_1",
+ body: { enabled: false },
+ });
+ expect(calls[0]).toMatchObject({
+ method: "PATCH",
+ body: { enabled: false },
+ });
+ expect(result).toEqual({ result: { id: "rule_1" } });
+ await expect(
+ call(connection(), "vercel_api_mutate", {
+ method: "GET",
+ path: "/v2/user",
+ }),
+ ).rejects.toMatchObject({ code: "invalid_args" });
+ });
+
+ it("uploads explicit base64 bytes with endpoint headers", async () => {
+ queue({ body: { url: "file.txt" } });
+ const result = await call(connection(), "vercel_api_upload", {
+ method: "POST",
+ path: "/v2/files",
+ contentType: "application/octet-stream",
+ headers: [{ name: "x-vercel-digest", value: "sha1-value" }],
+ base64Body: "aGk=",
+ });
+ expect(calls[0]?.method).toBe("POST");
+ expect(calls[0]?.headers["content-type"]).toBe(
+ "application/octet-stream",
+ );
+ expect(calls[0]?.headers["x-vercel-digest"]).toBe("sha1-value");
+ expect(result).toEqual({ result: { url: "file.txt" } });
+ });
+
+ it.each([
+ "authorization",
+ "cookie",
+ "host",
+ "content-length",
+ "content-type",
+ "transfer-encoding",
+ ])("refuses connector-owned upload header %s", async (name) => {
+ await expect(
+ call(connection(), "vercel_api_upload", {
+ method: "POST",
+ path: "/v2/files",
+ contentType: "application/octet-stream",
+ headers: [{ name, value: "caller-owned" }],
+ textBody: "hi",
+ }),
+ ).rejects.toMatchObject({ code: "invalid_args" });
+ expect(globalThis.fetch).not.toHaveBeenCalled();
+ });
+
+ it("confines arbitrary paths beneath the configured API base", async () => {
+ await expect(
+ call(connection(), "vercel_api_get", { path: "https://evil.example/v2/user" }),
+ ).rejects.toMatchObject({ code: "invalid_args" });
+ expect(globalThis.fetch).not.toHaveBeenCalled();
+ });
+
+ it("promotes, cancels, and deletes deployments on their current versions", async () => {
+ queue(
+ { body: {} },
+ { body: { uid: "dpl_1", name: "site", readyState: "CANCELED" } },
+ { body: {} },
+ );
+ const connector = connection();
+ await call(connector, "promote_deployment", {
+ projectId: "prj_1",
+ deploymentId: "dpl_1",
+ });
+ const canceled = await call(connector, "cancel_deployment", {
+ deploymentId: "dpl_1",
+ });
+ await call(connector, "delete_deployment", { deploymentId: "dpl_1" });
+ expect(new URL(calls[0]!.url).pathname).toBe(
+ "/v10/projects/prj_1/promote/dpl_1",
+ );
+ expect(new URL(calls[1]!.url).pathname).toBe(
+ "/v12/deployments/dpl_1/cancel",
+ );
+ expect(new URL(calls[2]!.url).pathname).toBe("/v13/deployments/dpl_1");
+ expect(canceled.state).toBe("CANCELED");
+ });
+});
+
+describe("Vercel typed failures and credential test", () => {
+ it("fails locally without a token", async () => {
+ await expect(
+ call(connection(), "list_projects", {}, context(null)),
+ ).rejects.toMatchObject({ code: "auth_required" });
+ expect(globalThis.fetch).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [403, "forbidden", "auth_required", false],
+ [404, "not_found", "not_found", false],
+ [400, "bad_request", "invalid_args", false],
+ [503, "unavailable", "unavailable", true],
+ ] as const)(
+ "maps HTTP %s (%s) to %s",
+ async (status, providerCode, code, retryable) => {
+ queue({
+ status,
+ body: { error: { code: providerCode, message: "provider detail" } },
+ });
+ const error = await call(connection(), "get_project", {
+ projectId: "missing",
+ }).catch((caught) => caught as ConnectorCallError);
+ expect(error).toMatchObject({ code, retryable });
+ expect(error.message).toContain(providerCode);
+ },
+ );
+
+ it("uses Vercel's reset header for rate-limit recovery", async () => {
+ vi.spyOn(Date, "now").mockReturnValue(1_000_000);
+ queue({
+ status: 429,
+ body: { error: { code: "rate_limited", message: "slow down" } },
+ headers: { "x-ratelimit-reset": "1002" },
+ });
+ const error = await call(connection(), "get_project", {
+ projectId: "prj_1",
+ }).catch((caught) => caught as ConnectorCallError);
+ expect(error).toMatchObject({
+ code: "rate_limited",
+ retryAfterMs: 2_000,
+ });
+ vi.restoreAllMocks();
+ });
+
+ it("tests the token against the current user and names the identity", async () => {
+ queue({ body: { user: { id: "usr_1", username: "zack" } } });
+ const result = await connection().testCredential!("candidate", context());
+ expect(url().origin).toBe(VERCEL_API_BASE_URL);
+ expect(url().pathname).toBe("/v2/user");
+ expect(calls[0]?.headers["authorization"]).toBe("Bearer candidate");
+ expect(result).toEqual({ ok: true, message: "Authenticated as zack." });
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index 168cad4..97ee2b3 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -56,6 +56,7 @@ export const WORKERS_SUITES = [
"test/ui.test.ts",
"test/ui-credentials.test.ts",
"test/validate.test.ts",
+ "test/vercel-provider.test.ts",
] as const;
export const NODE_ONLY_SUITES = [