思源笔记 MCP 服务器

mcp-siyuan 是一个 MCP 服务器,通过 SQL 查询、区块级编辑、幂等写入和 PDF 导出,让 Claude 及其他 MCP 客户端能够安全地读取、搜索和编写自托管思源笔记内容。

  • by CaseyRo
  • Python
  • 混合部署
  • 更新于
在 GitHub 查看 CaseyRo/mcp-siyuan

使用说明

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_pdf outside 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_siyuan as a subprocess. No auth, no network — IPC over pipes. Set TRANSPORT=stdio (the default).
  • streamable HTTP — used by Claude Desktop, Claude.ai connectors, and n8n. JSON-RPC over POST /mcp with optional server-streamed responses. Set TRANSPORT=http. Requires MCP_API_KEY (the server fails fast at startup without one).
  • HTTP+SSE (legacy) — the original /sse + /messages split. 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 with traced_tool(...), attaches ToolAnnotations (read-only / destructive / idempotent / open-world + title) and tags, and hands it to mcp.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: Context and send no log or progress notifications, because the MCP portal in front of production forwards no server-to-client messages.
  • Auth uses a TokenVerifier subclass (mcp_siyuan/auth.py). HMAC-compared static bearer (the MCP_API_KEY). Required in HTTP mode; the server refuses to start without it.
  • The /health endpoint is registered with @mcp.custom_route("/health", methods=["GET"]). It probes the upstream SiYuan kernel with a 30-second cache (configurable via UPSTREAM_PROBE_INTERVAL). Pass ?diag=1 to also dump the recent-tool-call ring buffer (see Operator Runbook).
  • Error propagation: tools raise ToolError for caller mistakes and SiYuanError for kernel failures. FastMCP converts both into MCP tool errors. The traced_tool wrapper 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) wraps httpx.AsyncClient. A single module-level sy = 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, plus MCP_API_KEY, SIYUAN_URL, and SIYUAN_TOKEN from your secret store. See compose.yaml for 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 need SIYUAN_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:

  1. Push to main.
  2. A git-push webhook triggers a Dockerfile build.
  3. The new image replaces the running container.
  4. 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:

  1. Retry the call once with the same args.
  2. If the retry succeeds, never commit destructive downstream operations (cancel tasks, delete notes, send messages) until a real success return value is received.
  3. 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:

  1. 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.
  2. 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.
  3. Reproduce with curl directly 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.
  4. 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.