Skip to content

feat(infra): hosted MCP endpoint for Cap Infra - #4174

Merged
richiemcilroy merged 17 commits into
mainfrom
feat/infra-hosted-mcp
Nov 13, 2026
Merged

richiemcilroy merged 17 commits into
mainfrom
feat/infra-hosted-mcp

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

What

A hosted MCP endpoint for Cap Infra at POST /api/developer/mcp. Any MCP client connects with one URL and the app's secret key:

claude mcp add --transport http cap-infra https://cap.so/api/developer/mcp --header "Authorization: Bearer $CAP_SECRET_KEY"

It serves the same tools as packages/infra-mcp, from the same definitions and descriptions, over stateless Streamable HTTP (@modelcontextprotocol/server 2.3.1 createMcpHandler, a fresh server per request, nothing held between invocations). Cap's own OAuth MCP at /api/mcp is untouched.

Tools: videos_create_upload, videos_complete_upload, videos_wait, videos_get, videos_list, videos_delete, transcripts_create, transcripts_wait, transcripts_text, transcripts_get, transcripts_list, transcripts_delete, account_usage, account_transactions.

Why

Cap Infra should be easy for agents to use. The stdio server only runs locally from a private package, so customers could not use it, and no public surface mentioned an MCP server.

How

  • One code path. Tools call the developer REST API in process: the v1 and SDK Hono apps now live in app.ts next to their route.ts (the route files keep the same handlers) and lib/infra-mcp-rest.ts dispatches the client's requests to app.fetch. Nothing loops back over the network. Every tool call runs the same validation, key check, per-key limit and preview gate as a direct REST request.
  • Route. app/api/developer/mcp/route.ts is an HttpApi group exported through mcpApiToHandler, like /api/mcp. It hands the request to lib/infra-mcp-endpoint.ts, a small Hono app that reuses the REST API's own developerRateLimiter and withDeveloperSecretAuth middleware before the MCP handler runs.
  • Package. packages/infra-mcp gets a ./hosted export. The stdio server and the hosted server share one runner for results, errors and redaction. The client takes an injectable fetch. The stdio bin and its tests are unchanged.
  • Uploads. The hosted server cannot read files, so videos_upload is replaced by videos_create_upload (creates the video, initiates the multipart upload, presigns every part and returns the links plus exact curl and split steps) and videos_complete_upload (gets a fresh upload token with the secret key and completes). File bytes and server-side paths are never accepted. transcripts_create takes url or videoId, and videoId goes straight to the REST field from feat(infra): transcribe a stored video by id and write its captions #4134, so the video gets its captions.
  • Local file check (stdio only). inspectFile now reads the first 4 KB and refuses anything that is not a known video or audio container with not_media, before any API call. The list mirrors isSelfContainedMedia in apps/web/lib/media-container.ts.

Auth and limits

  • Authorization: Bearer <secret key> only, checked by the same withDeveloperSecretAuth middleware as /api/developer/v1. Missing, public (cpk_), upload token (cut_), unknown and revoked keys get the same 401 bodies as REST, plus WWW-Authenticate: Bearer.
  • Preview gate: the REST API gates owners outside the preview at transcript start (infra_not_available). Tool calls run that same code, so the refusal is identical. infra-access.ts is not touched.
  • Headers: the endpoint uses the REST API's developerResponseHeaders (from fix(web): Infra upload idempotency, repeat transcript delete and no-store developer responses #4176), so every answer carries Cache-Control: private, no-store and echoes X-Request-Id. Refusals also carry WWW-Authenticate: Bearer realm="cap-infra".
  • Body: 64 KB, checked before parsing (413), and passed to createMcpHandler as maxRequestBodySize. Non-JSON bodies get 415. GET and DELETE get 405 because the endpoint holds no sessions.
  • Time: maxDuration 60. Wait tools cap timeoutSeconds at 45. Each tool call has a 50 second budget, after which it stops and returns a timeout error that keeps its context. Each REST call within it is capped at 25 seconds.
  • Secrets: every tool result, log line and logging notification goes through redact.ts. The signed part links that videos_create_upload returns to the caller are the one exception, and only in the result. They are wrapped so log lines and notifications still strip their signatures. The Authorization header is never logged. Upload tokens never leave the server.

Firewall

