AI Engineering· 10 min read

Writing an MCP Server Your Team's AI Assistant Can Use

What an MCP server is, how it differs from an API, and how to build one that gives your team's AI assistant safe, useful access to internal tools.

By

Full-Stack, Mobile & AI Engineer

Your team's AI assistant can write code, summarise documents and draft emails. Then someone asks it "did last night's Android build pass?" or "what plan is this customer on?" and it has no idea, because that knowledge lives in your build system, your admin panel and your internal docs. Copying and pasting between tabs works, but it turns a smart assistant into a slow intern.

An MCP server is the standard way to close that gap. This guide explains what an MCP server is in plain English, how it differs from an ordinary API, and how to write one that gives your team's assistants safe, useful access to internal tools, including the security rules in the current specification, which changed significantly with the 2026-07-28 revision.

Key takeaways

  • An MCP server exposes tools, resources and prompts to AI applications through the Model Context Protocol, so any compatible assistant can use them.
  • It's not a replacement for your API. It usually wraps your APIs in task-shaped tools a model can choose and call correctly.
  • Tool design decides success: clear names, descriptions written for the model, tight input types and actionable errors.
  • Local servers use the stdio transport and credentials from the environment; remote servers use OAuth-based authorization.
  • Never pass tokens through to downstream APIs, start with read-only tools, and keep humans in the loop for actions.

What is an MCP server?

The Model Context Protocol (MCP) is an open protocol for connecting AI applications to the data and tools they need. It uses JSON-RPC messages between three parts:

  • Hosts: the AI applications people use, such as a coding assistant, a chat app or an IDE.
  • Clients: connectors inside the host, one per server.
  • Servers: small services that offer capabilities to those clients.

An MCP server can offer three kinds of capability: tools (functions the model can call, such as "get build status"), resources (data the user or model can read, such as a runbook) and prompts (templates users can invoke). Write one server for your build system, and every MCP-compatible assistant your team uses can check builds, without a custom integration for each one. The specification itself compares MCP to the Language Server Protocol, which did the same thing for programming languages across editors.

MCP server vs API

Your APIAn MCP server
Built forDevelopers writing codeAI models choosing what to call
DiscoveryDocumentationSelf-describing: the client lists tools with descriptions and schemas
ShapeResources and endpointsTask-shaped tools ("find customer by email")
AuthYour API's schemeCredentials from the environment (local) or OAuth 2.1-based authorization (remote)
ConsumersYour apps and servicesAny MCP-compatible assistant

So why is an MCP server needed when you already have an API? Because a model doesn't read your API docs. It sees a list of tools, each with a name, a description and an input schema, and decides which to call. An MCP server is the layer that turns "GET /v2/builds?platform=android&limit=1" into a tool called get_latest_build with a description that says when to use it.

MCP server vs client, tools, agents and skills

The vocabulary gets muddled quickly, so here's how the pieces relate:

TermWhat it is
MCP serverA program that offers tools, resources and prompts over MCP
MCP clientThe connector inside an AI application that talks to one server
ToolOne function a server exposes, such as get_latest_build
AgentThe AI application deciding which tools to call, and when
SkillPackaged instructions for a workflow; the 2026 specification adds an optional extension for delivering skills over MCP

An agent without MCP servers can only work with what's in its context. Servers are how it reaches the systems your team actually uses.

A minimal internal-tools server

Here's a small server in Python using the official SDK, exposing one read-only tool. The SDK turns type hints and the docstring into the tool's schema and description:

from mcp.server import MCPServer

mcp = MCPServer("ops-tools")


@mcp.tool()
async def get_latest_build(app: str, platform: str = "android") -> str:
    """Get the latest CI build for one of our apps.

    Use this when someone asks whether a build passed, failed or is running.
    Read-only. For release status in the stores, use get_store_status instead.

    Args:
        app: App slug, for example "member-app" or "staff-app"
        platform: "ios" or "android"
    """
    build = await builds.latest(app=app, platform=platform)  # your internal API client
    if build is None:
        return f'No builds found for "{app}" on {platform}. Check the app slug.'
    return f"{app} {platform}: {build.status}, version {build.version}, finished {build.finished_at}"


if __name__ == "__main__":
    mcp.run(transport="stdio")

Point your assistant's MCP configuration at this script and it can answer "did last night's Android build pass?" by itself. The official SDKs for TypeScript, Python and other languages follow the same pattern: create a server, register tools with typed inputs, connect a transport.

The same server in TypeScript

