使用说明
mcp-siyuan
Model Context Protocol server for SiYuan Notes, built on FastMCP 4.x.
mcp-siyuan runs as a sidecar to the SiYuan kernel and exposes its HTTP API as MCP tools. It speaks two transports — streamable HTTP for remote clients (Claude Desktop, Claude.ai connectors, n8n) and stdio for local Claude Code — so the same image and code path serve both use cases.
flowchart LR
Client["Claude Desktop / Claude.ai / n8n / Claude Code"]
CFA["Cloudflare Access<br/>(JWT auth)"]
Portal["MCP Portal<br/>(aggregator)"]
Server["mcp-siyuan<br/>(this repo)"]
Kernel["SiYuan kernel<br/>(internal HTTP endpoint)"]
Client -->|streamable HTTP| CFA
CFA --> Portal
Portal -->|JSON-RPC over HTTP| Server
Server -->|/api/* POST| Kernel
Client -. stdio .-> Server
The stdio path is used directly by local Claude Code; the HTTP path traverses an authenticated edge (e.g. Cloudflare Access) and an MCP portal aggregator.
It is for people who keep their notes in a self-hosted SiYuan workspace and want Claude or another MCP client to read, search and write those notes safely: SQL-backed queries, section-level edits, idempotent writes and PDF export.
Requirements
- A running SiYuan kernel and its API token (SiYuan: Settings, About, API token)
- Python 3.11 or newer and uv for a local install, or Docker
- For
export_pdfoutside Docker: the WeasyPrint system libraries (Pango, Cairo)
Quick start
git clone https://github.com/CaseyRo/mcp-siyuan.git
cd mcp-siyuan
uv sync
SIYUAN_URL=http://localhost:6806 SIYUAN_TOKEN=... uv run python -m mcp_siyuan # stdio
Docker (streamable HTTP on host port 8006, path /mcp):
cp .env.example .env # set SIYUAN_TOKEN and MCP_API_KEY
docker compose up -d --build
compose.yaml builds from source and joins an external Docker network named siyuan-cluster_default so the server reaches the kernel at http://siyuan:6806. Change the network or SIYUAN_URL to match your setup.
Tool Catalog
All tools are exposed under the siyuan_ prefix at the portal (e.g., siyuan_list_notebooks). Internally they're registered as the bare name. Every write tool accepts an optional idempotency_key — see Operator Runbook.
Tier 1 — Read / Query
| Tool | Description |
|---|---|
siyuan_list_notebooks |
List all notebooks in the SiYuan workspace. |
siyuan_sql_query |
Execute a read-only SQL SELECT against SiYuan's internal database. |
siyuan_get_document |
Get a document's markdown content by its block ID. |
siyuan_search |
Quick full-text search across all SiYuan content (no surrounding context). |
siyuan_get_block |
Get a single block's content and metadata by ID. |
siyuan_get_block_attrs |
Get all attributes (system and custom) for a block. |
Tier 2 — Write
| Tool | Description |
|---|---|
siyuan_create_notebook |
Create a new notebook in SiYuan. |
siyuan_rename_notebook |
Rename an existing notebook. |
siyuan_remove_notebook |
Remove a notebook and all its documents. |
siyuan_create_document |
Create a new document in a SiYuan notebook. Accepts idempotency_key. |
siyuan_get_or_create_doc |
Idempotent upsert of a document by notebook + path. Returns {block_id, was_created, was_updated}. Optional update_if_exists replaces content. Accepts idempotency_key. |
siyuan_update_block |
Update an existing block's content. Accepts idempotency_key. |
siyuan_insert_block |
Insert a new block relative to an anchor block. Accepts idempotency_key. |
siyuan_append_block |
Append content to the end of a document or container block. Accepts idempotency_key. |
siyuan_upsert_section |
Replace (or create) the content between a named heading and the next heading at same/higher level. Heading match is case-insensitive and whitespace-tolerant, and finds headings anywhere in the document. Check action: created means nothing matched and a new section was appended at the end. Errors if two headings share the same text. Accepts idempotency_key. |
siyuan_append_to_section |
Append block(s) at the end of a named section (before the next heading at same/higher level). Errors if the heading is missing — use upsert_section to create — or if two headings share the same text. Accepts idempotency_key. |
siyuan_delete_block |
Delete a block by ID. |
siyuan_delete_doc |
Delete a document by its block ID (wraps /api/filetree/removeDocByID). Verifies the doc is actually gone via SQL — unlike delete_block, which silently no-ops on type='d' blocks. Accepts idempotency_key. |
siyuan_set_block_attrs |
Set attributes on a block. Accepts idempotency_key. |
siyuan_move_doc |
Move one or more documents to a new parent document or notebook. Optional new_title renames a single moved doc in the same call. Accepts idempotency_key. |
siyuan_rename_doc |
Rename a document without moving it. Accepts idempotency_key. |
siyuan_move_block |
Move a block to a new position. |
siyuan_daily_note |
Create or open today's daily note in a notebook. |
siyuan_bulk_create_documents |
Create up to 50 documents in one call. Per-item failures don't abort the batch; results include status and error so callers can retry only the failures. |
siyuan_bulk_set_attrs |
Set attributes on up to 50 blocks in one call, with per-item status reporting. |
Smart — LLM-ergonomic high-level tools
| Tool | Description |
|---|---|
siyuan_get_recent_docs |
Get recently modified documents, newest first. |
siyuan_list_documents |
List documents, optionally filtered by a title substring. Populates title/name (the raw SQL name column is blank for docs). |
siyuan_find_tasks |
Find task/TODO items across SiYuan notes. |
siyuan_get_backlinks |
Get all blocks that reference (link to) a given block or document. |
siyuan_get_tags |
List all tags used across the workspace with their usage count. |
siyuan_search_by_tag |
Find all blocks with a specific tag. |
siyuan_get_block_children |
Get a block and its child blocks as a tree, in document order. |
siyuan_search_with_context |
Search SiYuan and return results with surrounding context blocks. |
siyuan_capture_task |
Append a new task checkbox to today's daily note. |
siyuan_get_document_outline |
Get the heading outline of a document. |
siyuan_doc_exists |
Check if a document exists at notebook + hpath. Returns {exists, block_id} — no error on miss. |
siyuan_get_doc_summary |
One-call overview of a document: title, hpath, child count, content preview, first N blocks. |
siyuan_list_conflicts |
List documents with sync-conflict suffixes or malformed hpaths (workspace hygiene). |
siyuan_list_orphans |
List documents whose parent document no longer exists (orphans). |
Export
| Tool | Description |
|---|---|
siyuan_export_pdf |
Export a SiYuan document as PDF (WeasyPrint; A3/A4/A5/Letter/Legal/Tabloid, portrait/landscape, image quality 1–100). Math blocks render as raw LaTeX since WeasyPrint does not execute JavaScript. |
Example invocation (Claude.ai or any MCP client):
{
"tool": "siyuan_create_document",
"args": {
"notebook": "20210817205410-2kvfpfn",
"path": "/Inbox/2026-04-30 Quick note",
"markdown": "# Hello\n\nFrom the API.",
"idempotency_key": "inbox-2026-04-30-quick-note"
}
}
The hand-written catalog is kept honest by tests/test_readme_tool_catalog.py, which fails CI if a registered tool isn't documented here.
Tool annotations & structured output
Every tool is registered with tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, and a human-friendly title) plus tags (read / write / smart / destructive / export) so clients can auto-approve safe reads and gate only true destructive ops. Tools that returned structured dicts now return typed Pydantic models (DocUpsertResult, DocExistsResult, BulkDocResult, BulkAttrResult), so FastMCP advertises an output_schema and emits structured content. The wire-level JSON keys are unchanged from the previous prose Returns: contracts.
Write-tool response envelope (CDI-1093): the write-result models (WriteResult, DeleteBlockResult, DeleteDocResult, SectionResult, DocUpsertResult) carry an optional warnings: [] list for non-fatal advisories alongside ok and id (the block the write touched: the new block for insert/append, the target for update; update_block adds inserted_ids). The kernel's raw transactions echo (~4 KB of DOM per call) is not returned. On a failure, transient errors (HTTP 502/503/504, transport faults) surface a [retryable] marker in the error message so callers know an identical retry is safe; the underlying SiYuanError.retryable flag drives this. (create_document still returns a bare document-ID string for backwards compatibility — wrapping it in an envelope would break existing callers.)
Resources
Fetch-once reference data, exposed under the siyuan:// URI scheme:
| Resource | Description |
|---|---|
siyuan://schema |
SQL column legend + block-type codes for siyuan_sql_query (authoritative, single source). |
siyuan://notebooks |
Live notebook list (id, name, closed) — resolve IDs without a tool call. |
siyuan://status |
Server version, transport, and cached upstream-kernel reachability. |
Prompts
Guided multi-step workflows:
| Prompt | Description |
|---|---|
daily_review |
Combine get_recent_docs + find_tasks(checked=False) into a read-only review of recent work and open TODOs. Args: notebook, days. |
process_inbox |
Walk a notebook's recent docs, summarize each, extract actions, and propose routing. Args: notebook, limit. |
Transport Architecture
The MCP spec evolved through several wire formats; what we run in production is streamable HTTP (a.k.a. "Streamable HTTP transport") with stateless sessions, plus stdio for local subprocess clients. We deliberately do not use the older HTTP+SSE split:
- stdio — used by Claude Code launching
python -m mcp_siyuanas a subprocess. No auth, no network — IPC over pipes. SetTRANSPORT=stdio(the default). - streamable HTTP — used by Claude Desktop, Claude.ai connectors, and n8n. JSON-RPC over
POST /mcpwith optional server-streamed responses. SetTRANSPORT=http. RequiresMCP_API_KEY(the server fails fast at startup without one). - HTTP+SSE (legacy) — the original
/sse+/messagessplit. We don't run it. The current MCP spec consolidated on streamable HTTP with chunked or SSE-style responses negotiated per request.
stateless_http=True is set on the streamable-HTTP server so Cloudflare-killed idle connections do not leave orphaned MCP sessions sitting in memory; every request is independently auth'd and dispatched. See mcp_siyuan/server.py for the runner.
For deeper FastMCP protocol detail, see the FastMCP docs.
FastMCP Usage Notes
This repo follows a small set of conventions worth knowing if you're contributing:
- Tool registration is explicit in
mcp_siyuan/server.py: a small_register(...)helper wraps each tool function withtraced_tool(...), attachesToolAnnotations(read-only / destructive / idempotent / open-world + title) and tags, and hands it tomcp.tool(...). The wrapper preserves__name__,__doc__, and the full signature so FastMCP can introspect the schema. - Server instructions (
FastMCP(instructions=...)) orient clients on the SiYuan sidecar model and the SQL-first query pattern. - No progress events: tools take no
ctx: Contextand send no log or progress notifications, because the MCP portal in front of production forwards no server-to-client messages. - Auth uses a
TokenVerifiersubclass (mcp_siyuan/auth.py). HMAC-compared static bearer (theMCP_API_KEY). Required in HTTP mode; the server refuses to start without it. - The
/healthendpoint is registered with@mcp.custom_route("/health", methods=["GET"]). It probes the upstream SiYuan kernel with a 30-second cache (configurable viaUPSTREAM_PROBE_INTERVAL). Pass?diag=1to also dump the recent-tool-call ring buffer (see Operator Runbook). - Error propagation: tools raise
ToolErrorfor caller mistakes andSiYuanErrorfor kernel failures. FastMCP converts both into MCP tool errors. Thetraced_toolwrapper appends[request_id=...]to the error message so clients can correlate. - Async-first: every tool function is
async def. The kernel client (mcp_siyuan/client.py) wrapshttpx.AsyncClient. A single module-levelsy = SiYuanClient()singleton is reused across calls.
For the underlying framework's tool definition, transport, and middleware mechanics, defer to the FastMCP docs rather than re-documenting them here.
Configuration
All configuration is via environment variables. mcp_siyuan/config.py parses them through pydantic-settings.
Connection to SiYuan
| Variable | Default | Notes |
|---|---|---|
SIYUAN_URL |
http://siyuan:6806 |
Kernel endpoint. Must be http:// or https://. The default assumes the sidecar pattern with the SiYuan container reachable on the same Docker network. |
SIYUAN_TOKEN |
(empty) | API token from SiYuan settings. The server warns at startup if unset; calls will be unauthenticated. |
UPSTREAM_PROBE_INTERVAL |
30 |
Seconds the /health probe result is cached. |
Server transport
| Variable | Default | Notes |
|---|---|---|
TRANSPORT |
stdio |
One of stdio, http. |
HOST |
127.0.0.1 |
Bind host (HTTP mode). Container deploys typically bind 0.0.0.0. |
PORT |
8000 |
Bind port (HTTP mode). |
MCP_API_KEY |
(empty) | Bearer token. Required in HTTP mode; server refuses to start without it. Inject via your secret store / orchestrator variables in production. |
Observability & reliability
| Variable | Default | Notes |
|---|---|---|
SIYUAN_LOG_LEVEL |
INFO |
Sets the root log level. JSON formatter emits ts, level, request_id, tool_name, caller, args_size_bytes, kernel_status, latency_ms, outcome, message. |
SIYUAN_DIAG_BUFFER_SIZE |
50 |
Number of recent tool-call records held in memory for /health?diag=1. |
SIYUAN_IDEMPOTENCY_TTL_SECONDS |
300 |
TTL for the in-process write-tool replay cache. |
SIYUAN_RETRY_MAX_ATTEMPTS |
3 |
Attempts for transient kernel failures (5xx, transport errors). |
SIYUAN_RETRY_INITIAL_BACKOFF |
0.25 |
First retry delay in seconds. |
SIYUAN_RETRY_MAX_BACKOFF |
2.0 |
Upper bound for the retry delay in seconds. |
GIT_COMMIT |
(baked at build) | Commit reported by /health; falls back to /app/.git_commit. |
Where each is set
- Production (HTTP) sets
TRANSPORT=http,HOST=0.0.0.0,PORT=8000, plusMCP_API_KEY,SIYUAN_URL, andSIYUAN_TOKENfrom your secret store. Seecompose.yamlfor the container shape. - Local Claude Code (stdio) reads from your shell env or
.env. Defaults are correct for the common case; you'll typically only needSIYUAN_TOKEN. - CI / tests ignore most of these — tests mock the kernel client.
Authentication
stdio needs no server-side auth: the client that launches the process owns it. In HTTP mode every request to /mcp must send Authorization: Bearer <MCP_API_KEY>; the key is compared in constant time and the server will not start without one. Terminate TLS in front of the server (reverse proxy, tunnel or an authenticating edge) before exposing it beyond localhost. The server talks to SiYuan with SIYUAN_TOKEN; keep both secrets out of version control.
Deployment
The stack rebuilds from source on every merge to main, using the Dockerfile and compose.yaml in this repo. In our setup:
- Push to
main. - A git-push webhook triggers a
Dockerfilebuild. - The new image replaces the running container.
- A Cloudflare Tunnel + Access edge fronts the service; an MCP Portal aggregator routes the
/siyuan/*namespace to this server.
To check status hit the /health endpoint behind your authenticated edge.
The Dockerfile installs Pango, Cairo, and Noto fonts for WeasyPrint. The production container runs with a tight memory limit (see compose.yaml).
Single-replica constraint: the server keeps the idempotency cache and diag ring buffer in process memory. Running multiple replicas would split those caches per pod and break the replay semantics that callers depend on. Don't horizontally scale this service without first migrating the cache to Redis (or similar shared store).
Operator Runbook
Symptom: No approval received.
A reproducible failure pattern we have seen across multiple MCP servers in our fleet:
- Several consecutive identical MCP tool calls return the literal error
No approval received. - A byte-identical retry — same args, same conversation, same MCP session — eventually succeeds.
- The same string appears on unrelated upstream servers, strongly suggesting the failure originates above the upstream servers in a shared layer (the MCP portal aggregator, the FastMCP framework, or the Claude client). It is not produced by this server's code.
Manual workaround for any operator/agent now:
- Retry the call once with the same args.
- If the retry succeeds, never commit destructive downstream operations (cancel tasks, delete notes, send messages) until a real success return value is received.
- If the retry also fails, surface to the user — do not loop blindly.
For write tools, pass an idempotency_key so that an accidental client-side retry doesn't create duplicate documents/blocks. The cache is in-process with a default 5-minute TTL (SIYUAN_IDEMPOTENCY_TTL_SECONDS); failures are explicitly not cached, so a real retry can still produce a fresh kernel call.
Diagnostic surface: /health?diag=1
Every tool invocation is assigned a UUIDv4 request_id and appended to an in-memory ring buffer (size SIYUAN_DIAG_BUFFER_SIZE, default 50). To pull recent activity:
curl -H "Authorization: Bearer $MCP_API_KEY" \
"https://<your-mcp-host>/health?diag=1" | jq '.diag[-10:]'
Each entry contains ts, request_id, caller, tool_name, args_size_bytes, kernel_status, latency_ms, outcome, and error. The same request_id appears as [request_id=…] on any error message returned to the MCP client, and as the request_id field on every JSON log line in docker logs.
To filter container logs by request:
docker logs <mcp-siyuan-container> 2>&1 | jq -c 'select(.request_id == "abc-123-...")'
Cross-system correlation
Once you have a request_id from a failure, the next investigation steps live outside this repo:
- Grep the portal/FastMCP source for
"No approval received"— the literal string. Likely candidates: the portal aggregator, the FastMCP framework, or the Claude client connector code. - Pull edge auth logs (e.g. Cloudflare Access) for the public hostname at the failure timestamps. Look for JWT denials/refreshes or 5xx responses from origin.
- Reproduce with
curldirectly against the public hostname using the same bearer token Claude uses. If the failure pattern reproduces outside Claude → server/portal side. If not → Claude client involvement. - Audit per-call approval gating in the portal config and any Claude connector setting that might fire approval prompts in a way the call site doesn't expect.
FastMCP version pin
fastmcp is constrained to >=4.0.10,<5.0.0 in pyproject.toml; uv.lock pins the exact version and the Docker build installs it with uv sync --frozen. CI runs with FASTMCP_MCP_CAMELCASE_COMPAT=false, so MCP fields are read and written in snake_case (read_only_hint, mime_type).
Local Development
Set up the venv and run the test suite:
uv sync
uv run pytest
Run against a local SiYuan via stdio
export SIYUAN_URL=http://localhost:6806
export SIYUAN_TOKEN=your-siyuan-token
uv run python -m mcp_siyuan
In another terminal, configure Claude Code to launch this command as an MCP server.
Run streamable HTTP behind a tunnel
export TRANSPORT=http
export HOST=127.0.0.1
export PORT=8000
export MCP_API_KEY=$(openssl rand -hex 32)
export SIYUAN_TOKEN=your-siyuan-token
uv run python -m mcp_siyuan
Then expose with cloudflared tunnel --url http://127.0.0.1:8000 or tailscale serve for testing.
Usage telemetry
A vendored usage.py middleware writes one JSON line per tool call to stderr with the server, tool, duration, outcome and protocol. Tool arguments are never logged.
Smoke tests
# Unit + integration tests (mocks SiYuan)
uv run pytest
# Single test module
uv run pytest tests/test_idempotency.py -v
# Verify README catalog stays in sync with registered tools
uv run pytest tests/test_readme_tool_catalog.py
# Lint
uv run ruff check .
CI (.github/workflows/ci.yml) runs uv sync --locked, ruff check and pytest as the required test check. main is branch-protected, so every change lands through a pull request.
Versioning & Release
Releases are git tags only. After a merge to main, .github/workflows/release.yml runs the tests and pip-audit, then pushes the next patch tag vX.Y.Z and a multi-arch image to GHCR (published for convenience; the deployment does not pull it). There are no version-bump commits, so pyproject.toml's version and CHANGELOG.md stay static and lag the tags. Deployments build from source on each merge; /health reports the package version plus the git_commit baked at build time. Trust the git tags for the release number.
Related work
Cross-references to upstream investigations (No-approval-received RCA, portal caching, server consolidation, portal auth) live in the team's internal tracker, not in this public README.
Support
If this server saves you time, you can buy me a coffee.
License
MIT © 2026 Casey Romkes.
SiYuan Notes is an independent open-source project; this server is an unofficial third-party integration and is not affiliated with or endorsed by the SiYuan project.