The per-key limit is enforced in code, not by path. developerRateLimiter calls @vercel/firewall checkRateLimit with rule id rl_developer_api_key, keyed by a digest of the credential. The MCP endpoint runs that limiter on every MCP request, and each REST call a tool makes runs it again in process, with the caller's x-forwarded-for, x-real-ip, x-vercel-id and host forwarded. MCP and REST traffic for one key therefore share one bucket, with no new rule needed. The limit comes from DEVELOPER_KEY_REQUESTS_PER_MINUTE, so the endpoint follows #4160's new numbers when that lands. Tool and docs text in this PR states no number, so it cannot go stale. If the dashboard also has a path-scoped edge rule on /api/developer/v1/*, it does not match /api/developer/mcp. The in-code rule covers it, so no change is required, but a matching path rule could be added for parity.

Discovery

  • Get Started has a new "10. Use with agents (MCP)" section: URL, header, Claude Code, Cursor and Codex config, and the tool list.
  • OpenAPI info has an x-mcp object (url, transport, authorization, documentation) and one sentence in the description.
  • The console agent prompt has one line pointing at the endpoint.
  • llms.txt and llms-full.txt are left to the discovery branch. .well-known/mcp describes Cap's own server and is left alone.

Tests

  • apps/web/__tests__/unit/infra-hosted-mcp.test.ts (23): response headers, auth (missing, public key, upload token, unknown, revoked, gated owner), tools/list (hosted names, no path, wait maximum 45), one call per group against stubbed REST apps (usage and list, create and complete upload, transcript by url and by videoId, path refused), a new video's upload aborted when signing fails but a reused upload kept, an unclear completion reported as unknown, a removed file reported as needing a new upload, 413, 415, 405, the per-key limit (read from its constant), and the tool budget.
  • packages/infra-mcp: 143 tests pass, including 14 new media check tests. The harness now writes a real MP4 header into its temp files.
  • Updated agent-readiness (it reads route mounts from app.ts), and the prompt and Get Started tests, which used to ban the word MCP. They still ban the CLI, curl, npx and pasted keys.
  • tsc --noEmit for apps/web and the package.

Proof

Local, real handlers and database. Run on 260bdc0, then again on 4d59bfe after the route moved to HttpApi, with the same results. next dev from this branch against the local database and storage, with a seeded test app and secret key (removed afterwards). An MCP JSON-RPC session over HTTP:

Step Result
No key 401 invalid_secret_key, WWW-Authenticate: Bearer realm="cap-infra"
initialize 200, server cap-infra
tools/list 200, the 14 hosted tools
account_usage standing active, upload allowed
videos_create_upload for a 30 MB MP4 one signed part link (signature intact), no cut_ token in the result
The tool's own step, run literally: curl -X PUT --upload-file 200 with an ETag
videos_complete_upload processing
videos_get processing
videos_wait with timeoutSeconds 10 returned timedOut after 6.6 s
transcripts_create for an owner outside the preview 403 infra_not_available, the same body as the same request sent straight to REST
70 KB body 413

The server log had no secret key, no upload token, no signature and no Authorization header. The only tool line was cap-infra: video_created {"videoId":"..."}.

Vercel preview, same head. On the protected preview, through vercel curl: no key and a public key get 401 with WWW-Authenticate and the per-key X-RateLimit-* headers, counting down across requests. A production secret key also gets 401 invalid_secret_key there, and so does the same key on the preview's own GET /api/developer/v1/usage. Preview deployments use a database that is not production, so tools/list and a usage read with a production key can only be shown after this deploys. The same key answers production REST normally.

After merge, from any machine with the key in CAP_SECRET_KEY:

claude mcp add --transport http cap-infra https://cap.so/api/developer/mcp --header "Authorization: Bearer $CAP_SECRET_KEY"

Web bundle check

transpilePackages now lists @cap/infra-mcp, so I checked that no page ships more JavaScript. Next 16 no longer prints Size or First Load JS in the build's route table, and this laptop is under the 15 GB free needed for two local builds. So I measured the deployed bundles instead:

  • Main: the production deployment built from this PR's base commit, 6dbc7c7.
  • PR: the preview deployment of 1c028fd.

For each route, first-load JS is every /_next/static/immutable/chunks/*.js the HTML loads, fetched from that deployment, with raw bytes and gzip level 9.

Route main first-load JS PR first-load JS Difference
/ 972.6 KB (309.2 KB gzip), 19 chunks 972.5 KB (309.1 KB gzip), 19 chunks -148 B (-103 B gzip)
/s/[videoId] 1215.9 KB (388.9 KB gzip), 24 chunks 1215.7 KB (388.8 KB gzip), 24 chunks -148 B (-105 B gzip)
/embed/[videoId] 993.8 KB (317.2 KB gzip), 18 chunks 993.6 KB (317.1 KB gzip), 18 chunks -148 B (-104 B gzip)
/dashboard/caps (signed out, so the sign-in page) 1028.5 KB (328.9 KB gzip), 19 chunks 1028.3 KB (328.8 KB gzip), 19 chunks -188 B (-77 B gzip)
Shared by all four routes 695.2 KB (218.2 KB gzip), 11 chunks 695.0 KB (218.1 KB gzip), 11 chunks -188 B (-77 B gzip)

The share and embed rows use a video id that exists in neither database, so both deployments render the same page. Previews use a different database, so a real video only renders on main.

  • Real share page: on main, /s/001t7gwqk627xx4 loads 27 chunks (1344.5 KB, 429.4 KB gzip). 25 of them are served byte-identical by the PR deployment under the same content-hashed names.
  • Real embed page: on main, /embed/qk8gt56e1q1r735 loads 24 chunks. 22 of them are byte-identical on the PR deployment.
  • What differs: the two remaining chunks on each page, and every byte difference in the table, come from the environment, not from this PR. A token diff of each pair shows only:
    • build UUIDs;
    • "production" vs "preview" and the commit SHA;
    • https://cap.so vs https://staging.cap.so;
    • OpenPanel settings, inlined in production and read at runtime on previews;
    • the Vercel toolbar snippet in production;
    • minifier renaming.
  • Package code: none of the 41 PR chunks these routes load contains infra-mcp, cap-infra, videos_create_upload, createHostedTools, api/developer/mcp or modelcontextprotocol.

The package is imported only by the server route, so no client bundle includes it. Share page first-load JS does not grow.

RetriggerView in GreptileConfidence Score: 4/5

The PR should not merge until hosted transcript retries cannot start duplicate paid jobs.

Findings

  1. P1 Transcript retries can charge twice ▶
  2. P2 Refresh advice preserves stale ETags ▶
Fix with agent prompt
### Issue 1
packages/infra-mcp/src/tools.ts:604-618
If the database or workflow start takes longer than 20 seconds, the hosted client stops waiting, but the REST handler can still create the transcript. This `videoId` branch returns a timeout telling the agent to run the tool again. Without the optional `idempotencyKey`, that retry gets a new transcript ID and can start a second paid job.

Require a retry-safe key for hosted creates, or report an uncertain outcome that tells the caller to check existing transcripts before creating another.

### Issue 2
packages/infra-mcp/src/hosted-upload.ts:undefined-225
`videos_create_upload` promises that refreshing expired links keeps the same upload and ETags. But REST only reuses uploads opened within six hours, and it can open a new upload when listing existing uploads fails. Following this advice can make a caller skip parts that do not exist in the returned `uploadId`, so completion fails.

Tell callers to reuse ETags only when the returned `uploadId` matches their previous one. Otherwise, they must upload every part again.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a hosted Cap Infra MCP endpoint backed by the existing developer REST handlers.

  • Agents can use Cap Infra through a hosted HTTP endpoint.
  • Hosted agents send video parts through signed links and start transcription from URLs or stored videos.
  • Local uploads check file contents before sending them.

Reviews (11) · Last reviewed commit: "test(infra): expect the REST API's missi..." · Reviewed by Greptile

The stdio server and a hosted server now share one runner for results,
errors and redaction. The client takes an injectable fetch. The hosted
surface swaps the local file tools for videos_create_upload and
videos_complete_upload, which return signed part links instead of
reading files, drops path from transcripts_create and bounds waits.
POST /api/developer/mcp serves the Cap Infra tools over stateless
Streamable HTTP. It checks the secret key and the per-key limit with the
same middleware as the REST API, and tools call the REST handlers in
process, so key checks, limits and the preview gate stay identical.
Adds a short MCP section to Get Started, an x-mcp entry and a sentence in
the OpenAPI info, and one line in the console agent prompt.
The hosted surface sends videoId straight to the REST API, which reads the
stored MP4 and writes captions when the transcript completes.
… uploading

inspectFile reads the first 4 KB and checks it against the same container
list the API sniffs on upload, so a document, image or playlist is refused
with not_media before any API call.
@sandchest-com

sandchest-com Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Sandchest

Sandchest reviewed d2678a2: nothing exploitable found in this change.

Sandchest reviews every change for vulnerabilities an attacker could use · 10 credits · Review details

Comment thread packages/infra-mcp/src/hosted.ts Fixed
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 05:45 Inactive
@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Ignored Ignored Preview Nov 13, 2026 7:20am UTC

Comment thread packages/infra-mcp/src/hosted-upload.ts Resolved
Comment thread packages/infra-mcp/src/hosted-upload.ts Resolved
Comment thread apps/web/app/api/developer/mcp/route.ts Resolved

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 06:00 Inactive

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

…ions

videos_create_upload with videoId gets back the open multipart upload and
its stored parts, so it no longer aborts it when signing fails. A
completion that times out or fails on the server now says the outcome is
unknown and to check videos_get first.
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 06:05 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptile-apps review

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

Comment thread packages/infra-mcp/src/hosted-upload.ts Resolved
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 06:12 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 06:19 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

# Conflicts:
#	apps/web/app/api/developer/sdk/v1/[...route]/route.ts
#	apps/web/app/api/developer/v1/[...route]/route.ts
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 06:40 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please re-review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy
richiemcilroy merged commit a8d3fba into main Nov 13, 2026
42 checks passed
Comment on lines +604 to +618
if (!local && args.videoId !== undefined) {
const created = dataOf(
await client.call("createTranscript", {
body: {
videoId: args.videoId,
...options,
...(args.metadata === undefined
? {}
: { metadata: args.metadata }),
},
headers,
signal: call.signal,
}),
);
return { ...transcript(created), next };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Transcript retries can charge twice

If the database or workflow start takes longer than 20 seconds, the hosted client stops waiting, but the REST handler can still create the transcript. This videoId branch returns a timeout telling the agent to run the tool again. Without the optional idempotencyKey, that retry gets a new transcript ID and can start a second paid job.

Require a retry-safe key for hosted creates, or report an uncertain outcome that tells the caller to check existing transcripts before creating another.

Knowledge Base Used: Infrastructure MCP tooling

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/infra-mcp/src/tools.ts
Line: 604-618

Comment:
**Transcript retries can charge twice**

If the database or workflow start takes longer than 20 seconds, the hosted client stops waiting, but the REST handler can still create the transcript. This `videoId` branch returns a timeout telling the agent to run the tool again. Without the optional `idempotencyKey`, that retry gets a new transcript ID and can start a second paid job.

Require a retry-safe key for hosted creates, or report an uncertain outcome that tells the caller to check existing transcripts before creating another.

**Knowledge Base Used:** [Infrastructure MCP tooling](https://app.greptile.com/-/switch-tenant?tenant=cap&next=%2Fcustom-context%2Fknowledge-base%2Fcapsoftware%2Fcap%2F-%2Fdocs%2Finfrastructure-mcp-tooling.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

linksExpireInSeconds: PART_LINK_SECONDS,
...videoLinks(pages),
steps: uploadSteps(plan.partBytes, plan.count),
next: "Upload the parts as the steps say, then call videos_complete_upload. If the links expire first, call videos_create_upload again with this videoId: it continues the same upload, so parts already stored keep their ETags.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refresh advice preserves stale ETags

videos_create_upload promises that refreshing expired links keeps the same upload and ETags. But REST only reuses uploads opened within six hours, and it can open a new upload when listing existing uploads fails. Following this advice can make a caller skip parts that do not exist in the returned uploadId, so completion fails.

Tell callers to reuse ETags only when the returned uploadId matches their previous one. Otherwise, they must upload every part again.

Knowledge Base Used: Infrastructure MCP tooling

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/infra-mcp/src/hosted-upload.ts
Line: 225

Comment:
**Refresh advice preserves stale ETags**

`videos_create_upload` promises that refreshing expired links keeps the same upload and ETags. But REST only reuses uploads opened within six hours, and it can open a new upload when listing existing uploads fails. Following this advice can make a caller skip parts that do not exist in the returned `uploadId`, so completion fails.

Tell callers to reuse ETags only when the returned `uploadId` matches their previous one. Otherwise, they must upload every part again.

**Knowledge Base Used:** [Infrastructure MCP tooling](https://app.greptile.com/-/switch-tenant?tenant=cap&next=%2Fcustom-context%2Fknowledge-base%2Fcapsoftware%2Fcap%2F-%2Fdocs%2Finfrastructure-mcp-tooling.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

This branch was successfully deployed

1 active and 1 inactive (outdated) deployments
Preview – cap-web — d2678a2f Deployed Nov 13, 2026 by vercel[bot]
Preview – cap-status — 3411f085 Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants