# Elective 01: MCP Interface Layer

# source/project.md — Detailed Project Specification

> Generated by: skills/lesson-generator
> High-level overview: root `PROJECT.md`. Teaching content: `source/lesson.agent.md`.
> This file is implementation contracts, not teaching content.
> **Elective** — off the numbered 1–9 spine. Prereq skills: Projects 05, 06, 08.

---

## The One-Sentence Brief

Take the memory system you built in **Project 05** and expose it as an **MCP server** — a
reusable protocol surface with tools, resources, and a prompt — then consume that **one
server from two different hosts**, proving the provider/consumer split (M×N → M+N) instead
of hand-wiring tools into a single app loop as you did in Projects 06/08/09.

---

## Definition of Done

The project is complete when all of the following are true:

- [ ] The four learner modules (`memory_tools`, `memory_resources`, `prompts`, `security`)
      are implemented and **SDK-agnostic** (no `import mcp`) — pure, offline-testable.
- [ ] `memory_search` and `memory_save` have **JSON input schemas** (`type`, `required`,
      bounds, `enum` for `kind`) and handlers that **validate again** before touching the store.
- [ ] `security.py` **bounds** every input (`k`, text length, `importance` range, `kind`
      whitelist) and **contains** the resource URI (scheme + known-id allow-list); both fail closed.
- [ ] Memory entries are exposed as **resources** under `memory://entries/{id}` with
      list + read; a traversal/foreign-scheme URI is rejected.
- [ ] One reusable **prompt** (`reflect_on`) returns a parameterized message that injects
      recalled memories.
- [ ] **Two consumers** of the one server are demonstrated: the provided stdio smoke-test
      client **and** a second host (Claude Desktop or the MCP Inspector). Save in one, read in the other.
- [ ] The stdio server logs only to **stderr** (no `print` to stdout).
- [ ] Guiding tests in `code/tests/` pass offline (`python -m pytest`).
- [ ] `UNDERSTANDING.md` completed before any code, and states *MCP is an interface
      protocol, not an agent framework*, and *where the agent loop now lives*.
- [ ] `FAILURE_ANALYSIS.md` contains ≥3 intentional experiments (e.g. stdout corruption,
      removed schema bound + hostile input, unvalidated `save`, wrong-primitive classification).
- [ ] `EVALUATION.md` records the two-consumer demonstration and what each host saw.
- [ ] `STARCALLOS_REFLECTION.md` names ≥1 concrete capability StarcallOS should expose via MCP.

---

## File Specification

`code/` has eleven files. The **memory backend** (the thing being wrapped) and the
**transport/SDK wiring** are provided; the learning target is the **protocol surface**:
schemas, handlers, resources, prompt, and the trust-boundary checks.

### `config.py` — `provided`

Canonical provider-resolution block (do **not** edit) + a per-project `Config` adding:
`server_name` (`"personal-memory"`), `server_version`, `default_k` (5), `max_k` (50),
`max_text_chars` (4000), `min_importance`/`max_importance` (1.0 / 10.0), `allowed_kinds`
(`("episodic","semantic","procedural")`), `resource_scheme` (`"memory"`). The learner reads
these bounds in `security.py`; do not hardcode them elsewhere.

### `memory_backend.py` — `reference`

**Purpose:** A complete, working memory store — the **subsystem being wrapped** (Project 05,
condensed). Provided whole because memory is *not* the learning target here; the MCP surface is.

**Key API:**
- `Memory` dataclass (`id`, `text`, `kind`, `created_at`, `last_accessed`, `importance`,
  `embedding`) — faithful to Project 05.
- `MemoryBackend.save(text, *, kind, importance) -> Memory`
- `MemoryBackend.search(query, *, k) -> list[Memory]` (ranked; touches `last_accessed`)
- `MemoryBackend.get(entry_id) -> Memory | None`, `.all_entries() -> list[Memory]`
- Runs **offline by default** with a deterministic local embedder (so tests and the demo
  need no provider/network); a real provider embedder is an optional path via `config.py`.

**Does not:** speak MCP. It is plain Python — exactly the capability the learner will wrap.

### `security.py` — `learner`

**Purpose:** The **trust boundary**. Validate untrusted arguments and contain resource URIs.

**Key functions (learner-owned core, M1–M3):**

