diff --git a/.ai/active/SPRINT_PACKET.md b/.ai/active/SPRINT_PACKET.md index c1d6ec2..c66cd61 100644 --- a/.ai/active/SPRINT_PACKET.md +++ b/.ai/active/SPRINT_PACKET.md @@ -2,7 +2,7 @@ ## Sprint Title -Phase 9 Sprint 34 (P9-S34): CLI and Continuity UX +Phase 9 Sprint 35 (P9-S35): MCP Server ## Sprint Type @@ -10,7 +10,7 @@ feature ## Sprint Reason -`P9-S33` established the public-safe `alice-core` package boundary, one canonical local startup path, and deterministic sample data. The next non-redundant seam is a real local CLI so technical users can use Alice without the internal operator shell. +`P9-S33` established the public-safe `alice-core` package boundary and startup path. `P9-S34` established the deterministic local CLI contract. The next non-redundant seam is exposing that same continuity contract through a narrow MCP server so external assistants can use Alice without reopening core behavior. ## Planning Anchors @@ -24,20 +24,20 @@ feature ## Sprint Objective -Ship a deterministic local CLI for core continuity flows so an external technical user can run capture, recall, resume, open-loop review, correction, and status commands directly against the shipped `alice-core` runtime. +Ship a small deterministic MCP server for Alice continuity flows so one external MCP-capable client can call capture, recall, resume, open-loop, review, correction, and context-pack tools against the shipped local `alice-core` runtime. ## Git Instructions -- Branch Name: `codex/phase9-sprint-34-cli-and-continuity-ux` +- Branch Name: `codex/phase9-sprint-35-mcp-server` - Base Branch: `main` - PR Strategy: one sprint branch, one PR - Merge Policy: squash merge only after reviewer `PASS` and explicit Control Tower merge approval ## Why This Sprint Matters -- It is the first real user-facing runtime surface on top of the public core. -- It proves `alice-core` can be used directly by technical users before MCP arrives. -- It gives `P9-S35` a stable behavior contract to mirror instead of inventing a second UX path. +- It is the first real interop transport for external assistants. +- It should inherit the already-shipped `P9-S34` CLI semantics instead of inventing a second behavior model. +- It turns Alice from a local tool into a reusable memory layer for external agent clients. ## Redundancy Guard @@ -48,33 +48,35 @@ Ship a deterministic local CLI for core continuity flows so an external technica - Phase 7 chief-of-staff guidance layer. - Phase 8 operational chief-of-staff handoff, queue, routing, and outcome-learning seams. - `P9-S33` public-safe packaging, startup path, and sample-data baseline. -- Required now (`P9-S34`): - - deterministic CLI entrypoint for local Alice usage - - continuity command surface for capture/recall/resume/open loops/review/correction/status - - terminal-friendly output with provenance-backed summaries - - doc-matched CLI examples against the `P9-S33` local runtime path -- Explicitly out of `P9-S34`: - - MCP server implementation + - `P9-S34` deterministic local CLI contract for continuity workflows. +- Required now (`P9-S35`): + - narrow MCP transport for the shipped continuity contract + - deterministic tool schemas and serialization + - one local client interoperability proof + - parity tests between MCP outputs and shipped CLI/core behavior where relevant +- Explicitly out of `P9-S35`: - OpenClaw adapter implementation - - importer expansion beyond the shipped sample-data path - - hosted SaaS, remote auth, or unsafe autonomous execution - - reworking `P9-S33` packaging/runtime contracts unless required for CLI correctness + - importer expansion + - hosted auth or remote deployment systems + - widening the tool surface beyond the ADR-defined initial wedge + - reopening `P9-S33` packaging or `P9-S34` CLI semantics unless transport parity exposes a real defect ## Design Truth -- Alice remains a local-first memory and continuity engine first. -- The CLI should reuse shipped core semantics, not fork or reinterpret them. -- CLI output should be deterministic, readable, and provenance-backed. -- The CLI is the reference human-operated interface for `P9-S35` MCP parity, not a one-off wrapper. +- MCP is a transport layer over the shipped Alice continuity contract, not a new product surface with different semantics. +- Tool outputs must stay deterministic, provenance-backed, and narrowly scoped. +- External clients should get the same essential behavior as the local CLI for the same dataset and scope. +- The first MCP release should privilege stability and auditability over breadth. ## Exact Surfaces In Scope -- one installable CLI entrypoint under the `alice-core` package -- local commands for capture, recall, resume, open loops, review queue/detail, correction, and status -- deterministic terminal formatting for summary and detail views -- provenance snippets and clear empty states in CLI output -- doc updates for CLI installation and usage against the shipped local runtime -- tests covering command routing, output contracts, and correction/resumption behavior +- local MCP server entrypoint and runtime wiring +- deterministic tool schemas for the initial ADR-backed tool set +- transport wrappers for shipped continuity flows +- context-pack output where it can be defined directly from shipped continuity seams +- local auth/config model for MCP use on the documented startup path +- docs and examples for one compatible MCP client +- parity and transport tests for the scoped tool set ## Exact Files In Scope @@ -84,63 +86,65 @@ Ship a deterministic local CLI for core continuity flows so an external technica - `ROADMAP.md` - `pyproject.toml` - `apps/api/src/alicebot_api/__init__.py` -- `apps/api/src/alicebot_api/__main__.py` if introduced -- `apps/api/src/alicebot_api/cli.py` if introduced -- `apps/api/src/alicebot_api/cli_formatting.py` if introduced -- `apps/api/src/alicebot_api/config.py` if CLI config hooks are needed +- `apps/api/src/alicebot_api/config.py` +- `apps/api/src/alicebot_api/mcp_server.py` if introduced +- `apps/api/src/alicebot_api/mcp_tools.py` if introduced +- `apps/api/src/alicebot_api/mcp_models.py` if introduced +- `apps/api/src/alicebot_api/cli.py` if parity-alignment fixes are required +- `apps/api/src/alicebot_api/cli_formatting.py` if parity-alignment fixes are required - `apps/api/src/alicebot_api/continuity_capture.py` - `apps/api/src/alicebot_api/continuity_recall.py` - `apps/api/src/alicebot_api/continuity_resumption.py` - `apps/api/src/alicebot_api/continuity_open_loops.py` - `apps/api/src/alicebot_api/continuity_review.py` -- `apps/api/src/alicebot_api/store.py` -- `scripts/load_sample_data.sh` if CLI smoke setup needs alignment -- `tests/unit/test_cli.py` if introduced -- `tests/integration/test_cli_integration.py` if introduced +- `apps/api/src/alicebot_api/chief_of_staff.py` if `alice_context_pack` is implemented through existing brief assembly +- `tests/unit/test_mcp.py` if introduced +- `tests/integration/test_mcp_server.py` if introduced +- `tests/integration/test_mcp_cli_parity.py` if introduced - `BUILD_REPORT.md` - `REVIEW_REPORT.md` ## In Scope -- add a packaged CLI entrypoint that works from the documented local install -- support command coverage for: - - `capture` - - `recall` - - `resume` - - `open-loops` - - `review queue` - - `review show` - - `review apply` - - `status` -- keep terminal output deterministic enough for stable review and MCP follow-on mapping -- show provenance/confidence/status where it materially affects trust -- document exact command examples using the `P9-S33` sample dataset +- support an initial MCP tool set aligned to ADR-003: + - `alice_capture` + - `alice_recall` + - `alice_resume` + - `alice_open_loops` + - `alice_recent_decisions` + - `alice_recent_changes` + - `alice_memory_review` + - `alice_memory_correct` + - `alice_context_pack` +- define deterministic request/response shapes for those tools +- make one local MCP client call recall and resume successfully +- prove correction via MCP changes later retrieval behavior deterministically +- document exact local MCP startup/use path without changing the canonical `P9-S33` runtime flow ## Out Of Scope -- MCP transport or tool schemas -- OpenClaw or other external adapters -- broad repo packaging cleanup already handled in `P9-S33` -- broad TUI work or shell auto-completion polish -- any execution-autonomy expansion +- OpenClaw or other adapters +- broad tool-surface expansion beyond the ADR +- hosted or remote auth systems +- general-purpose agent execution tools +- broad repo restructuring +- replacing CLI as the reference behavior contract ## Required Deliverables -- packaged CLI entrypoint callable from a local install -- command coverage for core continuity flows -- deterministic text output for recall and resumption -- correction flow through CLI that updates later retrieval deterministically -- synced CLI docs and sprint reports +- packaged or runnable local MCP server entrypoint +- deterministic initial tool schemas and handlers +- one compatibility example for a real MCP client +- parity evidence showing MCP reflects shipped Alice continuity behavior +- synced docs and sprint reports ## Acceptance Criteria -- fresh local install can invoke the CLI from the documented path -- capture command writes a continuity event against real local data -- recall command returns deterministic ordered output with provenance snippets -- resume command returns recent decision, open loops, recent changes, and next action in terminal-friendly form -- open-loop and review commands expose correction-ready items without needing the internal web shell -- applying a correction via CLI changes later recall/resume behavior deterministically -- the CLI contract is narrow and stable enough for `P9-S35` MCP mirroring without reopening core UX semantics +- one MCP-capable client can call `alice_recall` successfully against the local runtime +- one MCP-capable client can call `alice_resume` successfully against the local runtime +- correction through `alice_memory_correct` changes a later retrieval/result deterministically +- MCP outputs remain narrow, deterministic, and provenance-backed +- the MCP tool contract is stable enough that `P9-S36` and `P9-S37` can build on it without reopening transport semantics ## Required Commands @@ -153,75 +157,72 @@ docker compose up -d ./scripts/api_dev.sh curl -sS http://127.0.0.1:8000/healthz ./.venv/bin/python -m alicebot_api --help -./.venv/bin/python -m alicebot_api status -./.venv/bin/python -m alicebot_api recall --query local-first -./.venv/bin/python -m alicebot_api resume ./.venv/bin/python -m pytest tests/unit tests/integration pnpm --dir apps/web test ``` -If the CLI is exposed through a console script instead of `python -m alicebot_api`, both invocation forms should be documented and at least one must be included in review evidence. +If a dedicated MCP server entrypoint or local MCP smoke command is introduced this sprint, it must be run and included in review evidence alongside one real client interoperability proof. ## Required Acceptance Evidence -- exact CLI install/invocation path used during verification -- one successful capture example -- one successful recall example -- one successful resumption example -- one successful review/correction example that changes a later retrieval result -- note of any deferred CLI ergonomics intentionally left for later phases +- exact MCP startup path used during verification +- exact client/config used for interoperability proof +- one successful `alice_recall` tool call +- one successful `alice_resume` tool call +- one successful correction flow showing later retrieval changed deterministically +- note of any intentionally deferred MCP ergonomics or auth concerns ## Implementation Constraints -- preserve shipped P5/P6/P7/P8/P9-S33 semantics -- do not require the internal web shell for the scoped flows -- keep CLI output deterministic and reviewable -- prefer stdlib CLI plumbing over new heavyweight dependencies unless clearly necessary -- do not widen the public surface beyond what `P9-S35` should inherit +- preserve shipped P5/P6/P7/P8/P9-S33/P9-S34 semantics +- keep the MCP surface narrow and ADR-aligned +- keep transport payloads deterministic and easily diffable +- do not introduce unsafe autonomous side effects +- prefer parity with shipped CLI/core behavior over transport cleverness ## Control Tower Task Cards -### Task 1: CLI Entry and Packaging +### Task 1: MCP Entry and Schemas -Owner: platform/package owner +Owner: platform/interop owner Write scope: - `pyproject.toml` - `apps/api/src/alicebot_api/__init__.py` -- `apps/api/src/alicebot_api/__main__.py` -- `apps/api/src/alicebot_api/cli.py` -- `apps/api/src/alicebot_api/cli_formatting.py` +- `apps/api/src/alicebot_api/mcp_server.py` +- `apps/api/src/alicebot_api/mcp_tools.py` +- `apps/api/src/alicebot_api/mcp_models.py` Responsibilities: -- define the packaged CLI entrypoint -- keep invocation local-install friendly -- keep command tree narrow and deterministic -- ensure output contracts are stable enough for follow-on MCP mapping +- define the runnable MCP server entrypoint +- keep the tool surface narrow and stable +- keep schema names and payloads deterministic +- avoid leaking internal-only helper seams -### Task 2: Continuity Command Wiring +### Task 2: Continuity Transport Wiring Owner: backend/runtime owner Write scope: +- `apps/api/src/alicebot_api/config.py` - `apps/api/src/alicebot_api/continuity_capture.py` - `apps/api/src/alicebot_api/continuity_recall.py` - `apps/api/src/alicebot_api/continuity_resumption.py` - `apps/api/src/alicebot_api/continuity_open_loops.py` - `apps/api/src/alicebot_api/continuity_review.py` -- `apps/api/src/alicebot_api/store.py` -- `apps/api/src/alicebot_api/config.py` +- `apps/api/src/alicebot_api/chief_of_staff.py` Responsibilities: -- wire core continuity functions into CLI-safe calls -- preserve deterministic ordering and validation behavior -- expose provenance/trust signals where needed -- ensure correction flows update later recall and resumption behavior +- map tool calls directly onto shipped continuity behavior +- expose provenance/trust signals consistently +- keep context-pack behavior grounded in shipped brief assembly +- fix only true parity gaps exposed during transport integration -### Task 3: Docs and Examples +### Task 3: Docs and Interop Example Owner: docs/integration owner @@ -233,33 +234,34 @@ Write scope: Responsibilities: -- document exact CLI invocation path -- add example commands against the shipped sample data -- keep docs aligned with the `P9-S33` startup path instead of reopening packaging guidance -- make the next seam toward MCP explicit +- document exact MCP startup path +- document one compatible local client example +- keep startup/sample-data instructions unchanged from `P9-S33` +- make the next seam toward adapters/importers explicit -### Task 4: Verification and Reports +### Task 4: Verification and Parity Owner: sprint integrator Write scope: -- `tests/unit/test_cli.py` -- `tests/integration/test_cli_integration.py` +- `tests/unit/test_mcp.py` +- `tests/integration/test_mcp_server.py` +- `tests/integration/test_mcp_cli_parity.py` - `BUILD_REPORT.md` - `REVIEW_REPORT.md` Responsibilities: -- prove command coverage against the shipped local runtime -- keep CLI output assertions stable and narrow -- document exact acceptance evidence and any deferred ergonomics -- keep scope hygiene explicit if any supporting files are touched +- prove recall/resume work through a real MCP client path +- prove correction changes later retrieval deterministically +- keep parity evidence explicit against shipped CLI/core behavior +- keep scope hygiene explicit if support files are touched ## Definition Of Done -- `P9-S34` CLI entrypoint exists and is callable from a documented local install -- core continuity flows are usable from the terminal without the internal shell -- command output is deterministic enough for review and future MCP parity +- `P9-S35` MCP server exists and is runnable from the documented local install +- the initial ADR-backed tool surface is implemented and deterministic +- one real client interoperability proof exists for recall and resume - docs, tests, build report, and review report are aligned -- no `P9-S35` MCP work or `P9-S36` adapter work leaks into the sprint +- no adapter or importer work leaks into the sprint diff --git a/.ai/handoff/CURRENT_STATE.md b/.ai/handoff/CURRENT_STATE.md index cb8e9c8..5d7bbd7 100644 --- a/.ai/handoff/CURRENT_STATE.md +++ b/.ai/handoff/CURRENT_STATE.md @@ -20,7 +20,7 @@ ## Incomplete / At-Risk Areas -- no published MCP server surface exists yet +- no external adapter/importer interop has shipped yet beyond local MCP - importer and adapter story is not yet public-ready - OSS license finalization is still open @@ -30,7 +30,7 @@ Phase 9: Alice Public Core and Agent Interop ## Latest State Summary -`P9-S33` and `P9-S34` are now shipped baselines: +`P9-S33`, `P9-S34`, and `P9-S35` are now shipped baselines: - package boundary is documented around `alice-core` - canonical local startup path is documented and script-backed @@ -39,12 +39,27 @@ Phase 9: Alice Public Core and Agent Interop - packaged CLI entrypoint exists at `python -m alicebot_api` (optional console script `alicebot`) - continuity command coverage exists for capture, recall, resume, open-loops, review queue/show/apply, and status - correction flow through CLI now deterministically changes later recall/resume outputs - -The next active seam is `P9-S35`: - -- mirror the shipped CLI continuity contract via MCP tool transport -- keep parity strict with existing deterministic CLI semantics -- avoid reopening continuity behavior in transport work +- local MCP server entrypoint exists at `python -m alicebot_api.mcp_server` (optional console script `alicebot-mcp`) +- ADR-003 MCP tools are wired to shipped continuity seams: + - `alice_capture` + - `alice_recall` + - `alice_resume` + - `alice_open_loops` + - `alice_recent_decisions` + - `alice_recent_changes` + - `alice_memory_review` + - `alice_memory_correct` + - `alice_context_pack` +- MCP interoperability proof is now covered by integration tests for: + - successful `alice_recall` and `alice_resume` calls + - correction via `alice_memory_correct` changing subsequent retrieval deterministically + - structured parity against shipped CLI/core behavior + +The next active seam is `P9-S36`: + +- implement OpenClaw adapter boundary on top of shipped CLI/MCP continuity contract +- keep parity strict with existing deterministic continuity semantics +- avoid widening MCP transport semantics while adapter boundary is established ## Critical Constraints @@ -55,11 +70,11 @@ The next active seam is `P9-S35`: ## Immediate Next Move -Execute `P9-S35` on top of the `P9-S33`/`P9-S34` boundary: +Execute `P9-S36` on top of the `P9-S33`/`P9-S34`/`P9-S35` boundary: -- map MCP tools directly to the shipped CLI behavior contract -- preserve deterministic output semantics for parity tests -- keep startup/sample-data path unchanged while MCP is introduced +- build the first OpenClaw adapter using the shipped MCP wedge +- preserve deterministic continuity output semantics and correction parity +- keep startup/sample-data path unchanged while adapter support is added ## Legacy Compatibility Markers diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 9d02a5e..3798307 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,48 +1,57 @@ # BUILD_REPORT.md ## sprint objective -Ship `P9-S34` by adding a deterministic local CLI for core continuity flows (`capture`, `recall`, `resume`, `open-loops`, `review queue/show/apply`, `status`) on top of the shipped `alice-core` runtime. +Ship `P9-S35` by adding a deterministic local MCP server that exposes the ADR-003 continuity tool surface (`alice_capture`, `alice_recall`, `alice_resume`, `alice_open_loops`, `alice_recent_decisions`, `alice_recent_changes`, `alice_memory_review`, `alice_memory_correct`, `alice_context_pack`) over the shipped `alice-core` runtime without changing `P9-S33` startup flow or `P9-S34` semantics. ## completed work -- Added packaged CLI entrypoint and module entry support: - - `apps/api/src/alicebot_api/cli.py` - - `apps/api/src/alicebot_api/cli_formatting.py` - - `apps/api/src/alicebot_api/__main__.py` - - `pyproject.toml` (`[project.scripts] alicebot = "alicebot_api.cli:main"`) -- Kept CLI behavior on existing continuity seams (no core semantic fork): - - capture: `capture_continuity_input` - - recall: `query_continuity_recall` - - resume: `compile_continuity_resumption_brief` - - open-loops: `compile_continuity_open_loop_dashboard` - - review queue/show/apply: `list_continuity_review_queue`, `get_continuity_review_detail`, `apply_continuity_correction` - - status: runtime health + continuity/review/open-loop/retrieval summary -- Added deterministic terminal formatting with provenance/trust signals: - - stable section order - - stable scope rendering - - stable list rendering and confidence/posture fields - - explicit empty states -- Added CLI-focused tests: - - `tests/unit/test_cli.py` - - `tests/integration/test_cli_integration.py` +- Added runnable MCP transport entrypoint: + - `apps/api/src/alicebot_api/mcp_server.py` + - stdio JSON-RPC loop with deterministic framing + - supported methods: `initialize`, `ping`, `tools/list`, `tools/call` +- Added deterministic MCP tool layer: + - `apps/api/src/alicebot_api/mcp_tools.py` + - static deterministic tool schemas (ADR-003 tool names in fixed order) + - direct mapping to shipped continuity seams (capture/recall/resume/open-loops/review/correction) + - deterministic structured serialization and narrow error envelopes +- Added package script entrypoint: + - `pyproject.toml`: `alicebot-mcp = "alicebot_api.mcp_server:main"` +- Added MCP unit and integration verification: + - `tests/unit/test_mcp.py` + - `tests/integration/test_mcp_server.py` + - `tests/integration/test_mcp_cli_parity.py` +- Added interoperability evidence for a real MCP client path (stdio JSON-RPC client subprocess): + - successful `alice_recall` + - successful `alice_resume` + - successful `alice_memory_correct` (`supersede`) with deterministic change in later recall/resume result +- Captured required acceptance evidence details: + - exact MCP startup path used: `./.venv/bin/python -m alicebot_api.mcp_server` + - exact local client/config used for proof: + - client type: stdio JSON-RPC MCP client subprocess + - transport command: `python -m alicebot_api.mcp_server` + - env: `DATABASE_URL=postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot` + - env: `ALICEBOT_AUTH_USER_ID=00000000-0000-0000-0000-000000000001` + - intentionally deferred concern: no hosted/remote auth layer (local process + local user scope only) - Updated sprint-scoped docs: - - `README.md` (CLI invocation and examples) - - `ROADMAP.md` (`P9-S34` shipped baseline, `P9-S35` next seam) - - `.ai/handoff/CURRENT_STATE.md` (CLI shipped summary and next seam) -- Preserved control-doc truth marker compatibility in `.ai/handoff/CURRENT_STATE.md` (`Active Sprint focus is Phase 4 Sprint 14`) to keep baseline gate checks green. + - `README.md` with exact MCP startup path and compatible local client config example + - `ROADMAP.md` marking `P9-S35` shipped baseline + - `.ai/handoff/CURRENT_STATE.md` with MCP shipped baseline and `P9-S36` next seam ## incomplete work -- None inside `P9-S34` scope. -- Intentionally deferred (out of scope): MCP transport/tool schemas (`P9-S35`), adapters/importer expansion, CLI ergonomics polish (autocomplete/TUI enhancements). +- None inside `P9-S35` scope. +- Intentionally deferred (out of scope): + - OpenClaw adapter implementation (`P9-S36`) + - importer expansion + - hosted/remote auth systems + - MCP ergonomics beyond the initial narrow wedge (pagination/advanced discovery ergonomics) ## files changed - `.ai/active/SPRINT_PACKET.md` -- `apps/api/src/alicebot_api/cli.py` -- `apps/api/src/alicebot_api/cli_formatting.py` -- `apps/api/src/alicebot_api/__main__.py` -- `apps/api/src/alicebot_api/__init__.py` +- `apps/api/src/alicebot_api/mcp_server.py` +- `apps/api/src/alicebot_api/mcp_tools.py` - `pyproject.toml` -- `tests/unit/test_cli.py` -- `tests/integration/test_cli_integration.py` +- `tests/unit/test_mcp.py` +- `tests/integration/test_mcp_server.py` +- `tests/integration/test_mcp_cli_parity.py` - `README.md` - `ROADMAP.md` - `.ai/handoff/CURRENT_STATE.md` @@ -59,43 +68,39 @@ Ship `P9-S34` by adding a deterministic local CLI for core continuity flows (`ca - `./scripts/migrate.sh` - PASS (required elevated local DB access) - `./scripts/load_sample_data.sh` - - PASS (`status=noop`, fixture already present) -- `APP_RELOAD=false ./scripts/api_dev.sh` + `curl -sS http://127.0.0.1:8000/healthz` - - PASS (`status=ok`) + - PASS (`status=noop`, fixture already loaded) +- `APP_RELOAD=false ./scripts/api_dev.sh` + - PASS (server started on `http://127.0.0.1:8000`) +- `curl -sS http://127.0.0.1:8000/healthz` + - PASS (`status":"ok"`) - `./.venv/bin/python -m alicebot_api --help` - PASS -- `./.venv/bin/python -m alicebot_api status` - - PASS (database reachable, continuity metrics rendered) -- `./.venv/bin/python -m alicebot_api recall --query local-first` - - PASS (deterministic ordered output with provenance snippets) -- `./.venv/bin/python -m alicebot_api resume` - - PASS (last decision/open loops/recent changes/next action sections) -- `./.venv/bin/python -m alicebot_api open-loops --limit 20` +- `./.venv/bin/python -m alicebot_api.mcp_server --help` - PASS -- `./.venv/bin/python -m alicebot_api capture "Decision: CLI verification keeps deterministic continuity output." --explicit-signal decision` - - PASS -- `./.venv/bin/python -m alicebot_api review queue --status correction_ready --limit 20` - - PASS -- `./.venv/bin/python -m alicebot_api review show b5bfdbcc-cbb2-440f-9e4e-7ebabdb41f3f` - - PASS -- `./.venv/bin/python -m alicebot_api review apply b5bfdbcc-cbb2-440f-9e4e-7ebabdb41f3f --action supersede ...` - - PASS -- `./.venv/bin/python -m alicebot_api recall --query local-first --limit 20` (post-correction) - - PASS (updated active decision ranked ahead of superseded prior decision) -- `./.venv/bin/python -m alicebot_api resume --max-recent-changes 5 --max-open-loops 5` (post-correction) - - PASS (last decision updated deterministically after correction) -- `./.venv/bin/python -m pytest tests/unit/test_cli.py -q` +- `./.venv/bin/python -m pytest tests/unit/test_mcp.py -q` - PASS (`5 passed`) -- `./.venv/bin/python -m pytest tests/integration/test_cli_integration.py -q` +- `./.venv/bin/python -m pytest tests/integration/test_mcp_server.py -q` + - PASS (`1 passed`) +- `./.venv/bin/python -m pytest tests/integration/test_mcp_cli_parity.py -q` - PASS (`1 passed`) +- `./.venv/bin/python -m pytest tests/unit/test_mcp.py tests/integration/test_mcp_server.py tests/integration/test_mcp_cli_parity.py -q` + - PASS (`7 passed`) - `./.venv/bin/python -m pytest tests/unit tests/integration` - - PASS (`954 passed in 85.02s`) (required elevated local DB access) + - PASS (`961 passed in 97.32s`) (required elevated local DB access) - `pnpm --dir apps/web test` - - PASS (`192 passed`) + - PASS (`57 files, 192 tests`) +- MCP smoke client against new entrypoint (`python -m alicebot_api.mcp_server`) + - PASS + - initialize protocol: `2024-11-05` + - `alice_recall`: `isError=false`, returned count `3` + - `alice_resume`: `isError=false`, last decision present ## blockers/issues -- Sandbox-restricted localhost Postgres access required elevated execution for DB-backed commands/tests. -- Initial backend full-suite run failed at collection due duplicate test-module basename (`test_cli.py` in both unit and integration); resolved by renaming integration test file to `tests/integration/test_cli_integration.py`. +- Sandbox restrictions required elevated execution for localhost Postgres connections and binding to localhost `:8000`. +- Initial MCP integration test failure due missing `if __name__ == "__main__":` in `mcp_server.py`; fixed by adding module entry invocation. +- Remaining non-sprint workspace artifacts are limited to untracked local archive directories: + - `.ai/archive/` + - `docs/archive/planning/` ## recommended next step -Start `P9-S35` by mirroring the shipped CLI continuity contract via a narrow MCP tool surface, with parity tests that compare MCP outputs to current CLI deterministic output for the same dataset/scope. +Start `P9-S36` by implementing the OpenClaw adapter against the now-stable MCP tool contract, keeping strict parity checks so adapter integration does not reopen continuity transport semantics. diff --git a/README.md b/README.md index 91399b5..84a86b7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Alice is a local-first memory and continuity engine for AI agents. -`P9-S33` shipped the public-core baseline. `P9-S34` ships a deterministic local CLI for continuity flows on top of that baseline. +`P9-S33` shipped the public-core baseline. `P9-S34` shipped the deterministic local CLI for continuity flows on top of that baseline. `P9-S35` now ships a narrow MCP transport for the same continuity contract. ## Canonical Local Startup Path (`P9-S33`) @@ -54,6 +54,60 @@ Run these against the `P9-S33` sample dataset after startup: The CLI output is deterministic text (stable section order and provenance snippets) to support `P9-S35` MCP parity. +## MCP Invocation Path (`P9-S35`) + +Run the MCP server from the same local runtime used by CLI: + +```bash +./.venv/bin/python -m alicebot_api.mcp_server --help +./.venv/bin/python -m alicebot_api.mcp_server +``` + +Optional console-script entrypoint (after editable install): + +```bash +alicebot-mcp --help +alicebot-mcp +``` + +MCP uses the same local auth/config scope as CLI: + +- `DATABASE_URL` selects the local database +- `ALICEBOT_AUTH_USER_ID` selects the user scope (or `--user-id`) +- if unset, scope defaults to `00000000-0000-0000-0000-000000000001` + +Initial ADR-003 MCP tools: + +- `alice_capture` +- `alice_recall` +- `alice_resume` +- `alice_open_loops` +- `alice_recent_decisions` +- `alice_recent_changes` +- `alice_memory_review` +- `alice_memory_correct` +- `alice_context_pack` + +### Compatible Client Example (Claude Desktop MCP) + +`claude_desktop_config.json` example: + +```json +{ + "mcpServers": { + "alice-core": { + "command": "/ABSOLUTE/PATH/TO/AliceBot/.venv/bin/python", + "args": ["-m", "alicebot_api.mcp_server"], + "cwd": "/ABSOLUTE/PATH/TO/AliceBot", + "env": { + "DATABASE_URL": "postgresql://alicebot_app:alicebot_app@localhost:5432/alicebot", + "ALICEBOT_AUTH_USER_ID": "00000000-0000-0000-0000-000000000001" + } + } + } +} +``` + ## Essential Verification Commands - API health: `curl -sS http://127.0.0.1:8000/healthz` diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index 7bd5a5f..b8d9138 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -4,35 +4,51 @@ PASS ## criteria met -- `P9-S34` packaged CLI entrypoint is implemented and callable from documented local install path (`python -m alicebot_api`). -- Required command coverage is present: `capture`, `recall`, `resume`, `open-loops`, `review queue`, `review show`, `review apply`, `status`. -- CLI output is deterministic and terminal-friendly, with stable section ordering, explicit empty states, and provenance/trust fields where relevant. -- Correction flow is wired end-to-end and deterministically updates later recall/resume outcomes. -- Sprint docs are aligned with delivered CLI surface (`README.md`, `ROADMAP.md`, `.ai/handoff/CURRENT_STATE.md`, reports). -- Merge-readiness gap is resolved: required new CLI/test files are now added to git (no longer untracked). -- The only remaining non-sprint worktree paths are local archive directories explicitly excluded from merge scope (`.ai/archive/`, `docs/archive/planning/`). +- Runnable MCP server entrypoint is implemented and callable from the documented local path: `python -m alicebot_api.mcp_server`, with console-script packaging via `alicebot-mcp`. +- ADR-003 initial MCP tool surface is implemented with deterministic schemas and fixed ordering: + - `alice_capture` + - `alice_recall` + - `alice_resume` + - `alice_open_loops` + - `alice_recent_decisions` + - `alice_recent_changes` + - `alice_memory_review` + - `alice_memory_correct` + - `alice_context_pack` +- One MCP-capable client path can call `alice_recall` successfully against the local runtime, verified in `tests/integration/test_mcp_server.py`. +- One MCP-capable client path can call `alice_resume` successfully against the local runtime, verified in `tests/integration/test_mcp_server.py`. +- Correction through `alice_memory_correct` changes later retrieval/resumption behavior deterministically, verified in `tests/integration/test_mcp_server.py`. +- MCP outputs remain narrow, deterministic, and provenance-backed through direct transport wrappers over shipped continuity seams in `apps/api/src/alicebot_api/mcp_tools.py`. +- Parity evidence exists between MCP and shipped CLI/core behavior in `tests/integration/test_mcp_cli_parity.py`. +- Sprint docs are aligned with the delivered MCP surface in `README.md`, `ROADMAP.md`, `.ai/handoff/CURRENT_STATE.md`, `BUILD_REPORT.md`, and this report. ## criteria missed - None. ## quality issues -- No blocking quality issues identified for `P9-S34` scope. -- Existing out-of-scope tracked churn previously noted in planning docs has been removed from the tracked diff. +- No blocking quality issues found in sprint scope. +- The sprint diff includes the MCP source and test files required by the packet, so the delivered feature matches the actual branch payload. ## regression risks -- Low. Relevant CLI unit/integration tests and full backend/web suites pass in this environment. -- Residual risk is standard future contract drift risk if downstream (`P9-S35`) parity tests are not kept strict against CLI behavior. +- Low. +- Directly verified during review: + - `./.venv/bin/python -m alicebot_api.mcp_server --help` + - `./.venv/bin/python -m pytest tests/unit/test_mcp.py tests/integration/test_mcp_server.py tests/integration/test_mcp_cli_parity.py -q` + - `./.venv/bin/python -m pytest tests/unit tests/integration` + - `pnpm --dir apps/web test` +- Residual risk is future contract drift between MCP and CLI/core behavior if later seams widen payloads without preserving parity tests. ## docs issues - No blocking docs issues in sprint scope. -- CLI invocation and command examples are present and consistent with shipped runtime behavior. +- `README.md` includes the exact local MCP startup path, auth/config assumptions, and one compatible client configuration example. +- The only remaining non-sprint worktree paths are local archive directories explicitly excluded from merge scope (`.ai/archive/`, `docs/archive/planning/`). ## should anything be added to RULES.md? -- Optional hardening: add a rule to avoid duplicate pytest module basenames across different test directories to prevent collection/import collisions. +- Optional hardening: require new runnable module entrypoints to include explicit `if __name__ == "__main__":` invocation whenever `python -m ...` is part of the documented startup path. ## should anything update ARCHITECTURE.md? -- No required update. Implementation aligns with existing Phase 9 public-layer architecture and does not alter core semantics. +- No required update. The implementation stays within the existing Phase 9 architecture and ADR-003 by treating MCP as a transport wrapper over shipped continuity seams. ## recommended next action -1. Finalize PR with current scoped changes and verification evidence. -2. Begin `P9-S35` by mirroring this CLI contract in MCP with explicit parity tests. +1. Finalize the sprint PR from the current staged diff. +2. Keep `tests/integration/test_mcp_cli_parity.py` as a required guard while building `P9-S36` so adapter work does not reopen MCP transport semantics. diff --git a/ROADMAP.md b/ROADMAP.md index 91e5773..83f3ff4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,13 +38,13 @@ Success condition: - terminal commands for capture, recall, resume, open loops, review, correction, and status - deterministic terminal formatting with provenance snippets -### P9-S35: MCP Server (next active seam) +### P9-S35: MCP Server (shipped baseline) - small stable MCP tool surface - local interop examples for compatible clients - deterministic tool contracts -### P9-S36: OpenClaw Adapter +### P9-S36: OpenClaw Adapter (next active seam) - import path for OpenClaw durable memory/workspace data - Alice MCP augmentation mode for OpenClaw-style workflows @@ -80,6 +80,7 @@ Success condition: - Phase 8 delivered operational chief-of-staff handoffs, routing, outcome learning, and closure quality. - `P9-S33` delivered the public-safe `alice-core` boundary, canonical local startup path, and deterministic sample-data proof. +- `P9-S34` delivered the shipped local CLI continuity contract that `P9-S35` should mirror through MCP. ## Legacy Compatibility Markers diff --git a/apps/api/src/alicebot_api/mcp_server.py b/apps/api/src/alicebot_api/mcp_server.py new file mode 100644 index 0000000..b319e03 --- /dev/null +++ b/apps/api/src/alicebot_api/mcp_server.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any, BinaryIO +from uuid import UUID + +from alicebot_api import __version__ +from alicebot_api.config import Settings, get_settings +from alicebot_api.mcp_tools import ( + MCPRuntimeContext, + MCPToolError, + MCPToolNotFoundError, + call_mcp_tool, + list_mcp_tools, +) + + +_JSONRPC_VERSION = "2.0" +_MCP_PROTOCOL_VERSION = "2024-11-05" +_MCP_SERVER_NAME = "alice-core-mcp" +_DEFAULT_MCP_USER_ID = "00000000-0000-0000-0000-000000000001" + + +def _parse_uuid(value: str) -> UUID: + try: + return UUID(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid UUID value: {value}") from exc + + +def _resolve_user_id(settings: Settings, user_id_flag: str | None) -> UUID: + if user_id_flag is not None: + return _parse_uuid(user_id_flag) + if settings.auth_user_id != "": + return UUID(settings.auth_user_id) + return UUID(os.getenv("ALICEBOT_AUTH_USER_ID", _DEFAULT_MCP_USER_ID)) + + +def _build_runtime_context(args: argparse.Namespace) -> MCPRuntimeContext: + settings = get_settings() + database_url = args.database_url if args.database_url is not None else settings.database_url + user_id = _resolve_user_id(settings, args.user_id) + return MCPRuntimeContext(database_url=database_url, user_id=user_id) + + +def _read_message(stream: BinaryIO) -> dict[str, Any] | None: + headers: dict[str, str] = {} + + while True: + line = stream.readline() + if line == b"": + return None + + if line in {b"\r\n", b"\n"}: + break + + decoded = line.decode("utf-8").strip() + if ":" not in decoded: + raise ValueError("invalid MCP header line") + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length_raw = headers.get("content-length") + if content_length_raw is None: + raise ValueError("missing Content-Length header") + try: + content_length = int(content_length_raw) + except ValueError as exc: + raise ValueError("invalid Content-Length header") from exc + if content_length < 0: + raise ValueError("invalid Content-Length header") + + body = stream.read(content_length) + if len(body) != content_length: + return None + payload = json.loads(body.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("JSON-RPC payload must be an object") + return payload + + +def _write_message(stream: BinaryIO, message: dict[str, Any]) -> None: + encoded = json.dumps(message, separators=(",", ":"), sort_keys=True).encode("utf-8") + header = f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii") + stream.write(header) + stream.write(encoded) + stream.flush() + + +def _response_success(request_id: object, *, result: object) -> dict[str, Any]: + return { + "jsonrpc": _JSONRPC_VERSION, + "id": request_id, + "result": result, + } + + +def _response_error(request_id: object, *, code: int, message: str) -> dict[str, Any]: + return { + "jsonrpc": _JSONRPC_VERSION, + "id": request_id, + "error": { + "code": code, + "message": message, + }, + } + + +class MCPServer: + def __init__(self, *, context: MCPRuntimeContext, input_stream: BinaryIO, output_stream: BinaryIO) -> None: + self._context = context + self._input_stream = input_stream + self._output_stream = output_stream + + def _handle_request(self, request: dict[str, Any]) -> dict[str, Any] | None: + if request.get("jsonrpc") != _JSONRPC_VERSION: + return _response_error(request.get("id"), code=-32600, message="invalid jsonrpc version") + + method = request.get("method") + if not isinstance(method, str): + return _response_error(request.get("id"), code=-32600, message="method must be a string") + + request_id = request.get("id") + params = request.get("params", {}) + if params is None: + params = {} + if not isinstance(params, dict): + return _response_error(request_id, code=-32602, message="params must be a JSON object") + + if method == "notifications/initialized": + return None + + if request_id is None: + return None + + if method == "initialize": + return _response_success( + request_id, + result={ + "protocolVersion": _MCP_PROTOCOL_VERSION, + "capabilities": { + "tools": {}, + }, + "serverInfo": { + "name": _MCP_SERVER_NAME, + "version": __version__, + }, + }, + ) + + if method == "ping": + return _response_success(request_id, result={}) + + if method == "tools/list": + return _response_success( + request_id, + result={ + "tools": list_mcp_tools(), + }, + ) + + if method == "tools/call": + name = params.get("name") + if not isinstance(name, str): + return _response_error(request_id, code=-32602, message="tools/call requires string name") + + arguments = params.get("arguments") + try: + structured = call_mcp_tool( + self._context, + name=name, + arguments=arguments, + ) + except (MCPToolError, MCPToolNotFoundError) as exc: + return _response_success( + request_id, + result={ + "content": [{"type": "text", "text": str(exc)}], + "isError": True, + }, + ) + + return _response_success( + request_id, + result={ + "content": [ + { + "type": "text", + "text": json.dumps(structured, separators=(",", ":"), sort_keys=True), + } + ], + "structuredContent": structured, + "isError": False, + }, + ) + + return _response_error(request_id, code=-32601, message=f"method not found: {method}") + + def run(self) -> int: + while True: + try: + request = _read_message(self._input_stream) + except json.JSONDecodeError as exc: + response = _response_error(None, code=-32700, message=f"parse error: {exc.msg}") + _write_message(self._output_stream, response) + continue + except ValueError as exc: + response = _response_error(None, code=-32600, message=str(exc)) + _write_message(self._output_stream, response) + continue + + if request is None: + return 0 + + response = self._handle_request(request) + if response is not None: + _write_message(self._output_stream, response) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="alicebot-mcp", + description="Deterministic local MCP server for Alice continuity workflows.", + ) + parser.add_argument( + "--database-url", + default=None, + help="Override database URL. Defaults to settings/env DATABASE_URL.", + ) + parser.add_argument( + "--user-id", + default=None, + help=( + "Override acting user UUID. Defaults to ALICEBOT_AUTH_USER_ID when set, " + f"otherwise {_DEFAULT_MCP_USER_ID}." + ), + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + context = _build_runtime_context(args) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + server = MCPServer( + context=context, + input_stream=sys.stdin.buffer, + output_stream=sys.stdout.buffer, + ) + return server.run() + + +__all__ = ["MCPServer", "build_parser", "main"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/api/src/alicebot_api/mcp_tools.py b/apps/api/src/alicebot_api/mcp_tools.py new file mode 100644 index 0000000..349cd30 --- /dev/null +++ b/apps/api/src/alicebot_api/mcp_tools.py @@ -0,0 +1,772 @@ +from __future__ import annotations + +from collections.abc import Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from alicebot_api.continuity_capture import ( + ContinuityCaptureValidationError, + capture_continuity_input, +) +from alicebot_api.continuity_open_loops import ( + ContinuityOpenLoopValidationError, + compile_continuity_open_loop_dashboard, +) +from alicebot_api.continuity_recall import ( + ContinuityRecallValidationError, + query_continuity_recall, +) +from alicebot_api.continuity_resumption import ( + ContinuityResumptionValidationError, + compile_continuity_resumption_brief, +) +from alicebot_api.continuity_review import ( + ContinuityReviewNotFoundError, + ContinuityReviewValidationError, + apply_continuity_correction, + get_continuity_review_detail, + list_continuity_review_queue, +) +from alicebot_api.contracts import ( + CONTINUITY_CAPTURE_EXPLICIT_SIGNALS, + CONTINUITY_CORRECTION_ACTIONS, + CONTINUITY_REVIEW_QUEUE_ORDER, + CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER, + DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RECALL_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + DEFAULT_CONTINUITY_REVIEW_LIMIT, + MAX_CONTINUITY_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RECALL_LIMIT, + MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + MAX_CONTINUITY_REVIEW_LIMIT, + ContinuityCaptureCreateInput, + ContinuityCorrectionInput, + ContinuityOpenLoopDashboardQueryInput, + ContinuityRecallQueryInput, + ContinuityResumptionBriefRequestInput, + ContinuityReviewQueueQueryInput, +) +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore, JsonObject + + +_REVIEW_STATUS_CHOICES = ("correction_ready", "active", "stale", "superseded", "deleted", "all") +_CONTEXT_PACK_ASSEMBLY_VERSION_V0 = "alice_context_pack_v0" + + +class MCPToolError(ValueError): + """Raised when MCP tool input or execution fails.""" + + +class MCPToolNotFoundError(LookupError): + """Raised when an MCP tool name is not supported.""" + + +@dataclass(frozen=True, slots=True) +class MCPRuntimeContext: + database_url: str + user_id: UUID + + +@contextmanager +def _store_context(context: MCPRuntimeContext): + with user_connection(context.database_url, context.user_id) as conn: + yield ContinuityStore(conn) + + +def _normalize_arguments(arguments: object) -> Mapping[str, object]: + if arguments is None: + return {} + if not isinstance(arguments, Mapping): + raise MCPToolError("tool arguments must be a JSON object") + return arguments + + +def _parse_optional_text(arguments: Mapping[str, object], key: str) -> str | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MCPToolError(f"{key} must be a string") + normalized = " ".join(value.split()).strip() + if normalized == "": + return None + return normalized + + +def _parse_required_text(arguments: Mapping[str, object], key: str) -> str: + value = arguments.get(key) + if not isinstance(value, str): + raise MCPToolError(f"{key} is required and must be a string") + normalized = " ".join(value.split()).strip() + if normalized == "": + raise MCPToolError(f"{key} must not be empty") + return normalized + + +def _parse_optional_uuid(arguments: Mapping[str, object], key: str) -> UUID | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MCPToolError(f"{key} must be a UUID string") + try: + return UUID(value) + except ValueError as exc: + raise MCPToolError(f"{key} must be a valid UUID") from exc + + +def _parse_required_uuid(arguments: Mapping[str, object], key: str) -> UUID: + value = _parse_optional_uuid(arguments, key) + if value is None: + raise MCPToolError(f"{key} is required and must be a UUID string") + return value + + +def _parse_optional_datetime(arguments: Mapping[str, object], key: str) -> datetime | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, str): + raise MCPToolError(f"{key} must be an ISO-8601 datetime string") + normalized = value.strip() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + return datetime.fromisoformat(normalized) + except ValueError as exc: + raise MCPToolError(f"{key} must be an ISO-8601 datetime string") from exc + + +def _parse_int( + arguments: Mapping[str, object], + *, + key: str, + default: int, + minimum: int, + maximum: int, +) -> int: + value = arguments.get(key, default) + if isinstance(value, bool): + raise MCPToolError(f"{key} must be an integer") + + if isinstance(value, int): + parsed = value + elif isinstance(value, str): + stripped = value.strip() + if stripped == "": + raise MCPToolError(f"{key} must be an integer") + try: + parsed = int(stripped) + except ValueError as exc: + raise MCPToolError(f"{key} must be an integer") from exc + else: + raise MCPToolError(f"{key} must be an integer") + + if parsed < minimum or parsed > maximum: + raise MCPToolError(f"{key} must be between {minimum} and {maximum}") + return parsed + + +def _parse_optional_json_object(arguments: Mapping[str, object], key: str) -> JsonObject | None: + value = arguments.get(key) + if value is None: + return None + if not isinstance(value, dict): + raise MCPToolError(f"{key} must be a JSON object") + return value + + +def _parse_optional_float(arguments: Mapping[str, object], key: str) -> float | None: + value = arguments.get(key) + if value is None: + return None + if isinstance(value, bool): + raise MCPToolError(f"{key} must be a number") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError as exc: + raise MCPToolError(f"{key} must be a number") from exc + raise MCPToolError(f"{key} must be a number") + + +def _build_recall_query(arguments: Mapping[str, object], *, limit: int) -> ContinuityRecallQueryInput: + return ContinuityRecallQueryInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + limit=limit, + ) + + +def _canonicalize_json(value: object) -> object: + if isinstance(value, dict): + return { + key: _canonicalize_json(value[key]) + for key in sorted(value) + } + if isinstance(value, list): + return [_canonicalize_json(item) for item in value] + return value + + +def _recency_sort_key(item: Mapping[str, object]) -> tuple[str, str]: + created_at = str(item.get("created_at", "")) + item_id = str(item.get("id", "")) + return created_at, item_id + + +def _handle_alice_capture(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + explicit_signal = arguments.get("explicit_signal") + if explicit_signal is not None and not isinstance(explicit_signal, str): + raise MCPToolError("explicit_signal must be a string when provided") + + with _store_context(context) as store: + return capture_continuity_input( + store, + user_id=context.user_id, + request=ContinuityCaptureCreateInput( + raw_content=_parse_required_text(arguments, "raw_content"), + explicit_signal=explicit_signal, + ), + ) + + +def _handle_alice_recall(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_RECALL_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + + with _store_context(context) as store: + return query_continuity_recall( + store, + user_id=context.user_id, + request=_build_recall_query(arguments, limit=limit), + ) + + +def _handle_alice_resume(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + max_recent_changes = _parse_int( + arguments, + key="max_recent_changes", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + max_open_loops = _parse_int( + arguments, + key="max_open_loops", + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ) + + with _store_context(context) as store: + return compile_continuity_resumption_brief( + store, + user_id=context.user_id, + request=ContinuityResumptionBriefRequestInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + max_recent_changes=max_recent_changes, + max_open_loops=max_open_loops, + ), + ) + + +def _handle_alice_open_loops(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_OPEN_LOOP_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_OPEN_LOOP_LIMIT, + ) + + with _store_context(context) as store: + return compile_continuity_open_loop_dashboard( + store, + user_id=context.user_id, + request=ContinuityOpenLoopDashboardQueryInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + limit=limit, + ), + ) + + +def _recent_decisions_payload( + context: MCPRuntimeContext, + *, + arguments: Mapping[str, object], + limit: int, +) -> JsonObject: + with _store_context(context) as store: + recall_payload = query_continuity_recall( + store, + user_id=context.user_id, + request=_build_recall_query(arguments, limit=MAX_CONTINUITY_RECALL_LIMIT), + apply_limit=False, + ) + + all_decisions = [ + item + for item in recall_payload["items"] + if item["object_type"] == "Decision" + ] + ordered = sorted(all_decisions, key=_recency_sort_key, reverse=True) + items = ordered[:limit] + return { + "items": items, + "summary": { + "scope": recall_payload["summary"]["filters"], + "limit": limit, + "returned_count": len(items), + "total_count": len(all_decisions), + "order": ["created_at_desc", "id_desc"], + }, + } + + +def _handle_alice_recent_decisions(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + return _recent_decisions_payload(context, arguments=arguments, limit=limit) + + +def _handle_alice_recent_changes(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + max_recent_changes = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + + with _store_context(context) as store: + resumption_payload = compile_continuity_resumption_brief( + store, + user_id=context.user_id, + request=ContinuityResumptionBriefRequestInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + max_recent_changes=max_recent_changes, + max_open_loops=0, + ), + ) + + brief = resumption_payload["brief"] + return { + "recent_changes": brief["recent_changes"], + "scope": brief["scope"], + "sources": brief["sources"], + "order": list(CONTINUITY_RESUMPTION_RECENT_CHANGE_ORDER), + } + + +def _handle_alice_memory_review(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + continuity_object_id = _parse_optional_uuid(arguments, "continuity_object_id") + if continuity_object_id is not None: + with _store_context(context) as store: + payload = get_continuity_review_detail( + store, + user_id=context.user_id, + continuity_object_id=continuity_object_id, + ) + return { + "mode": "detail", + "review": payload["review"], + } + + status = arguments.get("status", "correction_ready") + if not isinstance(status, str): + raise MCPToolError("status must be a string") + if status not in _REVIEW_STATUS_CHOICES: + allowed = ", ".join(_REVIEW_STATUS_CHOICES) + raise MCPToolError(f"status must be one of: {allowed}") + limit = _parse_int( + arguments, + key="limit", + default=DEFAULT_CONTINUITY_REVIEW_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_REVIEW_LIMIT, + ) + + with _store_context(context) as store: + payload = list_continuity_review_queue( + store, + user_id=context.user_id, + request=ContinuityReviewQueueQueryInput( + status=status, + limit=limit, + ), + ) + return { + "mode": "queue", + "items": payload["items"], + "summary": { + **payload["summary"], + "order": list(CONTINUITY_REVIEW_QUEUE_ORDER), + }, + } + + +def _handle_alice_memory_correct(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + with _store_context(context) as store: + return apply_continuity_correction( + store, + user_id=context.user_id, + continuity_object_id=_parse_required_uuid(arguments, "continuity_object_id"), + request=ContinuityCorrectionInput( + action=_parse_required_text(arguments, "action"), + reason=_parse_optional_text(arguments, "reason"), + title=_parse_optional_text(arguments, "title"), + body=_parse_optional_json_object(arguments, "body"), + provenance=_parse_optional_json_object(arguments, "provenance"), + confidence=_parse_optional_float(arguments, "confidence"), + replacement_title=_parse_optional_text(arguments, "replacement_title"), + replacement_body=_parse_optional_json_object(arguments, "replacement_body"), + replacement_provenance=_parse_optional_json_object(arguments, "replacement_provenance"), + replacement_confidence=_parse_optional_float(arguments, "replacement_confidence"), + ), + ) + + +def _handle_alice_context_pack(context: MCPRuntimeContext, arguments: Mapping[str, object]) -> JsonObject: + open_loops_limit = _parse_int( + arguments, + key="open_loops_limit", + default=DEFAULT_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + ) + recent_changes_limit = _parse_int( + arguments, + key="recent_changes_limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=0, + maximum=MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + ) + recent_decisions_limit = _parse_int( + arguments, + key="recent_decisions_limit", + default=DEFAULT_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + minimum=1, + maximum=MAX_CONTINUITY_RECALL_LIMIT, + ) + + with _store_context(context) as store: + resumption_payload = compile_continuity_resumption_brief( + store, + user_id=context.user_id, + request=ContinuityResumptionBriefRequestInput( + query=_parse_optional_text(arguments, "query"), + thread_id=_parse_optional_uuid(arguments, "thread_id"), + task_id=_parse_optional_uuid(arguments, "task_id"), + project=_parse_optional_text(arguments, "project"), + person=_parse_optional_text(arguments, "person"), + since=_parse_optional_datetime(arguments, "since"), + until=_parse_optional_datetime(arguments, "until"), + max_recent_changes=recent_changes_limit, + max_open_loops=open_loops_limit, + ), + ) + + brief = resumption_payload["brief"] + recent_decisions = _recent_decisions_payload( + context, + arguments=arguments, + limit=recent_decisions_limit, + ) + return { + "context_pack": { + "assembly_version": _CONTEXT_PACK_ASSEMBLY_VERSION_V0, + "scope": brief["scope"], + "last_decision": brief["last_decision"], + "next_action": brief["next_action"], + "open_loops": brief["open_loops"], + "recent_changes": brief["recent_changes"], + "recent_decisions": recent_decisions, + "sources": [ + "continuity_capture_events", + "continuity_objects", + "continuity_correction_events", + ], + } + } + + +_TOOL_DEFINITIONS: list[dict[str, object]] = [ + { + "name": "alice_capture", + "description": "Capture continuity input into deterministic continuity objects.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["raw_content"], + "properties": { + "raw_content": {"type": "string"}, + "explicit_signal": {"type": "string", "enum": list(CONTINUITY_CAPTURE_EXPLICIT_SIGNALS)}, + }, + }, + }, + { + "name": "alice_recall", + "description": "Recall continuity objects with deterministic ranking and provenance fields.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_CONTINUITY_RECALL_LIMIT}, + }, + }, + }, + { + "name": "alice_resume", + "description": "Compile continuity resumption brief for decisions, open loops, and next action.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "max_recent_changes": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + }, + "max_open_loops": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + }, + }, + }, + }, + { + "name": "alice_open_loops", + "description": "List continuity open loops grouped by deterministic posture sections.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": {"type": "integer", "minimum": 0, "maximum": MAX_CONTINUITY_OPEN_LOOP_LIMIT}, + }, + }, + }, + { + "name": "alice_recent_decisions", + "description": "List most recent continuity decisions in deterministic recency order.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_CONTINUITY_RECALL_LIMIT}, + }, + }, + }, + { + "name": "alice_recent_changes", + "description": "List recent continuity changes from the shipped resumption assembly logic.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + }, + }, + }, + }, + { + "name": "alice_memory_review", + "description": "List correction review queue or fetch review detail for one continuity object.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "continuity_object_id": {"type": "string", "format": "uuid"}, + "status": {"type": "string", "enum": list(_REVIEW_STATUS_CHOICES)}, + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_CONTINUITY_REVIEW_LIMIT}, + }, + }, + }, + { + "name": "alice_memory_correct", + "description": "Apply deterministic continuity correction actions and return correction evidence.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "required": ["continuity_object_id", "action"], + "properties": { + "continuity_object_id": {"type": "string", "format": "uuid"}, + "action": {"type": "string", "enum": list(CONTINUITY_CORRECTION_ACTIONS)}, + "reason": {"type": "string"}, + "title": {"type": "string"}, + "body": {"type": "object"}, + "provenance": {"type": "object"}, + "confidence": {"type": "number"}, + "replacement_title": {"type": "string"}, + "replacement_body": {"type": "object"}, + "replacement_provenance": {"type": "object"}, + "replacement_confidence": {"type": "number"}, + }, + }, + }, + { + "name": "alice_context_pack", + "description": "Assemble a deterministic continuity context pack for scoped external-agent use.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "query": {"type": "string"}, + "thread_id": {"type": "string", "format": "uuid"}, + "task_id": {"type": "string", "format": "uuid"}, + "project": {"type": "string"}, + "person": {"type": "string"}, + "since": {"type": "string", "format": "date-time"}, + "until": {"type": "string", "format": "date-time"}, + "recent_decisions_limit": { + "type": "integer", + "minimum": 1, + "maximum": MAX_CONTINUITY_RECALL_LIMIT, + }, + "recent_changes_limit": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_RECENT_CHANGES_LIMIT, + }, + "open_loops_limit": { + "type": "integer", + "minimum": 0, + "maximum": MAX_CONTINUITY_RESUMPTION_OPEN_LOOP_LIMIT, + }, + }, + }, + }, +] + +_TOOL_HANDLERS = { + "alice_capture": _handle_alice_capture, + "alice_recall": _handle_alice_recall, + "alice_resume": _handle_alice_resume, + "alice_open_loops": _handle_alice_open_loops, + "alice_recent_decisions": _handle_alice_recent_decisions, + "alice_recent_changes": _handle_alice_recent_changes, + "alice_memory_review": _handle_alice_memory_review, + "alice_memory_correct": _handle_alice_memory_correct, + "alice_context_pack": _handle_alice_context_pack, +} + + +def list_mcp_tools() -> list[dict[str, object]]: + return _canonicalize_json(_TOOL_DEFINITIONS) # type: ignore[return-value] + + +def call_mcp_tool( + context: MCPRuntimeContext, + *, + name: str, + arguments: object, +) -> JsonObject: + handler = _TOOL_HANDLERS.get(name) + if handler is None: + raise MCPToolNotFoundError(f"unknown tool '{name}'") + + parsed_arguments = _normalize_arguments(arguments) + try: + payload = handler(context, parsed_arguments) + except ( + ContinuityCaptureValidationError, + ContinuityRecallValidationError, + ContinuityResumptionValidationError, + ContinuityOpenLoopValidationError, + ContinuityReviewValidationError, + ContinuityReviewNotFoundError, + ) as exc: + raise MCPToolError(str(exc)) from exc + except (TypeError, ValueError) as exc: + raise MCPToolError(str(exc)) from exc + + return _canonicalize_json(payload) # type: ignore[return-value] + + +__all__ = [ + "MCPRuntimeContext", + "MCPToolError", + "MCPToolNotFoundError", + "call_mcp_tool", + "list_mcp_tools", +] diff --git a/pyproject.toml b/pyproject.toml index 10f17d7..0b7a6bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dev = [ [project.scripts] alicebot = "alicebot_api.cli:main" +alicebot-mcp = "alicebot_api.mcp_server:main" [tool.setuptools.package-dir] "" = "." diff --git a/tests/integration/test_mcp_cli_parity.py b/tests/integration/test_mcp_cli_parity.py new file mode 100644 index 0000000..c04ac17 --- /dev/null +++ b/tests/integration/test_mcp_cli_parity.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any +from uuid import UUID, uuid4 + +import psycopg + +from alicebot_api.continuity_recall import query_continuity_recall +from alicebot_api.continuity_resumption import compile_continuity_resumption_brief +from alicebot_api.contracts import ContinuityRecallQueryInput, ContinuityResumptionBriefRequestInput +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def build_runtime_env(*, database_url: str, user_id: UUID) -> dict[str, str]: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + env["ALICEBOT_AUTH_USER_ID"] = str(user_id) + pythonpath_entries = [str(REPO_ROOT / "apps" / "api" / "src"), str(REPO_ROOT / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + return env + + +def run_cli(args: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "alicebot_api", *args], + cwd=REPO_ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def _write_mcp_message(stream, payload: dict[str, object]) -> None: + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + stream.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii")) + stream.write(encoded) + stream.flush() + + +def _read_mcp_message(stream) -> dict[str, object]: + headers: dict[str, str] = {} + while True: + line = stream.readline() + if line == b"": + raise RuntimeError("MCP server closed stdout unexpectedly") + if line in {b"\r\n", b"\n"}: + break + decoded = line.decode("utf-8").strip() + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length = int(headers["content-length"]) + body = stream.read(content_length) + return json.loads(body.decode("utf-8")) + + +@dataclass +class MCPClient: + process: subprocess.Popen[bytes] + _next_id: int = 1 + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + request_id = self._next_id + self._next_id += 1 + payload: dict[str, object] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + payload["params"] = params + _write_mcp_message(self.process.stdin, payload) + response = _read_mcp_message(self.process.stdout) + assert response.get("id") == request_id + return response + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + payload: dict[str, object] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + _write_mcp_message(self.process.stdin, payload) + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + +def start_mcp_client(*, database_url: str, user_id: UUID) -> MCPClient: + env = build_runtime_env(database_url=database_url, user_id=user_id) + process = subprocess.Popen( + [sys.executable, "-m", "alicebot_api.mcp_server"], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + assert process.stdin is not None + assert process.stdout is not None + + client = MCPClient(process=process) + initialize = client.request( + "initialize", + params={ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "pytest-mcp-client", "version": "1.0"}, + "capabilities": {}, + }, + ) + assert initialize["result"]["protocolVersion"] == "2024-11-05" + client.notify("notifications/initialized", {}) + return client + + +def _call_tool(client: MCPClient, *, name: str, arguments: dict[str, object]) -> dict[str, Any]: + response = client.request("tools/call", params={"name": name, "arguments": arguments}) + assert "error" not in response + result = response["result"] + assert result["isError"] is False + return result["structuredContent"] + + +def test_mcp_recall_and_resume_match_core_and_cli_behavior(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="mcp-parity@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + + decision_capture = store.create_continuity_capture_event( + raw_content="Decision: Keep release freeze", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + decision_object = store.create_continuity_object( + capture_event_id=decision_capture["id"], + object_type="Decision", + status="active", + title="Decision: Keep release freeze", + body={"decision_text": "Keep release freeze"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-parity-1"]}, + confidence=0.96, + ) + + next_action_capture = store.create_continuity_capture_event( + raw_content="Next Action: Draft release memo", + explicit_signal="next_action", + admission_posture="DERIVED", + admission_reason="explicit_signal_next_action", + ) + next_action_object = store.create_continuity_object( + capture_event_id=next_action_capture["id"], + object_type="NextAction", + status="active", + title="Next Action: Draft release memo", + body={"action_text": "Draft release memo"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-parity-2"]}, + confidence=0.92, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=decision_object["id"], + created_at=datetime(2026, 4, 2, 9, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=next_action_object["id"], + created_at=datetime(2026, 4, 2, 9, 5, tzinfo=UTC), + ) + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + core_recall = query_continuity_recall( + store, + user_id=user_id, + request=ContinuityRecallQueryInput( + thread_id=thread_id, + query="release", + limit=20, + ), + ) + core_resume = compile_continuity_resumption_brief( + store, + user_id=user_id, + request=ContinuityResumptionBriefRequestInput( + thread_id=thread_id, + max_recent_changes=5, + max_open_loops=5, + ), + ) + + client = start_mcp_client(database_url=migrated_database_urls["app"], user_id=user_id) + try: + mcp_recall = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(thread_id), + "query": "release", + "limit": 20, + }, + ) + mcp_resume = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(thread_id), + "max_recent_changes": 5, + "max_open_loops": 5, + }, + ) + finally: + client.close() + + assert mcp_recall == core_recall + assert mcp_resume == core_resume + + env = build_runtime_env(database_url=migrated_database_urls["app"], user_id=user_id) + cli_recall = run_cli( + ["recall", "--thread-id", str(thread_id), "--query", "release", "--limit", "20"], + env=env, + ) + assert cli_recall.returncode == 0 + assert core_recall["items"][0]["title"] in cli_recall.stdout + assert core_recall["items"][0]["id"] in cli_recall.stdout + + cli_resume = run_cli( + ["resume", "--thread-id", str(thread_id), "--max-recent-changes", "5", "--max-open-loops", "5"], + env=env, + ) + assert cli_resume.returncode == 0 + assert core_resume["brief"]["last_decision"]["item"]["title"] in cli_resume.stdout + assert core_resume["brief"]["next_action"]["item"]["title"] in cli_resume.stdout diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py new file mode 100644 index 0000000..5079ac5 --- /dev/null +++ b/tests/integration/test_mcp_server.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any +from uuid import UUID, uuid4 + +import psycopg + +from alicebot_api.db import user_connection +from alicebot_api.store import ContinuityStore + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def seed_user(database_url: str, *, email: str) -> UUID: + user_id = uuid4() + with user_connection(database_url, user_id) as conn: + ContinuityStore(conn).create_user(user_id, email, email.split("@", 1)[0].title()) + return user_id + + +def set_continuity_timestamps( + admin_database_url: str, + *, + continuity_object_id: UUID, + created_at: datetime, +) -> None: + with psycopg.connect(admin_database_url, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE continuity_objects SET created_at = %s, updated_at = %s WHERE id = %s", + (created_at, created_at, continuity_object_id), + ) + + +def build_runtime_env(*, database_url: str, user_id: UUID) -> dict[str, str]: + env = os.environ.copy() + env["DATABASE_URL"] = database_url + env["ALICEBOT_AUTH_USER_ID"] = str(user_id) + + pythonpath_entries = [str(REPO_ROOT / "apps" / "api" / "src"), str(REPO_ROOT / "workers")] + existing_pythonpath = env.get("PYTHONPATH") + if existing_pythonpath: + pythonpath_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_entries) + return env + + +def _write_mcp_message(stream, payload: dict[str, object]) -> None: + encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + stream.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii")) + stream.write(encoded) + stream.flush() + + +def _read_mcp_message(stream) -> dict[str, object]: + headers: dict[str, str] = {} + while True: + line = stream.readline() + if line == b"": + raise RuntimeError("MCP server closed stdout unexpectedly") + if line in {b"\r\n", b"\n"}: + break + decoded = line.decode("utf-8").strip() + key, value = decoded.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length = int(headers["content-length"]) + body = stream.read(content_length) + return json.loads(body.decode("utf-8")) + + +@dataclass +class MCPClient: + process: subprocess.Popen[bytes] + _next_id: int = 1 + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + request_id = self._next_id + self._next_id += 1 + payload: dict[str, object] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + + _write_mcp_message(self.process.stdin, payload) + response = _read_mcp_message(self.process.stdout) + assert response.get("id") == request_id + return response + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + payload: dict[str, object] = { + "jsonrpc": "2.0", + "method": method, + } + if params is not None: + payload["params"] = params + _write_mcp_message(self.process.stdin, payload) + + def close(self) -> None: + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + + +def start_mcp_client(*, database_url: str, user_id: UUID) -> MCPClient: + env = build_runtime_env(database_url=database_url, user_id=user_id) + process = subprocess.Popen( + [sys.executable, "-m", "alicebot_api.mcp_server"], + cwd=REPO_ROOT, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + assert process.stdin is not None + assert process.stdout is not None + + client = MCPClient(process=process) + initialize = client.request( + "initialize", + params={ + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "pytest-mcp-client", "version": "1.0"}, + "capabilities": {}, + }, + ) + assert initialize["result"]["protocolVersion"] == "2024-11-05" + client.notify("notifications/initialized", {}) + return client + + +def _call_tool(client: MCPClient, *, name: str, arguments: dict[str, object]) -> dict[str, object]: + response = client.request("tools/call", params={"name": name, "arguments": arguments}) + assert "error" not in response + return response["result"] + + +def test_mcp_server_tool_calls_and_correction_flow(migrated_database_urls) -> None: + user_id = seed_user(migrated_database_urls["app"], email="mcp-user@example.com") + thread_id = UUID("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + + with user_connection(migrated_database_urls["app"], user_id) as conn: + store = ContinuityStore(conn) + legacy_capture = store.create_continuity_capture_event( + raw_content="Decision: Legacy rollout plan", + explicit_signal="decision", + admission_posture="DERIVED", + admission_reason="explicit_signal_decision", + ) + legacy_decision = store.create_continuity_object( + capture_event_id=legacy_capture["id"], + object_type="Decision", + status="active", + title="Decision: Legacy rollout plan", + body={"decision_text": "Legacy rollout plan"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-seed-1"]}, + confidence=0.93, + ) + + waiting_capture = store.create_continuity_capture_event( + raw_content="Waiting For: Reviewer PASS", + explicit_signal="waiting_for", + admission_posture="DERIVED", + admission_reason="explicit_signal_waiting_for", + ) + waiting_for = store.create_continuity_object( + capture_event_id=waiting_capture["id"], + object_type="WaitingFor", + status="active", + title="Waiting For: Reviewer PASS", + body={"waiting_for_text": "Reviewer PASS"}, + provenance={"thread_id": str(thread_id), "source_event_ids": ["mcp-seed-2"]}, + confidence=0.9, + ) + + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=legacy_decision["id"], + created_at=datetime(2026, 4, 1, 10, 0, tzinfo=UTC), + ) + set_continuity_timestamps( + migrated_database_urls["admin"], + continuity_object_id=waiting_for["id"], + created_at=datetime(2026, 4, 1, 10, 5, tzinfo=UTC), + ) + + client = start_mcp_client(database_url=migrated_database_urls["app"], user_id=user_id) + try: + tools_list = client.request("tools/list") + tool_names = [tool["name"] for tool in tools_list["result"]["tools"]] + assert "alice_recall" in tool_names + assert "alice_resume" in tool_names + assert "alice_memory_correct" in tool_names + + recall_before = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(thread_id), + "query": "rollout", + "limit": 20, + }, + ) + assert recall_before["isError"] is False + before_payload = recall_before["structuredContent"] + assert before_payload["items"][0]["id"] == str(legacy_decision["id"]) + + resume_before = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(thread_id), + "max_recent_changes": 5, + "max_open_loops": 5, + }, + ) + assert resume_before["isError"] is False + assert resume_before["structuredContent"]["brief"]["last_decision"]["item"]["id"] == str(legacy_decision["id"]) + + correction = _call_tool( + client, + name="alice_memory_correct", + arguments={ + "continuity_object_id": str(legacy_decision["id"]), + "action": "supersede", + "reason": "Latest rollout decision supersedes legacy plan", + "replacement_title": "Decision: Updated rollout plan", + "replacement_body": {"decision_text": "Updated rollout plan"}, + "replacement_provenance": { + "thread_id": str(thread_id), + "source_event_ids": ["mcp-correction-1"], + }, + "replacement_confidence": 0.98, + }, + ) + assert correction["isError"] is False + replacement_id = correction["structuredContent"]["replacement_object"]["id"] + + recall_after = _call_tool( + client, + name="alice_recall", + arguments={ + "thread_id": str(thread_id), + "query": "rollout", + "limit": 20, + }, + ) + assert recall_after["isError"] is False + after_payload = recall_after["structuredContent"] + assert after_payload["items"][0]["id"] == replacement_id + assert any(item["id"] == str(legacy_decision["id"]) for item in after_payload["items"]) + + resume_after = _call_tool( + client, + name="alice_resume", + arguments={ + "thread_id": str(thread_id), + "max_recent_changes": 5, + "max_open_loops": 5, + }, + ) + assert resume_after["isError"] is False + assert resume_after["structuredContent"]["brief"]["last_decision"]["item"]["id"] == replacement_id + finally: + client.close() diff --git a/tests/unit/test_mcp.py b/tests/unit/test_mcp.py new file mode 100644 index 0000000..fc7807f --- /dev/null +++ b/tests/unit/test_mcp.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from io import BytesIO +from uuid import UUID + +import pytest + +import alicebot_api.mcp_server as mcp_server +from alicebot_api.mcp_tools import MCPRuntimeContext, MCPToolError, MCPToolNotFoundError, call_mcp_tool, list_mcp_tools + + +def test_mcp_tool_surface_is_adr_aligned_and_deterministic() -> None: + tools = list_mcp_tools() + names = [tool["name"] for tool in tools] + assert names == [ + "alice_capture", + "alice_recall", + "alice_resume", + "alice_open_loops", + "alice_recent_decisions", + "alice_recent_changes", + "alice_memory_review", + "alice_memory_correct", + "alice_context_pack", + ] + + for tool in tools: + assert isinstance(tool["inputSchema"], dict) + assert tool["inputSchema"].get("type") == "object" + assert tool["inputSchema"].get("additionalProperties") is False + + +def test_call_mcp_tool_rejects_unknown_tool() -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + with pytest.raises(MCPToolNotFoundError, match="unknown tool"): + call_mcp_tool(context, name="alice_nonexistent", arguments={}) + + +def test_call_mcp_tool_requires_object_arguments() -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + with pytest.raises(MCPToolError, match="tool arguments must be a JSON object"): + call_mcp_tool(context, name="alice_recall", arguments=["not-a-json-object"]) + + +def test_mcp_server_initialize_and_tools_list(monkeypatch) -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + server = mcp_server.MCPServer(context=context, input_stream=BytesIO(), output_stream=BytesIO()) + + initialize_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {}, + } + ) + assert initialize_response is not None + assert initialize_response["result"]["protocolVersion"] == "2024-11-05" + assert initialize_response["result"]["serverInfo"]["name"] == "alice-core-mcp" + + monkeypatch.setattr( + mcp_server, + "list_mcp_tools", + lambda: [{"name": "alice_recall", "description": "Recall", "inputSchema": {"type": "object"}}], + ) + list_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {}, + } + ) + assert list_response is not None + assert list_response["result"]["tools"] == [ + {"name": "alice_recall", "description": "Recall", "inputSchema": {"type": "object"}} + ] + + +def test_mcp_server_tools_call_success_and_error_paths(monkeypatch) -> None: + context = MCPRuntimeContext( + database_url="postgresql://localhost/alicebot", + user_id=UUID("11111111-1111-4111-8111-111111111111"), + ) + server = mcp_server.MCPServer(context=context, input_stream=BytesIO(), output_stream=BytesIO()) + + monkeypatch.setattr(mcp_server, "call_mcp_tool", lambda *_args, **_kwargs: {"ok": True}) + success_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "alice_recall", "arguments": {}}, + } + ) + assert success_response is not None + assert success_response["result"]["isError"] is False + assert success_response["result"]["structuredContent"] == {"ok": True} + + def raise_tool_error(*_args, **_kwargs): + raise MCPToolError("invalid input") + + monkeypatch.setattr(mcp_server, "call_mcp_tool", raise_tool_error) + error_response = server._handle_request( + { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": {"name": "alice_recall", "arguments": {}}, + } + ) + assert error_response is not None + assert error_response["result"]["isError"] is True + assert error_response["result"]["content"][0]["text"] == "invalid input"