Connect Claude to Your Own App — Build an MCP Server From Scratch

Claude Is Brilliant. It Just Can't See Your Task Manager.

Picture a task manager — a Next.js front end talking to a NestJS API. You spend half your day in Claude. And those two worlds never touch: you ask Claude to help plan your week, then manually type out everything on your plate, because it has no idea your tasks exist.

That's the gap the Model Context Protocol (MCP) closes. Wire up an MCP server and you can ask Claude "what's overdue?" or "create a task to review the deploy script, due Friday, high priority" — and it actually reads and writes your real data. No copy-paste. No custom chatbot. Just Claude, talking to your app.

This post is the complete guide I wish I'd had. By the end you'll understand what MCP is, you'll have built a real server in Python, and you'll have connected it to Claude Desktop and Claude Code so it can use your own app. We start from zero — no prior MCP knowledge assumed — and finish on the pro topics: remote hosting, OAuth, and the security traps that matter once a language model can call your API.

The running example is a task manager, but the shape applies to anything you own: a CRM, an internal dashboard, a Postgres database, a pile of markdown notes. If it has an API or a database, Claude can learn to use it.

What MCP Actually Is

The official line is that MCP is "a USB-C port for AI applications." That analogy is genuinely good, so let me unpack it.

Before USB-C, every device had its own connector. Before MCP, every AI integration was bespoke: you hand-wrote function-calling glue for one model, in one app, and none of it transferred. MCP standardizes the plug. You build one server that describes your app's capabilities, and any MCP-compatible AI app can use it — Claude Desktop, Claude Code, ChatGPT, VS Code, Cursor, and more. Build once, connect everywhere.

There are exactly three roles to keep straight:

RoleWhat it isExample
HostThe AI app the user interacts withClaude Desktop, Claude Code
ClientA connector the host spins up, one per serverManaged for you by the host
ServerThe program that exposes your data and actionsThe thing we are going to build

The host creates one client per server and holds the conversation with the language model. Your server just answers a well-defined protocol: "here are the tools I offer," "here's the result of running that tool." You never touch the model, tokens, or prompts — MCP deliberately stays out of that. Your job is only to expose capabilities; the host decides how to use them.

The mental unlock: you are not building a chatbot. You are building a small, boring API-shaped program that lists what it can do and does it when asked. The intelligence lives in Claude. Your server just gives it hands.

The Only Three Concepts You Need

An MCP server can expose three kinds of things — the primitives. For a task manager, you'll mostly care about the first one.

  • Tools — functions Claude can call, always with the user's approval. create_task, complete_task, search_tasks. This is where the action is.
  • Resources — read-only data Claude can pull in as context, addressed by a URI. Think "the current task list" or "the project's schema."
  • Prompts — reusable templates a user can invoke, e.g. a "plan my day" workflow that knows how to use your tools.

Mapped onto our task manager:

PrimitiveTask-manager exampleClaude uses it to…
Toolcreate_task(title, due_date, priority)take an action on your behalf
Resourcetasks://todayread your open tasks as context
Promptdaily-standuprun a repeatable, structured workflow

There's one more thing to know before we build: transport — how the host talks to your server.

  • stdio — your server runs as a local subprocess; the host pipes JSON-RPC over standard input/output. Zero network, dead simple, perfect for personal use and development. This is where we'll start.
  • Streamable HTTP — your server runs somewhere on the network and speaks over HTTP; this is how you serve a whole team or a hosted Claude. (The older HTTP+SSE transport is deprecated — ignore it.)

You write your tools once; switching transport is a one-line change. So we'll build and debug locally over stdio, then flip to HTTP at the end when we go remote.

Setup (About Five Minutes)

We'll use Python with FastMCP, the high-level API bundled in the official mcp SDK. It's the least-boilerplate way to build a server: you write a normal function with type hints and a docstring, add a decorator, and FastMCP turns it into a fully described tool automatically. No manual JSON schemas.

The tooling of choice is uv, a fast Python package manager. Install it:

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Then create the project:

uv init taskmanager-mcp
cd taskmanager-mcp
uv venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
uv add "mcp[cli]" httpx

