Module 8: Project — Real-World MCP Server

MCP Server Design and Architecture

MCP Server Design and Architecture

Capsule description

Before writing a single line of code, you design. Not for formality's sake — because the decisions you make now determine whether your server will be maintainable or a tangle. Which data to expose as resources. Which operations to turn into tools. Which repetitive interactions to automate with prompts. How to organize the files. How to handle errors.

This capsule guides you through the design process. When you finish, you'll have a complete architecture document and the file structure ready to implement.


The design process

Step 1: Define the domain

Your MCP server revolves around a domain — a coherent set of data and operations. The domain determines everything else.

Questions you must answer:

  1. What data does it handle? — Tasks, products, users, files, repos, issues...
  2. What operations do you need? — Create, read, update, delete, search, report, analyze...
  3. What questions do you ask frequently? — "How many pending tasks are there?", "What's the best-selling product?", "Which files were modified yesterday?"
  4. What data should Claude Code be able to see without you asking explicitly? — These are your resources.
  5. What actions should Claude Code be able to execute when you ask? — These are your tools.
  6. Which interactions do you repeat constantly? — These are candidates for prompts.

Step 2: Map primitives to the domain

Once you're clear about the domain, map each need to the correct primitive:

NeedPrimitiveWhy
"See what tables exist"ResourceStatic/lookup data, no side effects
"See a table's schema"ResourceMetadata that Claude Code needs to understand the structure
"Read a table's records"ResourceRead access to data
"Create a new record"ToolOperation with side effects (modifies the database)
"Update a record"ToolOperation with side effects
"Delete a record"ToolOperation with side effects
"Run a custom SQL query"ToolFlexible operation that can have side effects
"Search records by criteria"ToolParameterized operation
"Analyze a table and generate a report"PromptMulti-step interaction that combines resources and tools
"Generate a weekly summary"PromptReusable template with parameters

The simple rule

  • Does Claude Code need to read it to have context? → Resource
  • Does Claude Code need to execute something that changes data? → Tool
  • Is it a multi-step interaction that you repeat? → Prompt

Design for Option A: SQLite + Python

This is the main example. If you chose Option B or C, read this section to understand the process and then adapt it to yours.

Domain: Task Manager

A task management system with categories and tags. Simple enough to implement in a capsule, complex enough to demonstrate all the primitives.

Database schema

