Build a Custom Claude Connector with a Remote MCP Server
Kashish Hora
Co-founder of AgentCat
The quick answer
A custom connector is a remote MCP server that Claude connects to over a URL you supply. Three steps:
- Serve MCP over Streamable HTTP at a public HTTPS endpoint, conventionally
/mcp. - In Claude, open Settings > Connectors, click "Add custom connector", paste the URL, click "Add".
- Turn it on for a chat with the "+" button, then "Connectors".
$npm install @modelcontextprotocol/sdk express zod
One structural fact shapes everything else in this guide: claude.ai connectors run on Anthropic's infrastructure and reach your server over the public internet (Anthropic troubleshooting docs). The connection isn't opened by the browser tab you're typing into, so http://localhost:8000/mcp will never work, no matter how well your server runs on your laptop. Anything you want Claude to talk to has to resolve to a globally routable IPv4 address and accept traffic from Anthropic's egress range.
So the walkthrough below runs a tunnel rather than treating one as optional polish, and when a connector refuses to connect, DNS is the first thing to check rather than your code.
If you're not sure a connector is even the right shape for what you're building, Claude Connectors vs. the MCP connector API sorts out the three different things that share the word.
Prerequisites
- Node.js 18 or newer and npm.
- A Claude account. Any plan can add a custom connector, and Free accounts are limited to one (Anthropic support).
- A way to put localhost on the public internet. Anthropic recommends a tunnel such as Cloudflare Tunnel or
ngrokfor testing a local server (Anthropic testing docs).
Set up the project
Three runtime dependencies and the TypeScript toolchain. express terminates HTTP, zod describes tool inputs, and the MCP SDK does everything else.
$npm init -y$npm pkg set type=module$npm install @modelcontextprotocol/sdk express zod$npm install -D typescript tsx @types/node @types/express
Add a standard Node ESM tsconfig.json ("module": "ESNext", "moduleResolution": "bundler", "strict": true) and you're ready to write the server.
Write a tool worth connecting
Build something with a real shape, because tool design is what decides whether Claude uses your connector once it's attached. This one answers a question every team asks in Slack ten times a week: what shipped, and did it work. It reads from an in-memory array here; swap in your deploy database later.
// src/deploys.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const DEPLOYS = [
{ service: "checkout-api", version: "2026.7.28", status: "succeeded", at: "2026-07-28T16:42:11Z", by: "rina" },
{ service: "checkout-api", version: "2026.7.26", status: "rolled back", at: "2026-07-26T09:03:47Z", by: "deploybot" },
{ service: "search-indexer", version: "2026.7.27", status: "succeeded", at: "2026-07-27T22:15:02Z", by: "amir" }
];
const SERVICES = [...new Set(DEPLOYS.map((d) => d.service))].join(", ");
export function buildServer(): McpServer {
const server = new McpServer({ name: "deploy-log", version: "1.0.0" });
server.registerTool(
"get_recent_deploys",
{
title: "Get recent deploys",
description:
"List recent deploys for one service, newest first. Use this when someone asks " +
"what shipped, when a service last deployed, or whether a deploy failed.",
inputSchema: {
service: z.string().describe(`Service name. One of: ${SERVICES}`),
limit: z.number().int().min(1).max(20).default(5)
},
annotations: { readOnlyHint: true }
},
async ({ service, limit }) => {
const rows = DEPLOYS.filter((d) => d.service === service).slice(0, limit);
if (rows.length === 0) {
return {
content: [{ type: "text", text: `No service named "${service}". Known services: ${SERVICES}.` }],
isError: true
};
}
const text = rows.map((d) => `${d.at} ${d.version} ${d.status} (${d.by})`).join("\n");
return { content: [{ type: "text", text }] };
}
);
return server;
}Four of those config fields have non-obvious effects:
annotations: { readOnlyHint: true }changes how Claude behaves at call time, not just how the tool is documented. Anthropic's review criteria require every tool to carry atitleplusreadOnlyHint: trueordestructiveHint: true, and those hints "determine auto-permissions in Claude: read-only tools can run without per-call confirmation; destructive tools always prompt" (review criteria). A read-only tool that forgets the hint gets a confirmation dialog on every single call.- The description says when to invoke, not just what the code does. Claude picks tools from names and descriptions, so "Use this when someone asks what shipped" is doing more work than the function signature is.
- The unknown-service branch returns an actionable message plus
isError: truerather than throwing. Claude sees the text and can retry with a real service name. Generic errors fail directory review, and validating inputs with useful messages is an explicit criterion (review criteria). inputSchematakes a raw Zod shape, not a wrappedz.object(...). Passing a wrapped object fails to compile withregisterTool.
Splitting reads from writes matters here too: a single tool that accepts both safe HTTP methods and unsafe ones is rejected outright by directory review, so no catch-all api_request tool with a method parameter (review criteria).
Arguments that don't match the schema never reach your handler. The SDK catches them and returns a normal tool result carrying isError, rather than a JSON-RPC error, so the model can read the message and correct itself. Calling this tool with limit: "five" produces:
{
"content": [{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool get_recent_deploys: Invalid input: expected number, received string at limit"
}],
"isError": true
}An unknown tool name comes back the same way, as an isError result reading "MCP error -32602: Tool ... not found". That means a mistyped tool name in a client won't crash the connection; it produces a message Claude reads and retries around.
Serve it, and log what arrives
Now the transport. This is a stateless Streamable HTTP endpoint: one fresh server and transport per POST, no session map, no shared memory. For sessions, resumability, and multi-instance deployment, our Streamable HTTP guide covers the stateful variant and the scaling trade-offs.
The logRpc call is the part specific to running as a connector.
// src/server.ts
import express, { type Request } from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./deploys.js";
function logRpc(req: Request) {
const { method, params } = req.body ?? {};
console.log(JSON.stringify({
at: new Date().toISOString(),
method,
session: req.headers["mcp-session-id"] ?? null,
client: method === "initialize" ? params?.clientInfo?.name : undefined,
tool: method === "tools/call" ? params?.name : undefined,
args: method === "tools/call" ? params?.arguments : undefined
}));
}
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
logRpc(req);
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(8000, () => console.log("deploy-log MCP server on http://localhost:8000/mcp"));Anthropic is direct about why those four log fields matter: "Partner-visible error logs are in development. In the meantime, use server-side logging on your end and the MCP Inspector to diagnose connection failures" (Anthropic testing docs). You can't see Anthropic's half of the connection, so your access log is the only evidence that exists. A healthy session leaves a trail like this:
{"at":"2026-07-29T16:58:32.820Z","method":"initialize","session":null,"client":"claude-ai"}
{"at":"2026-07-29T16:58:47.531Z","method":"tools/list","session":null}
{"at":"2026-07-29T16:58:47.581Z","method":"tools/call","session":null,"tool":"get_recent_deploys","args":{"service":"checkout-api"}}Three questions those lines answer instantly:
- Did
initializearrive at all? No line means the request never left Anthropic's network, which is a reachability problem rather than a code problem. - Did
tools/listfollow? Initialize without a list means the handshake failed after connecting. - Did
tools/callever fire, and with what arguments? A connector that lists cleanly but never gets called is a tool-description problem, not a wiring one.
The client field reads clientInfo.name off the initialize request. Claude identifies itself there, though the value varies by surface: you may see claude-ai, Anthropic (sometimes with a service suffix), or claude-code. Use it for telemetry and coarse feature detection only, since it's unauthenticated and any client can claim any name (Anthropic testing docs). Once several people are using the connector, per-tool call counts, argument shapes, and error rates become the thing you actually want, which is the gap AgentCat fills for production MCP servers.
Prove it works before Claude sees it
Start the server, then exercise it locally. Every Streamable HTTP POST has to advertise both content types in its Accept header, so a hand-rolled curl needs that spelled out.
$npx tsx src/server.ts$curl -s -X POST http://localhost:8000/mcp \$ -H 'Content-Type: application/json' \$ -H 'Accept: application/json, text/event-stream' \$ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
The response comes back as a one-event SSE stream carrying your serverInfo. That confirms the transport, but it doesn't confirm your tools behave, so drive the whole surface with MCP Inspector next.
$npx @modelcontextprotocol/inspector --cli http://localhost:8000/mcp \$ --transport http --method tools/call \$ --tool-name get_recent_deploys --tool-arg service=checkout-api
Anthropic asks submitters to exercise every tool through the Inspector before connecting to Claude (review criteria). Do it even if you never submit: a failure here is far cheaper to read than a failure inside a chat. Our MCP Inspector guide covers the UI mode and the auth flows.
Put it on the public internet
Claude needs a hostname that resolves publicly, so tunnel the local port:
$ngrok http 8000
Your connector URL is the HTTPS forwarding address plus the path: https://abc123.ngrok.app/mcp. Before pasting it into Claude, check it the way Anthropic's infrastructure will, from a network that isn't yours. Every address returned by public DNS has to be globally routable, and connectors are IPv4-only, so a hostname publishing only AAAA records can't be reached (troubleshooting docs).
$dig +short abc123.ngrok.app$curl -sI https://abc123.ngrok.app/mcp
Any response at all is fine there, including a 401, 404, or 405, since the server only answers POST on that path. A timeout, a connection refused, or a 3xx redirect to a different host is not. Keep the tunnel down when you're not testing, since it exposes your local server to anyone who finds the URL.
Add it to Claude
The flow differs by plan. On Free, Pro, and Max, go to Settings > Connectors, click "Add custom connector", enter the remote MCP server URL, optionally set an OAuth Client ID and Secret under Advanced settings, and click "Add". On Team and Enterprise, an owner adds it under Admin settings > Connectors first, then members go to their own Settings > Connectors, find the entry labeled "Custom", and click "Connect" to authenticate (Anthropic docs).
[Screenshot: The claude.ai Settings > Connectors page with the "Add custom connector" dialog open, showing the remote MCP server URL field and the collapsed Advanced settings section]
Adding a connector doesn't switch it on everywhere. Enable it per conversation with the "+" button in the chat interface, then "Connectors", where each configured connector has a toggle (Anthropic docs). Start a new chat, turn on deploy-log, and ask something a human would ask: "Did checkout-api ship this week?"
Watch your server log while that runs. You should see initialize, then tools/list, then tools/call with {"service":"checkout-api"}. If the first two land and the third never does, Claude connected fine and simply decided your tool wasn't relevant, which is a description problem you can fix in one line.
Terminal-first teams can point the same server at a different surface: adding an MCP server to Claude Code covers the CLI path, which connects from your machine instead of from Anthropic's.
Authentication options
The walkthrough above runs authless, which Claude supports as a first-class type, listed as none alongside the OAuth variants (Anthropic auth docs). Real data needs one of the other two paths.
OAuth is the default answer. Users grant access without handing over credentials, and your server acts as an OAuth 2.1 Resource Server: it advertises RFC 9728 protected resource metadata, validates bearer tokens, and confirms each token was minted for it. A few Claude-specific requirements sit on top of the spec:
- Register
https://claude.ai/api/mcp/auth_callbackas a redirect URI for the hosted surfaces (web, Desktop, mobile, and Cowork). - Support S256 PKCE and advertise
"code_challenge_methods_supported": ["S256"]. - Offer dynamic client registration, a Client ID Metadata Document, or pre-registered credentials, and keep discovery, registration, and token responses under 10 seconds (Anthropic auth docs).
- Register the URL your server actually listens on. If the registered URL redirects to a different host, the
Authorizationheader is dropped on the redirect and the connection fails with an authorization error (troubleshooting docs).
Our OAuth 2.1 for MCP servers guide walks through the resource-server implementation itself.
Request-header auth covers the fixed-credential case, and it's in beta and rolling out gradually, so contact Anthropic for early access (Anthropic docs). It fits internal tools and service accounts where one credential is shared across an organization rather than issued per person. The rules are unusually specific:
| Rule | Detail |
|---|---|
| How many | Up to four headers per connector |
| Which names | A reviewed allowlist of standard auth and routing names such as authorization, x-api-key, x-auth-token |
| Value handling | Sent exactly as entered, with no scheme prefix added |
| Bearer tokens | Enter Bearer followed by the token, including the space |
| Required headers | A required header with no stored value fails the connection; an optional one is just omitted |
| With OAuth | Headers can ride alongside OAuth for routing or verification, except Authorization, which OAuth owns |
Entering a bare token is what produces silent 401s. If your docs show Authorization: Bearer YOUR_TOKEN, the field needs Bearer your-token, not your-token; most servers reject the second form (Anthropic docs).
Whichever path you pick, connectors can read, create, modify, and delete data on the user's behalf, so review the scopes you request, keep prompt-injection risk in mind, and treat "Allow always" as something users should reserve for servers they trust (Anthropic docs).
Common issues
"Couldn't reach the MCP server"
Root cause. Claude resolves your hostname and validates the result before any HTTP request leaves its network. If any resolved address isn't globally routable, the connection is rejected and your access logs see nothing at all. Private ranges, carrier-grade NAT, loopback, link-local, a mix of public and non-public addresses, and hostnames with no A record all trip this (troubleshooting docs). Split-horizon DNS is a sneaky version: the same hostname resolves publicly for you and privately for Anthropic.
Solution. Run dig +short your-host.example.com from outside your network and confirm every returned address is public. If a CDN or WAF sits in front of the server, check its logs for 403 or 429 responses your application didn't generate, and allowlist Anthropic's published outbound range (160.79.104.0/21 as of July 2026) (Anthropic auth docs).
Prevention. Grab the ofid_ reference ID from the error toast or the settings page URL whenever a connection fails. It lets Anthropic trace the exact failure server-side, and it's time-limited, so report it promptly.
The connector connects, but Claude never calls the tool
Root cause. Nothing is broken. Claude selects tools from names, titles, and descriptions, so a tool called query described as "Queries data" gives the model nothing to match a user's question against. Your log shows initialize and tools/list and then stops, which is the fingerprint of a discovery problem rather than a transport one.
Solution. Name the tool after the question it answers and write the description in "use this when..." form, the way get_recent_deploys does above. Enumerate valid values in the parameter descriptions so Claude doesn't have to guess service names. Then re-ask in the phrasing a real user would use rather than one that names your tool.
Prevention. Log tool-call arguments from day one. Calls that arrive with plausible-but-wrong arguments tell you exactly which part of your schema is ambiguous, and that signal only exists on your side of the connection.
406 Not Acceptable: Client must accept both application/json and text/event-stream
Root cause. Streamable HTTP negotiates per request between a plain JSON body and an SSE stream, so every POST has to advertise both types. A curl command with Accept: application/json gets a JSON-RPC error with code -32000 and that message before your handler ever runs.
Solution. Send Accept: application/json, text/event-stream on the POST, as in the smoke test earlier. Real MCP clients, Claude included, set this correctly on their own.
Prevention. Reach for MCP Inspector instead of hand-built requests for anything past a liveness check. It speaks the transport properly, which keeps your debugging focused on your code rather than on your curl flags.
Where to go next
You now have the whole loop: a tool with annotations Claude respects, a Streamable HTTP endpoint, a public URL, a connector entry, and a log that tells you what Claude did. The pieces that come next are mostly about hardening. Add OAuth so the server can serve real user data, split reads from writes as the tool surface grows, and move off the tunnel onto a real host with a stable domain before anyone else depends on it.
The habit worth keeping from the walkthrough is the logging. Every connector question that isn't answered by the docs ("why did it stop calling this tool", "which argument shape breaks it", "is the timeout ours or theirs") gets answered from your side of the wire, because that's the only side you have.
Related Guides
Claude Connectors vs. MCP Servers vs. the MCP Connector API
Three separate things get called a connector: the integration you enable in Claude, a Messages API feature, and MCP servers generally. Which is which.
Building a Streamable HTTP MCP server
Build a remote MCP server on the Streamable HTTP transport with the official TypeScript SDK, in the shape the 2026-07-28 spec revision requires: one POST-only endpoint, the headers that bite people, OAuth, and scaling out now that sessions are gone.
MCP Inspector: testing and debugging MCP servers
The complete guide to MCP Inspector: launch it with npx, point it at stdio or Streamable HTTP servers, drive it from the CLI in CI, test OAuth, and understand the post-CVE-2025-49596 token model.