If your internal tools live in a Node.js codebase, the TypeScript SDK follows the same shape. Version 2 of the SDK ships the server as its own package (npm install @modelcontextprotocol/server zod):

import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

const server = new McpServer({ name: "ops-tools", version: "1.0.0" });

server.registerTool(
  "get_latest_build",
  {
    description:
      "Get the latest CI build for one of our apps. Use this when someone asks " +
      "whether a build passed, failed or is running. Read-only.",
    inputSchema: z.object({
      app: z.string().describe('App slug, for example "member-app" or "staff-app"'),
      platform: z.enum(["ios", "android"]).default("android"),
    }),
  },
  async ({ app, platform }) => {
    const build = await builds.latest({ app, platform }); // your internal API client
    if (!build) {
      return {
        isError: true,
        content: [{ type: "text", text: `No builds found for "${app}" on ${platform}. Check the app slug.` }],
      };
    }
    return {
      content: [{ type: "text", text: `${app} ${platform}: ${build.status}, version ${build.version}` }],
    };
  },
);

await server.connect(new StdioServerTransport());

Notice the error path. The specification separates protocol errors (an unknown tool, a malformed request) from tool execution errors, which are returned as a normal result with isError: true. The second kind is the one a model can act on: a clear message lets it fix its arguments and try again, while a thrown exception just ends the conversation turn.

What the model actually sees

When a client connects, it calls tools/list, and your server answers with each tool's name, description and input schema. For the tool above, the model receives roughly this:

{
  "name": "get_latest_build",
  "description": "Get the latest CI build for one of our apps. Use this when someone asks whether a build passed, failed or is running. Read-only.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "app": { "type": "string", "description": "App slug, for example \"member-app\" or \"staff-app\"" },
      "platform": { "type": "string", "enum": ["ios", "android"], "default": "android" }
    },
    "required": ["app"]
  }
}

That JSON is the model's entire view of your tool. It never sees your source code, your API documentation or your database. Everything it knows about when to call get_latest_build, and with which arguments, comes from those few lines, which is why the next section matters more than the code.

Connecting it to Claude Code, Cursor and VS Code

The same stdio server works in every major client; only the configuration differs.

Claude Code registers servers from the command line. Everything after -- is the command that starts your server:

claude mcp add --transport stdio ops-tools -- python /path/to/ops_tools.py
claude mcp list   # shows Connected, Needs authentication or Failed to connect

Cursor reads .cursor/mcp.json in the project (or ~/.cursor/mcp.json for every project):

{
  "mcpServers": {
    "ops-tools": { "command": "python", "args": ["ops_tools.py"] }
  }
}

VS Code reads .vscode/mcp.json, with a servers key instead:

{
  "servers": {
    "ops-tools": { "type": "stdio", "command": "python", "args": ["ops_tools.py"] }
  }
}

Commit the project-level files so the whole team gets the same tools, and keep credentials in environment variables rather than in the file.

Tool design that assistants get right

The model never sees your code, only each tool's name, description and schema. That contract is where the real work is:

  • Name tools by what they do, in verb and noun form: get_latest_build, find_customer, create_ticket. Avoid vague names like query or internal jargon.
  • Write descriptions for the model: what the tool does, when to use it, what it returns, and when to use a different tool instead.
  • Type inputs tightly: enums for fixed values, bounds on numbers, and an example in every field description.
  • Keep tools focused. Four small tools beat one manage_customer tool with a mode argument.
  • Return small, structured results. A wall of JSON burns the model's context and buries the useful fields.
  • Make errors actionable. "No builds found for 'membr-app'. Check the app slug." lets the model correct itself; a stack trace doesn't.

When a tool gets misused, fix the description or schema first. Most "the AI called the wrong thing" problems are contract problems.

Security: internal doesn't mean safe

The current MCP specification is explicit about security, and internal tools are exactly where shortcuts get taken.

  • Local servers: for servers that run on a developer's machine, use the stdio transport, which limits access to the client that launched it. The authorization specification says stdio implementations should not use its OAuth flow and should "instead retrieve credentials from the environment", so the server acts with that user's own credentials.
  • Remote servers: servers shared over HTTP should implement the specification's authorization, which is based on OAuth 2.1. The server acts as a resource server and must validate that tokens were issued for it.
  • No token passthrough. The specification states that MCP servers "MUST NOT accept or transit any other tokens". Your server gets its own credentials for downstream APIs, with only the permissions it needs.
  • Least privilege: start with read-only tools, scope each server to one job, and request minimal scopes.
  • Humans approve actions. The specification says hosts must obtain explicit user consent before invoking any tool. Design write tools (create ticket, refund order) to be clear about consequences, and keep destructive actions out of early versions.
  • Treat tool output as untrusted. A ticket description or customer note can contain text written to manipulate a model (prompt injection). Return data, never instructions, and keep sensitive actions behind confirmation.
  • Log every call with who, which tool, which arguments and the result, so you can audit and improve.

