Build a Custom MCP Server in Python

Build a Python MCP server that gives Claude Desktop live GitHub and task tools.

Introduction

30 Second Summary

AI assistants can answer almost any question, but they have no idea what's in your GitHub repos or on your to-do list. Imagine if your AI could pull your latest commits, read your code, and track your tasks directly.

In this project, you will build a custom MCP server in Python that gives Claude Desktop direct access to your GitHub repositories and a local task tracker. You'll start with a blank Python file and finish with a server that Claude calls in real time.

What You'll Build

You'll open Claude Desktop, ask about your GitHub repos, and watch Claude fetch live repository data, read your code files, and manage a persistent task list.

By the end of this project, you'll have:

  • A live GitHub connection where you ask Claude about your repos and it fetches real-time data, including commit history and file contents, from the GitHub REST API.
  • A multi-tool MCP server where you ask Claude one complex question and it chains together repo lookups, commit reads, and file previews to give you a complete answer.
  • A persistent task tracker backed by SQLite, where Claude creates, lists, and completes tasks that survive between conversations.
  • Secret Mission: Add a cross-repository code search tool that lets Claude find code patterns across all your GitHub repos.

Are there any prerequisites?

You'll need Python 3.10 or higher installed on your machine, a GitHub account, and VS Code. Claude Desktop will be installed during the project.

Before We Start

Before diving into code and configuration, let's take a moment to lock in what you're about to build and why it matters.

Set Up Your Environment

You know what you're building and why it matters. Before writing any server code, you need the right tools and credentials in place.

In this step, you'll install Claude Desktop, set up the MCP Python SDK, scaffold your project folder, and generate a GitHub access token so your server can talk to the GitHub REST API.

In this step, get ready to:
  • Install Claude Desktop and sign in with a free account.
  • Install the MCP Python SDK and create the project folder.
  • Create a GitHub fine-grained personal access token for API access.
Install Claude Desktop

Claude Desktop is the app where you chat with Claude. It also acts as an MCP host, meaning it discovers and launches your custom MCP server behind the scenes whenever you start a conversation.

  • Open your browser and go to claude.ai/download.
  • Find the Windows section and click Download.
  • Open the downloaded installer file and follow the on-screen prompts to complete the installation.
  • Once the installation finishes, press the Windows key to open your search bar.
  • Type Claude and press Enter to launch Claude Desktop.
  • Sign in with your Claude account. If you don't have one yet, click Sign up and create a free account.

You should see Claude Desktop's chat interface with a text input at the bottom of the screen.

What is an MCP host?

An MCP host is any application that manages the lifecycle of MCP servers. Claude Desktop starts your server, discovers what tools it offers, and calls those tools during a conversation.

Your custom server won't do anything on its own. It needs a host like Claude Desktop to launch it and send requests to it.

Claude Desktop not launching?

Make sure your Windows version is Windows 10 or higher. Claude Desktop requires at least Windows 10 to run.

If the installer completed but the app doesn't appear in search, try restarting your computer and searching for Claude again.

Help me troubleshoot Claude Desktop not launching on Windows.

Install the MCP Python SDK and set up the project

The MCP Python SDK is the library that turns a Python script into a fully functional MCP server. It handles the protocol layer so you can focus on writing tools.

  • Open a terminal. Press the Windows key, type PowerShell, and press Enter.
  • Install the MCP Python SDK by running this command:
pip install "mcp[cli]"

What does this install?

The mcp package is the MCP Python SDK. It provides the MCPServer class and the @mcp.tool() decorator you'll use to build your server.

The [cli] extra installs command-line utilities like the MCP Inspector. You'll use the Inspector to test your server in the next step.

Before you run the next command, what do you think will happen if the SDK installed correctly?

  • Verify the installation by running this command:
python -c "from mcp.server import MCPServer; print('MCP SDK ready')"

You should see MCP SDK ready printed in your terminal. This confirms that Python can find and import the MCP SDK.

Seeing an import error?

If you see ModuleNotFoundError, the pip install command may have installed the package for a different Python version than the one you're running. Try pip3 install "mcp[cli]" instead.

Help me fix an import error after installing the MCP Python SDK.

Now create the project folder where your server will live.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir dev-productivity-server
cd dev-productivity-server
  • Open the folder in VS Code by running this command:
code .

VS Code opens with your empty dev-productivity-server folder loaded in the sidebar.

  • In the VS Code Explorer sidebar on the left, hover over the DEV-PRODUCTIVITY-SERVER folder name.
  • Click the New File icon (a page with a plus sign).
  • Type server.py and press Enter.
  • Click the New File icon again.
  • Type .env and press Enter.

You should see both server.py and .env listed in the Explorer sidebar.

Don't see the .env file?

Some systems hide files that start with a dot. In VS Code, the Explorer sidebar shows all files regardless, so if .env doesn't appear, double-check that you typed the name correctly with the leading dot.

Help me create a .env file in VS Code.

Create a GitHub personal access token

