MCP FastMCP Reference Architecture
Document
Description
MCP / FastMCP Reference Architecture
Artifact ID: 57
Type: Document (Reference)
Required: False
Produced By Activity ID: DTA (integration & API section of SAO)
Consumers: DTA → SAO authors; BPE plans that implement MCP
Description
Portable blueprint for adding MCP to a Django application with FastMCP. Use during DTA; copy chosen modules into the project SAO. Peer to artifacts 53 (INFRA), 54 (CICD), 56 (AI Agent).
Out of scope: in-app agent loops (artifact 56); project-specific package maps and domain write pipelines.
0. Purpose & How to Use
0.1 What this is
A modular catalog for MCP on Django. Modules are optional unless the mission requires them. Dependencies are explicit so you assemble a stack without inventing glue.
0.2 DTA workflow
- Answer mission questions (§1).
- Pick Case A, Case B, or both (§2).
- Treat tool descriptors as part of the contract (§3) — not polish.
- Design payload discipline — filters, limits, batch tools (§4).
- Choose transport topology (§5) — process model first, URL second.
- Copy modules + proof into the project SAO; implement via §8 slices.
0.3 Design principles
- Services (or API) are truth — tools adapt; they do not own business rules.
- Descriptors are the LLM UX — weak docstrings produce wrong calls.
- Default-narrow, explicit-widen — list tools filter + limit; never dump unbounded history into the model.
- Batch common "all matching X" intents — one scoped mutation, not N× update round-trips.
- MCP is its own process — not a Django
urlpatternsentry. - Async boundary is explicit — FastMCP is async; Django ORM is sync.
- Stdout is sacred on stdio — logs go to file or stderr only.
- Prove with real calls — Client, direct tool, or process JSON-RPC.
1. Mission Assessment
| # | Question | Drives |
|---|---|---|
| Q1 | Consumers: local IDE only, or remote AI clients too? | stdio vs HTTP/SSE sidecar |
| Q2 | Must MCP run without Django/ORM in-process? | Case B |
| Q3 | Is there a stable token-authenticated DRF API? | Case B cost |
| Q4 | Same rules for Web, REST, and MCP? | Shared services (always) |
| Q5 | Auth: process user, PAT per call, or both? | Identity module |
| Q6 | Should end users avoid putting DB credentials in the IDE? | Distributable facade image (Case B) |
| Q7 | Do tools compose multiple API calls / side effects? | Facade orchestration vs fat service methods |
| Signal | Prefer |
|---|---|
| Local Django + IDE, fastest path | Case A |
| Public/minimal MCP image, no ORM | Case B |
| Both local speed and distributable client | Hybrid (A + B, aligned signatures) |
| API immature | Case A first; extract B later |
| Need parity audit tool↔endpoint | Case B + mapping table |
2. Two Integration Cases
flowchart LR subgraph caseA [CaseA_ServiceBridge] CA[AI_Client] --> FA[FastMCP] FA --> ST[sync_to_async] ST --> SV[services.py] SV --> ORM[Django_ORM] end subgraph caseB [CaseB_APIFacade] CB[AI_Client] --> FB[FastMCP] FB --> HX[httpx] HX --> API[DRF_API_endpoints] API --> SVB[services.py] end
2.1 Case A — Direct bridge to services
| When | MCP may load Django; lowest latency; API may lag the tool surface |
| How | Thin @mcp.tool wrappers call *_service methods via sync_to_async |
| Guidance | No business rules in tools; return JSON-safe dicts only; enforce authz inside services |
| Integration | Same services as views/ViewSets; manage.py mcp_server (or equivalent) after django.setup() |
# Pattern: register_tools(mcp) per domain module
@mcp.tool()
async def list_items(*, user_id: str, active_only: bool = True) -> dict:
"""See §3 for full descriptor requirements."""
return await sync_to_async(_list_items, thread_sensitive=True)(
user_id=user_id, active_only=active_only
)
Layout:
mcp/
server.py # FastMCP singleton, initialize_mcp()
context.py # optional: contextvars current user
tools/
<domain>_tools.py # register_tools(mcp) or plain functions
management/commands/
mcp_server.py # stdio entry; no stdout noise
2.2 Case B — Facade over DRF
| When | Separate process/container; no ORM in MCP image; reuse API validation/auth |
| How | Sync tools call httpx with Authorization: Token|Bearer …; map status → exceptions |
| Guidance | Keep tool names and signatures aligned with Case A if both exist; prefer a dedicated @action when composition needs a transaction |
| Integration | Depends only on base URL + token; OpenAPI helps humans, descriptors help models |
def create_item(name: str, category: str) -> dict:
"""See §3 for full descriptor requirements."""
r = get_client().post("/api/items/", json={"name": name, "category": category})
return check_response(r, "create_item")
Layout:
mcp_facade/
server.py # argparse: --server, --token, --transport
client.py # shared httpx.Client + check_response
tools_http.py # one tool → one or more endpoints
Dockerfile.mcp # fastmcp + httpx only
Composition: a tool may call several endpoints (create + link) or API + local FS (export then write files). Document partial-failure behavior; push true transactions into the API.
2.3 Hybrid
Two adapters, one tool contract (names, params, errors, return shapes). Case A for in-process/local; Case B for the public image. Do not fork descriptors.
3. Tool Descriptors — The LLM Contract
Tool descriptors are not documentation polish. They are the primary interface the model uses to choose tools and fill arguments. FastMCP turns type hints into JSON Schema and surfaces the docstring to the client. A vague docstring is a broken API: wrong tool selection, hallucinated parameters, wasted retries. Concrete examples in the docstring are what make correct first calls the default.
Invest here before adding more tools.
3.1 What the model sees
| Source | Becomes |
|---|---|
| Function name | Tool name |
Type hints (Literal, defaults, Optional) |
JSON Schema constraints |
| Docstring body | Tool description text |
| Param docs with examples | Guidance for argument values |
| Return / raises docs | Expectations after the call |
3.2 Required elements
- Verb-led name matching intent (
list_*,create_*,get_*). - Opening sentence: what it does and when to use it.
- Sentence or clause: when not to use it (if a sibling tool exists).
- Every param: type + meaning + Example: value.
- Return: field list + Example: payload.
- Raises: what the model should do next (
ValueError→ fix args;PermissionError→ stop/ask user). - For nested structures (
list[dict]): a full example object in the docstring.
3.3 Bad vs good
Pair 1 — Vague CRUD
Bad (model will invent fields):
@mcp.tool()
async def update_item(id: int, data: dict) -> dict:
"""Update item."""
...
Good:
@mcp.tool()
async def update_item(
item_id: int,
name: str | None = None,
category: str | None = None,
status: Literal["draft", "active"] | None = None,
) -> dict:
"""
Update a draft item. Pass only fields to change.
Do not use for released items — create a change proposal instead.
:param item_id: Item primary key. Example: 42
:param name: New display name, or omit. Example: "React Patterns"
:param category: Taxonomy bucket, or omit. Example: "frontend"
:param status: "draft" or "active", or omit. Example: "draft"
:return: Updated item. Example: {"id": 42, "name": "React Patterns",
"category": "frontend", "status": "draft", "version": "0.2"}
:raises ValueError: unknown id, empty name, or duplicate name
:raises PermissionError: item is released or caller is not owner
"""
...
Pair 2 — Nested batch payload
Bad (model guesses key names and date formats):
@mcp.tool()
async def create_log_entries(user_id: str, entries: list) -> dict:
"""Create log entries."""
...
Good:
@mcp.tool()
async def create_log_entries(*, user_id: str, entries: list[dict]) -> dict:
"""
Batch-create time-log entries for a day. Prefer direct foreign keys
(imperative_id / objective_id) over legacy linked_to_* fields.
:param user_id: Username. Example: "denis.petelin"
:param entries: List of entry dicts. Each entry supports:
- when: ISO-8601 UTC. Example: "2026-01-26T14:30:00Z"
- duration: HH:MM:SS. Example: "02:00:00"
- subject: Short label. Example: "Architecture work"
- imperative_id: Optional link. Example: "3"
- energy / mood: Optional 1–5. Example: 4
:return: {"status": "ok", "created": [<entry dicts>]}
:raises ValueError: invalid duration, unknown ids, or empty entries
Example:
entries = [{
"when": "2026-01-26T14:30:00Z",
"duration": "02:00:00",
"subject": "Architecture work",
"imperative_id": "3",
"energy": 4,
"mood": 4
}]
"""
...
Pair 3 — Enums and disambiguation
Bad (two similar tools; free-form status):
@mcp.tool()
async def list_playbooks(status: str = "all") -> list:
"""List playbooks."""
...
@mcp.tool()
async def list_all_playbooks() -> list:
"""Get playbooks for user."""
...
Good (one tool, constrained filter, clear scope):
@mcp.tool()
async def list_playbooks(
status: Literal["draft", "released", "active", "all"] = "all",
) -> list:
"""
List playbooks visible to the current user (owned + shared), newest first.
Use get_playbook when you already know the id and need nested workflows.
:param status: Filter. Example: "draft". Use "all" for no filter.
:return: List of summaries. Example:
[{"id": 1, "name": "React Dev", "status": "draft", "version": "0.1"}]
:raises ValueError: if no user context is configured
"""
...
3.4 Merge gate
Before merging a tool, an engineer or agent must successfully call it from an IDE using only tools/list output — not the Python source. If they cannot, the descriptor failed.
List tools additionally fail the gate if they lack a documented default filter and a hard limit (or cursor). Batch tools fail if scope is unbounded (no filter and no id list).
4. Payload Discipline: Filters, Pagination, Batch Tools
MCP tool results are model context, not a UI table. An unbounded list_* that returns years of history can be thousands of lines: token blow-up, truncated JSON, IDE/client timeouts that look like "hangs", and agents that retry or loop on partial data. Default-narrow, explicit-widen.
4.1 Why filters and limits are mandatory
| Failure mode | What happens |
|---|---|
| No filter, no limit | Entire table serialized into the tool result |
| Huge payload | Context window fills; later reasoning degrades or the call times out |
| Agent "fixes" it wrong | Calls the same list again, or pages manually with broken offsets |
| N× follow-up updates | Each row update re-sends context; cost and latency explode |
Therefore every list-style tool needs:
- Common-case filters that match how users actually speak ("today", "active only", "this week").
- A hard
limit(or page cursor) with a safe default and a server-side max. total_count(and ideallyfilters_applied) so the model knows to narrow the query or fetch another page — instead of assuming "this is everything."
Prefer a summary tool (get_day_summary, get_today) when the question is "how is today?" — do not dump full history by default.
4.2 How — common-case filters and pagination
Bad (will hang real deployments once data grows):
@mcp.tool()
async def list_log_entries(*, user_id: str) -> dict:
"""List all log entries for the user."""
...
Good (filters first, limit default, total_count back):
@mcp.tool()
async def list_log_entries(
*,
user_id: str,
date: str | None = None,
week_id: str | None = None,
objective_id: str | None = None,
imperative_id: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict:
"""
List log entries with filters. Always pass the narrowest filter you can
(prefer date or week_id). Default limit is 100; raise only if total_count
shows you need another slice.
:param user_id: Username. Example: "denis.petelin"
:param date: ISO date filter. Example: "2026-01-02"
:param week_id: Week id filter. Example: "1"
:param objective_id: Objective id filter. Example: "5"
:param imperative_id: Imperative id filter. Example: "3"
:param search: Subject substring. Example: "meeting"
:param limit: Max rows to return (default 100, server max 500). Example: 50
:return: {"log_entries": [...], "total_count": 240, "filters_applied": {...}}
"""
...
Common-case filter patterns (teach these in descriptors):
| Pattern | Example tool shape | User phrase it serves |
|---|---|---|
| Boolean default | list_intents(..., active_only=True) |
"my intents" → not archived junk |
| Date / due | list_objectives(..., due_date="2026-02-06") |
"today's tasks" |
| Multi-dim + limit | list_log_entries(date=…, limit=100) |
"what did I log this week?" |
| Small backlog cap | get_backlog_objectives(..., limit=10) |
"what's in the backlog?" |
Guidance:
- Name the top ~3 user phrases as real parameters — not a single opaque
q: strif you can avoid it. - Cap
limitserver-side (typical default 10–100; hard max 100–500 by entity density). - Return
total_counteven when truncated so the model can refine filters. - Case B: forward the same query params to DRF; unwrap
{results, count}and exposetotal_countin the tool return — do not silently drop pagination metadata. - Cursor/
offsetis fine whenlimitalone is not enough; document the paging params in the docstring with examples.
4.3 Why batch tools — filter then mutate once
User intent: "Reschedule all today's tasks to tomorrow."
Bad agent path (common without batch tools):
list_objectives(due_date=today)→ 25 rows (already large if unfiltered history was used).- Twenty-five
update_objective(id, due_date=tomorrow)calls. - N round-trips, N partial failures, context thrash — slow and fragile.
Good path:
- Optional confirm:
list_objectives(due_date=today)with a smalllimit/ count. - One batch tool scoped by the same common-case filter (or by ids from that list).
Flow: user intent → filtered list → one batch mutation → summary (updated_count).
Two batch styles
| Style | Signature idea | When |
|---|---|---|
| Filter-scoped (preferred for NL) | reschedule_objectives(from_due_date=…, to_due_date=…) |
User named a set ("all today") |
| Id-list | update_objectives(ids=[…], due_date=…) |
Model already listed and selected ids |
Ship list tools with common-case filters (list_objectives(due_date=…)) and batch create (create_log_entries(entries=[…])) first; add the mutation twin when journeys say "all matching X".
Good batch descriptor (illustrative)
@mcp.tool()
async def reschedule_objectives(
*,
user_id: str,
from_due_date: str,
to_due_date: str,
completed: bool = False,
) -> dict:
"""
Move all objectives with from_due_date to to_due_date in one call.
Use instead of calling update_objective per row. Only incomplete
objectives are moved unless completed=True.
:param user_id: Username. Example: "denis.petelin"
:param from_due_date: Source due date YYYY-MM-DD. Example: "2026-02-06"
:param to_due_date: Target due date YYYY-MM-DD. Example: "2026-02-07"
:param completed: Include completed items (default False)
:return: Summary only — not the full list. Example:
{"updated_count": 12, "from_due_date": "2026-02-06",
"to_due_date": "2026-02-07", "sample_ids": ["41", "42", "43"]}
:raises ValueError: invalid dates, or zero matches when caller expected some
"""
...
Batch anti-patterns
- List-all (no filter) then loop
update_*per row. - Batch with neither filter nor ids (accidental full-table update).
- Batch that returns the entire updated collection — return
updated_count+ sample ids; offer a filtered list if the user wants detail.
4.4 Payload merge gate
- List tool: documented common-case filter(s) + default
limit+total_countin the return shape. - Batch tool: explicit scope (filter and/or ids); refuses unbounded "all rows"; returns a summary.
- "All matching X" appears in a user journey → batch mutate is required, not optional polish.
5. Transport & Process Topology
MCP is not mounted through Django's URL router. Reference implementations run FastMCP as a separate process. A public path like /mcp is a reverse-proxy concern, not a path("mcp/", …) view.
Topology (summary): local IDE → stdio → MCP process → services (Case A) or API (Case B); remote clients → reverse proxy → MCP process over HTTP/SSE.
5.1 Recommended patterns
| Pattern | Wiring | Use |
|---|---|---|
| Stdio | mcp.run(transport="stdio", show_banner=False, log_level="ERROR") from a manage command or __main__ |
Local IDE (Cursor, Claude Desktop) |
| HTTP sidecar | Separate process: mcp.run(transport="http", host=…, port=…) or uvicorn on mcp.http_app() |
Remote / cloud AI clients |
| Legacy SSE sidecar | mcp.run(transport="sse", host=…, port=…) |
Older clients that still speak SSE |
Public /mcp URL |
nginx/ALB forwards /mcp → MCP process port; disable response buffering for streams |
Friendly cloud URL without Django |
Case A local: one process loads Django + FastMCP (stdio).
Case B remote: MCP process has no Django; only httpx → API.
Cloud "same host": still two processes (web + MCP); proxy joins them on one hostname.
5.2 Do not do this
# WRONG — MCP is not a Django view
urlpatterns = [
path("mcp/", somehow_fastmcp), # does not work as people hope
]
Reasons: FastMCP's HTTP stack is ASGI (Starlette), needs its own lifespan/session manager, conflicts with WSGI, and fights Django middleware/auth assumptions. Production deployments use a sidecar process, not Django URL routing.
5.3 Proxy sketch (public /mcp)
location /mcp/ {
proxy_pass http://127.0.0.1:8001/; # MCP sidecar
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # required for SSE/streamable HTTP
proxy_read_timeout 3600s;
}
Health: expose a small /health on the MCP process for container probes (facade pattern).
5.4 Advanced — Starlette mount (not Django urls)
FastMCP can build an ASGI app and mount it in Starlette/FastAPI:
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import FastMCP
mcp = FastMCP("App")
mcp_app = mcp.http_app(path="/") # Streamable HTTP
app = Starlette(
routes=[Mount("/mcp", app=mcp_app)],
lifespan=mcp_app.lifespan, # required — nested lifespans are not picked up
)
# run: uvicorn module:app
Use this when the outer app is already Starlette/FastAPI. Gluing the same into Django ASGI (Channels/get_asgi_application + Mount) is possible in theory and painful in practice (lifespan merge, workers, sync ORM). This RA recommends the sidecar instead.
Prefer Streamable HTTP (http / http_app) for new work; keep sse only for legacy clients.
6. Sync ↔ Async & Stdio Hygiene
6.1 Async boundary (Case A)
FastMCP invokes tools on an async event loop. Django ORM is sync.
| Rule | Detail |
|---|---|
| Wrap sync work | await sync_to_async(fn, thread_sensitive=True)(...) |
| One unit of work | Prefer one sync helper over many tiny awaits |
| Prefetch inside sync | select_related / list(qs) before returning to async |
| JSON-safe returns | dict/list/primitives only; no model instances; stringify Decimals |
| Contextvars | get_current_user() usually needs sync_to_async from async tools |
| Escape hatch | DJANGO_ALLOW_ASYNC_UNSAFE=true hides errors; do not treat as architecture |
Case B sync tools avoid the ORM problem entirely — a reason facade images stay simple.
6.2 Stdio hygiene
On stdio transport, stdout is the protocol.
- Suppress Django setup prints (redirect during
django.setup()). - Remove console
StreamHandlers; log to file and/or stderr. requires_system_checks = []on the manage command.show_banner=False, quiet FastMCP log level.- Never
print()orself.stdout.write()in the hot path.
7. Testing Tools
Use at least two tiers.
| Tier | How | Proves |
|---|---|---|
| T1 Client | async with Client(transport=mcp) as c: await c.call_tool(...) |
Schema, registration, async path |
| T2 Direct | await create_item(...) (or sync facade fn) + real DB/API |
Service/API wiring |
| T3 Process | Subprocess JSON-RPC initialize → tools/list → tools/call |
Entrypoint + stdio cleanliness |
T1 recipe — FastMCP Client (proves schema, registration, async path):
from fastmcp.client import Client
@pytest.fixture
async def mcp_client():
# `mcp` is the FastMCP server instance imported from your server module
async with Client(transport=mcp) as client:
yield client
@pytest.mark.asyncio
@pytest.mark.django_db
async def test_list_items(mcp_client):
result = await mcp_client.call_tool(
name="list_items",
arguments={"user_id": "test.user", "active_only": True},
)
assert "items" in result.data
@pytest.mark.asyncio
@pytest.mark.django_db
async def test_tool_names_registered(mcp_client):
tools = await mcp_client.list_tools()
tool_names = [t.name for t in tools]
assert "list_items" in tool_names
assert "create_item" in tool_names
@pytest.mark.asyncio
@pytest.mark.django_db
async def test_validation_error_is_actionable(mcp_client):
# Blank required field must return a clear ValueError message, not a 500
with pytest.raises(Exception, match="name"):
await mcp_client.call_tool("create_item", arguments={"user_id": "test.user", "name": ""})
Also assert: Case B bad/missing token fails cleanly (403/401, not 500).
T2 recipe — Direct service call (proves service/ORM wiring, no MCP overhead):
import pytest
from myapp.services import ItemService # adjust import to your project
@pytest.mark.django_db
def test_create_item_persists(django_user_model):
user = django_user_model.objects.create_user(username="test.user", password="pw")
svc = ItemService()
item = svc.create(user_id=user.pk, name="Widget", active=True)
assert item.pk is not None
assert item.name == "Widget"
@pytest.mark.django_db
def test_list_items_returns_active_only(django_user_model):
user = django_user_model.objects.create_user(username="test.user2", password="pw")
svc = ItemService()
svc.create(user_id=user.pk, name="Active", active=True)
svc.create(user_id=user.pk, name="Inactive", active=False)
results = svc.list(user_id=user.pk, active_only=True)
assert all(r["active"] for r in results)
assert len(results) == 1
T2 uses the real DB (@pytest.mark.django_db), no mocks, no MCP transport. It is fast and runs without the async overhead of T1.
T3 recipe — Subprocess JSON-RPC (proves entrypoint boots cleanly, no stdout noise):
Required when stdio transport is selected. Verifies that the server process emits only valid JSON on stdout.
import json
import subprocess
import sys
def _send(proc, payload: dict) -> dict:
line = json.dumps(payload) + "\n"
proc.stdin.write(line.encode())
proc.stdin.flush()
return json.loads(proc.stdout.readline())
def test_mcp_server_stdio_clean():
proc = subprocess.Popen(
[sys.executable, "-m", "myapp.mcp.server"], # adjust to your entrypoint
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
# Step 1: initialize handshake
resp = _send(proc, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}},
})
assert resp.get("result", {}).get("protocolVersion") is not None, "initialize failed"
# Step 2: list tools
resp = _send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
tool_names = [t["name"] for t in resp["result"]["tools"]]
assert "list_items" in tool_names
# Step 3: call a read tool
resp = _send(proc, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "list_items", "arguments": {"active_only": False}},
})
assert "error" not in resp, f"tool call failed: {resp}"
# Step 4: verify no stray stdout noise before/between responses
# (all non-JSON stdout would have broken json.loads above)
finally:
proc.terminate()
proc.wait()
stderr_output = proc.stderr.read().decode()
# Logging goes to stderr — that is fine; assert it is not empty only if you expect startup logs
assert "Traceback" not in stderr_output, f"server crashed:\n{stderr_output}"
Key assertion: if any print() or Django startup message reached stdout, json.loads would have raised before reaching this point — so passing the test proves stdout is clean.
8. Modules, Profiles, Proof, Slices
8.1 Module map
| ID | Module | Role |
|---|---|---|
| M1 | Transport | stdio and/or HTTP/SSE sidecar |
| M2 | Server core | FastMCP instance, lifecycle, logging |
| M3 | Tool surface | Names, schemas, descriptors, filters/limits/batch (§4) |
| M4a | Service adapter | Case A → services.py |
| M4b | HTTP adapter | Case B → DRF via httpx |
| M5 | Identity | Process user / PAT / who_am_i |
| M6 | Error contract | ValueError / PermissionError / 5xx mapping |
| M7 | Test harness | T1–T3 |
| M8 | Distribution | manage command and/or Docker facade |
| M9 | Parity (Case B) | Tool ↔ endpoint map |
Deps: M3 → exactly one of M4a/M4b per process; M4a → services → ORM; M4b → HTTP → API → services; M2 → M1+M3+M5+M6.
Forbidden: tools → models; facade → ORM.
8.2 Profiles
| Profile | Modules | When |
|---|---|---|
| Desktop A | M1 stdio, M2, M3, M4a, M5, M6, M7, M8 manage | Single-user local |
| Facade B | M1 HTTP/SSE, M2, M3, M4b, M5 token, M6, M7, M8 Docker, M9 | Public image / no ORM |
| Hybrid | Desktop A + Facade B, shared tool contract | Local + distributable |
8.3 Integration proof (DoD)
tools/listmatches the contracted set.- Happy path via T1 or T3.
- Auth failure controlled (bad token / missing user).
- Validation → actionable
ValueError. - Mutations go through services (assert side effects).
- Stdio session: no banner/log pollution on stdout.
- Facade (if any): runs with base URL + token only.
- Descriptor gate (§3.4) passed for every new tool.
- Payload gate (§4.4): list tools filter+limit+
total_count; batch tools scoped; no unbounded dumps.
8.4 Build slices
| Slice | Deliverable |
|---|---|
| S0 | Mission answers + profile in SAO |
| S1 | Server core + logging hygiene + empty registry |
| S2 | First read tool + full descriptor + filters/limit + T1/T2 |
| S3 | First write tool through service/API + error contract |
| S4 | Expand by bounded context; registration count test |
| S5 | Batch mutate when journey says "all matching X" (filter→batch) |
| S6 | (Hybrid/B) facade + Docker + proxy /mcp + T3 |
| S7 | Parity table (Case B); IDE smoke on descriptors + payload gate |
Generic reference. Project SAOs adapt modules and name their write-policy constraints; they do not belong in this artifact.
Metadata
- Type:
- Document
- Required:
- No
- Created:
- Jul 17, 2026
- Updated:
- Jul 24, 2026
Producer Activity
Define MCP Architecture
Define Architecture
Consumer Activities 0
No activities consume this artifact yet.