Swapping an extension's MCP server (in-repo ↔ vendor-hosted)
Date: 2026-06-01
Scope: How the Vercel/v0 and Printful+Shopify integrations get their executable tools, and how to repoint them at a vendor's own hosted MCP server instead of the in-repo one — without touching the agent, the vault, or the tool registry.
Companion docs: /help/extensions/overview (the full extension author guide), tshirt-commerce-workflow-plan.md
TL;DR
Every extension contributes its executable tools through one remote MCP server, declared as manifest.mcp.url. That URL can point at either:
| Mode | mcp.url |
Tool implementation | What you get |
|---|---|---|---|
| In-repo (default today) | {BASE_URL}/api/ext/<name>-mcp/mcp |
app/api/ext/<name>-mcp/[transport]/route.ts — our own MCP server that makes REST calls to the vendor |
Guardrails (pricing band, idempotency, curated tool surface), simulated-without-a-key fallback, and a tool surface we control |
| Vendor-hosted | https://<vendor>/mcp |
the vendor's MCP server | Vendor-maintained tools, zero code to keep current — but no guardrails, no simulated fallback, and their tool surface |
Swapping is a manifest edit — change mcp.url (+ authSecret/headers). Nothing else changes: connectExtensionMcp still connects as an MCP client, still injects the vault secret as a header, and the agent still calls ext__<name>__<tool>.
We currently keep the in-repo servers on purpose, for the guardrails (see below).
How a tool call flows today
agent / workflow
→ buildToolRegistry() lib/tools/registry.ts
→ connectExtensionMcp(install) lib/extensions/mcp-client.ts
• resolves EVERY declared secret from the vault (per workspace)
• interpolates them into manifest.mcp.headers ({secret}, {secret.KEY})
• opens an MCP client to manifest.mcp.url over Streamable-HTTP
→ ext__<name>__<tool> registered
→ tool.execute(args)
→ MCP callTool → manifest.mcp.url
= in-repo route app/api/ext/<name>-mcp/[transport]/route.ts
• readMcpSecret(extra, 'X-…', 'ENV_NAME') header → env fallback
• fetch() the vendor REST API (api.v0.dev / api.vercel.com / Shopify Admin / Printful)
• enforce guardrails, return MCP result
Two key pieces make this work and are mode-independent:
- Secret resolution — secrets live encrypted in
vault_secrets, configured per workspace at/extensions.connectExtensionMcp(lib/extensions/mcp-client.ts) resolves all declaredmanifest.secrets(not justauthSecret) so multi-credential headers work, then interpolates them intomcp.headers. This is identical whether the URL is in-repo or a vendor's. readMcpSecret— only the in-repo routes use this (lib/extensions/mcp-secret.ts). It reads the vault-injected request header first, then falls back toprocess.env(local dev). A vendor MCP server authenticates the header itself; it never seesreadMcpSecret.
Why we keep the in-repo servers (the guardrails)
The in-repo route is not just a passthrough — it adds things a generic vendor MCP server won't:
- Pricing guardrail —
shopify_set_pricerejects a price outside[1.2×, 5×]of base cost (fat-finger / runaway-spend protection), enforced in code, not just the prompt. (app/api/ext/commerce-mcp/[transport]/route.ts.) - Idempotency —
printful_create_sync_producttakes anidempotencyKeyso workflow re-runs don't duplicate products. - Simulated fallback — with no key configured, every tool returns a realistic synthetic result so the whole workflow demos end-to-end on a fresh checkout. A vendor server just errors without auth.
- Curated, stable tool surface — we expose exactly the tools the skills call (
generate_landing_page,buy_domain,deploy; the Printful/Shopify set), with descriptions tuned for the agent. A vendor server exposes their full surface, which can change under you. - Spend tagging —
manifest.mcp.mutatingToolsmarks money-/store-facing tools so they carry the "mutates state" hint the agent respects.
If you swap to a vendor MCP server you lose all of the above unless the vendor implements equivalents.
How to swap to a vendor-hosted MCP server
Edit the extension's manifest in lib/extensions/catalog.ts (and the canonical copy in extensions/<name>/nihmbus.extension.json). Change only the mcp block:
// BEFORE — in-repo (guardrails)
mcp: {
transport: 'streamable-http',
url: '{BASE_URL}/api/ext/commerce-mcp/mcp', // our route
authSecret: 'PRINTFUL_API_KEY',
headers: {
'X-Printful-Key': '{secret.PRINTFUL_API_KEY}',
'X-Shopify-Token': '{secret.SHOPIFY_ACCESS_TOKEN}',
'X-Shopify-Shop': '{secret.SHOPIFY_SHOP}',
},
mutatingTools: ['shopify_set_price', /* … */],
}
// AFTER — vendor-hosted (example: Shopify's hosted MCP)
mcp: {
transport: 'streamable-http',
url: 'https://<your-store>.myshopify.com/api/mcp', // the vendor's MCP endpoint
authSecret: 'SHOPIFY_ACCESS_TOKEN',
headers: { Authorization: 'Bearer {secret.SHOPIFY_ACCESS_TOKEN}' },
// toolAllowlist is now important — a vendor server may expose many tools;
// restrict to the ones your skills actually call:
toolAllowlist: ['get_product', 'update_product', 'set_price'],
mutatingTools: ['set_price', 'update_product'],
}What happens after the edit:
- Re-seed the catalog manifests onto the workspace's installed rows (
extensions.seed_catalog/ reinstall via/extensions), so the newmcpblock is persisted on theextension_installationsrow. - On the next agent request,
connectExtensionMcpresolves the declared secrets, opens an MCP client to the vendor URL, and registers whatever tools the vendor exposes (filtered bytoolAllowlist). - The in-repo route (
app/api/ext/<name>-mcp/...) is now dead code — leave it (useful as a fallback / reference) or delete it.
Notes:
- Secrets are unchanged. They still live in the vault per workspace;
connectExtensionMcpinterpolates them into whatever headers the vendor expects ({secret}= theauthSecret's primary value,{secret.KEY}= any declared secret's value). Multi-secret headers are supported. - URL safety.
mcp.urlmust behttps://(localhost allowed for dev); private/link-local and cloud-metadata hosts are blocked (SSRF guard inassertSafeMcpUrl). - Tool names change. A vendor's tool is
ext__<name>__<vendorToolName>. If your skill bodies hardcodeext__commerce__shopify_set_price, update them to the vendor's tool name (or keep the in-repo server so the names stay stable). - You lose the guardrails. If you still want the pricing band / idempotency on top of a vendor server, keep the in-repo route and have it proxy to the vendor MCP server (best of both) rather than repointing the manifest directly.
Per-integration status
| Integration | In-repo route | Real API today | Vendor MCP server? |
|---|---|---|---|
| Vercel / v0 | app/api/ext/vercel-mcp |
v0 chat-create (api.v0.dev), Vercel domain price/buy + domain-attach (api.vercel.com) are live; deploy attaches the domain (build is published from v0) |
Check Vercel's MCP docs; repoint mcp.url if/when available |
| Printful | app/api/ext/commerce-mcp |
key is validated against the live API; full catalog→variant product creation is the remaining piece (Printful is REST-first) | Printful is REST-only today — keep the in-repo route |
| Shopify | app/api/ext/commerce-mcp |
Admin REST get/update/set-price are live (needs SHOPIFY_ACCESS_TOKEN + SHOPIFY_SHOP) |
Shopify ships official MCP servers — repointing is viable (see the example above); you'd trade the pricing guardrail for vendor maintenance |
Adding a NEW guardrailed tool (staying in-repo)
- Add the tool to the route's
createMcpHandlercallback:server.tool(name, description, zodShape, async (args, extra) => { … }). - Read credentials with
readMcpSecret(extra, '<Header>', '<ENV_FALLBACK>'). - Call the vendor API with
httpJson(...); enforce any guardrail in code before the mutating call. - If the tool spends money or mutates a store, add it to
manifest.mcp.mutatingTools. - If it needs a new credential, add it to
manifest.secrets(sourcevault) and tomanifest.mcp.headersas'<Header>': '{secret.<KEY>}', then read it withreadMcpSecret. Surface it invault_audit(lib/repositories/handlers.ts) so it shows on the vault dashboard.