Your MCP server needs a way to authenticate with the GitHub API. A fine-grained personal access token gives your server read-only access to your repositories without exposing your GitHub password.

  • Open your browser and go to github.com.
  • In the upper-right corner, click your profile picture.
  • Click Settings.
  • In the left sidebar, click Developer settings.
  • Under Personal access tokens, click Fine-grained tokens.
  • Click Generate new token.

You should see the new token creation form. Fill it out with these settings:

  • Under Token name, enter MCP Server.
  • Under Expiration, choose 30 days (or any duration you prefer).
  • Under Repository access, select All repositories.
  • Expand the Repository permissions section.
  • Set Contents to Read-only.
  • Set Metadata to Read-only.
  • Scroll down and click Generate token.

Copy your token now

GitHub only shows your token once. Copy it immediately and keep it somewhere safe. You'll paste it into your .env file in the next step.

Why these specific permissions?

The Contents permission lets your server read file contents from your repos. The Metadata permission lets it list repos and read basic info like names and descriptions.

Both are read-only, so your server can never modify your repositories. This follows the principle of least privilege.

Now store your token in the project's .env file so your server can use it.

  • In VS Code, click on .env in the Explorer sidebar to open the file.
  • Type the following line, replacing your-token-here with the token you just copied:
GITHUB_TOKEN=your-token-here
  • Save the file by pressing Ctrl+S.

What does this code do?

The .env file stores environment variables that your server reads at runtime. By keeping the token here instead of hardcoding it in server.py, you avoid accidentally sharing your credentials if you ever push your code to GitHub.

Lost your token?

If you navigated away before copying, you'll need to delete the token from your GitHub settings and generate a new one. Go back to Settings then Developer settings then Fine-grained tokens, delete the old token, and repeat the creation steps.

Help me regenerate my GitHub personal access token.

✔️ Awesome, I've got everything!

Great. Double check you've saved your .env file. The GITHUB_TOKEN line should be the only content in the file.

ⓧ I'd like to double check the full code

Your .env file should contain exactly one line:

GITHUB_TOKEN=your-token-here

The value after the = sign should be your actual token, not the literal text your-token-here.

Your server.py file should be empty. You'll add code to it in the next step.

Your environment is ready. You have Claude Desktop running, the MCP SDK installed, a project folder with server.py and .env, and a GitHub token stored safely. Next up, you'll write your first MCP tool and see it respond to a real API call.

Build a Basic MCP Server with Your First Tool

You have a project folder, a GitHub token, and the MCP SDK installed. Now it is time to see the Model Context Protocol actually working.

In this step, you will write a single MCP tool that fetches your GitHub repos and test it with the MCP Inspector. One tool, one successful call, and you will have proof that the entire pipeline works before adding any complexity.

In this step, get ready to:
  • Write an MCP server skeleton with a list_repos tool that calls the GitHub API.
  • Test the server with the MCP Inspector and verify your repos appear.
Write the server skeleton and GitHub helper

Your server.py file is currently empty. You will build it up in two chunks: first the server foundation (imports, configuration, and a reusable GitHub API helper), then the list_repos tool itself.

  • Open server.py in VS Code and paste the following code:
import os
import httpx2
from mcp.server import MCPServer

# Create the MCP server instance with a descriptive name
mcp = MCPServer("DevProductivity")

# Read GitHub token from environment and set up API constants
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
GITHUB_API = "https://api.github.com"
HEADERS = {
    "Authorization": f"Bearer {GITHUB_TOKEN}",
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": "2026-03-10",
}

# Reusable helper for authenticated GitHub API GET requests
async def github_get(path: str) -> dict | list | None:
    async with httpx2.AsyncClient() as client:
        try:
            response = await client.get(
                f"{GITHUB_API}{path}", headers=HEADERS, timeout=30.0
            )
            response.raise_for_status()
            return response.json()
        except httpx2.HTTPStatusError as e:
            if e.response.status_code == 403:
                return None
            raise
        except Exception:
            return None

What does this code do?

MCPServer("DevProductivity") creates your MCP server instance. The name you pass is what clients like Claude Desktop will see when they discover your server.

GITHUB_TOKEN reads your token from the environment (not directly from the .env file). The HEADERS dictionary sets up Bearer authentication and pins the GitHub API version.

github_get is an async helper that wraps every GitHub API call with error handling. If the API returns a 403 (rate limit or permission error), it returns None instead of crashing. You will reuse this helper for every GitHub tool you build.

Now add the list_repos tool and the main entry point below your existing code.

  • Paste the following code at the bottom of server.py, after the github_get function:
