If you are building a SaaS product right now, there is a good chance users will eventually want Claude, Cursor, or another AI client to talk to it directly. MCP is the cleanest way to do that, but the interesting work starts after the tools are defined. The hard part is deciding how the server should be deployed, how authentication should work, and how much setup friction you are willing to push onto the user.
This guide is about that part. It explains what MCP is, why remote MCP matters for SaaS products, the different ways you can ship it, and what it takes to get to the version most teams actually want: a hosted integration where the user signs in and approves access instead of generating and pasting a token.
What MCP Actually Is
MCP is a protocol that lets AI clients interact with external systems in a structured way. Instead of hoping a model can scrape your web app, infer your API shape, and guess the right mutation flow, you expose a smaller and more explicit surface: tools, resources, and sometimes prompts. The AI client does not need to know the full internals of your product. It only needs to know what operations you have chosen to expose and how to call them safely.
Under the hood, the important distinction is usually transport. A local MCP server commonly uses stdio. A remote MCP server uses HTTP, which is what makes hosted auth, hosted discovery, and normal SaaS-style onboarding possible. For a SaaS product, that usually means operations like reading data, searching, creating records, updating records, or triggering workflows. If those capabilities already exist inside your application, MCP is often the shortest path from "this works internally" to "an AI client can use this with a stable contract." That is why MCP matters. It reduces scraping, reduces prompt glue, and gives you a narrower place to enforce auth, validation, and limits.
A remote MCP server reads best as a simple sequence.
Why Remote MCP Matters for SaaS Products
A local MCP server is fine for internal tools, prototypes, or products aimed only at technical users. A hosted SaaS product, though, usually wants a remote MCP server as the end state because the alternative is to ship part of your integration story onto the user's machine. Once that happens, your product inherits local runtime issues, PATH issues, install issues, and credential-management issues that have nothing to do with the core value of the app.
Remote MCP keeps the business logic in your infrastructure, where you can monitor it, rate-limit it, update it, and keep it aligned with the rest of your product. Just as importantly, it lets you move toward a normal onboarding flow. That matters because most users do not actually want "MCP support." They want to connect an AI client to your app without turning themselves into part-time integration engineers.
The Three Practical Ways to Ship MCP
If you are building this today, you usually end up choosing between three models.
1. Local stdio server
This is the fastest way to get something working. The user runs a local process, often through npx or a downloaded binary, and the AI client talks to it over stdio. This model is excellent for internal tooling and for technical users who are comfortable with local setup. It is also the easiest model to document because you are only solving for one environment at a time.
If you are building in TypeScript, the normal starting point is the MCP TypeScript SDK with StdioServerTransport. In practice, that usually means something like:
1npm install @modelcontextprotocol/sdk zodFor customers, onboarding looks like this:
- Install your MCP package or binary.
- Add a local command to their MCP client.
- Run that command on their own machine whenever the client connects.
The downside is that your onboarding now depends on the user's machine. Node versions, binary distribution, shell quirks, and local config all become part of your product whether you wanted them or not.
2. Remote MCP with manual token auth
This is a common middle step. The server is hosted, which is a big improvement, but the user still has to generate a token and paste it into the client. Depending on the client, they may also still need a local bridge to reach the hosted server. This version can already be useful, especially if your target users are technical and motivated, but it still leaks setup friction into the experience.
For the hosted server itself, you can still use the MCP TypeScript SDK and switch to Streamable HTTP instead of stdio. If your target client only knows how to spawn local commands, the usual bridge is mcp-remote:
1npm install @modelcontextprotocol/sdk zod2npm install -g mcp-remoteor, if the user is launching it ad hoc:
1npx mcp-remote https://yourapp.com/dashboard-mcpFor customers, onboarding usually looks like this:
- Create a token in your product.
- Add your hosted MCP URL or a local
mcp-remotebridge command. - Paste the token into the client config.
3. Remote MCP with hosted OAuth and tokenless onboarding
This is the model most SaaS teams eventually want. The user adds a connector URL, signs in, approves access, and starts using the integration. There is no manual token creation, no pasted bearer secret, and no local helper process just to reach your hosted endpoint. It is the cleanest version from the user's point of view, and it is usually the hardest one to build because it forces auth, discovery, deployment, and product UX to line up cleanly.
Implementation-wise, this is still the same remote server shape. The difference is that you finish the auth layer. In practice that usually means:
1npm install @modelcontextprotocol/sdk zodand then building the OAuth and discovery routes around that server instead of shipping manual token setup.
For customers, onboarding becomes:
- Open the connector UI in the client.
- Paste your hosted MCP URL.
- Sign in to your product.
- Approve access.
| Model | Useful package(s) | How customers onboard | Main downside |
|---|---|---|---|
| Local stdio | @modelcontextprotocol/sdk with stdio transport | Install package or binary, add local command | Local runtime and config become part of your product |
| Remote MCP + manual token | @modelcontextprotocol/sdk for the server, mcp-remote when a client still expects local stdio | Add hosted URL or bridge command, then paste token | Credential and bridge friction remain |
| Remote MCP + hosted OAuth | @modelcontextprotocol/sdk with Streamable HTTP plus your OAuth layer | Add hosted URL, sign in, approve access | Hardest to implement cleanly |
A Minimal Way to Start
If you want a sane implementation path, do not start with OAuth. Start by proving that your tool surface is useful locally, then move the exact same operations behind a hosted transport, and only then add the auth and discovery layers.
A minimal local TypeScript server looks like this:
1import * as z from "zod/v4";2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";4
5const server = new McpServer({6 name: "my-server",7 version: "1.0.0",8});9
10server.registerTool(11 "greet",12 {13 description: "Test tool",14 inputSchema: {15 name: z.string(),16 },17 },18 async ({ name }) => ({19 content: [{ type: "text", text: `Hello, ${name}!` }],20 })21);22
23const transport = new StdioServerTransport();24await server.connect(transport);That does not get you a hosted connector, but it does answer the first question that matters: are your tools coherent enough that an AI client can do something useful with them?
Once the local version is working, the recommended build order is:
- Keep the same tool definitions and move them behind Streamable HTTP.
- Give the server one clean public MCP URL.
- Add the unauthenticated
401challenge. - Publish protected-resource and auth-server metadata.
- Add consent and token issuance.
- Test the entire flow through a public tunnel before calling it done.
That order matters because most remote MCP projects do not fail on the first tool. They fail in the layers around it.
Step 1: Define the Smallest Useful Tool Surface
The tool layer is rarely the hardest part, but it is still where the project should start. The trick is not to expose every internal operation you have. Start with the smallest set of tools that covers a real workflow end to end. A good MCP surface should feel deliberate because it needs to map to things users actually want the model to do, not to your raw internal API topology.
If your product is documentation software, the surface might look like: list docs, inspect a manifest, pull content, push content, publish. If your product is a CRM, it might be: search contacts, fetch an account, create a note, update a stage. The details vary, but the rule stays the same: expose coherent workflows, not just miscellaneous RPC endpoints.
Step 2: Give the Server a Real Public URL
Once you move beyond local development, the MCP URL becomes part of the integration design. This is easy to overlook because teams often expose whatever internal-looking route already exists, then wonder why the integration still feels unfinished. A clean public path does not solve protocol problems, but it does help make the feature feel like a first-class product surface instead of an implementation detail that escaped from your router.
In practice, that means preferring something like:
1https://yourapp.com/dashboard-mcpover something like:
1https://yourapp.com/api/internal/v1/mcpThe first reads like a capability. The second reads like a leak. Users notice this even when they do not say it out loud.
As one concrete example, DocsAlot moved its write-capable dashboard MCP flow onto a canonical public path, https://docsalot.dev/dashboard-mcp, and kept the older internal-looking route only as a compatibility alias. That made the integration easier to explain, easier to document, and easier to recognize as a product surface instead of an API accident.
Step 3: Implement the MCP Endpoint Like a Real Transport
Current remote MCP servers should behave like proper HTTP transports, not just "an API route that happens to accept JSON." In practice, that means implementing the Streamable HTTP shape cleanly. The server exposes one MCP endpoint. Clients send JSON-RPC messages to it over HTTP POST. The server can reply with normal JSON for simple requests or text/event-stream when it wants to stream messages, progress, or notifications back to the client.
That one detail matters more than it sounds. A lot of integrations feel flaky because the tool layer exists, but the endpoint still behaves like an ad hoc RPC surface rather than the transport the client expects.
At a minimum, your endpoint should be able to handle something like this:
1POST /dashboard-mcp2Accept: application/json, text/event-stream3Content-Type: application/json4MCP-Protocol-Version: 2025-06-185
6{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}If you want stateful sessions, return an Mcp-Session-Id during initialization and require that header on subsequent requests. If you do not want sessions, you can stay stateless, but make that choice intentionally. Either way, the endpoint should be acting like an MCP transport, not a loose collection of tool handlers.
Step 4: Make the Unauthenticated Endpoint Useful
If you want tokenless onboarding, the unauthenticated endpoint cannot just say "missing token" and stop there. It has to tell the client how to continue. In practice, that means returning a 401 with a WWW-Authenticate header that points to your protected-resource metadata.
1GET /dashboard-mcp2
3401 Unauthorized4WWW-Authenticate: Bearer realm="yourapp", resource_metadata="https://yourapp.com/.well-known/oauth-protected-resource/dashboard-mcp"That resource_metadata URL is where the client learns what resource it is trying to access and how to find the rest of the authorization flow. Once you have this in place, the endpoint stops being "something that eventually wants a token" and starts behaving like a remote integration surface that knows how to onboard a client properly. This one step is where many half-finished remote servers reveal themselves, because the hosted endpoint is present but the hosted onboarding path is still missing.
Step 5: Publish the Discovery Documents and Support Client Registration
The 401 challenge is only the start. A client still needs to discover two things:
- Which authorization server is responsible for this MCP resource.
- Which authorization, token, and registration endpoints that server exposes.
That usually means publishing:
- protected-resource metadata for the MCP URL
- authorization-server metadata for the OAuth server
- dynamic client registration, if you want new MCP clients to onboard without pre-created client credentials
The shape looks roughly like this:
1// /.well-known/oauth-protected-resource/dashboard-mcp2{3 "authorization_servers": ["https://yourapp.com/oauth"]4}1// OAuth server metadata2{3 "issuer": "https://yourapp.com/oauth",4 "authorization_endpoint": "https://yourapp.com/oauth/authorize",5 "token_endpoint": "https://yourapp.com/oauth/token",6 "registration_endpoint": "https://yourapp.com/oauth/register"7}The exact metadata shape depends on your auth server, but the practical point is simple: a client should be able to start from your MCP URL and discover the rest without hardcoded tribal knowledge. If you skip dynamic client registration, every new client either needs a pre-provisioned client ID or a manual setup screen. That is usually where "tokenless onboarding" quietly turns back into a configuration project.
Step 6: Treat Discovery Routes as First-Class Routes
This is where a lot of teams lose time. The main MCP URL works, the app is up, and yet the connector still fails because the client is probing issuer-specific /.well-known/... URLs that your main routing stack is not handling correctly. Instead of returning JSON metadata, those paths often return HTML, redirects, or some generic page response because they fell through to the wrong handler.
The fix is not complicated, but it needs to be explicit. Define the discovery routes directly and test them directly. Do not assume your framework routing will get them right by accident. If you are using Next.js, Rails, Laravel, or anything else with a large implicit routing layer, hit each discovery endpoint with curl before you trust the connector flow. If those routes are wrong, the rest of your onboarding work never gets a chance to matter.
Step 7: Reuse Your Existing Login System, but Add a Real Consent Layer
If your SaaS product already has a working sign-in system, the MCP OAuth flow should usually land inside that system. This is one of the biggest advantages of hosted onboarding. The user does not have to think about "API credentials" as a separate universe. They just sign in to the product they already know and approve access.
But a normal login page is not enough. You still need a consent step that is specific to the MCP resource and its scopes. That is where you decide whether the connector can read, write, publish, delete, or trigger actions. For a lot of SaaS products, the right move is to reuse the same user session and identity system while adding a separate grant model for the MCP server itself.
This is also the point where subtle bugs tend to show up, because your app auth, your public domain, your redirect handling, and your MCP metadata all need to agree. When they do not, you get failures that look almost healthy. The endpoint responds. The auth page opens. Sign-in appears to work. The callback still fails because a cookie, origin, or redirect assumption broke somewhere in the chain.
The rule here is simple but unforgiving: the connector flow needs one stable public identity. If the public host changes halfway through the handshake, you are going to get confusing failures.
Step 8: Validate Resource Indicators and Token Audience
This is one of the more technical parts of the current MCP auth model, and it is worth implementing correctly. The client should request a token for a specific MCP resource, not just "whatever this auth server hands out." In OAuth terms, that means carrying a resource parameter that points at the canonical MCP server URI.
In practice, the auth and token requests end up looking more like this:
1GET /oauth/authorize?...&resource=https%3A%2F%2Fyourapp.com%2Fdashboard-mcp2POST /oauth/token3resource=https://yourapp.com/dashboard-mcpOn the server side, you should not just mint a token and hope for the best. Validate that the token was issued for the MCP resource that is receiving it. If your product hosts multiple MCP surfaces, or if your auth infrastructure is shared across products, this becomes a real security boundary rather than a theoretical nicety.
If you skip this, you are much closer to "opaque bearer string that happens to work" than to a clean remote MCP implementation.
Step 9: Protect the HTTP Edge
Most of the painful failures are not in the tool handler itself. They are in the edge conditions around the HTTP transport. There are a few checks worth treating as mandatory:
- Validate the
Originheader on incoming HTTP connections. - Never accept access tokens in query strings.
- Return
401,403, and400distinctly so clients can tell auth failure from permission failure from malformed requests. - Be strict about the MCP endpoint shape and headers, especially on initialize and token-bearing requests.
The Origin validation piece is easy to miss, but it matters for local and browser-adjacent deployments because it helps defend against DNS rebinding-style problems. If you are hosting a remote MCP server on the public internet, that is part of the real implementation, not just optional hardening for later.
Step 10: Budget for Tunnel-Based Testing
Remote connectors are awkward to test locally because "local" is only part of the truth. For the full loop to work, the client still needs a publicly reachable HTTPS endpoint. That usually means running your app locally and exposing it through a tunnel, then aligning your app base URL and callback URLs to that temporary hostname.
This is where a lot of real bugs show up. Social sign-in providers reject callback mismatches. Discovery URLs resolve differently than they do in production. Session cookies behave differently across hostnames. If you are testing with Claude custom connectors specifically, there is one more wrinkle: the connection comes from Anthropic's cloud, not from the Claude Desktop app on your own machine. If you only test the app on localhost, you will miss some of the most important onboarding failures. In practice, the first fully working tunnel-based test tells you more about your real connector than a week of localhost confidence.
The simplest way to do this is with ngrok. Their tunnels documentation is also worth reading because it explains exactly how the public URL is forwarded back to your local port.
If your app is running on port 3000, the basic setup is:
1brew install ngrok/ngrok/ngrok2ngrok config add-authtoken <your-ngrok-token>3ngrok http 3000Once that is running, ngrok gives you a public HTTPS URL. Use that hostname for the parts of your auth flow that care about the public origin:
- your app's public base URL
- your MCP URL
- your OAuth callback allowlist
- any social sign-in callback settings you depend on
So the practical advice is: treat tunnel-based auth testing as part of the feature, not as cleanup work after the feature. It is not cleanup. It is where the real system finally reveals itself.
What to Expect to Go Wrong
The hardest bugs will probably not be in your tool handlers. They will be in the edges around them. Discovery routes returning HTML instead of JSON is common. Public origin mismatches are common. Redirect and cookie problems are common. Time handling inside auth flows can also surprise you more than it should, especially if you compare expiration values across different layers and different assumptions about timestamps.
This is one reason MCP can feel deceptively simple at the start. The first tool call might be easy. The part where a real desktop client discovers the server, signs in, stores the authorization state correctly, and comes back later for the second tool call is where you find out whether the integration is actually finished.
A Good End State
If you are building a remote MCP server for a SaaS product, a good end state looks like this:
- The public URL is clean and stable.
- The tool surface is narrow and intentional.
- Discovery and protected-resource metadata are explicit.
- The user signs in through your normal app auth.
- The user approves access instead of creating a token manually.
- The whole flow works without local runtime setup.
That is the version that reduces friction. It is also the version that starts to feel less like a protocol demo and more like a real product feature.
MCP itself is not the hard part. The hard part is making the onboarding disappear.