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 declared manifest.secrets (not just authSecret) so multi-credential headers work, then interpolates them into mcp.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 to process.env (local dev). A vendor MCP server authenticates the header itself; it never sees readMcpSecret.

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 guardrailshopify_set_price rejects 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.)
  • Idempotencyprintful_create_sync_product takes an idempotencyKey so 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 taggingmanifest.mcp.mutatingTools marks 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:

  1. Re-seed the catalog manifests onto the workspace's installed rows (extensions.seed_catalog / reinstall via /extensions), so the new mcp block is persisted on the extension_installations row.
  2. On the next agent request, connectExtensionMcp resolves the declared secrets, opens an MCP client to the vendor URL, and registers whatever tools the vendor exposes (filtered by toolAllowlist).
  3. 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; connectExtensionMcp interpolates them into whatever headers the vendor expects ({secret} = the authSecret's primary value, {secret.KEY} = any declared secret's value). Multi-secret headers are supported.
  • URL safety. mcp.url must be https:// (localhost allowed for dev); private/link-local and cloud-metadata hosts are blocked (SSRF guard in assertSafeMcpUrl).
  • Tool names change. A vendor's tool is ext__<name>__<vendorToolName>. If your skill bodies hardcode ext__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)

  1. Add the tool to the route's createMcpHandler callback: server.tool(name, description, zodShape, async (args, extra) => { … }).
  2. Read credentials with readMcpSecret(extra, '<Header>', '<ENV_FALLBACK>').
  3. Call the vendor API with httpJson(...); enforce any guardrail in code before the mutating call.
  4. If the tool spends money or mutates a store, add it to manifest.mcp.mutatingTools.
  5. If it needs a new credential, add it to manifest.secrets (source vault) and to manifest.mcp.headers as '<Header>': '{secret.<KEY>}', then read it with readMcpSecret. Surface it in vault_audit (lib/repositories/handlers.ts) so it shows on the vault dashboard.