@mcp.tool()
async def list_repos(sort: str = "updated", per_page: int = 10) -> str:
    """List your GitHub repositories.

    Args:
        sort: Sort by 'updated', 'created', 'pushed', or 'full_name'. Defaults to 'updated'.
        per_page: Number of repos to return (1-30). Defaults to 10.
    """
    # Clamp per_page to a safe range
    per_page = min(max(per_page, 1), 30)
    data = await github_get(f"/user/repos?sort={sort}&per_page={per_page}")
    if data is None:
        return "Failed to fetch repositories. Check your GitHub token."
    if not data:
        return "No repositories found."
    # Format each repo into a readable summary
    lines = []
    for repo in data:
        name = repo.get("full_name", "unknown")
        desc = repo.get("description") or "No description"
        stars = repo.get("stargazers_count", 0)
        lang = repo.get("language") or "Not specified"
        updated = repo.get("updated_at", "unknown")
        lines.append(
            f"- {name}\n  Description: {desc}\n  Language: {lang} | Stars: {stars} | Updated: {updated}"
        )
    return f"Found {len(data)} repositories:\n\n" + "\n\n".join(lines)

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

What does this code do?

The @mcp.tool() decorator registers list_repos as an MCP tool. The SDK automatically generates the tool's schema from the function's type hints and docstring. Any MCP client can discover and call it.

The function calls GET /user/repos through your github_get helper, then formats each repository's name, description, language, star count, and last-updated date into a readable string.

The if __name__ == "__main__" block starts the server using stdio transport. This is how Claude Desktop will communicate with your server: by launching it as a subprocess and passing messages over standard input and output.

  • Save server.py.

✔️ Awesome, I've got everything!

Great. Make sure you have saved server.py before moving on.

ⓧ I'd like to double check the full code

Your server.py should contain these sections in order. Scroll up through the two code chunks you pasted and confirm each is present:

  • Imports (os, httpx2, MCPServer).
  • Server instance: mcp = MCPServer("DevProductivity").
  • GitHub constants: GITHUB_TOKEN, GITHUB_API, HEADERS.
  • The github_get async helper function.
  • The list_repos tool function decorated with @mcp.tool().
  • The if __name__ == "__main__" block calling mcp.run(transport="stdio").
Test your server with the MCP Inspector

The MCP SDK ships with a built-in testing tool called the MCP Inspector. It launches your server, discovers its tools, and lets you call them from a web UI. The Inspector uses Node.js under the hood, so you need Node.js installed.

  • Check if Node.js is installed by running this command in your terminal:
node --version

✔️ I see a version number

Node.js is ready. Move on to the next instruction.

ⓧ Command not found

The MCP Inspector requires Node.js to run. Install it now.

  • Download the Node.js installer from nodejs.org.
  • Run the installer and accept the defaults.
  • Close and reopen your terminal, then run node --version again to confirm it works.

The mcp dev command needs your GITHUB_TOKEN loaded from the .env file. It does not read .env automatically, so you pass -f .env to tell it where to find your environment variables.

  • Launch the MCP Inspector with your server by running:
mcp dev server.py -f .env

What does this command do?