```python
class SecurityError(ValueError): ...

def validate_search_args(args: dict) -> tuple[str, int]:
    """Return (query, k). Reject non-str/empty query; clamp k to [1, cfg.max_k]; default cfg.default_k."""

def validate_save_args(args: dict) -> tuple[str, str, float]:
    """Return (text, kind, importance). Cap text len; whitelist kind; range-check importance."""

def validate_resource_uri(uri: str) -> str:
    """Return the entry id from a memory://entries/{id} URI. Reject other schemes, traversal,
    and malformed ids. Fail closed (raise SecurityError)."""
```

**Provided:** `SecurityError`, signatures, docstrings. **Learner:** the checks (raise
`NotImplementedError` until done). Bounds come from `config.py`.

### `memory_tools.py` — `learner`

**Purpose:** The **tool layer** (model-controlled actions). SDK-agnostic.

**Key symbols (learner-owned core, M1–M2):**
- `TOOL_DEFINITIONS: list[dict]` — each `{name, description, inputSchema}`; the JSON schema
  is the contract the model sees ("the description is the API" — Project 06).
- `handle_search(args: dict, backend) -> dict` — validate → `backend.search` → `{"text": ...}`.
- `handle_save(args: dict, backend) -> dict` — validate → `backend.save` → `{"text": ...}`.
- `dispatch(name: str, args: dict, backend) -> dict` — name → handler table.

**Does not:** import `mcp`. `server.py` adapts these plain dicts into MCP content.

### `memory_resources.py` — `learner`

**Purpose:** The **resource layer** (application-controlled context).

**Key functions (learner-owned core, M3):**
- `list_resources(backend) -> list[dict]` — one descriptor per entry (`uri`, `name`,
  `mimeType`), uri = `memory://entries/{id}`.
- `read_resource(uri: str, backend) -> dict` — validate uri (via `security`) → `{"text": ...}`.

**Does not:** import `mcp`.

### `prompts.py` — `learner`

**Purpose:** The **prompt layer** (user-controlled template).

**Key symbols (learner-owned core, M4):**
- `PROMPT_DEFINITIONS: list[dict]` — `{name, description, arguments}` for `reflect_on`.
- `get_prompt(name: str, args: dict, backend) -> dict` — return `{"description", "text"}`
  where `text` injects memories recalled for `args["topic"]`.

**Does not:** import `mcp`.

### `server.py` — `provided`

**Purpose:** FastMCP transport boilerplate + registration. Imports the learner modules and
the backend, registers `memory_search`/`memory_save` tools, the `memory://entries/{id}`
resource, and the `reflect_on` prompt, then `mcp.run(transport="stdio")`. Logs to **stderr**.
Until the learner cores are implemented it starts but tool calls raise `NotImplementedError`
— the guiding feedback. **Do not** add an agent loop here; the loop belongs to the host.

### `client_smoke_test.py` — `provided`

**Purpose:** **Consumer #1** — a programmatic stdio client. Launches `server.py`, runs
`initialize()` (capability negotiation), `list_tools()` (discovery), then calls
`memory_save`, `memory_search`, reads a resource, and gets the prompt. Prints what it saw.
This is the offline end-to-end proof once the cores work.

### `tests/` — `provided`

`conftest.py` (puts `code/` on `sys.path`), `test_config_models.py` (drift guard — passes
today), `test_security.py` (bounds + URI containment, valid + hostile), `test_tools.py`
(schemas present; search/save handlers; round-trip), `test_resources.py` (list + read; URI
rejection), `test_prompts.py` (template injects the topic + recalled entries). All **offline**;
they import the learner modules + backend only (never `server.py`), and fail with
`NotImplementedError` until the cores are implemented.

### `pytest.ini` — `provided`

Scopes collection to `tests/` (`python_files = test_*.py`) so `client_smoke_test.py` is not
collected (it imports the `mcp` SDK and is the live path, not an offline test).

### `README.md` / `.env.example` / `requirements.txt` — `provided`

Setup, one-command run + test, milestones, file roles, and the **two-consumer** instructions
(stdio client + Claude Desktop / MCP Inspector config). The backend runs offline by default,
so `.env` is optional.

---

## Input / Output Contracts