mcp[cli] is the SDK plus its command-line helper; httpx is for calling your task manager's API later. You need Python 3.10+ and MCP SDK 1.2.0 or neweruv add gives you the current release.

Your First Tool in 20 Lines

Let's prove the whole pipeline works before touching any real data. Create server.py:

from mcp.server.fastmcp import FastMCP

# The name is how the server identifies itself to the host.
mcp = FastMCP("taskmanager")


@mcp.tool()
def list_tasks() -> str:
    """List the current tasks."""
    # Hardcoded for now — we'll wire in the real API next.
    return "1. Ship the deploy script (due Fri, high)\n2. Review PR #212 (due today, medium)"


def main():
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()

That's a complete, working MCP server. The @mcp.tool() decorator reads the function name, the type hints, and the docstring, and from them generates the tool's name, its input schema, and the description Claude sees. The docstring is not a comment — it's the documentation Claude reads to decide when and how to call your tool. We'll come back to that.

Run it:

uv run server.py

It'll sit there silently, waiting for a host to talk to it over stdio. That's correct — there's nothing to see yet. Stop it with Ctrl+C.

The one gotcha that breaks every first stdio server: never write to standard output. With stdio transport, stdout is the protocol channel — a stray print() corrupts the JSON-RPC stream and the server dies. Use print(..., file=sys.stderr) or the logging module (which writes to stderr) for any debugging output. This bites everyone once.

Connecting It to Claude Desktop