mcp dev starts your server and opens the MCP Inspector, a web-based testing UI. The -f .env flag loads your GITHUB_TOKEN from the .env file so your server can authenticate with GitHub.

  • Look at the terminal output. It prints a URL (something like http://localhost:6274 with a session token). Open that URL in your browser.

Seeing an error about npx?

The MCP Inspector uses npx under the hood, which comes bundled with Node.js. If you see a message about npx not being found, install Node.js from nodejs.org, then restart your terminal and try again.

help me fix the npx not found error when running mcp dev

You should now see the MCP Inspector web UI in your browser. Time to call your tool.

  • Click the Tools tab in the Inspector.
  • You should see list_repos listed as an available tool. Click on it.
  • The Inspector displays a form with the tool's input fields (sort and per_page). Leave the defaults as they are.

Before you call the tool, what do you think will happen? Will you see your GitHub repos, or will something go wrong?

  • Click the Call Tool button (or equivalent run button) to execute list_repos.

Your GitHub repositories appear in the result pane, showing each repo's name, description, language, star count, and last-updated date.

Seeing 'Failed to fetch repositories'?

This means your GITHUB_TOKEN did not reach the server. Check these things:

  • Make sure your .env file contains GITHUB_TOKEN= followed by your actual token (no spaces around the = sign).
  • Make sure you ran mcp dev server.py -f .env (with the -f .env flag). Without it, the server cannot read your token.
  • Confirm your GitHub token has the Contents and Metadata permissions enabled.

help me debug why my list_repos tool is returning 'Failed to fetch repositories'

Your MCP server is alive. Claude Desktop does not know it exists yet, though. In the next step, you will connect your server to Claude Desktop so Claude can discover and call list_repos in a real conversation.

Connect Your Server to Claude Desktop

Your MCP Inspector test in the last step proved the server works. It listed your real GitHub repositories, confirming the code and your token are functioning correctly.

Claude Desktop doesn't know about your server yet, though. It discovers MCP servers through a local JSON config file. In this step, you will create that config file so Claude Desktop can launch your server automatically and call your tools during a conversation.

In this step, get ready to:
  • Create the Claude Desktop configuration file that registers your MCP server.
  • Test the list_repos tool in a live Claude Desktop conversation.
Create the Claude Desktop config file

Claude Desktop reads a JSON file called claude_desktop_config.json to discover MCP servers. This file tells Claude which command to run, where your server script lives, and what environment variables to pass to it.

  • Make sure your terminal is in the project folder that contains server.py.
  • Find the full path to your server.py by running this command:
python -c "import os; print(os.path.abspath('server.py'))"

You should see a full file path printed, something like C:\Users\YourName\Desktop\mcp-server\server.py.

  • Copy the path that prints out. You will paste it into the config file in a moment.

Use forward slashes in the path

Windows paths use backslashes (\), but JSON requires you to either double them (\\) or use forward slashes (/). Forward slashes are simpler.

For example, change C:\Users\YourName\Desktop\mcp-server\server.py to C:/Users/YourName/Desktop/mcp-server/server.py.

  • Open the Claude Desktop config file in VS Code by running this command:
code "%APPDATA%\Claude\claude_desktop_config.json"

VS Code didn't open?

If code is not recognized, open VS Code manually. Then click File, select Open File, paste %APPDATA%\Claude\claude_desktop_config.json into the address bar, and press Enter. If the file does not exist, VS Code will create it for you.

If the Claude folder itself does not exist, create it first by running:

mkdir "%APPDATA%\Claude"

Then try the code command again.

Help me open the Claude Desktop config file

  • Paste the following JSON into the file:
{
  "mcpServers": {
    "DevProductivity": {
      "command": "python",
      "args": ["[[SERVER_PATH=\"full path to server.py with forward slashes\"]]"],
      "env": {
        "GITHUB_TOKEN": "your-token-here"
      }
    }
  }
}

What does each field do?

  • mcpServers is the top-level key Claude Desktop looks for. Every MCP server you register goes inside it.
  • DevProductivity is the name of your server. It matches the name you passed to MCPServer("DevProductivity") in server.py.
  • command tells Claude Desktop which program to run. python works because Python is on your system PATH.
  • args passes the full path to server.py so Claude Desktop can find your script from any working directory.
  • env sets environment variables that Claude Desktop passes to the server process. Your server reads GITHUB_TOKEN from os.environ, so this is how it gets the token.
  • Replace the SERVER_PATH placeholder with the full path you copied earlier, using forward slashes.
  • Replace your-token-here with your actual GitHub personal access token from the .env file you created in Step 1.
  • Save the file.

Common config file mistakes

  • A single missing comma or mismatched bracket in the JSON silently disables all servers. Double-check that every opening brace has a closing brace.
  • Make sure you used the full absolute path to server.py, not a relative path like ./server.py. Claude Desktop launches your server from its own directory, so it cannot find relative paths.
  • If you used backslashes in the path, every single backslash must be doubled (\\). Forward slashes are easier.

Help me fix my Claude Desktop config file

✔️ Awesome, I've got everything!

Great. Double check you've saved claude_desktop_config.json with your real path and token before moving on.

ⓧ I'd like to double check the full code

Your claude_desktop_config.json should look like this, with your own path and token filled in:

{
  "mcpServers": {
    "DevProductivity": {
      "command": "python",
      "args": ["[[SERVER_PATH=\"full path to server.py with forward slashes\"]]"],
      "env": {
        "GITHUB_TOKEN": "your-token-here"
      }
    }
  }
}
Restart Claude Desktop and test your server

Claude Desktop reads its config file only at startup. To pick up the new server entry, you need to fully quit and relaunch the app. Closing the window is not enough.

  • Right-click the Claude icon in the system tray (near the clock in the bottom-right corner of your taskbar).
  • Click Quit.
  • Relaunch Claude Desktop by pressing the Windows key, typing Claude, and pressing Enter.

Why a full quit?

Claude Desktop keeps running in the background when you close its window. The system tray icon proves the process is still alive. Right-clicking and selecting Quit fully stops the process so the app re-reads claude_desktop_config.json on the next launch.

Claude Desktop takes a moment to start up and connect to your MCP server in the background.

Before you test, think about what you expect to happen. If the config file is correct, what should Claude be able to do when you ask about your GitHub repos?

  • Start a new conversation in Claude Desktop.
  • Type this message and send it:
What are my GitHub repos?

Claude reads your message, discovers the list_repos tool from your server, and calls it. You should see Claude present your actual GitHub repositories with their names, descriptions, and star counts.

Claude didn't call the tool?

  • Make sure you fully quit Claude Desktop from the system tray before relaunching. Closing the window is not enough.
  • Open claude_desktop_config.json and check for JSON syntax errors. A trailing comma or missing bracket silently disables all servers.
  • Verify the path in args points to the correct server.py file. Run the path command from earlier again to double-check.
  • Make sure python is on your system PATH. Open a new terminal and run python --version to confirm.

Help me debug my MCP server connection

Your MCP server is now a live AI-powered tool that Claude calls on demand. Next up, you will add more tools that let Claude dig deeper into your commit history and file contents.

Add GitHub Commit History and File Reading Tools

In the last step, you connected your MCP server to Claude Desktop and watched Claude call your list_repos tool to pull live data from GitHub. That proved the full pipeline works.

Listing repos is useful, but it is shallow. Real developer tools let you drill into a repository to see what changed recently and read specific files. In this step, you will add two more tools that give Claude deeper access to your repositories. You will also see Claude chain multiple tools together in a single conversation.

In this step, get ready to:
  • Add a get_commits tool that fetches recent commit history for any repository.
  • Add a read_file tool that reads file contents directly from GitHub.
  • Test Claude chaining both tools together in a single conversation.
Add a tool to view commit history

Your first new tool will call the GitHub REST API commits endpoint. It takes a repository name and returns formatted commit messages with the author, date, and short SHA.

  • In VS Code, open server.py.
  • Find the last line of the list_repos function (the return statement with "Found {len(data)} repositories:").
  • Add the following get_commits tool below it, before the if __name__ == "__main__": block:
@mcp.tool()
async def get_commits(repo: str, count: int = 5) -> str:
    """Get recent commits for a repository.

    Args:
        repo: Full repository name (e.g. 'owner/repo-name')
        count: Number of commits to return (1-20). Defaults to 5.
    """
    # Clamp count between 1 and 20
    count = min(max(count, 1), 20)
    data = await github_get(f"/repos/{repo}/commits?per_page={count}")
    if data is None:
        return f"Failed to fetch commits for {repo}. Check the repo name and your token permissions."
    if not data:
        return f"No commits found for {repo}."
    # Format each commit with short SHA, message, author, and date
    lines = []
    for commit in data:
        sha = commit.get("sha", "unknown")[:7]
        message = commit.get("commit", {}).get("message", "No message").split("\n")[0]
        author = commit.get("commit", {}).get("author", {}).get("name", "Unknown")
        date = commit.get("commit", {}).get("author", {}).get("date", "unknown")
        lines.append(f"- [{sha}] {message}\n  Author: {author} | Date: {date}")
    return f"Last {len(data)} commits for {repo}:\n\n" + "\n\n".join(lines)

What does this code do?

This tool calls the GitHub endpoint GET /repos/{owner}/{repo}/commits to fetch recent commit history for a specific repository.

  • repo is the full repository name in owner/repo-name format. Claude figures this out from context or asks you.
  • [:7] trims the commit SHA to its first 7 characters. This is the standard short format you see on GitHub.
  • .split("\n")[0] grabs only the first line of multi-line commit messages to keep the output clean.
  • Save server.py.
  • Test the new tool in the MCP Inspector by running this command:
mcp dev server.py
  • Open the Inspector URL it prints in your browser.
  • Navigate to Tools.
  • Select get_commits from the tool list.
  • In the repo field, enter the full name of one of your GitHub repositories (e.g. yourusername/your-repo).
  • Click Run.

You should see a formatted list of recent commits with short SHAs, commit messages, authors, and dates.

Not seeing commits?

  • Make sure the repo value uses the full owner/repo-name format, not just the repo name by itself.
  • Check that you saved server.py before running mcp dev. The Inspector loads the file at startup.
  • If you see a token error, confirm your .env file is in the same folder as server.py.

Still stuck? Ask for help.

  • Stop the Inspector by pressing Ctrl+C in your terminal.
Add a tool to read file contents from GitHub

Commits tell you what changed, but sometimes Claude needs to read the actual file. Your next tool fetches file contents from the GitHub API and decodes them so Claude can read the raw text.

GitHub returns file contents as base64-encoded strings. Your tool needs to decode that encoding back into readable text. Python has a built-in module for this.

  • In server.py, scroll to the top and find this line:
import os
  • Add import base64 on a new line right after it, so your imports look like this:
import os
import base64
import httpx2
from mcp.server import MCPServer
  • Now scroll to the end of the get_commits function you just added.
  • Add the following read_file tool below it, before the if __name__ == "__main__": block:
@mcp.tool()
async def read_file(repo: str, path: str) -> str:
    """Read a file from a GitHub repository.

    Args:
        repo: Full repository name (e.g. 'owner/repo-name')
        path: Path to the file in the repository (e.g. 'README.md' or 'src/main.py')
    """
    data = await github_get(f"/repos/{repo}/contents/{path}")
    if data is None:
        return f"Failed to fetch {path} from {repo}. Check the file path and repo name."
    # If the path points to a directory, list its contents
    if isinstance(data, list):
        items = [
            f"  {'[dir]' if item.get('type') == 'dir' else '[file]'} {item.get('name', 'unknown')}"
            for item in data
        ]
        return f"'{path}' is a directory in {repo}:\n" + "\n".join(items)
    # Decode the base64-encoded file content
    content_b64 = data.get("content", "")
    encoding = data.get("encoding", "")
    if encoding == "base64":
        try:
            content = base64.b64decode(content_b64).decode("utf-8")
        except Exception:
            return f"Could not decode {path}. It may be a binary file."
    else:
        content = content_b64
    size = data.get("size", 0)
    return f"File: {path} ({size} bytes)\n\n{content}"

What does this code do?

This tool calls GET /repos/{owner}/{repo}/contents/{path} to fetch a file from any repository you have access to.

  • If the path points to a directory instead of a file, the API returns a list. The tool detects this and shows the directory contents with [dir] and [file] labels.
  • base64.b64decode() converts the encoded string back into raw bytes, then .decode("utf-8") turns those bytes into readable text.
  • The try/except block catches binary files (images, compiled code) that cannot be decoded as text.
  • Save server.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.py with both new tools added.

ⓧ I'd like to double check the full code

Scroll through your server.py from top to bottom. Confirm the following sections are present in this order:

  • Imports: import os, import base64, import httpx2, from mcp.server import MCPServer.
  • Server setup: MCPServer("DevProductivity"), GITHUB_TOKEN, GITHUB_API, and HEADERS.
  • Helper function: github_get().
  • Tool 1: list_repos() (from the previous step).
  • Tool 2: get_commits() (added in this step).
  • Tool 3: read_file() (added in this step).
  • Entry point: if __name__ == "__main__": mcp.run(transport="stdio").
Test both tools in Claude Desktop

Claude Desktop loads your server once at startup. It does not pick up code changes automatically, so you need to restart it to register the new tools.

  • Right-click the Claude Desktop icon in the system tray (bottom-right of your taskbar).
  • Click Quit to fully close it.
  • Relaunch Claude Desktop from the Start menu or by pressing the Windows key, typing Claude, and pressing Enter.
  • Start a new conversation.

Before you type your prompt, think about what you expect to happen. You are about to ask Claude to do two things in sequence. Do you think Claude will call one tool or two separate tools?

  • Type a prompt that asks Claude to chain both tools together. For example: "Show me the last 5 commits on my [repo-name] repo and then show me the README file." Replace [repo-name] with one of your actual repository names.
  • Press Enter and watch Claude's response.

Claude calls get_commits first, then read_file, and presents both results in a single conversation. You should see your recent commit messages followed by the full contents of your README.

Why does tool chaining matter?

Claude decides on its own which tools to call and in what order. You gave it one natural-language request, and it broke that into two separate API calls. This is the power of building a multi-tool MCP server. Each tool does one thing well, and the AI orchestrates them together.

Claude not calling the new tools?

  • Make sure you fully quit Claude Desktop from the system tray before relaunching. Closing the window alone does not restart the server.
  • Check that your server.py was saved before you restarted. Claude Desktop loads the file from disk at startup.
  • If Claude responds with text but does not call any tools, try being more explicit: "Use your get_commits tool to show me commits for owner/repo-name."

Need more help troubleshooting?

Your MCP server now has three tools that give Claude real access to your GitHub repositories. Next up, you will add persistent local storage so Claude can remember tasks across conversations.

Build a Local Task Tracker with SQLite

Your MCP server can now read from GitHub in three different ways. Claude can list your repos, browse commit history, and read file contents directly from a conversation.

There is a gap, though. Your server can read data, but it cannot remember anything. Every time you close a conversation, Claude loses all context. In this step, you will add a SQLite task tracker that lets Claude create, list, and complete tasks that persist across conversations and restarts.

In this step, get ready to:
  • Add SQLite database initialization to the server.
  • Build three task management tools for Claude to call.
  • Test the full workflow in Claude Desktop with persistent data.
Add SQLite database initialization

SQLite is a lightweight database engine built directly into Python's standard library. No extra installs needed. You will create a tasks.db file in your project folder that stores tasks locally on your machine.

First, the server needs new imports to work with SQLite and timestamps. The database setup code also needs to run every time the server starts so the table is always ready.

  • In VS Code, open server.py.
  • Find the import block at the top of the file. It currently looks like this:
import os
import base64
import httpx2
from mcp.server import MCPServer
  • Replace the entire import block with this updated version:
import os
import json
import sqlite3
import base64
from datetime import datetime

import httpx2
from mcp.server import MCPServer

What's new in these imports?

  • sqlite3 is Python's built-in SQLite database module. No install required.
  • datetime provides timestamps so each task records when it was created.
  • json is imported for JSON handling utilities.

Now you need code that creates the database and table when the server starts. This goes between the HEADERS dictionary and the github_get function.

  • Find the closing } of the HEADERS dictionary.
  • Add the following code below it, before the github_get function:
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.db")


