Building a Custom MCP Server for Your Business (Without Being an Engineer)
A non-technical founder's afternoon path to a working custom MCP server: skeleton, auth layer, and team publish step, using Claude Code to scaffold.
Most founders who hear about MCP (the Model Context Protocol) quickly hit the same wall. There is a public MCP server for GitHub, for Slack, for Google Drive, for Notion. But the system that actually runs your business, the internal orders dashboard, the custom CRM your ops team lives in, the Airtable base with all the client data, the homegrown billing portal, has no MCP server. And every vendor response you get back says the same thing: "it is on the roadmap."
You do not need to wait. A custom MCP server for an internal system is not a quarter of engineering work. With Claude Code doing the heavy lifting, the realistic timeline is an afternoon. This article walks through the three pieces that actually matter: the SDK skeleton, the auth layer, and the publish-to-team step. It also gives you the prompt you can paste into Claude Code to have the scaffold generated for you.
What an MCP server actually is, in one paragraph
An MCP server is a small program that sits between an AI client (Claude Desktop, Claude Code, Cursor, whatever your team uses) and one of your systems. The AI client speaks MCP, your internal system speaks whatever it speaks (REST, GraphQL, a database, a CSV folder on a shared drive), and the MCP server translates. It exposes three kinds of primitives to the client. Tools are actions the model can invoke, things like create_order or refund_customer. Resources are readable documents or records the model can pull into context, things like a customer profile or a weekly P&L. Prompts are saved prompt templates your team can share, things like "triage this support ticket using our playbook." You do not need all three. Most useful internal servers start with two or three tools and one resource.
The SDK skeleton (tools, resources, prompts)
There are two official SDKs. TypeScript (@modelcontextprotocol/sdk) is the most mature and is what we recommend if you have any Node.js in your stack. Python (mcp) is a good choice if your internal APIs are already wrapped in Python. Both expose the same three primitives with near-identical shapes. Pick one based on what your ops engineer, fractional CTO, or Claude Code session is most comfortable with.
Here is the minimum viable TypeScript skeleton. This is not pseudocode. This file, a package.json, and a tsconfig.json are the whole server.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "acme-internal",
version: "0.1.0",
});
// TOOL: an action the model can invoke
server.tool(
"create_order",
"Create a new order in the internal ops system.",
{
customer_id: z.string().describe("Internal customer ID, format CUST-xxxx"),
sku: z.string(),
quantity: z.number().int().positive(),
},
async ({ customer_id, sku, quantity }) => {
const res = await fetch(`${process.env.ACME_API}/orders`, {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ACME_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ customer_id, sku, quantity }),
});
const data = await res.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
);
// RESOURCE: something the model can read into context
server.resource(
"customer-profile",
"acme://customers/{id}",
async (uri) => {
const id = uri.pathname.split("/").pop();
const res = await fetch(`${process.env.ACME_API}/customers/${id}`, {
headers: { "Authorization": `Bearer ${process.env.ACME_API_TOKEN}` },
});
const body = await res.text();
return { contents: [{ uri: uri.href, mimeType: "application/json", text: body }] };
},
);
// PROMPT: a saved template your team can invoke
server.prompt(
"triage-ticket",
"Triage a support ticket using the Acme playbook.",
{ ticket_id: z.string() },
({ ticket_id }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Pull ticket ${ticket_id}, classify severity, and draft a response using the Acme tone guide.`,
},
}],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
The Python equivalent using the official mcp SDK is shorter and reads almost the same way.
from mcp.server.fastmcp import FastMCP
import os, httpx
mcp = FastMCP("acme-internal")
@mcp.tool()
async def create_order(customer_id: str, sku: str, quantity: int) -> dict:
"""Create a new order in the internal ops system."""
async with httpx.AsyncClient() as c:
r = await c.post(
f"{os.environ['ACME_API']}/orders",
headers={"Authorization": f"Bearer {os.environ['ACME_API_TOKEN']}"},
json={"customer_id": customer_id, "sku": sku, "quantity": quantity},
)
return r.json()
@mcp.resource("acme://customers/{id}")
async def customer_profile(id: str) -> str:
async with httpx.AsyncClient() as c:
r = await c.get(
f"{os.environ['ACME_API']}/customers/{id}",
headers={"Authorization": f"Bearer {os.environ['ACME_API_TOKEN']}"},
)
return r.text
@mcp.prompt()
def triage_ticket(ticket_id: str) -> str:
return f"Pull ticket {ticket_id}, classify severity, and draft a response using the Acme tone guide."
if __name__ == "__main__":
mcp.run()
That is the skeleton. Everything else, richer tool descriptions, input validation, error handling, is a refinement of this shape. Most internal servers end up with five to twelve tools, two or three resources, and a couple of prompts. That is the whole file.
The auth layer pattern
This is the step the SERP tutorials skip over, and it is also the step founders panic about. The auth model depends on one question: is this server going to run on your laptop only, or is the whole team going to install it?
For a solo founder or a small team where everyone uses the same internal account, the pattern is an API token loaded from an environment variable. You generate a long-lived token inside your internal system, paste it into the MCP client config, and the server reads it from process.env.ACME_API_TOKEN (or os.environ["ACME_API_TOKEN"] in Python). Every request to your internal API goes out with that token in the Authorization header. This is what both skeletons above do. It is the fastest working path and it is perfectly fine for an internal tool. The token never leaves the team member's machine because the MCP server runs locally as a subprocess of Claude Desktop or Claude Code.
For a larger team, or when you need audit trails that show which team member did what inside your internal system, you want per-user OAuth. The MCP server becomes an OAuth client. On first use it opens a browser, the user logs into your internal system, the server receives an access token and refresh token, caches them in a per-user keychain entry, and uses them from then on. The TypeScript SDK supports this through AuthorizationServerMetadata and a token store. The structure looks like this.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { OAuthClientProvider } from "./oauth-client.js";
const oauth = new OAuthClientProvider({
authorizationUrl: "https://acme.internal/oauth/authorize",
tokenUrl: "https://acme.internal/oauth/token",
clientId: process.env.ACME_CLIENT_ID!,
clientSecret: process.env.ACME_CLIENT_SECRET!,
redirectUri: "http://localhost:33418/callback",
scopes: ["orders:read", "orders:write", "customers:read"],
});
server.tool("create_order", "...", schema, async (args) => {
const token = await oauth.getAccessToken();
const res = await fetch(`${process.env.ACME_API}/orders`, {
method: "POST",
headers: { "Authorization": `Bearer ${token}` },
body: JSON.stringify(args),
});
return { content: [{ type: "text", text: await res.text() }] };
});
The rule of thumb. One user, one shared team account, start with a bearer token in env. Multiple users who each have their own login to the internal system, use OAuth. Do not try to do OAuth on day one. Ship the token version, let the team use it, upgrade when the audit log question comes up in a meeting.
One more thing about the token version. Never hardcode the token in the source file. Never commit it to git. The token always lives in the MCP client config (more on that in the next section) or in the user's environment. The server reads it at runtime. This is not a style preference. If you paste a token into the code and publish the package, you have leaked a production credential.
Publish to your team (npm or pip)
You now have a working server on your laptop. The gap between "works on my machine" and "the whole team can install it with one command" is smaller than you think.
The npm path. Add a bin field to package.json so the server can be invoked as a CLI. Bump the version. Run npm publish. If the code touches anything sensitive you can publish privately to an npm organization scope, which means only team members logged into the org can install it.
{
"name": "@acme/mcp-server",
"version": "0.1.0",
"bin": { "acme-mcp": "dist/index.js" },
"main": "dist/index.js",
"files": ["dist"],
"scripts": { "build": "tsc", "prepublishOnly": "npm run build" },
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.0"
}
}
The Python path looks the same in spirit. Add a [project.scripts] entry in pyproject.toml, build with python -m build, publish with twine upload to PyPI or to a private index. The install side becomes uvx acme-mcp or pipx install acme-mcp.
Team members then add one block to their Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS) or to the project-level .mcp.json for Claude Code.
{
"mcpServers": {
"acme-internal": {
"command": "npx",
"args": ["-y", "@acme/mcp-server"],
"env": {
"ACME_API": "https://api.acme.internal",
"ACME_API_TOKEN": "paste-your-personal-token-here"
}
}
}
}
That is the whole install. Paste, restart Claude, the tools appear. If you picked the private npm org route, the team member has to be logged in with npm login --scope=@acme once. Project-level .mcp.json in Claude Code means you can commit the config to the repo (without the token) and new team members get the server wired up the moment they clone.
The "ask Claude Code to scaffold" prompt
You do not need to write any of the code above from a blank file. Claude Code can produce the entire scaffold in one pass if you give it the right brief. The template below is the one we use at WitsCode. Copy it, fill in the four bracketed fields, paste it into Claude Code in an empty directory.
I need a custom MCP server for our internal system. Scaffold it end to end.
Business context:
- Company: [your company]
- Internal system: [one sentence about what it does]
- API base URL: [https://api.yours.internal or a description of how it is accessed]
- Auth model: [bearer token from env var, OR OAuth 2.0 with these scopes: ...]
Requirements:
1. TypeScript, @modelcontextprotocol/sdk, published as an npm package under @[org]/mcp-server.
2. Expose these tools, each with Zod input validation and a clear one-line description the model can read:
[list 3 to 8 actions, e.g. create_order, cancel_order, lookup_customer]
3. Expose these resources (URI template + what they return):
[list 1 to 3, e.g. acme://customers/{id} returns the customer profile as JSON]
4. Expose these prompts:
[list 0 to 3, e.g. triage-ticket, weekly-revenue-summary]
5. Read the auth credential from process.env. Never log it. Fail loud with a clear message if missing.
6. Include a README section showing the claude_desktop_config.json and .mcp.json install blocks.
7. Include package.json with bin, tsconfig.json, .gitignore, and a dist build step.
Do not invent endpoints. Where an endpoint is unclear, leave a TODO with the exact question I need to answer, and keep the tool signature correct so I can fill in the fetch call.
The last paragraph is the important one. Non-technical founders who ask Claude Code to "build an MCP server" get a hallucinated wrapper around an API that does not exist. Telling it to leave TODOs anywhere it is guessing turns the output into a scaffold you can actually finish in a second pass with your internal API docs open.
A sensible first-afternoon scope
Do not try to expose your whole internal system on day one. Pick the three actions your team currently does in-browser five or more times a day. For most ops-heavy companies that looks like: look up a customer record, update an order status, create a draft something (invoice, email, ticket). Expose those three as tools. Expose one resource (the customer profile). Ship it to one power user on your team. Let them use it for a week. The next batch of tools will design itself, because you will have a running list of "I wish Claude could just..." moments from that week of real use.
Common traps, in order of how often we see them
Descriptions that are too vague. The tool description is what the model reads to decide whether to use the tool. "Create an order" is weak. "Create a new order for an existing customer. Requires a valid customer ID, a SKU from the current catalog, and a positive integer quantity. Does not handle shipping or payment; those are downstream." is the shape that makes the model use the tool correctly.
Returning giant blobs of unstructured text. If your tool dumps a 40KB HTML page into context, every subsequent call costs more and the model gets confused. Summarize in the server. Return the three to ten fields that matter.
Forgetting stderr vs stdout. MCP over stdio uses stdout for protocol messages. If your code does a stray console.log or print, you break the connection. Route all debug output to stderr (console.error in Node, print(..., file=sys.stderr) in Python).
Skipping the publish step. A server that only runs on the founder's laptop is a demo. Publishing to npm or PyPI (private is fine) is what turns it into team infrastructure. The publish step is five minutes. Do not defer it.
Where WitsCode fits
If you have read this far and the afternoon path still feels like someone else's afternoon, that is the work we do. WitsCode builds custom MCP servers for non-technical founders end to end: we interview your team to pick the right tools, wire up the auth layer (bearer or OAuth), publish under your org, and ship the Claude Desktop and Claude Code configs your team pastes in once. Most engagements are scoped in days, not months, because the scope of a first MCP server is genuinely small. The payoff is the rest of the year, when "go check the dashboard" becomes a question your team asks Claude instead of a tab they switch to.
Ready to give your team Claude-native access to your internal systems? Book a WitsCode custom MCP server engagement and have a working, published server by the end of the week.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Custom Web Applications
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss non-tech founders for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.