This guide shows how to use RUNE specifications to define clear contracts for Model Context Protocol (MCP) server tools.
MCP (Model Context Protocol) allows AI assistants to interact with external tools and data sources. RUNE helps you:
| Without RUNE | With RUNE |
|---|---|
| Tool behavior implicit | Explicit specification |
| Inconsistent interfaces | Standard structure |
| Limited testing | Comprehensive test cases |
| Poor documentation | Self-documenting tools |
| Hard to review | Review specs before code |
Create a spec for your MCP tool:
# search_documents.rune
---
meta:
name: search_documents
language: python
version: 1.0
tags: [mcp-tool, search, documents]
mcp_server: document_server
---
RUNE: search_documents
SIGNATURE: |
async def search_documents(
query: str,
max_results: int = 10
) -> list[dict[str, Any]]
INTENT: |
MCP tool that searches indexed documents using semantic search.
Returns ranked results with relevance scores.
BEHAVIOR:
- WHEN query is empty THEN raise ValueError("Query cannot be empty")
- WHEN max_results < 1 or > 100 THEN raise ValueError
- PERFORM semantic search on document index
- RANK results by relevance score
- RETURN top max_results documents
CONSTRAINTS:
- "query: non-empty string, max 500 chars"
- "max_results: 1-100 inclusive"
EDGE_CASES:
- "empty query: raises ValueError"
- "no matches found: returns []"
- "max_results = 1: returns single result"
TESTS:
- "await search_documents('python') returns list"
- "await search_documents('') raises ValueError"
- "await search_documents('test', 0) raises ValueError"
DEPENDENCIES:
- "mcp>=0.1.0"
- "sentence-transformers>=2.0.0"
Use AI to generate the tool implementation:
# generated from search_documents.rune
async def search_documents(
query: str,
max_results: int = 10
) -> list[dict[str, Any]]:
"""
MCP tool that searches indexed documents using semantic search.
Returns ranked results with relevance scores.
"""
if not query or not query.strip():
raise ValueError("Query cannot be empty")
if max_results < 1 or max_results > 100:
raise ValueError("max_results must be between 1 and 100")
# Semantic search implementation
results = await perform_semantic_search(query)
ranked = rank_by_relevance(results)
return ranked[:max_results]
Register the implementation in your MCP server:
from mcp.server.lowlevel import Server
from .tools import search_documents
server = Server("document-server")
@server.tool(
name="search_documents",
description="Search indexed documents (see search_documents.rune)"
)
async def search_tool(query: str, max_results: int = 10):
return await search_documents(query, max_results)
Tools that search or retrieve data:
RUNE: query_database
SIGNATURE: |
async def query_database(
sql: str,
params: dict | None = None,
timeout: int = 30
) -> list[dict]
BEHAVIOR:
- VALIDATE SQL syntax
- SANITIZE inputs to prevent injection
- EXECUTE query with timeout
- WHEN timeout exceeded THEN raise TimeoutError
- RETURN query results as list of dicts
Tools that perform operations:
RUNE: send_email
SIGNATURE: |
async def send_email(
to: str,
subject: str,
body: str,
attachments: list[str] | None = None
) -> dict[str, Any]
BEHAVIOR:
- VALIDATE email address format
- VALIDATE attachments exist
- CONSTRUCT email message
- SEND via SMTP
- RETURN status with message_id
Tools that process and analyze data:
RUNE: analyze_sentiment
SIGNATURE: |
async def analyze_sentiment(
text: str,
language: str = "en"
) -> dict[str, float]
BEHAVIOR:
- VALIDATE text is non-empty
- DETECT language if not specified
- PERFORM sentiment analysis
- CALCULATE scores (positive, negative, neutral)
- RETURN scores dictionary
MCP tools should be async:
✅ Good:
SIGNATURE: async def tool_name(param: str) -> Result
❌ Bad:
SIGNATURE: def tool_name(param: str) -> Result
BEHAVIOR:
- WHEN operation exceeds timeout THEN raise TimeoutError
- WHEN network fails THEN raise ConnectionError
- WHEN rate limited THEN raise RateLimitError
Always return structured, predictable data:
✅ Good:
SIGNATURE: |
async def get_weather(city: str) -> dict[str, Any]
# Returns: {"temp": 72, "conditions": "sunny", "humidity": 45}
❌ Bad:
SIGNATURE: |
async def get_weather(city: str) -> Any
# Returns: sometimes dict, sometimes string, sometimes None
Link tool to its server:
meta:
name: search_documents
language: python
mcp_server: document_server # ← Server name
BEHAVIOR:
- WHEN input validation fails THEN raise ValueError
- WHEN resource not found THEN raise NotFoundError
- WHEN permission denied THEN raise PermissionError
- WHEN rate limit exceeded THEN raise RateLimitError
- WHEN timeout THEN raise TimeoutError
- WHEN unexpected error THEN raise RuntimeError with details
mcp-server/
├── server.py # MCP server definition
├── tools/ # RUNE specifications
│ ├── search.rune
│ ├── create.rune
│ └── update.rune
└── implementations/ # Generated from RUNE specs
├── search.py
├── create.py
└── update.py
# server.py
from mcp.server.lowlevel import Server
from .implementations import search_documents, create_document, update_document
server = Server("document-server")
# Register tools (implementations from RUNE specs)
@server.tool(name="search", description="Search documents")
async def search(query: str, max_results: int = 10):
return await search_documents(query, max_results)
@server.tool(name="create", description="Create document")
async def create(title: str, content: str):
return await create_document(title, content)
@server.tool(name="update", description="Update document")
async def update(doc_id: str, updates: dict):
return await update_document(doc_id, updates)
if __name__ == "__main__":
import asyncio
asyncio.run(server.run())
Create RUNE specs for all tools:
mcp-server/tools/
├── search_documents.rune
├── get_document.rune
├── create_document.rune
├── update_document.rune
└── delete_document.rune
Team reviews specs before implementation:
Generate code from specs:
# With Claude
for spec in tools/*.rune; do
claude "Generate implementation from $spec"
done
Run tests from RUNE specs:
# tests/test_search.py
import pytest
from implementations.search import search_documents
@pytest.mark.asyncio
async def test_search_valid_query():
results = await search_documents("python")
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_search_empty_query():
with pytest.raises(ValueError):
await search_documents("")
Package and deploy MCP server with validated tools.
# Generate tests directly from TESTS section
import pytest
from implementations.search import search_documents
class TestSearchDocuments:
@pytest.mark.asyncio
async def test_valid_query(self):
"""From: await search_documents('python') returns list"""
result = await search_documents('python')
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_empty_query(self):
"""From: await search_documents('') raises ValueError"""
with pytest.raises(ValueError):
await search_documents('')
@pytest.mark.asyncio
async def test_invalid_max_results(self):
"""From: await search_documents('test', 0) raises ValueError"""
with pytest.raises(ValueError):
await search_documents('test', 0)
Test tools in MCP context:
@pytest.mark.asyncio
async def test_mcp_tool_search():
from mcp.client import Client
client = Client("document-server")
result = await client.call_tool("search", query="python")
assert result["success"]
assert isinstance(result["data"], list)
RUNE: process_large_file
SIGNATURE: |
async def process_large_file(
filepath: str,
callback: Callable[[float], None] | None = None
) -> ProcessResult
BEHAVIOR:
- OPEN file for reading
- FOR each chunk in file
- PROCESS chunk
- IF callback provided THEN call with progress percentage
- RETURN processing result
EXAMPLES:
- |
async def progress_handler(percent: float):
print(f"Progress: {percent}%")
result = await process_large_file(
"large.csv",
callback=progress_handler
)
RUNE: stream_search_results
SIGNATURE: |
async def stream_search_results(
query: str
) -> AsyncIterator[SearchResult]
BEHAVIOR:
- VALIDATE query
- START search operation
- FOR each result as it arrives
- YIELD result
- WHEN all results processed THEN complete
EXAMPLES:
- |
async for result in stream_search_results("python"):
print(f"Found: {result['title']}")
RUNE: connect_database
SIGNATURE: |
async def connect_database(
connection_string: str
) -> AsyncContextManager[Connection]
BEHAVIOR:
- VALIDATE connection string
- ESTABLISH database connection
- RETURN async context manager
- ON exit THEN close connection properly
EXAMPLES:
- |
async with connect_database(conn_str) as db:
results = await db.query("SELECT * FROM users")
Check RUNE spec:
Update RUNE spec:
Review TESTS section:
Build better MCP servers with RUNE! 🗿