def init_db():
    # Create the tasks table if it doesn't already exist
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        """CREATE TABLE IF NOT EXISTS tasks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'pending',
            created_at TEXT NOT NULL
        )"""
    )
    conn.commit()
    conn.close()


# Initialize the database when the server starts
init_db()

What does this code do?

  • DB_PATH builds the full path to tasks.db in the same folder as server.py. This ensures the database is always created next to your server file, no matter where you run the command from.
  • init_db() opens a connection, creates the tasks table if it does not exist, then closes the connection. The CREATE TABLE IF NOT EXISTS clause makes this safe to run every time the server starts.
  • The bare init_db() call at module level means the database is created as soon as the server file loads.
  • Save server.py.
  • Test that the database gets created by running your server with the MCP Inspector:
mcp dev server.py
  • Once the Inspector starts, press Ctrl+C to stop it.
  • Confirm the database file was created by running:
dir

You should see tasks.db listed alongside server.py and .env in your project folder.

Don't see tasks.db?

  • Make sure you saved server.py before running the command.
  • Check that init_db() is called at the module level (not indented inside a function). It should be on its own line after the function definition.
  • Verify that import sqlite3 is in your import block at the top of the file.

Why isn't my tasks.db file being created when I run the server?

Add task management tools

The database is ready. Now you need three tools that Claude can call: one to add tasks, one to list them, and one to mark them complete.

  • In server.py, scroll down to the read_file function.
  • Add the add_task tool below the read_file function, before the if __name__ == "__main__": block:
@mcp.tool()
def add_task(title: str) -> str:
    """Add a new task to the local task tracker.

    Args:
        title: The task description
    """
    # Connect to the database and insert a new task
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.execute(
        "INSERT INTO tasks (title, status, created_at) VALUES (?, 'pending', ?)",
        (title, datetime.now().isoformat()),
    )
    task_id = cursor.lastrowid
    conn.commit()
    conn.close()
    return f"Task added with ID {task_id}: {title}"

What does this tool do?

  • When Claude calls add_task, it inserts a new row into the tasks table with a status of pending and the current timestamp.
  • cursor.lastrowid grabs the auto-generated ID so Claude can report back which task was created.
  • Notice this is a regular def function, not async def. SQLite operations are local and fast, so they do not need to be asynchronous.
  • Add the list_tasks tool directly below add_task:
@mcp.tool()
def list_tasks(status: str = "all") -> str:
    """List tasks from the local task tracker.

    Args:
        status: Filter by 'all', 'pending', or 'done'. Defaults to 'all'.
    """
    # Query tasks, optionally filtering by status
    conn = sqlite3.connect(DB_PATH)
    if status == "all":
        rows = conn.execute(
            "SELECT id, title, status, created_at FROM tasks ORDER BY id DESC"
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT id, title, status, created_at FROM tasks WHERE status = ? ORDER BY id DESC",
            (status,),
        ).fetchall()
    conn.close()
    if not rows:
        return f"No tasks found (filter: {status})."
    # Format each task with a checkbox marker
    lines = []
    for row in rows:
        task_id, title, task_status, created = row
        marker = "[x]" if task_status == "done" else "[ ]"
        lines.append(f"{marker} #{task_id}: {title} (created: {created})")
    return f"Tasks ({len(rows)}):\n" + "\n".join(lines)

