Menu
Public repository / c2f43d4988686a5b5142bb6dd4255f9b473792dc
AkurAI-Notes / .plan/epics/E06-mcp-server-https.md
Latest change f1d9b8cc6ff48113d891a2d95fd79b2c49f1f4ff - Initial plan and .plan directory by Claude Code
# E06 — HTTPS MCP server at /mcp
## Goal
JSON-RPC 2.0 over Streamable-HTTP at `https://akurai-notes.olibuijr.com/mcp`.
Same binary as the web app, second HTTP listener or `/mcp` route handler.
Claude Desktop / Anthropic Claude Code (MCP clients that speak HTTP transport)
can connect with no special adapter — they point their client config at
`https://akurai-notes.olibuijr.com/mcp` with a Bearer token header.
## Reference sources
- `~/Projects/akurai-crm/crates/crm-mcp/src/server.rs` (61 lines) — exact
port source. `McpServer::new`, `handle_request`, `serve`. Same shape.
- `~/Projects/akurai-crm/crates/crm-mcp/src/tools.rs` (520 lines) — the
JSON-RPC dispatch + `tool_def` helper. Read first 80 lines for the tool
registration pattern.
- JSON-RPC 2.0 over Streamable-HTTP spec (Anthropic MCP — HTTP transport,
one POST per request, no SSE in v0.0.1, batched `_meta` minimal).
## Steps
### 1. Port `crm-mcp/src/server.rs` into `notes-mcp/src/server.rs` verbatim
Swap `CrmConfig` → `NotesMcpConfig`, `crm-mcp.db_path` → `notes-mcp.db_path`,
and the `println!` boot message:
```rust
println!("🔌 akurai-notes MCP server listening on http://{addr}/mcp");
```
That's the only change. The handler shape is exactly.crm:
```rust
let handler = move |req: &Request| -> Response {
if req.path != "/mcp" {
return Response::new(404).with_text("Not found");
}
if req.method != Method::Post {
return Response::new(405).with_text("Method not allowed");
}
// NEW (notes): bare-token bearer check (configurable off for dev)
if !check_bearer(req, &bearer_token) {
return Response::new(401).with_text("unauthorized");
}
let body = req.body_str();
let result = tools.handle_request(body.as_ref());
Response::ok()
.with_header("Access-Control-Allow-Origin", "*")
.with_header("Content-Type", "application/json")
.with_body("application/json", result.into_bytes())
};
```
### 2. Add bearer-token auth
Decide between two tiers (state Ólafur's preference in the PR):
- **Tier A (default for v0.0.1):** static bearer token in env
`AKURAI_NOTES_MCP_TOKEN`. MCP client passes `Authorization: Bearer <token>`.
Implement with `check_bearer`:
```rust
fn check_bearer(req: &Request, expected: &str) -> bool {
if expected.is_empty() { return true; } // dev mode: any caller ok
match req.header("Authorization").and_then(|s| s.strip_prefix("Bearer ")) {
Some(tok) => tok.trim() == expected,
None => false,
}
}
```
- **Tier B (cookie-shared with web app):** re-use
`notes-api::auth::session::extract_session_id`, look up the user the same
way. Sensible if MCP client is browser-launched; less clean for headless
Claude Desktop. v0.0.1 ships Tier A.
`mcp` subcommand parses `--token` or reads the env var. **Empty token = reject
all requests (production safe).**
### 3. Write `notes-mcp/src/tools.rs`
Port CRM's `tool_def` + `ToolRegistry` shape. Swap the CRM tools for the
Notes tool set:
| Tool name | Args | Body |
|-----------|------|------|
| `search_notes` | `query: str, k: int?` | calls notes-api semantic search; returns top-k notes |
| `semantic_search` | `query: str, k: int?` | same as `search_notes` but always embed path (mode=semantic) |
| `get_note` | `id: int` | fetch a single note by id, owner-scoped to the session-less world → token-scoped to the configured "service user" sub |
| `list_notebooks` | `(none)` | owner's notebooks |
| `create_note` | `title: str, body: str, notebookId: int?` | insert + embed |
| `update_note` | `id: int, title? str, body? str` | patch, re-embed on body change |
| `delete_note` | `id: int` | |
| `add_tag` | `noteId: int, tagId: int` | |
| `remove_tag` | `noteId: int, tagId: int` | |
**Owner scoping question:** the MCP token doesn't carry a user identity. Two
answers:
- **Per-token owner:** issue one token per user. `notes.env` has
`AKURAI_NOTES_MCP_TOKEN=sess_<long-hex>` and
`AKURAI_NOTES_MCP_OWNER_SUB=olibuijr@olibuijr.com`. The MCP
server hardcodes the bearer → owner mapping from this single config row.
For v0.0.1 (single-user), this is the simplest. Default in this epic.
- **Per-user tokens:** introduce a tokens table + admin UI for issue/revoke.
v0.0.2+.
Implement v0.0.1's per-token owner. The tool registry captures the configured
owner sub at startup; every owner-scoped query uses it. The code shape:
```rust
pub struct ToolRegistry {
db: Arc<Mutex<BTree>>,
embed_url: Option<String>,
embed_model: Option<String>,
owner_sub: String, // from AKURAI_NOTES_MCP_OWNER_SUB
}
impl ToolRegistry {
fn owner_sub(&self) -> &str { &self.owner_sub }
pub fn handle_request(&self, body: &str) -> String {
// Parse JSON-RPC envelope; dispatch methods → tool handlers;
// return JSON-RPC response. Port the layout from crm-mcp/src/tools.rs:
// - validate jsonrpc=="2.0" + id present
// - method "initialize" → reply server info + capabilities
// - method "notifications/initialized" → ack (no-op)
// - method "tools/list" → return vec of tool schemas
// - method "tools/call" → match args.name → dispatch
// - response envelope { "jsonrpc":"2.0", "id":*, "result": { … } }
}
}
```
### 4. Write `cmd_mcp.rs` in `notes-cli/src/`
```rust
use std::path::PathBuf;
use std::process::ExitCode;
use akurai_http::Server;
use notes_mcp::server::{McpConfig, McpServer};
pub struct McpRunConfig {
pub host: String, // 127.0.0.1
pub port: u16, // 8100
pub db: String, // /var/lib/akurai-notes/notes.db (shared with the web app)
pub token: String, // AKURAI_NOTES_MCP_TOKEN
pub embed_url: String,
pub embed_model: String,
pub owner_sub: String,
}
pub fn run(cfg: McpRunConfig) -> std::io::Result<()> {
let mut server = McpServer::new(McpConfig { … }).map_err(std::io::Error::other)?;
server.serve().map_err(std::io::Error::other)
}
```
Note the MCP server **binds to a separate port** (`127.0.0.1:8100`) so that
the route handler is one-liner (`/mcp` only) and authentication is decoupled
from the web-server session cookie. nginx routes
`https://akurai-notes.olibuijr.com/mcp` → `127.0.0.1:8100/mcp`; everything
else → `127.0.0.1:8099`.
### 5. Configure nginx vhost for both paths (deploy.sh in E07)
The same nginx vhost routes both `/mcp` and everything else. Add to the vhost
config written by `akurai-ec2 nginx-proxy akurai-notes.olibuijr.com 8099`:
```nginx
location /mcp {
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Authorization $http_authorization;
proxy_read_timeout 600s; # MCP calls may run tools that take a while
}
```
`akurai-ec2 nginx-proxy` may not write the `/mcp` block by default — E07's
deploy.sh writes the vhost as a `tee`'d file (one extra `<<'NGINX'` heredoc per
deploy), then reloads nginx. See E07 §3 for the exact `tee` snippet.
### 6. Tests
`crates/notes-mcp/tests/mcp_test.rs`:
```rust
#[test] fn initialize_returns_server_info() { … }
#[test] fn tools_list_returns_all_notes_tools() { … }
#[test] fn unauthorized_request_returns_401() { … }
#[test] fn search_notes_returns_owned_only() { … }
#[test] fn get_note_returns_full_note() { … }
#[test] fn create_note_inserts_and_returns_id() { … }
#[test] fn empty_token_allows_dev_requests() { … } // dev mode
```
## Tool schema shape
`tool_def` should match the MCP spec's JSON schema format (one of: `string`,
`integer`, `boolean`, optional `default`, `description`). The CRM's existing
helper already produces the right shape:
```rust
fn tool_def(
&self,
name: &str,
description: &str,
params: Vec<(&str, &str, &str, bool)>, // (name, type, description, required)
) -> Value {
Value::Object(vec![
("name".into(), Value::Str(name.into())),
("description".into(), Value::Str(description.into())),
("inputSchema".into(), Value::Object(vec![
("type".into(), Value::Str("object".into())),
("properties".into(), Value::Object(params.iter().map(|(n, t, d, _)| {
(n.to_string(), Value::Object(vec![
("type".into(), Value::Str(t.to_string())),
("description".into(), Value::Str(d.to_string())),
]))
}).collect())),
("required".into(), Value::Array(params.iter().filter(|(_, _, _, req)| *req).map(|(n, _, _, _)| Value::Str(n.to_string())).collect())),
])),
])
}
```
## Files touched
```
crates/notes-mcp/Cargo.toml ← add notes-api dep + akurai-http + akurai-json + akurai-storage
crates/notes-mcp/src/lib.rs ← pub mod server; pub mod tools;
crates/notes-mcp/src/server.rs ← port of crm-mcp/server.rs + bearer check
crates/notes-mcp/src/tools.rs ← NOTES tool set + per-token owner scoping
crates/notes-mcp/tests/mcp_test.rs ← test file
crates/notes-cli/src/main.rs ← add "mcp" subcommand branch
crates/notes-cli/src/cmd_mcp.rs ← run() helper
site/backend/routes.json ← (no MCP route — runs on separate port)
deploy.sh ← system unit + nginx /mcp block (covered by E07)
```
## Acceptance
```bash
cargo test -p notes-mcp
cargo clippy -p notes-mcp -- -D warnings
# Local e2e against a freshly seeded DB:
AKURAI_NOTES_DB=/tmp/e6.db akurai-notes seed
akurai-notes mcp --host 127.0.0.1 --port 8100 --db /tmp/e6.db \
--token testtoken --owner-sub olibuijr@olibuijr.com &
MCPID=$!
# initialize handshake
curl -fsS -X POST http://127.0.0.1:8100/mcp -H 'Authorization: Bearer testtoken' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}' \
| jq .
curl -fsS -X POST http://127.0.0.1:8100/mcp -H 'Authorization: Bearer testtoken' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools | length'
# expect > 0; semantic-search tool present
curl -fsS -X POST http://127.0.0.1:8100/mcp -H 'Authorization: Bearer testtoken' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"semantic_search","arguments":{"query":"meeting"}}}' \
| jq .
# unauthorized request:
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8100/mcp -H 'Authorization: Bearer wrong' -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":9,"method":"tools/list"}'
# Expect: 401
kill $MCPID
```
## Time
≈ 15 minutes.
## Depends on
E01 (workspace), E04 (notes storage + handlers), E05 (semantic search; the
MCP `semantic_search` tool just delegates to the same `embed` + cosine path).
E07 (nginx second-location block + systemd second unit) for live deploy.