This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
ra-mcp (Riksarkivet Model Context Protocol) is an MCP server that provides access to transcribed historical documents from the Swedish National Archives (Riksarkivet).
The project is organized as a uv workspace with eight modular packages plus a root server:
ra-mcp/
├── src/ra_mcp_server/ # Root: Server composition and main CLI
│ ├── server.py # FastMCP composition entry point
│ └── cli/app.py # Typer CLI root (ra command)
├── packages/
│ ├── common/ # ra-mcp-common: Shared HTTP client and utilities
│ ├── search/ # ra-mcp-search: Search domain (models, clients, operations)
│ ├── browse/ # ra-mcp-browse: Browse domain (models, clients, operations)
│ ├── search-mcp/ # ra-mcp-search-mcp: MCP tools for search
│ ├── browse-mcp/ # ra-mcp-browse-mcp: MCP tool for browse
│ ├── search-cli/ # ra-mcp-search-cli: CLI command for search
│ ├── browse-cli/ # ra-mcp-browse-cli: CLI command for browse
│ └── guide-mcp/ # ra-mcp-guide-mcp: MCP resources for historical guides
├── resources/ # Historical guide markdown files
├── pyproject.toml # Workspace configuration
└── uv.lock # Shared lockfile
ra-mcp-common (no internal dependencies):
- http_client.py: Centralized urllib-based HTTP client with logging
ra-mcp-search (depends on common):
- config.py: Search API URL and constants
- models.py: Pydantic models (SearchRecord, RecordsResponse, SearchResult)
- clients/search_client.py: SearchAPI client
- operations/search_operations.py: Search business logic
ra-mcp-browse (depends on common):
- config.py: Browse API URLs and constants
- models.py: Pydantic models (BrowseResult, PageContext)
- clients/: API clients (ALTOClient, IIIFClient, OAIPMHClient)
- operations/browse_operations.py: Browse business logic
- url_generator.py: URL construction helpers
ra-mcp-search-mcp (depends on search + fastmcp):
- tools.py: FastMCP server setup, instructions, and tool registration
- search_tool.py:
search_transcribedandsearch_metadataMCP tools - server.py: Standalone entry point for isolated dev/testing
- formatter.py: Search result formatting for LLM output
ra-mcp-browse-mcp (depends on browse + fastmcp):
- tools.py: FastMCP server setup, instructions, and tool registration
- browse_tool.py:
browse_documentMCP tool - server.py: Standalone entry point for isolated dev/testing
- formatter.py: Browse result formatting for LLM output
ra-mcp-search-cli (depends on search + typer + rich):
- app.py: Typer sub-app
- search_cmd.py:
ra searchCLI command - formatter.py: CLI output formatting
ra-mcp-browse-cli (depends on browse + typer + rich):
- app.py: Typer sub-app
- browse_cmd.py:
ra browseCLI command - formatter.py: CLI output formatting
ra-mcp-guide-mcp (depends on common + fastmcp):
- tools.py: FastMCP server and MCP resources for historical guides from
resources/ - server.py: Standalone entry point for isolated dev/testing
Root package — ra-mcp (depends on all MCP and CLI packages):
- server.py: FastMCP composition server (imports search, browse, guide modules)
- cli/app.py: Main Typer CLI entry point (
racommand)
ra-mcp-common (no internal deps)
↑
ra-mcp-search (depends on common)
ra-mcp-browse (depends on common)
↑
ra-mcp-search-mcp (depends on search + fastmcp)
ra-mcp-browse-mcp (depends on browse + fastmcp)
ra-mcp-guide-mcp (depends on common + fastmcp)
ra-mcp-search-cli (depends on search + typer + rich)
ra-mcp-browse-cli (depends on browse + typer + rich)
↑
ra-mcp (root) (composes all MCP and CLI packages)
# Clone repository
git clone https://github.com/AI-Riksarkivet/ra-mcp.git
cd ra-mcp
# Install dependencies (syncs workspace packages)
uv sync# MCP server (stdio) - for Claude Desktop integration
uv run ra serve
# MCP server (HTTP/SSE) - for web clients, testing, and development
uv run ra serve --port 7860
# With verbose logging
uv run ra serve --port 7860 --logRun the server using .docker/docker-compose.yml on the Dagger engine — no Docker daemon required.
Configuration mirrors the Helm chart (charts/ra-mcp/values.yaml).
# Start server (exposed on host port 7860)
dagger call compose-up up --ports 7860:7860
# or
make compose-up
# Run health check
dagger call compose-test
# or
make compose-testConnect to Claude Code:
# 1. Start the server (keep this terminal running)
dagger call compose-up up --ports 7860:7860
# 2. In another terminal, add as MCP server
claude mcp add --transport http ra-mcp http://localhost:7860/mcp
# 3. Verify connection inside Claude Code
/mcp
# 4. Test with a search
# Ask Claude: "search for trolldom"Verify manually:
# Health check
curl http://localhost:7860/health
# MCP endpoint (should return server info)
curl -X POST http://localhost:7860/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}'The project includes a full-featured CLI for searching and browsing documents:
# Search for documents
uv run ra search "trolldom"
uv run ra search "Stockholm" --max 50
# Browse specific documents
uv run ra browse "SE/RA/310187/1" --page "7,8,52"
uv run ra browse "SE/RA/420422/01" --pages "1-10" --search-term "Stockholm"
# Get help
uv run ra --help
uv run ra search --help
uv run ra browse --help# Run all tests
uv run pytest
# Run specific package tests
uv run pytest packages/common/tests/ -v
uv run pytest packages/search/tests/ -v
uv run pytest packages/browse/tests/ -v
# Run with coverage
uv run pytest --cov=ra_mcp_common --cov=ra_mcp_search --cov=ra_mcp_browse --cov-report=htmlTests follow patterns drawn from httpx, pydantic, and FastMCP. Build bottom-up through the dependency stack:
Layer 0: ra-mcp-common ← pure utilities, no internal deps
Layer 1: ra-mcp-search, browse ← domain models, clients, operations
Layer 2: *-mcp, *-cli packages ← MCP tools, CLI commands
Layer 3: ra-mcp root server ← composition
Structure:
- One test file per source module:
test_formatting.pytestsformatting.py - Flat module-level functions — no test classes (httpx pattern)
- Each test file is self-contained with its own helpers and mock data
- Fixtures in
conftest.pyonly for truly shared setup (e.g., mock HTTP response factory) - XML/JSON fixture files in
packages/<pkg>/tests/fixtures/
Parametrize for edge cases (pydantic pattern):
@pytest.mark.parametrize("page_id,expected", [
pytest.param("_00066", 66, id="standard"),
pytest.param("_H0000459_00005", 5, id="compound"),
pytest.param("_00000", 0, id="all-zeros"),
])
def test_page_id_to_number(page_id, expected):
assert page_id_to_number(page_id) == expectedMock at the right boundary:
- Domain packages (search, browse): inject a mock
HTTPClientvia constructor — don't patchurllib - MCP tool packages: use
Client(mcp)for in-process testing, mock at the operations layer - Never mock telemetry — test behavior, not instrumentation
MCP tool testing (FastMCP pattern):
async def test_tool_returns_error_on_empty_keyword():
async with Client(search_mcp) as client:
result = await client.call_tool("transcribed", {"keyword": "", "offset": 0})
assert "empty" in result.content[0].text.lower()Naming: test_<subject>_<scenario> — e.g., test_get_json_success, test_get_content_returns_none_on_404
What to test vs skip:
- Test: behavior, return values, error handling, edge cases
- Skip: telemetry span attributes, log messages,
__init__.pyre-exports, config constants
# Format code
uv run ruff format .
# Lint and auto-fix issues
uv run ruff check --fix .
# Type check (ty is configured in pyproject.toml)
uv run ty checkIMPORTANT: Before committing code, always run CI checks locally to catch issues early.
# Run full CI pipeline (same as GitHub Actions)
dagger call checks && dagger call test
# Quick check: Run code quality checks only
dagger call checks
# Quick check: Run tests only
dagger call testBest Practice: Run dagger call checks before every commit to ensure:
- Code is properly formatted
- No linting errors
- Type checking passes
- Tests pass (when implemented)
This matches exactly what runs in GitHub Actions CI, preventing failed builds.
# Test MCP server with MCP Inspector
npx @modelcontextprotocol/inspector uv run ra serve
# Test HTTP/SSE server with curl
uv run ra serve --port 7860
curl http://localhost:7860/mcpYou can build and test the containerized server using Dagger:
# Build and test server with automatic health check
dagger call test-server --source=.
# Just build the container
dagger call build --source=.
# Start server as a Dagger service (for testing with other containers)
dagger call serve --source=. --port=7860
# Expose server on host for manual testing
dagger call serve-up --source=. --port=7860 upInteractive testing with Dagger shell:
# Build and get a shell in the container
dagger call build --source=. terminal
# Inside the container, you can:
# - Test the server: ra serve --host 0.0.0.0 --port 7860
# - Run CLI commands: ra search "trolldom"
# - Check environment: python --version
# - Debug issues: ls -la /appThe project uses Dagger for containerized builds and publishing.
For Docker publishing:
export DOCKER_USERNAME="your-dockerhub-username"
export DOCKER_PASSWORD="your-dockerhub-token-or-password"For PyPI publishing:
export PYPI_TOKEN="your-pypi-token"# Build container image locally (default: Alpine)
dagger call build --source=.
# Build with specific base image
dagger call build --source=. --base-image="python:3.13-alpine"
dagger call build --source=. --base-image="cgr.dev/chainguard/python:latest-dev"
dagger call build --source=. --base-image="python:3.13-slim"
# Run tests (currently skipped until test suite exists)
dagger call test --source=.
# Build with custom settings
dagger call build-local \
--source=. \
--image-repository="riksarkivet/ra-mcp" \
--base-image="python:3.13-alpine"The project supports generating Software Bill of Materials (SBOM) and security scanning:
Generate SBOM (Software Bill of Materials):
# Generate SBOM in SPDX format (default)
dagger call generate-sbom-spdx --source=. --base-image="python:3.13-alpine"
# Generate SBOM in CycloneDX format
dagger call generate-sbom-cyclone-dx --source=. --base-image="python:3.13-alpine"
# Export SBOM to local file
dagger call export-sbom \
--source=. \
--base-image="python:3.13-alpine" \
--format="spdx-json" \
--output-path="./sbom.spdx.json"Vulnerability Scanning:
# Scan for vulnerabilities (CRITICAL and HIGH)
dagger call scan-ci --source=.
# Scan with custom severity levels
dagger call scan --source=. --severity="CRITICAL,HIGH,MEDIUM" --format="table"
# Generate JSON scan report
dagger call scan-json --source=. --severity="CRITICAL,HIGH"
# Generate SARIF output for GitHub Security
dagger call scan-sarif --source=. --output-path="trivy-results.sarif"Important Notes:
- SBOM Generation: Uses Trivy to scan the built container and generate SPDX or CycloneDX SBOMs
- Provenance: SLSA provenance is generated by BuildKit during the GitHub Actions publish workflow
- GitHub Releases: SBOMs and provenance are automatically generated and attached to releases as assets
- Verification: SBOMs can be used to verify container contents and detect supply chain issues
- Format Support: SPDX-JSON and CycloneDX-JSON formats are both supported
The project supports multiple base images for different use cases:
Supported Base Images:
python:3.13-alpine- Lightweight Alpine Linux (default)cgr.dev/chainguard/python:latest-dev- Wolfi-based Chainguard image (minimal CVEs)cgr.dev/chainguard/python:latest- Chainguard production imagepython:3.13-slim- Debian slim variant- Any Python 3.13+ image with pip support
Publishing Examples:
# Publish Alpine variant with explicit tag
dagger call publish-docker \
--docker-username=env:DOCKER_USERNAME \
--docker-password=env:DOCKER_PASSWORD \
--image-repository="riksarkivet/ra-mcp" \
--tag="v0.3.0" \
--base-image="python:3.13-alpine" \
--tag-suffix="-alpine" \
--source=.
# Result: riksarkivet/ra-mcp:v0.3.0-alpine
# Publish Wolfi/Chainguard variant
dagger call publish-docker \
--docker-username=env:DOCKER_USERNAME \
--docker-password=env:DOCKER_PASSWORD \
--image-repository="riksarkivet/ra-mcp" \
--tag="v0.3.0" \
--base-image="cgr.dev/chainguard/python:latest-dev" \
--tag-suffix="-wolfi" \
--source=.
# Result: riksarkivet/ra-mcp:v0.3.0-wolfi
# Publish Debian slim variant
dagger call publish-docker \
--docker-username=env:DOCKER_USERNAME \
--docker-password=env:DOCKER_PASSWORD \
--image-repository="riksarkivet/ra-mcp" \
--tag="v0.3.0" \
--base-image="python:3.13-slim" \
--tag-suffix="-slim" \
--source=.
# Result: riksarkivet/ra-mcp:v0.3.0-slim
# Auto-tag from pyproject.toml version (prefixes with "v")
dagger call publish-docker \
--docker-username=env:DOCKER_USERNAME \
--docker-password=env:DOCKER_PASSWORD \
--image-repository="riksarkivet/ra-mcp" \
--base-image="python:3.13-alpine" \
--tag-suffix="-alpine" \
--source=.GitHub Actions Publishing:
The publishing workflow (publish.yml) uses docker/build-push-action for native BuildKit attestation support:
-
Publishes container images with embedded attestations:
- Alpine:
riksarkivet/ra-mcp:v0.3.0-alpine - Wolfi:
riksarkivet/ra-mcp:v0.3.0-wolfi - SBOM attestations embedded in registry manifest
- SLSA Provenance (mode=max) embedded in registry
- Alpine:
-
Also generates standalone SBOM files:
sbom-v0.3.0-alpine.spdx.json(as release asset)sbom-v0.3.0-wolfi.spdx.json(as release asset)
Verify registry attestations:
# Inspect SBOM in registry
docker buildx imagetools inspect riksarkivet/ra-mcp:v0.3.0-alpine --format "{{json .SBOM}}"
# Inspect provenance
docker buildx imagetools inspect riksarkivet/ra-mcp:v0.3.0-alpine --format "{{json .Provenance}}"
# Verify with Docker Scout
docker scout attestation riksarkivet/ra-mcp:v0.3.0-alpineSecurity Benefits:
- Registry-native attestations (embedded in image manifest)
- SLSA Provenance Level 3 (build process transparency)
- SBOM attestations (dependency transparency)
- Multi-platform builds (amd64, arm64)
- Compliance ready (NTIA, EO 14028, SLSA)
# Build and publish to PyPI
dagger call publish-pypi \
--pypi-token=env:PYPI_TOKEN \
--source=.# HTTP/SSE transport (recommended for development)
claude mcp add --transport sse ra-mcp http://localhost:7860/sse
# Verify connection
claude mcp listAdd to claude_desktop_config.json:
macOS/Linux: ~/.config/claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"ra-mcp": {
"command": "uv",
"args": ["run", "ra", "serve"],
"env": {}
}
}
}- Test-First Development: Never write code without testing it! Always verify your changes work.
- Modify Over Create: Prefer editing existing files over creating new ones.
- Read Completely: Always read whole files, don't just read the head.
- Multiple Recommendations: When possible, provide 2-3 alternative solutions.
When to use docstrings:
- Public APIs and MCP tools
- Complex business logic
- Non-obvious behavior or algorithms
- Functions with important pre/post-conditions
When to use better naming instead:
- Simple, obvious functions
- Well-known patterns
- Standard operations
- Search API:
https://data.riksarkivet.se/api/records- Documentation - IIIF Collections:
https://lbiiif.riksarkivet.se/collection/arkiv- Documentation - IIIF Images:
https://lbiiif.riksarkivet.se - ALTO XML:
https://sok.riksarkivet.se/dokument/alto - Bildvisning:
https://sok.riksarkivet.se/bildvisning(Interactive viewer) - OAI-PMH:
https://oai-pmh.riksarkivet.se/OAI- Documentation
- Riksarkivet Data Platform Wiki: Comprehensive API documentation
- Förvaltningshistorik: Semantic search interface (experimental)
- HTRflow: Handwritten text recognition pipeline (PyPI package)
- Create a new tool file in the appropriate MCP package (e.g., search_tool.py)
- Define a
register_*_tool(mcp)function that uses@mcp.tool()decorator - Add detailed docstring with examples and parameter documentation
- Call the register function from the package's tools.py
Example pattern (from search_tool.py):
def register_search_tool(mcp: FastMCP):
@mcp.tool()
async def search_transcribed(keyword: str, offset: int, ...) -> str:
"""Tool description for LLM understanding."""
...To add a new module (e.g., ra-mcp-metadata):
- Create domain package:
packages/metadata/with models, clients, operations - Create MCP package:
packages/metadata-mcp/with tool registration - Optionally create CLI package:
packages/metadata-cli/ - Register in server.py
AVAILABLE_MODULES:
from metadata_mcp.tools import metadata_mcp
AVAILABLE_MODULES = {
...
"metadata": {
"server": metadata_mcp,
"description": "Advanced metadata search and filtering",
"default": False,
},
}- Add dependencies to root
pyproject.toml
- Create new client in the appropriate domain package (e.g., packages/browse/src/ra_mcp_browse/clients/)
- Follow existing patterns (see alto_client.py)
- Use the centralized HTTPClient from
ra_mcp_common - Add comprehensive error handling
- Use dependency injection for HTTP client
Resources provide static or dynamic content to MCP clients (see guide_mcp/tools.py for examples):
@mcp.resource("riksarkivet://my-resource/{param}")
def get_my_resource(param: str) -> str:
"""Description of what this resource provides."""
return f"Content for {param}"# Add new dependency to a package
cd packages/common && uv add package-name
# Add development dependency (root)
uv add --dev package-name
# Update all dependencies
uv sync --upgrade
# Update specific dependency
uv add package-name@latest# Check status
git status
# Stage changes
git add .
# Commit with conventional commit format
git commit -m "feat: add new search feature"
git commit -m "fix: resolve timeout issue"
git commit -m "docs: update API documentation"
# Push changes
git pushConventional Commit Types:
feat: New featurefix: Bug fixdocs: Documentation changesrefactor: Code refactoringtest: Adding testschore: Maintenance tasksperf: Performance improvements
Releases are automated via GitHub Actions. The workflow chain is:
git tag v0.X.Y && git push --tags
→ release.yml: generates changelog with git-cliff, creates GitHub Release
→ publish.yml: triggers on release:published, builds & pushes Docker images
To cut a new release:
# One command: bumps pyproject.toml, commits, tags, and pushes
make release VERSION=0.5.0This runs: sed version update → git commit → git tag v0.5.0 → git push && git push --tags.
How it works:
- release.yml triggers on
v*tag pushes - Installs
git-cliffand runsgit cliff --latest --strip headerto generate notes for the tagged version - Creates a GitHub Release with those notes via
softprops/action-gh-release - Release notes include commit links back to GitHub (configured in cliff.toml)
- The existing publish.yml triggers on
release: [published]to build and push Docker images
Local changelog generation:
# Full changelog to CHANGELOG.md
make changelog
# Preview release notes for the latest tag
uvx git-cliff --latest --strip headerEnvironment Variables for Debugging:
# Set logging level (DEBUG, INFO, WARNING, ERROR)
export RA_MCP_LOG_LEVEL=DEBUG
# Enable API call logging to file (ra_mcp_api.log)
export RA_MCP_LOG_API=1
# Override timeout (useful for Hugging Face)
export RA_MCP_TIMEOUT=120MCP Server Issues:
# Use MCP Inspector for interactive testing
npx @modelcontextprotocol/inspector uv run ra serve
# Enable verbose logging with environment variable
RA_MCP_LOG_LEVEL=DEBUG uv run ra serve --port 7860
# Test HTTP endpoint
curl -X POST http://localhost:7860/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Search Issues:
# Test search directly via CLI with debug logging
RA_MCP_LOG_LEVEL=DEBUG uv run ra search "test query" --max 5
# Check API response
curl "https://data.riksarkivet.se/api/records?q=Stockholm&rows=1"
# Monitor API logs (if RA_MCP_LOG_API=1)
tail -f ra_mcp_api.logThe project has a two-layer OpenTelemetry instrumentation strategy:
-
FastMCP built-in instrumentation (automatic): FastMCP 3.0+ has native OTel support. It automatically creates spans for all
tools/call,resources/read,prompts/get, anddelegateoperations with MCP semantic convention attributes. Do NOT add manual spans to MCP tool handlers — FastMCP already covers them. -
Manual instrumentation (project code): Domain operations, API clients, and the HTTP client have manual spans and metrics via
ra_mcp_common.telemetry.get_tracer().
- OTel SDK is initialized only in the root package (
src/ra_mcp_server/telemetry.py), gated onRA_MCP_OTEL_ENABLED=true - All sub-packages use only
opentelemetry-api(no-op when SDK absent) - FastMCP uses the same global
TracerProvider, so its automatic spans and project manual spans form a unified trace tree - Module-level tracers:
get_tracer("ra_mcp.<component>")fromra_mcp_common.telemetry
tools/call search_transcribed ← FastMCP (automatic)
└── delegate search_transcribed ← FastMCP (composed server)
└── tools/call search_transcribed ← FastMCP (local provider)
└── SearchOperations.search ← manual span
└── SearchAPI.search ← manual span
└── HTTP GET ← manual span
RA_MCP_OTEL_ENABLED=true # Master switch (default: false)
OTEL_EXPORTER_OTLP_ENDPOINT=... # Collector endpoint (default: http://localhost:4317)
OTEL_EXPORTER_OTLP_PROTOCOL=grpc # grpc or http/protobuf (default: grpc)
OTEL_SERVICE_NAME=ra-mcp # Service name (default: ra-mcp)
RA_MCP_OTEL_LOG_BRIDGE=true # Bridge Python logging to OTel (default: true)| Component | Tracer name | Spans | Metrics |
|---|---|---|---|
| HTTP client | ra_mcp.http_client |
HTTP GET |
request count, error count, duration, response size |
| Search API | ra_mcp.search_api |
SearchAPI.search |
— |
| Search ops | ra_mcp.search_operations |
SearchOperations.search |
— |
| Browse ops | ra_mcp.browse_operations |
browse_document, _fetch_page_contexts |
— |
| ALTO client | ra_mcp.alto_client |
ALTOClient.fetch_content |
— |
| IIIF client | ra_mcp.iiif_client |
IIIFClient.get_collection |
— |
| OAI-PMH client | ra_mcp.oai_pmh_client |
get_record, get_metadata, extract_manifest_id |
— |
| Search CLI | ra_mcp.cli.search |
cli.search |
— |
| Browse CLI | ra_mcp.cli.browse |
cli.browse |
— |
Follow the HTTP client pattern (the gold standard in this codebase):
except SomeError as e:
span.set_status(StatusCode.ERROR, str(e))
span.record_exception(e)
self._error_counter.add(1, {"error.type": type(e).__name__})
raise# Run full telemetry verification (starts Jaeger, exercises CLI, checks trace tree)
dagger call verify-telemetry --source=.For detailed information about the Model Context Protocol specification, implementation details, or when clarification is needed about MCP-specific features, refer to the official documentation:
- MCP Specification: Official protocol specification
- FastMCP Documentation: FastMCP library documentation
Issue: Server won't start
- Check if port 7860 is already in use:
lsof -i :7860 - Try a different port:
uv run ra serve --port 8001
Issue: No search results found
- Verify API is accessible:
curl https://data.riksarkivet.se/api/records - Check search syntax (use exact terms first, then wildcards)
- Try broader search terms
Issue: Import errors
- Reinstall dependencies:
uv sync --reinstall - Check Python version:
python --version(requires 3.13+)
Issue: Tests not running
- Test infrastructure is being set up - see Testing section
- Dagger currently accepts zero tests as passing
# General help
uv run ra --help
# Command-specific help
uv run ra serve --help
uv run ra search --help
uv run ra browse --help
# Check version
uv run ra --versionWhen working with this codebase:
- Always test changes: Run the server or CLI to verify functionality
- Read full context: Use the Read tool on complete files, not just snippets
- Prefer modifications: Edit existing code rather than creating new files
- Check types: The project uses type hints - maintain them in all code
- Follow patterns: Match existing code style and patterns (see packages/search/src/ra_mcp_search/operations/)
- Document thoroughly: MCP tools need excellent documentation for LLM understanding
- Workspace awareness: Changes to common affect all packages; changes to search affect search-mcp and search-cli
- Layered architecture: Domain logic lives in search/browse packages; MCP wrappers in *-mcp packages; CLI in *-cli packages