Is an MCP server without auth ever fine? For a local stdio server running as the user with their own credentials, yes, that's the intended model. For anything reachable over a network that exposes internal data, no.

When an MCP server isn't working

Most "MCP server not showing up" problems come down to a handful of causes:

  1. Check the status first. In Claude Code, claude mcp list shows whether each server connected, and claude mcp get <name> prints the failure reason. Cursor and VS Code show server status in their MCP settings.
  2. Never print to stdout in a stdio server. Stdout carries the protocol's JSON-RPC messages, so a stray print() or console.log() corrupts them. Log to stderr instead.
  3. Use commands the client can find. The client starts your server itself, often without your shell's PATH; use full paths to the interpreter and script if in doubt.
  4. Pass the environment explicitly. API keys your terminal has may not exist when the client launches the server; set them in the client's config or with --env.
  5. Restart or reload the client after editing its configuration.
  6. Check versions. The 2026-07-28 specification changed the base protocol, so an old client and a new server (or the reverse) may not connect until the SDKs match.

What changed in 2026

The 2026-07-28 revision of the specification is the largest so far. The base protocol is now stateless, with self-contained requests and per-request capability negotiation, and servers that need state across calls pass an explicit handle as a tool argument, which the specification says must never be treated as authentication. Optional extensions add long-running Tasks, Skills over MCP and MCP Apps, which render interactive UI inside conversations. If you wrote a server against an older revision, check your SDK's migration notes before upgrading.

From the field: I build MCP servers that wire internal tools into AI assistants, and I use Claude Code and Cursor daily for review, refactors and tests, with every change reviewed by a person before merge. The servers worth starting with are the boring ones: a handful of read-only tools that answer questions people already ask in chat.

What an MCP server costs

The protocol and the official SDKs are free and open source, so a server costs what it takes to build and run: engineering time, hosting if it's a remote server, and the AI usage of whichever client calls it. Vendor-hosted servers, such as those from GitHub, Sentry, Notion, Atlassian (for Jira and Confluence) or Expo, come with the pricing of the product behind them. For internal tools, a small server that wraps APIs you already have is usually a few days of work, plus the security review.

Rolling it out to a team

  1. List the questions people ask in chat that require opening another tool. Those are your first tools.
  2. Start with three to five read-only tools in one server, and ship them to a few people.
  3. Read the logs. Failed or wrong calls show you which descriptions and schemas to fix.
  4. Distribute configuration through your internal docs or a shared config, so everyone connects the same way.
  5. Add write tools slowly, each with clear confirmation and a full audit trail.

FAQ

What is an MCP server?

An MCP server is a small service that exposes tools, resources and prompts to AI applications using the Model Context Protocol, an open standard. Any MCP-compatible assistant can discover its tools, read their descriptions and schemas, and call them, without a custom integration for each assistant.

What's the difference between an MCP server and an API?

An API is built for developers writing code against documented endpoints. An MCP server is built for AI models: it describes task-shaped tools in a standard format so a model can decide which to call. MCP servers usually wrap existing APIs rather than replacing them.

Do MCP servers need authentication?

Local servers using the stdio transport take credentials from the environment and act as the user who runs them. Remote servers over HTTP should implement the specification's OAuth 2.1-based authorization, validate that tokens were issued for them and never pass client tokens through to other APIs.

Which language should I use to write an MCP server?

Whichever your team maintains best. Official SDKs exist for TypeScript, Python and several other languages, and they follow the same pattern: create a server, register tools with typed inputs and descriptions, and connect a transport.

Next steps

If you want your team's AI assistants connected to your internal tools safely, I can design and build MCP servers with the right tools, permissions and audit trail. Get in touch, or see my services.

Sources

Bhuwanesh Sisodia

Bhuwanesh Sisodia

Full-Stack, Mobile & AI Engineer

Bhuwanesh Sisodia is a Full-Stack, Mobile & AI Engineer based in Mumbai, with 7+ years of experience and 50+ apps shipped to the App Store and Google Play. He works independently with startups and product teams worldwide.

About meGitHubLinkedInX

Related