MCP Protocol Architecture describes three server primitives: tools, resources, and prompts. Resources and prompts solve different problems from tools; using the wrong primitive leads to tools handling cases that resources or prompts handle more cleanly.
Resources: Read-Only Data Exposure
Resources expose data that the AI can read, similar to GET endpoints. Unlike tools, resources are application-controlled (the client decides when to fetch them, not the AI) and they don’t have side effects. They’re the right primitive when you want to make information available without the AI actively executing anything.
@mcp.resource("schema://{table_name}")def get_table_schema(table_name: str) -> str: """Expose table schema as a readable resource.
URI pattern: schema://analytics.orders """ # Fetch from your metadata store return f""" Table: {table_name} Columns: - id: INT (Primary Key) - customer_id: INT (Foreign Key) - total_amount: DECIMAL(10,2) - created_at: TIMESTAMP """
@mcp.resource("config://pipelines/{pipeline_id}")def get_pipeline_config(pipeline_id: str) -> str: """Expose pipeline configuration.""" return f"Pipeline {pipeline_id} configuration: schedule=daily, owner=data-team"Resources use URI templates with {variable} placeholders. The AI can browse available resources and read them without executing actions. The URI scheme (schema://, config://) is arbitrary — choose something that makes the resource’s purpose clear.
When to Use Resources Instead of Tools
The distinction matters for data engineering servers:
Table schemas are better as resources. The AI reads them for context before deciding what to do next. No side effects, no parameters beyond the table identifier.
Pipeline configurations are better as resources. They’re static data that the AI might read to understand a system before taking action.
Query execution is better as a tool. It has side effects (costs money, uses compute), takes complex parameters, and the AI explicitly chooses to invoke it.
Pipeline triggers are better as tools. They have side effects and require explicit invocation with user consent.
The rule of thumb: if the AI is reading information to inform its reasoning, use a resource. If the AI is performing an action, use a tool.
Prompts: Reusable Templates
Prompts are user-controlled templates that guide AI interactions. They’re different from tools (AI-invoked) and resources (application-controlled) — prompts are initiated by the user, who selects them from a menu in the client UI.
@mcp.prompt(title="Data Quality Report")def data_quality_prompt(table_name: str) -> str: """Generate a data quality analysis prompt.
Args: table_name: Table to analyze """ return f"""Analyze the data quality of the '{table_name}' table.
Please check:1. Completeness: What percentage of required fields are populated?2. Uniqueness: Are there duplicate records?3. Freshness: When was the data last updated?4. Validity: Do values conform to expected formats and ranges?
Provide a summary with specific recommendations for improvement."""
@mcp.prompt(title="Schema Review")def schema_review_prompt(table_name: str, changes: str) -> str: """Generate a schema change review prompt.""" return f"""Review the proposed schema changes for {table_name}:
{changes}
Evaluate:- Backward compatibility with existing queries- Impact on downstream dependencies- Performance implications- Data migration requirements"""Prompts encode institutional knowledge about how to ask good questions. Instead of team members writing ad-hoc requests, they pick “Data Quality Report” from a menu and fill in the table name. The prompt ensures the AI gets a complete, well-structured request every time.
For data engineering teams, prompts are useful for standardizing review workflows (schema reviews, migration reviews), quality analysis patterns, and incident investigation templates.
Progress Reporting with Context
Long-running operations should report progress so the AI can inform users about what’s happening. FastMCP provides a Context object for this:
from mcp.server.fastmcp import FastMCP, Context
mcp = FastMCP("DataEngineering")
@mcp.tool()async def process_large_dataset(dataset_id: str, ctx: Context) -> str: """Process a large dataset with progress updates.
Args: dataset_id: Identifier of the dataset to process ctx: MCP context for progress reporting (injected automatically)
Returns: Processing summary """ await ctx.info(f"Starting processing of {dataset_id}")
total_batches = 10 for i in range(total_batches): # Simulate batch processing await ctx.report_progress( progress=(i + 1) / total_batches, total=1.0, message=f"Processing batch {i + 1}/{total_batches}" )
await ctx.info("Processing complete") return f"Processed dataset {dataset_id}: 10 batches, 100,000 rows"The Context object is injected automatically when you include it as a parameter — you don’t construct it yourself. It provides:
ctx.info(),ctx.warning(),ctx.error()— logging at different severity levels, routed through the MCP protocol to the clientctx.report_progress()— progress bars in clients that support them
Context is especially valuable for data engineering tools that interact with warehouses or orchestrators. A quality check that scans millions of rows, a pipeline trigger that waits for completion, a catalog search that queries multiple backends — these all benefit from progress reporting so the user knows the tool is working, not hanging.
Note that tools using Context must be async functions. FastMCP handles the event loop, but your tool function needs the async keyword to use await with the context methods.
Combining Primitives
A well-designed MCP server typically uses all three primitives together:
- Resources expose your data catalog, schemas, and configurations for the AI to read as context
- Tools let the AI execute queries, run checks, trigger pipelines
- Prompts give users standardized workflows for common tasks
The MCP Data Catalog Server Pattern demonstrates this combination in practice — resources for browsing schemas, tools for searching and querying lineage, and prompts for standardized analysis workflows.