What does this tool do?

  • list_tasks accepts a status filter so Claude can show all tasks, only pending ones, or only completed ones.
  • Each task gets a checkbox marker: [ ] for pending and [x] for done. This gives Claude a clean format to present to you.
  • Save server.py.
  • Verify both tools show up by starting the MCP Inspector:
mcp dev server.py
  • Open the Inspector URL it prints in your browser.
  • Navigate to Tools.
  • Call add_task with a title like Review pull requests.
  • Call list_tasks. You should see your new task listed with a [ ] checkbox, an ID, and a timestamp.
  • Press Ctrl+C to stop the Inspector.

Not seeing add_task or list_tasks in the Inspector?

  • Check that both functions have the @mcp.tool() decorator directly above the def line.
  • Make sure the new tools are placed after the read_file function and before the if __name__ == "__main__": block.
  • Look for syntax errors in your terminal output when the Inspector starts.

Help me debug why my new task tools are not appearing in the MCP Inspector.

One more tool to go. The complete_task function lets Claude mark a task as done by its ID.

  • Add the complete_task tool below list_tasks, still before the if __name__ == "__main__": block:
@mcp.tool()
def complete_task(task_id: int) -> str:
    """Mark a task as complete.

    Args:
        task_id: The ID of the task to complete
    """
    # Update the task's status to 'done'
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.execute(
        "UPDATE tasks SET status = 'done' WHERE id = ?", (task_id,)
    )
    conn.commit()
    if cursor.rowcount == 0:
        conn.close()
        return f"No task found with ID {task_id}."
    conn.close()
    return f"Task #{task_id} marked as complete."

What does this tool do?

  • complete_task runs an UPDATE query to change a task's status from pending to done.
  • cursor.rowcount checks whether any row was actually updated. If the ID does not exist, the tool tells Claude no matching task was found.
  • Save server.py.

✔️ Awesome, I've got everything!

Great. Double check that you have saved server.py and move on to the final test.

ⓧ I'd like to double check the full code