CREATE TABLE categories (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE,
    description TEXT,
    color TEXT DEFAULT '#6B7280',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled')),
    priority TEXT DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')),
    category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
    due_date TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE tags (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE
);

CREATE TABLE task_tags (
    task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
    tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (task_id, tag_id)
);

Why this schema:

  • categories and tags give 1:N and M:N relationships, demonstrating queries with JOINs
  • status and priority with CHECK constraints demonstrate validation at the database level
  • due_date allows temporal filters ("overdue tasks", "this week's tasks")
  • It's enough for 5+ interesting tools without being overwhelming

Planned resources

URIDescriptionWhat it returns
taskdb://tablesList of tables in the databaseNames and record count
taskdb://table/{name}/schemaSchema of a specific tableColumns, types, constraints
taskdb://statsGeneral statisticsCount by status, category, priority
taskdb://tasks/overdueOverdue tasksTasks with a past due_date and status != completed
taskdb://categoriesAll the categoriesList with task count per category

Design decisions:

  • The URIs use the taskdb:// prefix for clear namespacing
  • taskdb://tables is the entry point — Claude Code reads it first to understand the structure
  • taskdb://stats gives a quick summary without needing to run queries
  • taskdb://tasks/overdue is a derived resource — calculated data, not a table dump

Planned tools

ToolDescriptionInputsSide effects
create_taskCreate a new tasktitle, description?, category_id?, priority?, due_date?, tags?INSERT into tasks + task_tags
list_tasksList tasks with filtersstatus?, priority?, category_id?, limit?None (read only)
update_taskUpdate a task's fieldstask_id, fields to updateUPDATE on tasks
delete_taskDelete a tasktask_idDELETE on tasks + task_tags
search_tasksSearch tasks by textquery, search_in? (title/description/both)None
create_categoryCreate a categoryname, description?, color?INSERT into categories
run_queryRun a custom SQL querysql (SELECT only)None (read only)
get_task_summarySummary by periodperiod (today/week/month)None

Design decisions:

  • list_tasks is a tool because it has complex filters that need parameters
  • run_query allows arbitrary queries but restricted to SELECT (no modifications)
  • get_task_summary could be a resource, but it needs a parameter (period), so it's a tool
  • Each tool that modifies data returns the modified object for confirmation

Planned prompts

PromptDescriptionParameters
analyze_tableAnalyzes a table and suggests improvementstable_name
weekly_reportGenerates a report of the week's tasksweek_start? (default: this week)
optimize_queryAnalyzes and optimizes a SQL querysql_query

Design decisions:

  • analyze_table combines resources (schema, data) with the model's analysis
  • weekly_report standardizes an interaction you'd do regularly
  • optimize_query is advanced — Claude Code analyzes the query, consults the schema, and suggests improvements

Prompt template: analyze_table

@mcp.prompt()
async def analyze_table(table_name: str) -> str:
    """Analyzes the structure and data of a table, suggests improvements."""
    return f"""Analyze the table '{table_name}' in the database.

Please:
1. Read the resource taskdb://table/{table_name}/schema to see the structure
2. Use the run_query tool to count records: SELECT COUNT(*) FROM {table_name}
3. Use the run_query tool to see a sample: SELECT * FROM {table_name} LIMIT 5
4. Read the resource taskdb://stats for general context

With that information, generate an analysis that includes:
- Table structure (columns, types, constraints)
- Data volume
- Distribution of values in key columns
- Possible schema improvements (indexes, additional constraints)
- Useful queries for this table"""

Prompt template: weekly_report

@mcp.prompt()
async def weekly_report(week_start: str = "") -> str:
    """Generates a weekly report of tasks."""
    date_filter = f"for the week of {week_start}" if week_start else "for this week"
    return f"""Generate a productivity report {date_filter}.

Please:
1. Use the get_task_summary tool with period='week'
2. Use the list_tasks tool with status='completed' to see finished tasks
3. Read the resource taskdb://tasks/overdue for overdue tasks
4. Read the resource taskdb://stats for general context

With that information, generate a report that includes:
- Executive summary (completed vs pending tasks)
- Tasks completed this week (with category)
- Priority pending tasks
- Overdue tasks that require attention
- Metrics: completion rate, distribution by priority
- Recommendations for next week"""

Design for Option B: File System + TypeScript

If you chose Option B, here's the corresponding design.

Domain: Project Analyzer

A server that analyzes code projects — structure, dependencies, metrics.

Planned resources

URIDescription
project://infoProject metadata (name, type, size)
project://structureDirectory tree
project://dependenciesList of dependencies (package.json / requirements.txt)
project://statsMetrics: lines of code, files by type, total size

Planned tools

ToolDescription
list_directoryLists the content of a directory with filters
read_fileReads a file's content (with a size limit)
search_contentSearches text in the project's files
analyze_fileMetrics of a file: LOC, complexity, imports
find_unused_filesDetects files that aren't imported anywhere
generate_treeGenerates a visual tree of the project

Planned prompts

PromptDescription
project_reviewComplete analysis of a project directory
dependency_auditReviews dependencies: versions, vulnerabilities, unused

Example of interaction with Claude Code

You: "Analyze the structure of my project in ./my-app"
Claude Code: [uses resource project://structure] → reads the directory tree
Claude Code: [uses tool analyze_file for the main files]
Claude Code: "Your project my-app has 23 TypeScript files in 8 directories.
Most of the code is in src/components/ (42%). There are 3 files that aren't
imported anywhere: utils/legacy.ts, helpers/deprecated.ts, types/old.ts."

File structure

project-analyzer/
├── src/
│   ├── index.ts          # Entry point, MCP registrations
│   ├── tools/
│   │   ├── filesystem.ts # Read/search tools
│   │   └── analysis.ts   # Analysis tools
│   ├── resources/
│   │   └── project.ts    # Project resources
│   └── utils/
│       ├── tree.ts       # Tree generator
│       └── metrics.ts    # Metric calculations
├── tests/
│   ├── tools.test.ts
│   ├── resources.test.ts
│   └── integration.test.ts
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── README.md

Design for Option C: External API

If you chose Option C, here's the design framework. The example uses Todoist but adapt it to your API.

Domain: Todoist Integration

A server that connects Claude Code with the Todoist API for task management.

Planned resources

URIDescription
todoist://projectsList of projects
todoist://project/{id}Details of a project
todoist://labelsAll the available labels
todoist://statsProductivity statistics

Planned tools

ToolDescription
list_tasksLists tasks with filters (project, label, priority)
create_taskCreates a new task
complete_taskMarks a task as completed
update_taskUpdates an existing task
move_taskMoves a task to another project
search_tasksSearches tasks by text

Planned prompts

PromptDescription
daily_planGenerates the day's plan based on pending tasks
project_statusStatus report of a specific project

Special considerations for external APIs

  1. Authentication: You need an API token. Pass it as an environment variable, never hardcoded.
  2. Rate limiting: Respect the API's limits. Implement retry with backoff.
  3. Latency: Requests to external APIs are slower than a local database. Consider caching.
  4. Availability: The API can go down. Your server must handle timeouts and connection errors gracefully.
  5. Pagination: Many APIs paginate results. Your tool must handle pages or set a reasonable limit.
  6. Versioning: APIs change. Pin the API version you use (e.g., X-API-Version header).

File structure: Option A (main example)

task-manager-mcp/
├── src/
│   ├── __init__.py
│   ├── server.py              # Entry point — FastMCP instance and registrations
│   ├── database.py            # SQLite connection, table setup, seed data
│   ├── models.py              # Pydantic models for inputs and outputs
│   ├── tools/
│   │   ├── __init__.py
│   │   ├── tasks.py           # Task CRUD tools
│   │   ├── categories.py      # Category tools
│   │   └── queries.py         # Query and report tools
│   └── resources/
│       ├── __init__.py
│       └── database.py        # Database resources
├── tests/
│   ├── __init__.py
│   ├── conftest.py            # Shared fixtures (in-memory database)
│   ├── test_tools_tasks.py    # Task tool tests
│   ├── test_tools_categories.py
│   ├── test_tools_queries.py
│   ├── test_resources.py      # Resource tests
│   └── test_integration.py   # End-to-end tests
├── data/
│   └── tasks.db               # SQLite database (generated by the server)
├── requirements.txt
└── README.md

Why this structure

  • src/server.py as the entry point: A single file where everything is registered. Easy to find and modify.
  • src/database.py separated: The database connection and setup is independent of MCP. You can test the database without the server.
  • src/models.py centralized: All the Pydantic models in one place. Avoids circular imports.
  • src/tools/ in its own directory: Each group of tools in its file. When you have 8+ tools, a single file becomes hard to navigate.
  • src/resources/ separated from tools: Resources and tools have distinct responsibilities. Separating them makes the difference explicit.
  • tests/conftest.py with fixtures: The testing database (in memory) is configured once and reused across all the tests.
  • data/ for the database: The SQLite database doesn't go in src/. It's a data artifact, not code.

Files that shouldn't exist

  • src/utils.py — Too generic. If you need utilities, name them by what they do.
  • src/helpers.py — Same problem. Helper for what?
  • src/config.py — For a project of this size, configurations go in environment variables or in server.py.

Pydantic Models design

The models are the contract between Claude Code and your server. Designing them well now saves you problems later.

Input models (for tools)

from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum


class TaskStatus(str, Enum):
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"
    CANCELLED = "cancelled"


class TaskPriority(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"


class CreateTaskInput(BaseModel):
    title: str = Field(min_length=1, max_length=200, description="Task title")
    description: Optional[str] = Field(default=None, max_length=2000, description="Detailed description")
    status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status")
    priority: TaskPriority = Field(default=TaskPriority.MEDIUM, description="Priority: low, medium, high, critical")
    category_id: Optional[int] = Field(default=None, description="Category ID")
    due_date: Optional[str] = Field(default=None, description="Due date in YYYY-MM-DD format")
    tags: Optional[list[str]] = Field(default=None, description="List of tags for the task")


class UpdateTaskInput(BaseModel):
    task_id: int = Field(description="ID of the task to update")
    title: Optional[str] = Field(default=None, min_length=1, max_length=200)
    description: Optional[str] = Field(default=None, max_length=2000)
    status: Optional[TaskStatus] = Field(default=None)
    priority: Optional[TaskPriority] = Field(default=None)
    category_id: Optional[int] = Field(default=None)
    due_date: Optional[str] = Field(default=None)


class ListTasksInput(BaseModel):
    status: Optional[TaskStatus] = Field(default=None, description="Filter by status")
    priority: Optional[TaskPriority] = Field(default=None, description="Filter by priority")
    category_id: Optional[int] = Field(default=None, description="Filter by category")
    limit: int = Field(default=20, ge=1, le=100, description="Maximum results")


class SearchTasksInput(BaseModel):
    query: str = Field(min_length=1, description="Text to search")
    search_in: str = Field(default="both", description="Search in: title, description, both")


class CreateCategoryInput(BaseModel):
    name: str = Field(min_length=1, max_length=50, description="Category name")
    description: Optional[str] = Field(default=None, max_length=200)
    color: str = Field(default="#6B7280", pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color, e.g. '#FF5733'")


class RunQueryInput(BaseModel):
    sql: str = Field(min_length=1, description="SQL query (only SELECT allowed)")


class TaskSummaryInput(BaseModel):
    period: str = Field(description="Period: today, week, month")

Design principles for the models

  1. Each field has a description — Claude Code uses these descriptions to understand what to send. A clear description = fewer errors from the model.

  2. Explicit constraints — min_length, max_length, ge, le, pattern. They validate automatically before your code touches the database.

  3. Reasonable defaults — status default pending, priority default medium, limit default 20. Claude Code doesn't need to specify everything all the time.

  4. Enums for fixed values — TaskStatus and TaskPriority as Enums. Claude Code can't send an invalid status.

  5. Optional for editable fields — In UpdateTaskInput, everything is Optional except task_id. You only update what you send.


Error handling design

Define how your server handles errors before implementing. The errors fall into predictable categories:

Error categories

CategoryExampleHow to handle
Not foundTask with ID 999 doesn't existReturn a clear message: "Task with ID 999 not found"
ValidationPriority "super_high" isn't validPydantic handles it automatically. Returns a descriptive error.
DatabaseCorrupt or locked SQLite fileCatch sqlite3.Error, return a generic message + logging
QuerySQL syntax error in run_queryCatch sqlite3.OperationalError, return the SQLite error
PermissionQuery with DELETE/DROP in run_queryDetect before executing, return "Only SELECT queries allowed"
ConnectionExternal API doesn't respond (Option C)Timeout + retry + message: "API unavailable, try later"

Consistent error pattern

All the errors return the same JSON format:

import json

def error_response(error_type: str, message: str, details: str = "") -> str:
    result = {
        "error": True,
        "error_type": error_type,
        "message": message,
    }
    if details:
        result["details"] = details
    return json.dumps(result, ensure_ascii=False)

Usage example:

async def delete_task(task_id: int) -> str:
    conn = get_connection()
    cursor = conn.execute("SELECT id FROM tasks WHERE id = ?", (task_id,))
    if not cursor.fetchone():
        return error_response("not_found", f"Task with ID {task_id} not found")

    conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
    conn.commit()
    return json.dumps({"deleted": True, "task_id": task_id})

Claude Code receives clear errors that it can communicate to the user. "Task with ID 999 not found" is infinitely better than a Python stack trace.


Tool descriptions design

Your tools' descriptions are more important than they seem. Claude Code reads them to decide when to use each tool. A vague description = Claude Code uses the tool incorrectly or doesn't use it when it should.

Good vs bad descriptions

# ❌ Bad: too vague
async def list_tasks() -> str:
    """Lists tasks."""
    ...

# ❌ Bad: too technical
async def list_tasks(input: ListTasksInput) -> str:
    """Runs SELECT * FROM tasks with optional WHERE filters on status, priority and category_id."""
    ...

# ✅ Good: clear and usage-oriented
async def list_tasks(input: ListTasksInput) -> str:
    """Lists tasks with optional filters by status, priority and category.

    Without filters it returns the 20 most recent tasks. Use status='completed'
    to see only the finished ones, priority='high' for the urgent ones.
    """
    ...

Principles for descriptions

  1. First sentence = what the tool does. Claude Code often only reads the first line.
  2. Second sentence = how to use it. Example of common parameters.
  3. Don't explain the implementation. Claude Code doesn't need to know that you use SQLite underneath.
  4. Mention the available filters. If the tool accepts filters, list the most useful ones.
  5. Mention edge cases. "If not found, returns an error message."

Apply the same to resources — the description you pass to the @mcp.resource() decorator tells Claude Code what data it contains.


URI design for resources

The resource URIs should be predictable and descriptive. Follow these conventions:

Conventions

{prefix}://{entity}                    → List or summary of the entity
{prefix}://{entity}/{id}               → Specific entity by ID
{prefix}://{entity}/{id}/{sub-entity}  → Sub-entity of an entity
{prefix}://{metadata-type}             → System metadata

Examples for Task Manager

taskdb://tables                 → List of tables
taskdb://table/tasks/schema     → Schema of the tasks table
taskdb://table/categories/schema → Schema of the categories table
taskdb://stats                  → General statistics
taskdb://tasks/overdue          → Overdue tasks (derived resource)
taskdb://categories             → List of categories

URI anti-patterns

taskdb://getTaskById/5          → Don't use verbs in resource URIs
taskdb://all-the-tables         → Don't use ambiguous names
taskdb://data                   → Too generic
taskdb://tasks?status=pending   → Resources don't have query params; use a tool

Design decisions to document

Before implementing, document your decisions. These questions help you think about the trade-offs:

Decisions checklist

  1. Which operations to expose as tools vs resources?

    • Tools: parameterized operations or ones with side effects
    • Resources: context data that Claude Code reads to understand the situation
  2. Does the run_query tool allow arbitrary queries?

    • Yes, but only SELECT. It rejects INSERT/UPDATE/DELETE/DROP.
    • Trade-off: flexibility vs security. For a local server it's acceptable.
  3. Do the tools return the complete object or just confirmation?

    • Complete object after create/update. Claude Code needs to see what was created/changed.
    • Only confirmation after delete (the object no longer exists).
  4. How to handle the database in tests?

    • In-memory database (sqlite3.connect(":memory:")) for tests.
    • File database for the real server.
    • A pytest fixture that creates and destroys the database for each test.
  5. Is seed data included?

    • Yes. The server creates tables and optionally loads example data.
    • Useful for demos and so the user sees data immediately.

Your turn: design your server

If you're following along with Option A (SQLite + Python), the designs in this capsule are your starting point. You can use them as they are or modify them for your domain.

If you chose Option B or C, use the design frameworks above to create your own plan:

Design template

Copy and complete it for your project:

## My MCP Server: [Name]

### Domain
[Describe what data it handles and what problem it solves]

### Stack
- Language: [Python / TypeScript]
- Data source: [SQLite / File system / External API]
- Validation: [Pydantic / Zod]
- Testing: [pytest / Vitest]

### Resources (minimum 3)
| URI | Description | What it returns |
|-----|-------------|-------------|
| | | |

### Tools (minimum 5)
| Tool | Description | Inputs | Side effects |
|------|-------------|--------|--------------|
| | | | |

### Prompts (minimum 2)
| Prompt | Description | Parameters |
|--------|-------------|------------|
| | | |

### File structure
[Directory tree]

### Design decisions
1. [Decision] → [Reason]
2. [Decision] → [Reason]
3. [Decision] → [Reason]

Create the file structure

Once you have the design, create the empty structure. For Option A:

mkdir task-manager-mcp
cd task-manager-mcp

python -m venv .venv
source .venv/bin/activate  # Linux/macOS
# .venv\Scripts\activate   # Windows

pip install "mcp[cli]" pydantic pytest pytest-asyncio

mkdir -p src/tools src/resources tests data

touch src/__init__.py
touch src/server.py
touch src/database.py
touch src/models.py
touch src/tools/__init__.py
touch src/tools/tasks.py
touch src/tools/categories.py
touch src/tools/queries.py
touch src/resources/__init__.py
touch src/resources/database.py
touch tests/__init__.py
touch tests/conftest.py
touch tests/test_tools_tasks.py
touch tests/test_tools_categories.py
touch tests/test_tools_queries.py
touch tests/test_resources.py
touch tests/test_integration.py

requirements.txt

mcp[cli]>=1.0.0
pydantic>=2.0.0
pytest>=8.0.0
pytest-asyncio>=0.23.0

.gitignore

.venv/
__pycache__/
*.pyc
data/tasks.db
.pytest_cache/
*.egg-info/
dist/
build/

Don't version the database (data/tasks.db) — it's generated automatically. Don't version the virtualenv or the Python caches. If your project uses API tokens, never include them in the repo — use .env and add it to .gitignore.

Verify that the environment works

python -c "import mcp; print(f'MCP SDK version: {mcp.__version__}')"
python -c "import pydantic; print(f'Pydantic version: {pydantic.__version__}')"
pytest --version

If all three commands run without error, your environment is ready.


Milestone of this capsule

When you finish this capsule, you should have:

  • Domain defined — You know exactly what data and operations your server handles
  • Resources planned — List of at least 3 resources with defined URIs
  • Tools planned — List of at least 5 tools with defined inputs
  • Prompts planned — List of at least 2 prompts with parameters
  • Pydantic/Zod models designed — Validation schemas for each tool input
  • File structure created — All the files/directories exist (empty)
  • Environment configured — SDK, dependencies, and testing framework installed
  • Decisions documented — You know why you chose each resource, tool, and prompt

If all of this is checked, you're ready to implement. Capsule 03 is where the code takes shape.


Common errors in the design phase

Designing too many tools from the start

It's tempting to plan 15 tools before writing code. The problem: half of those tools will change when you start implementing. Plan the 5-8 core ones, implement, and then add if you need more.

Confusing resources with tools

A frequent error is creating a resource for something that requires variable parameters. If Claude Code needs to send a status filter, it's a tool, not a resource. Resources are for context data with fixed URIs or with simple templates like {table_name}.

Forgetting the descriptions

Leaving descriptions empty or with a single "Lists tasks" is like naming a function doStuff(). Claude Code needs detailed descriptions to decide when to use each tool. Invest time in writing them well now — it saves you debugging later.

Not designing the error handling

"I'll handle it later" is the phrase that precedes stack traces in production. Define the error pattern now (consistent JSON format, predictable categories) and all your tools follow it.

Making the schema too complex

For a 4-hour project, 3-4 tables is ideal. More than that and you spend more time on the database than on MCP. Remember: the goal is to demonstrate MCP, not to design an enterprise schema.


Troubleshooting

"I don't know which domain to choose"

Think of a task you do weekly with data. Do you manage tasks? Do you keep an inventory? Do you organize notes? Any of those works. If you really have no preference, use the Task Manager from this capsule.

"Do I need exactly the file structure that's shown?"

No. The structure is a proven suggestion. If you prefer to put everything in a single file to start, do it. You can refactor later. What matters is that it works.

"Can I add more tools/resources later?"

Yes. The design isn't final. Implement the minimum first (3 resources, 5 tools, 2 prompts). If you have time to spare and want to add more, do it. The rubric gives extra points for going beyond the minimum.

"How do I decide if something is a resource or a tool?"

Ask yourself: "Does Claude Code need parameters to get this?" If yes, it's probably a tool. If no, it's a resource. Another question: "Does this modify data?" If yes, it's necessarily a tool.

"Are prompts mandatory?"

Yes, at least 2. Prompts demonstrate that you understand the third primitive. Besides, they're extremely useful in practice — they turn 3-step interactions into 1.

"Can I use PostgreSQL instead of SQLite?"

You can, but it adds complexity (installation, connection, credentials). SQLite comes included with Python and needs no setup. For this project, SQLite is the recommended option. If you already have PostgreSQL configured and prefer it, go ahead.


Summary

  • You designed the complete architecture of the MCP server: database schema, models, and folder structure
  • The schema uses 3 related tables: tasks, categories, and task_tags
  • The Pydantic models define clear contracts for data input and output
  • You planned the 3 primitives: Resources (reading data), Tools (CRUD with side effects), Prompts (reusable templates)
  • The folder structure separates responsibilities: database/, tools/, resources/, prompts/
  • The design prioritizes clarity over optimization — a server that's easy to understand and maintain

Resources

  1. SQLite Documentation — Complete reference of SQL and data types
  2. Pydantic v2 — Fields — Documentation of Field constraints
  3. MCP Resources Specification — Official specification of Resources
  4. MCP Tools Specification — Official specification of Tools
  5. MCP Prompts Specification — Official specification of Prompts
  6. Python Enums — Enums for status and priority
  7. Zod Documentation — Validation for TypeScript (Option B)
  8. MCP URI Design — URI conventions