| Function | Input | Expected Output | Error Behavior |
|----------|-------|-----------------|----------------|
| `validate_search_args(args)` | `{"query": str, "k"?: int}` | `(query, k)` with `1 ≤ k ≤ max_k` | non-str/empty query, k≤0 → `SecurityError`; missing k → `default_k` |
| `validate_save_args(args)` | `{"text": str, "kind"?: str, "importance"?: float}` | `(text, kind, importance)` | text>`max_text_chars` or empty, bad `kind`, importance out of range → `SecurityError` |
| `validate_resource_uri(uri)` | `str` | entry id (e.g. `"m3"`) | non-`memory://`, `..`/`/`, unknown shape → `SecurityError` |
| `handle_search(args, backend)` | dict, backend | `{"text": "[id] (kind) text\n…"}` | empty store / no hits → `{"text": "No matching memories."}` |
| `handle_save(args, backend)` | dict, backend | `{"text": "Saved m{n}."}` | invalid args → propagate `SecurityError` |
| `list_resources(backend)` | backend | `[{"uri","name","mimeType"}, …]` | empty store → `[]` |
| `read_resource(uri, backend)` | str, backend | `{"text": entry.text}` | unknown id → `SecurityError`/empty; bad uri → `SecurityError` |
| `get_prompt("reflect_on", args, backend)` | name, dict, backend | `{"description", "text"}`, text contains topic + recalled | missing topic → `SecurityError` |

---

## Extended Requirements

Complete after the core works:

- [ ] **Streamable HTTP transport:** run the *same* server over HTTP and connect a remote
      client — change only the `mcp.run(...)` line, proving the data/transport split
      (`sources/official-docs/mcp-architecture.md`).
- [ ] **Resource templates / search-as-resource:** expose `memory://search/{query}` as a
      dynamic resource in addition to per-entry resources.
- [ ] **Real provider embeddings:** flip the backend to LiteLLM/Ollama embeddings (Project 02
      rule: same model both sides) and compare recall quality vs the offline embedder.
- [ ] **A second tool host as agent:** point your Project 08 agent at this server as a host
      (consumer #3) so the agent's `TASK` route uses the MCP memory instead of in-process code.
- [ ] **Elicitation:** have `memory_save` ask the host to confirm before storing
      (`elicitation/create`).

---

## Known Difficulty Spikes

1. **"Where's the loop?"** The hardest unlearning from P06/P08. There is **no loop in the
   server.** The host runs it. If you find yourself calling the model inside a handler, stop.
2. **stdout corruption.** A single `print(...)` (without `file=sys.stderr`) in a stdio server
   corrupts the JSON-RPC stream and the server dies opaquely. Log to stderr only.
3. **Primitive classification.** Search/save are **tools** (model acts); entries are
   **resources** (app reads); reflect-on is a **prompt** (user invokes). Putting a read-only
   entry behind a tool, or an action behind a resource, is the classic error.
4. **The schema is advisory.** The model can ignore or violate your `inputSchema`. Validate
   *again* in the handler. The schema documents; `security.py` enforces.
5. **URI containment is like path containment (P06), but for URIs.** Check the **scheme** and
   resolve to a **known id**; reject `..`, absolute paths, and foreign schemes. Fail closed.
6. **Keep learner modules SDK-free.** If `memory_tools` imports `mcp`, your offline tests need
   the SDK and the clean separation (surface vs transport) is lost. Only `server.py` imports `mcp`.

---

## Debugging Approach

1. Tests first: `python -m pytest` — the offline cores (security → tools → resources →
   prompt) should pass before you touch the live server.
2. Server import: `python -c "import server"` — does FastMCP wire up? (Needs `mcp` installed.)
3. Smoke test (consumer #1): `python client_smoke_test.py` — does `initialize`/`list_tools`/
   `call_tool` round-trip? Watch **stderr** for your logs.
4. If the server "breaks" with no error: search for `print(` without `file=sys.stderr`.
5. Second host (consumer #2): add the `mcpServers` entry, **fully restart** the host, check
   its MCP logs (e.g. `mcp-server-personal-memory.log`). Use the **MCP Inspector** if you
   don't have a GUI host.
6. Source: re-read `source/lesson.agent.md` §3/§9 and
   `sources/official-docs/mcp-build-server.md`.

---

## Integration Notes

**Depends on:** Project 05 (the memory store being wrapped — `save`/`search` over a stream),
Project 06 (tool schemas + the agent-computer interface — "the description is the API"; tool
sandboxing → now URI/argument containment across a process boundary), Project 02 (embeddings
behind the backend's relevance, optional).

**Relates to:** Project 08 (an agent is a *host* that consumes servers — the inverse role),
Project 09 (the Learning OS could route to MCP servers instead of in-process workers). MCP is
the connective tissue between the capabilities the spine built — not a new capability itself.