Your server.py is now over 30 lines, so scroll through the file and confirm each section is present in this order:

  • Imports: os, json, sqlite3, base64, datetime, httpx2, and MCPServer.
  • Server setup: MCPServer("DevProductivity"), GITHUB_TOKEN, GITHUB_API, and HEADERS.
  • Database setup: DB_PATH, init_db() function, and the init_db() call.
  • GitHub helper: github_get async function.
  • GitHub tools: list_repos, get_commits, read_file.
  • Task tools: add_task, list_tasks, complete_task.
  • Entry point: if __name__ == "__main__": mcp.run(transport="stdio").
Test the full workflow in Claude Desktop

Your server now has six tools. Time to restart Claude Desktop so it picks up the three new task tools.

  • Right-click the Claude Desktop icon in the system tray.
  • Click Quit to fully close the app.
  • Relaunch Claude Desktop from your search bar.
  • Start a new conversation.

Before you test, think about what you expect to happen. If you ask Claude to add a task, what should it return? If you then ask to see all tasks, will the task you just added appear?

  • Type: "Add a task to review the pull requests."

Claude should call the add_task tool and confirm the task was created with an ID number.

  • Now type: "Show all my tasks."

Claude calls list_tasks and shows you the task you just added with a [ ] pending marker.

  • Finally, type: "Mark task 1 as complete, then show all my tasks."

Claude calls complete_task first, then list_tasks. The task now shows [x] as its status.

Why does this prove persistence?

Because your tasks live in a SQLite database file (tasks.db), they survive server restarts and new conversations. Close Claude Desktop, relaunch it, and ask to list your tasks. The completed task from earlier is still there.

This is what separates a real productivity tool from a demo. Your GitHub tools fetch live data from the cloud. Your task tools read and write to local state. Claude can now combine both: scan recent commits and create tasks based on what it finds.

Claude not calling the task tools?

  • Make sure you fully quit Claude Desktop from the system tray before relaunching. Just closing the window does not restart the MCP server.
  • Check that the three new tool functions all have the @mcp.tool() decorator and valid docstrings.
  • If Claude says it cannot access task tools, verify that your claude_desktop_config.json still points to the correct path for server.py.

Why isn't Claude calling my task management tools?

Your MCP server now combines live cloud data from GitHub with persistent local storage. Claude can read your repos, browse commits, view files, and manage a personal task list. Next up, take on the Secret Mission to add code search across all your repositories.

Secret mission

Add Cross-Repository Code Search

Your MCP server can read repos, commits, and files one at a time. But what if Claude could search for a specific pattern across every repository you own in seconds? In this secret mission, you'll add a code search tool powered by the GitHub Search API.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses free APIs, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Claude Desktop config file at %APPDATA%\Claude\claude_desktop_config.json.
  • Project folder containing server.py, .env, and tasks.db.
  • GitHub fine-grained personal access token.

Keep everything running

No action needed. Choose this if you are still actively building on your MCP server or plan to keep using it with Claude Desktop.

  • Your claude_desktop_config.json stays in place. Claude Desktop launches your MCP server automatically whenever you start a new conversation.
  • Your tasks.db database retains all saved tasks between sessions.
  • Your GitHub personal access token remains active with read-only repository access.

Pause - I'll come back to this later

Remove the server entry from your config file so Claude Desktop stops launching the MCP server. Your code, database, and token remain intact for when you want to resume.

  • Open %APPDATA%\Claude\claude_desktop_config.json in VS Code or Notepad.
  • Delete the DevProductivity entry from inside the mcpServers object so the file looks like this:
{
  "mcpServers": {}
}
  • Save the file and restart Claude Desktop.
  • To resume later, re-add the DevProductivity entry with your server path and token.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your config entry, project files, local database, and revokes your GitHub token.

  • Open %APPDATA%\Claude\claude_desktop_config.json in VS Code or Notepad.
  • Delete the DevProductivity entry from inside the mcpServers object, then save the file.
  • Open your Desktop in File Explorer (Windows) or Finder (macOS).
  • Find the folder that contains server.py, right-click it, and delete it. This removes server.py, .env, and tasks.db.
  • Go to your GitHub profile picture and click Settings.
  • Click Developer settings in the left sidebar.
  • Under Personal access tokens, click Fine-grained tokens.
  • Find the token you created for this project and click Delete.
  • Restart Claude Desktop to confirm the server is no longer available.

Nice Work!

Nice Work!

You just built a fully functional Developer Productivity MCP server in Python that turns Claude Desktop into a real-time bridge between your GitHub account and a local task tracker.

You've learned how to:

  • Build a custom MCP server in Python using the MCP Python SDK v2, connect it to Claude Desktop on Windows via stdio transport, and configure claude_desktop_config.json to launch your server as a subprocess.
  • Integrate the GitHub REST API with token authentication, fetching live repository data, commit history, and file contents that Claude can access in a conversation.
  • Build a persistent SQLite-backed task tracker that Claude can read and write to, demonstrating how local state survives across conversations.
  • Secret Mission: Add cross-repository code search using the GitHub Search API with rate limit handling, letting Claude find code patterns across your entire codebase.

Ready to quiz yourself?