Repository navigation
feat(infra): hosted MCP endpoint for Cap Infra - #4174
Conversation
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 reviewed Sandchest reviews every change for vulnerabilities an attacker could use · 10 credits · Review details |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…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.
|
@greptile-apps review |
|
hey @greptileai, please re-review the PR |
|
hey @greptileai, please re-review the PR |
|
hey @greptileai, please re-review the PR |
# Conflicts: # apps/web/app/api/developer/sdk/v1/[...route]/route.ts # apps/web/app/api/developer/v1/[...route]/route.ts
|
hey @greptileai, please re-review the PR |
|
@greptileai please re-review |
| 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 }; |
There was a problem hiding this 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
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.", |
There was a problem hiding this 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
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.
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/server2.3.1createMcpHandler, a fresh server per request, nothing held between invocations). Cap's own OAuth MCP at/api/mcpis 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
app.tsnext to theirroute.ts(the route files keep the same handlers) andlib/infra-mcp-rest.tsdispatches the client's requests toapp.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.app/api/developer/mcp/route.tsis an HttpApi group exported throughmcpApiToHandler, like/api/mcp. It hands the request tolib/infra-mcp-endpoint.ts, a small Hono app that reuses the REST API's owndeveloperRateLimiterandwithDeveloperSecretAuthmiddleware before the MCP handler runs.packages/infra-mcpgets a./hostedexport. The stdio server and the hosted server share one runner for results, errors and redaction. The client takes an injectablefetch. The stdio bin and its tests are unchanged.videos_uploadis replaced byvideos_create_upload(creates the video, initiates the multipart upload, presigns every part and returns the links plus exactcurlandsplitsteps) andvideos_complete_upload(gets a fresh upload token with the secret key and completes). File bytes and server-side paths are never accepted.transcripts_createtakesurlorvideoId, andvideoIdgoes 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.inspectFilenow reads the first 4 KB and refuses anything that is not a known video or audio container withnot_media, before any API call. The list mirrorsisSelfContainedMediainapps/web/lib/media-container.ts.Auth and limits
Authorization: Bearer <secret key>only, checked by the samewithDeveloperSecretAuthmiddleware as/api/developer/v1. Missing, public (cpk_), upload token (cut_), unknown and revoked keys get the same 401 bodies as REST, plusWWW-Authenticate: Bearer.infra_not_available). Tool calls run that same code, so the refusal is identical.infra-access.tsis not touched.developerResponseHeaders(from fix(web): Infra upload idempotency, repeat transcript delete and no-store developer responses #4176), so every answer carriesCache-Control: private, no-storeand echoesX-Request-Id. Refusals also carryWWW-Authenticate: Bearer realm="cap-infra".createMcpHandlerasmaxRequestBodySize. Non-JSON bodies get 415. GET and DELETE get 405 because the endpoint holds no sessions.maxDuration60. Wait tools captimeoutSecondsat 45. Each tool call has a 50 second budget, after which it stops and returns atimeouterror that keeps its context. Each REST call within it is capped at 25 seconds.redact.ts. The signed part links thatvideos_create_uploadreturns 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.
developerRateLimitercalls@vercel/firewallcheckRateLimitwith rule idrl_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'sx-forwarded-for,x-real-ip,x-vercel-idandhostforwarded. MCP and REST traffic for one key therefore share one bucket, with no new rule needed. The limit comes fromDEVELOPER_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
infohas anx-mcpobject (url, transport, authorization, documentation) and one sentence in the description.llms.txtandllms-full.txtare left to the discovery branch..well-known/mcpdescribes 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, nopath, 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.agent-readiness(it reads route mounts fromapp.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 --noEmitfor 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 devfrom 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:invalid_secret_key,WWW-Authenticate: Bearer realm="cap-infra"initializecap-infratools/listaccount_usagevideos_create_uploadfor a 30 MB MP4cut_token in the resultcurl -X PUT --upload-filevideos_complete_uploadprocessingvideos_getprocessingvideos_waitwithtimeoutSeconds10timedOutafter 6.6 stranscripts_createfor an owner outside the previewinfra_not_available, the same body as the same request sent straight to RESTThe 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 withWWW-Authenticateand the per-keyX-RateLimit-*headers, counting down across requests. A production secret key also gets 401invalid_secret_keythere, and so does the same key on the preview's ownGET /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
transpilePackagesnow 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:For each route, first-load JS is every
/_next/static/immutable/chunks/*.jsthe HTML loads, fetched from that deployment, with raw bytes and gzip level 9.//s/[videoId]/embed/[videoId]/dashboard/caps(signed out, so the sign-in page)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.
/s/001t7gwqk627xx4loads 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./embed/qk8gt56e1q1r735loads 24 chunks. 22 of them are byte-identical on the PR deployment."production"vs"preview"and the commit SHA;https://cap.sovshttps://staging.cap.so;infra-mcp,cap-infra,videos_create_upload,createHostedTools,api/developer/mcpormodelcontextprotocol.The package is imported only by the server route, so no client bundle includes it. Share page first-load JS does not grow.
The PR should not merge until hosted transcript retries cannot start duplicate paid jobs.
Findings
Fix with agent prompt
Summary
Adds a hosted Cap Infra MCP endpoint backed by the existing developer REST handlers.
Reviews (11) · Last reviewed commit: "test(infra): expect the REST API's missi..." · Reviewed by Greptile