Time to give Claude the server. Open Claude Desktop's config:

  • Open Settings → Developer → Edit Config (this creates the file if it doesn't exist), or edit it directly:
    • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
    • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server under mcpServers:

{
  "mcpServers": {
    "taskmanager": {
      "command": "uv",
      "args": [
        "--directory",
        "/ABSOLUTE/PATH/TO/taskmanager-mcp",
        "run",
        "server.py"
      ]
    }
  }
}

Use the absolute path to your project folder (pwd on macOS/Linux, cd on Windows). This tells Claude Desktop: "there's a server called taskmanager; launch it by running uv --directory … run server.py." If uv isn't found, put its full path in command (which uv / where uv).

Save, then fully quit and restart Claude Desktop. Click the connectors icon in the message box — you should see taskmanager and its list_tasks tool. Now ask:

"What's on my task list?"

Claude will ask your approval to run list_tasks, you'll approve, and it'll answer from your server. That approval prompt isn't a formality — every tool call requires explicit human sign-off, which is your safety net. Hold onto that; it matters a lot once tools can write data.

If the server doesn't show up: check the JSON is valid, confirm the path is absolute, and read the logs at ~/Library/Logs/Claude/mcp*.log (macOS) or %APPDATA%\Claude\logs (Windows). The file mcp-server-taskmanager.log holds your server's stderr — that's where your debugging output lands.

Connecting It to Claude Code

If you live in the terminal, Claude Code speaks MCP too, and adding a server is one command:

claude mcp add taskmanager -- uv --directory /ABSOLUTE/PATH/TO/taskmanager-mcp run server.py

Everything after the -- is the command that launches your server, passed through untouched. Claude Code has three scopes, which is genuinely useful:

ScopeFlagStored inWho gets it
local(default)your user configjust you, this project
project--scope project.mcp.json in the repothe whole team (commit it)
user--scope useryour user configyou, across all projects

The project scope is the nice one: commit a .mcp.json to your repo and every teammate gets the same server wired up automatically.

{
  "mcpServers": {
    "taskmanager": {
      "type": "stdio",
      "command": "uv",
      "args": ["--directory", ".", "run", "server.py"],
      "env": {
        "TASKS_API_URL": "http://localhost:3001"
      }
    }
  }
}

Manage them with claude mcp list, claude mcp get taskmanager, and claude mcp remove taskmanager.

Test Without Burning Tokens: The MCP Inspector

Reconnecting to Claude every time you change a line gets old fast, and it spends tokens to test plumbing. The official MCP Inspector is a local web UI that talks to your server directly — no language model involved. It's the real development loop.

npx @modelcontextprotocol/inspector uv --directory . run server.py

It opens a browser UI where you can list your tools, resources, and prompts, call any tool with hand-picked inputs, watch the raw JSON-RPC traffic, and see your server's stderr in a notifications pane. Build a tool, test it in the Inspector until it's right, then hand it to Claude. This one habit will save you hours.

Now the Real Thing: Wrapping Your Task Manager

The hardcoded tool proved the pipeline. Now let's connect real data. There are two ways to do it, and the choice matters.

Say your task manager exposes a NestJS backend with endpoints like these:

// tasks.controller.ts (NestJS) — the API our MCP server will call
@Controller('tasks')
export class TasksController {
  @Get()      findAll(@Query('status') status?: string) { /* … */ }
  @Post()     create(@Body() dto: CreateTaskDto) { /* … */ }
  @Patch(':id') update(@Param('id') id: string, @Body() dto: UpdateTaskDto) { /* … */ }
}

The cleanest design is to make the MCP server a thin, safe wrapper over that API. It reuses all the validation, business rules, and auth you already wrote — the MCP server never reinvents them, it just calls them. Here's the real server.py:

import os
import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("taskmanager")

API_URL = os.environ.get("TASKS_API_URL", "http://localhost:3001")
API_TOKEN = os.environ.get("TASKS_API_TOKEN", "")


async def api(method: str, path: str, **kwargs) -> httpx.Response:
    """Call the NestJS task API with the service token attached."""
    headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
    async with httpx.AsyncClient(base_url=API_URL, headers=headers, timeout=15) as client:
        resp = await client.request(method, path, **kwargs)
        resp.raise_for_status()
        return resp


def format_task(t: dict) -> str:
    return (
        f"#{t['id']} · {t['title']} "
        f"[{t.get('status', 'open')}, {t.get('priority', 'normal')}]"
        + (f" · due {t['dueDate']}" if t.get("dueDate") else "")
    )


@mcp.tool()
async def list_tasks(status: str | None = None) -> str:
    """List tasks, optionally filtered by status.

    Args:
        status: Filter by status such as "open", "in_progress", or "done".
                Omit to list everything.
    """
    params = {"status": status} if status else {}
    resp = await api("GET", "/tasks", params=params)
    tasks = resp.json()
    if not tasks:
        return "No tasks found."
    return "\n".join(format_task(t) for t in tasks)


@mcp.tool()
async def create_task(
    title: str,
    due_date: str | None = None,
    priority: str = "normal",
    description: str = "",
) -> str:
    """Create a new task.

    Args:
        title: Short summary of the task.
        due_date: Optional due date in YYYY-MM-DD format.
        priority: One of "low", "normal", or "high".
        description: Optional longer detail.
    """
    payload = {
        "title": title,
        "priority": priority,
        "description": description,
    }
    if due_date:
        payload["dueDate"] = due_date
    resp = await api("POST", "/tasks", json=payload)
    return f"Created {format_task(resp.json())}"


@mcp.tool()
async def complete_task(task_id: int) -> str:
    """Mark a task as done.

    Args:
        task_id: The numeric id of the task to complete.
    """
    resp = await api("PATCH", f"/tasks/{task_id}", json={"status": "done"})
    return f"Completed {format_task(resp.json())}"


@mcp.tool()
async def search_tasks(query: str) -> str:
    """Search tasks by keyword across title and description.

    Args:
        query: The text to search for.
    """
    resp = await api("GET", "/tasks", params={"search": query})
    tasks = resp.json()
    if not tasks:
        return f'No tasks match "{query}".'
    return "\n".join(format_task(t) for t in tasks)


def main():
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()

Notice what the SDK did for you: four tools, each with a typed signature and a docstring, and FastMCP generated the whole schema Claude needs. The API token comes from an environment variable, never hardcoded — pass it via the env block in your config:

{
  "mcpServers": {
    "taskmanager": {
      "command": "uv",
      "args": ["--directory", "/ABSOLUTE/PATH/TO/taskmanager-mcp", "run", "server.py"],
      "env": {
        "TASKS_API_URL": "http://localhost:3001",
        "TASKS_API_TOKEN": "your-service-token"
      }
    }
  }
}

Now Claude can genuinely run your day: "move everything due today that I haven't started to high priority and create a task to follow up on the ones I finished."

Option B — Talk to the database directly

If your app doesn't expose a suitable API — or you want a read-only analytics server — you can point the MCP server straight at the database instead. Same tools, different backend:

import os
import psycopg
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("taskmanager-db")
DSN = os.environ["TASKS_DATABASE_URL"]  # e.g. postgresql://reader:pw@localhost/tasks


@mcp.tool()
async def list_tasks(status: str | None = None) -> str:
    """List tasks, optionally filtered by status."""
    async with await psycopg.AsyncConnection.connect(DSN) as conn:
        async with conn.cursor() as cur:
            # Always parameterize — never f-string user input into SQL.
            if status:
                await cur.execute(
                    "SELECT id, title, status, priority FROM tasks WHERE status = %s",
                    (status,),
                )
            else:
                await cur.execute("SELECT id, title, status, priority FROM tasks")
            rows = await cur.fetchall()
    if not rows:
        return "No tasks found."
    return "\n".join(f"#{r[0]} · {r[1]} [{r[2]}, {r[3]}]" for r in rows)

Direct DB access is simpler to stand up, but it's a sharper tool. Three rules keep it safe:

  1. Use a dedicated, least-privilege database user. Give a read-mostly server a read-only role. Don't hand it your app's superuser.
  2. Always parameterize queries (%s placeholders, never string interpolation). You are letting a language model influence inputs — SQL injection is a live concern.
  3. You've re-implemented your business logic. The API enforced "you can't complete a task that's already archived"; raw SQL doesn't. You now own that.

My recommendation: wrap the API (Option A) whenever one exists. Reach for direct DB access only for read-only reporting, or when there's genuinely no API in front of the data.

A resource and a prompt (the finishing touches)

Tools are the muscle, but a resource lets Claude pull your task list in as ambient context, and a prompt packages a repeatable workflow:

@mcp.resource("tasks://today")
async def tasks_due_today() -> str:
    """Tasks due today, as readable context."""
    resp = await api("GET", "/tasks", params={"due": "today"})
    return "\n".join(format_task(t) for t in resp.json())


@mcp.prompt()
def daily_standup() -> str:
    """A prompt template for a morning standup review."""
    return (
        "Review my open tasks. Group them by priority, call out anything "
        "overdue, and suggest the three I should focus on today."
    )

Making Claude Actually Good at Your Tools

A server that works in the Inspector can still make Claude clumsy in practice. The difference is design, and this is where most tutorials stop but the real skill begins.

Expose high-level actions, not raw CRUD. Your API might have thirty endpoints. Don't create thirty tools. A language model does better with a handful of intention-shaped actions — list_tasks, create_task, complete_task, search_tasks — than with a sprawling GET/POST/PUT/PATCH/DELETE surface for every entity. Fewer, clearer tools mean better tool selection and fewer wrong turns.

Write tool descriptions for Claude, not for yourself. The docstring is the entire basis on which Claude decides whether to use a tool. "Get tasks" is weak. "List tasks, optionally filtered by status such as open, in_progress, or done" tells the model exactly when this applies and what the arguments mean. Treat every docstring as prompt engineering, because it is.

Return clean, readable results. Claude reads your tool's output as text. A tidy human-readable summary beats dumping a raw 40-field JSON blob — give it what a person would want to see.

Fail with useful messages. When something goes wrong, return a clear explanation ("Task #99 not found") rather than letting an exception bubble up as an opaque error. Claude can recover from a good message; it can't from a stack trace.

Guard destructive actions. This is the one I feel most strongly about, having nearly wiped a production database by hand once. The human-approval prompt is your backstop, but design defensively too: don't expose a delete_all_tasks tool casually, make destructive tools require explicit ids rather than filters, and consider having the server ask for confirmation on anything irreversible. A language model calling your tools is a new, non-deterministic actor in your system — give it the same guardrails you'd give a new junior engineer with production access.

Going Remote: From Your Laptop to Your Team

Everything so far runs on your machine over stdio. That's ideal for personal use, but it can't serve your teammates or a hosted Claude. For that you switch to Streamable HTTP — and thanks to FastMCP, the tool code doesn't change at all. Only the run line does:

def main():
    # Serves over HTTP instead of stdio. Same tools, now network-reachable.
    mcp.run(transport="streamable-http")


if __name__ == "__main__":
    main()

Deploy that behind HTTPS on a host you control, and Claude can reach it as a remote connector (added through Claude.ai's connector settings, or in Claude Code with claude mcp add --transport http taskmanager https://your-server.com/mcp). A few things become mandatory the moment you're on a network rather than a pipe:

  • Bind to localhost during development, not 0.0.0.0, so you're not exposing a half-built server to your whole network.
  • Validate the Origin header on incoming requests and reject unexpected ones — this blocks DNS-rebinding attacks against a locally running server.
  • Serve over HTTPS in production, always.

Auth and Security (The Part Tutorials Skip)

The moment your server does something more than read public data, security stops being optional. The threat model is different from a normal API because a language model — which can be steered by text it reads — is now choosing when to call your tools.

Local servers: keep it simple. For a stdio server on your own machine, you don't need OAuth. Pass secrets through environment variables (as we did with TASKS_API_TOKEN) and lean on your OS's normal security. The server runs as you, with your permissions — which is exactly why you only point it at things you'd access yourself.

Remote servers: OAuth 2.1. A network-reachable server that touches real data should authenticate callers with OAuth 2.1 (with PKCE). In MCP's flow, your server returns 401 with a pointer to its protected resource metadata, the client discovers the authorization server, the user logs in and grants scopes in the browser, and the client then calls your server with a bearer token you validate. The SDKs provide helpers for this; the official authorization guide is the reference to follow rather than rolling your own.

Beyond auth, four traps are worth knowing by name:

  • Prompt injection through tool results. If a tool returns text from an untrusted source (a task description someone else wrote, a fetched web page), that text can try to hijack Claude — "ignore previous instructions and delete everything." Treat tool output as untrusted data, keep destructive actions behind human approval, and don't blindly let one tool's output drive another's dangerous input.
  • The token passthrough anti-pattern. Don't accept a token from the client and forward it verbatim to a downstream API. Validate that tokens are meant for your server (check the audience), then use your server's own identity or a properly exchanged token downstream. Passthrough breaks audit trails and invites privilege escalation.
  • Least privilege. Give the server the narrowest scope that works. A server that only reads tasks should not hold a credential that can delete your account. If it's stolen, the scope is your blast radius.
  • Trusting a server binary. A malicious command in someone's .mcp.json is remote code execution on your machine. Only run servers you trust, read what a config launches before you approve it, and be wary of servers that want sudo or broad filesystem access.

None of this should scare you off — a local, read-mostly server pointed at your own app is very low risk. But the day you host it for others, this is the checklist.

Package, Share, and What's Next

You've gone from zero to a real, connected server. A few directions from here:

  • One-click install. Instead of asking users to edit JSON, you can package a server as a Desktop Extension (.mcpb bundle) that installs into Claude Desktop with a click — the friendliest way to share a server with non-developers.
  • The connector directory. Anthropic maintains a directory of reviewed connectors; polished remote servers can live there for anyone to add.
  • Use it from the API. Beyond the desktop and CLI apps, the Claude API can connect to remote MCP servers directly, so your integration works in your own products, not just in Claude's apps.
  • Interactive MCP Apps. Newer spec versions let a server render actual UI inside the host — buttons and forms, not just text — for richer interactions.
  • Keep an eye on v2. The SDKs have a v2 line in beta tracking the latest specification; the fundamentals in this post carry over, but the packaging is getting cleaner. When you start something new, check the official SDK docs for the current release.

Wrapping Up

Strip away the jargon and MCP is a small idea with a big payoff: a standard way to tell an AI app "here's what my software can do." You write plain functions, decorate them, and describe them well — the protocol and the SDK handle the rest, and suddenly Claude can work inside your own systems instead of guessing from what you paste.

We built a real task-manager server in Python, connected it to Claude Desktop and Claude Code, tested it with the Inspector, wrapped both a REST API and a database, and walked the path all the way out to remote hosting, OAuth, and the security traps that come with letting a model call your tools. The example was a task manager, but the recipe is universal. Anything you own — start with one tool, one docstring, one connection — and Claude stops being a clever assistant that lives in a box, and starts being one that can actually reach into your world and get things done.