Confluence to Markdown MCP Server

Servidor para búsqueda híbrida sobre contenido de Confluence guardado e indexado localmente en clientes de IA.

Documentación

confluence2md-mcp - MCP Server for confluence2md Indexes

CI Release License: MIT Go Version

MCP server that exposes confluence2md-indexer search to any MCP-compatible AI client. Runs as a local stdio server, queries a SQLite index built from confluence2md exports, and returns ranked results with score metadata.

Part of the confluence2md Platform

confluence2md-mcp is the third step in a three-tool local Confluence knowledge pipeline. It wraps a SQLite index built by confluence2md-indexer (which indexes output from confluence2md) and serves it to AI clients via MCP. See docs/platform.md for the full architecture.

Requirements

  • A SQLite index at schema version 1, written by confluence2md-indexer v0.5.0 or newer. This server links v0.9.0; an index from an older release is refused with a message naming the rebuild command.
  • Source content must use confluence2md metadata format — other formats are not supported

Environment Variables

VariableRequiredDescription
CONFLUENCE_INDEX_DBrecommendedPath to the SQLite DB file. Falls back to confluence2md-index.db in the current working directory if unset.
CONFLUENCE2MD_EMBEDDING_PROVIDERoptionalbow-local (default: local, offline, no API key), openai, or openai-compatible.
CONFLUENCE2MD_EMBEDDING_MODELoptionalModel id, for example text-embedding-3-small.
CONFLUENCE2MD_EMBEDDING_DIMoptionalVector dimension such as 1024. Part of the embedding identity.
CONFLUENCE2MD_EMBEDDING_BASE_URLoptionalEndpoint for an openai-compatible provider.
CONFLUENCE2MD_EMBEDDING_API_KEY_ENVoptionalName of the variable that holds the API key (preferred over a literal key).
CONFLUENCE2MD_EMBEDDING_API_KEYoptionalLiteral API key.
CONFLUENCE2MD_EMBEDDING_SKIPoptionaltrue disables the vector channel and leaves lexical search.
CONFLUENCE2MD_EMBEDDING_CONTEXToptionalDocument context header the index was built with, for example title,space; it must match the index or the query fails with an identity mismatch.

The remaining CONFLUENCE2MD_EMBEDDING_* variables of the indexer are honoured too — AUTH_HEADER, AUTH_SCHEME, HEADERS, QUERY_PARAMS, DOCUMENT_PREFIX, QUERY_PREFIX, BATCH_SIZE, TIMEOUT, MAX_RETRIES — see the indexer's docs/embedding-providers.md.

The index and the query must agree on the embedding configuration. An index records the identity of the vectors it holds (provider:variant@dimension, for example bow-local:fnv1a@256), while a hybrid or vector query resolves its own identity from these variables. When the two differ, the query fails with embedding mismatch: ... instead of returning weak results; the tool error names both identities and how to fix it. Use mode: "lexical" to search without embeddings at all.

This server reads environment variables only. The indexer CLI additionally accepts a config.yaml; if you indexed through a configuration file, export the equivalent CONFLUENCE2MD_EMBEDDING_* variables here so that both sides resolve the same provider.

Upgrading an Index

This server links confluence2md-indexer v0.9.0, and every indexer release since v0.5.0 writes the same schema version 1 — the one that introduced the document metadata columns. An index built by any of those releases keeps working without a rebuild. A database written by an older indexer is refused with a message naming the rebuild command:

confluence2md-indexer index ./output --rebuild

Installation

Download the binary for your platform from Releases and place it somewhere on your PATH.

VS Code

Create or edit .vscode/mcp.json in your workspace:

{
  "servers": {
    "confluence2md": {
      "type": "stdio",
      "command": "confluence2md-mcp",
      "args": [],
      "env": {
        "CONFLUENCE_INDEX_DB": "/path/to/confluence2md-index.db"
      }
    }
  }
}

MCP: Add Server in the Command Palette also works.

Claude Code

claude mcp add confluence2md \
  confluence2md-mcp \
  -e CONFLUENCE_INDEX_DB=/path/to/confluence2md-index.db

WSL note: Use the Linux binary, not the Windows .exe — the .exe does not inherit WSL environment variables. The DB path must be a native Linux path (e.g. /home/user/confluence2md-index.db), not /mnt/c/, to avoid SQLite locking issues on NTFS mounts.

Codex CLI

Add to ~/.codex/config.json:

{
  "mcpServers": {
    "confluence2md": {
      "command": "confluence2md-mcp",
      "args": [],
      "env": {
        "CONFLUENCE_INDEX_DB": "/path/to/confluence2md-index.db"
      }
    }
  }
}

Tools

confluence.search

Search indexed Confluence content from a local SQLite DB.

ArgumentRequiredDescription
query✓Search query text
dbPathOverride DB path. Falls back to CONFLUENCE_INDEX_DB env var, then to confluence2md-index.db in the current working directory.
modehybrid (default) | lexical | vector
fusionweighted (default) | rrf
alphaWeighted fusion alpha [0..1], default 0.70
rrfKRRF k constant, default 60
topKHow many ranked results to consider; also the default for limit. Default 10
limitMax results to return. Defaults to topK (10); 0 inherits topK — it does not mean unlimited (that is maxPerPage)
offsetResult offset
candidateKCandidates per retrieval channel, default 50
expandContext expansion chunk count
groupdocument returns one result per page (page score, chunkCount); default chunk-level
maxPerPageCap chunks per document in the results; 0 (default) means unlimited
spaceKeyFilter by space key
pageIdFilter by page ID
fromDateLower bound YYYY-MM-DD
toDateUpper bound YYYY-MM-DD
spacesFilter by any of several space keys; wins over spaceKey
hostFilter by crawled site host
authorCreator or last modifier name, case-insensitive
createdByCreator name only
modifiedByLast modifier name only
depthMin / depthMaxCrawl depth bounds; depthMin: 1 excludes seed pages
seedOnlyOnly the pages the crawl started from
hasAttachmentsOnly pages that carry at least one attachment
updatedSinceModified within an age (30d, 2w, 12h) or after an absolute date (2026-01-01)
facetsCount documents per bucket over the ranked candidate set (bounded by candidateK), before topK: space, author
embeddingProviderOverride the provider for this call: bow-local | openai | openai-compatible
embeddingModelOverride the embedding model
embeddingDimOverride the embedding dimension
embeddingBaseURLOverride the embedding endpoint
embeddingApiKeyEnvName of the environment variable that holds the API key
embeddingAuthHeader / embeddingAuthSchemeOverride the authentication header and scheme
embeddingSkipDisable the vector channel for this call
embeddingDocumentPrefix / embeddingQueryPrefixText prefixes for asymmetric models
embeddingContextDocument context header the index was built with: title, space, section. An index built with one only matches when the query names the same selection

Metadata filters apply to lexical, vector and hybrid retrieval alike. Arguments win over environment variables: the indexer fills only the embedding fields that the arguments leave unset, so embeddingModel overrides CONFLUENCE2MD_EMBEDDING_MODEL for that call. A literal API key is deliberately not an argument — it would travel through the client conversation and the server log — so set CONFLUENCE2MD_EMBEDDING_API_KEY or name another variable with embeddingApiKeyEnv.

Response fields:

FieldDescription
schemaVersionSchema version string for contract stability
toolAlways "confluence.search"
dbPathResolved DB path used for the query
requestEchoed request parameters
countNumber of results returned in this response
totalResults available after ranking — topK caps it — and before offset/limit; in group: "document" mode it counts documents. It is not the number of matching documents when topK truncates: read facets for that
resultsArray of result objects with chunk text and score breakdown
facetsPresent only when facets was requested: { "space": {...}, "author": {...} } with document counts

Result objects carry canonicalUrl when the crawler recorded one, and chunkCount in group: "document" mode.

Failures are returned as tool errors that keep the indexer's message and add the fix when the cause is actionable: an embedding mismatch names both identities, a missing or vector-less index names the rebuild command, and a disabled vector channel points at CONFLUENCE2MD_EMBEDDING_SKIP. mode: "lexical" works without any embedding configuration.

confluence.list_spaces

List the Confluence space keys the index contains, so a client can scope a search without knowing the keys in advance.

ArgumentRequiredDescription
dbPathOverride DB path. Falls back to CONFLUENCE_INDEX_DB, then to confluence2md-index.db in the current working directory.

Response fields: schemaVersion, tool, dbPath, count, and spaces — a sorted array of space key strings, empty when the index holds no spaces.

confluence.stats

Report what an index holds, the crawl it was built from and the embedding identity behind its vectors. This is the tool that turns a failed or empty search into an answer: the index is empty, it is older than the crawl, or it was built with another provider.

ArgumentRequiredDescription
dbPathOverride DB path. Falls back to CONFLUENCE_INDEX_DB, then to confluence2md-index.db in the current working directory.

Response fields: schemaVersion, tool, dbPath, and stats. The stats object is the indexer's stats --json object unchanged — only the envelope is this server's (tool here, command there) — and it carries:

  • stats.documents, stats.chunks, stats.embeddings, stats.runs
  • stats.vectorReady, stats.vectorName (the stored embedding identity), stats.vectorCapability
  • stats.embeddingModels — one entry per identity present: name, dimension, chunks
  • stats.metadata — coverage: documents, withAuthors, withLinks, withAttachments, withComments, seeds, nested, hosts
  • stats.corpus — the crawl the index was built from: runId, crawlStartedAt, crawlCompletedAt, crawlSucceededAt, crawlMode, seedCount, pageCount, indexedAt. A crawlCompletedAt later than indexedAt means the indexing run read a moving corpus; the indexer's --fail-if-stale is the CLI equivalent.

stats.corpus is omitted when the index holds no crawl snapshot, which is what a content folder indexed without a metadata.json produces; stats.metadata is always present. crawlStartedAt, crawlSucceededAt, crawlMode and seedCount stay empty when the corpus only carried a completion timestamp.

confluence.facets

Count the documents a query matches per space and author, without returning them. It takes the same arguments as confluence.search and defaults facets to ["space", "author"].

Response fields: schemaVersion, tool, dbPath, request, total (matching documents), and facets; there are no result rows. The tool groups by page internally, so total counts documents even when a page has several matching chunks, and counts describe the ranked candidate set before topK — bounded by candidateK, not every document in the corpus.

Query semantics

The tool schemas cover the argument names and types; this section covers what the arguments do once they reach the indexer.

Defaults and how they interact

The server sends the ranking arguments explicitly rather than leaving them empty, so a client sees the same numbers the CLI's defaults produce: mode hybrid, fusion weighted, alpha 0.70, rrfK 60, topK 10, candidateK 50.

Three of them bound each other:

  • candidateK is how many candidates each retrieval channel fetches before fusion.
  • topK truncates the fused ranking, so it caps everything downstream. Asking for more than topK rows cannot return more rows, however large limit is.
  • limit pages through that truncated set, starting at offset. limit: 0 inherits topK; it does not mean unlimited.

total therefore reports the size of the truncated set, not the number of matching documents. A query with two matching pages and topK: 1 answers total: 1 while the facets count both.

What is rejected, and what is silently defaulted

Returned as a tool error: group set to anything but document; a negative maxPerPage, depthMin, depthMax or embeddingDim; depthMin greater than depthMax; an updatedSince that is neither an age (30d, 2w, 12h) nor a date; an unknown facet dimension (unknown facet "x" (available: space, author)).

Not rejected: values are coerced where they can be. A number or boolean sent as a string is parsed when it parses — limit: "5" is 5, alpha: "0.4" is 0.4, seedOnly: "true" is true — and a list accepts a comma-separated string as well as an array (spaces: "ENG,OPS"). When a value cannot be parsed, the argument silently falls back to its default: limit: "ten" behaves like limit: 0, which inherits topK; alpha: "high" behaves like 0.70; seedOnly: "yes" behaves like false.

One sharp edge: depthMin and depthMax treat a supplied but unparseable value — including null — as an explicit 0, not as absent. depthMax: 0 keeps only seed pages, so a typo there silently narrows a search to the crawl seeds. Send those two as numbers, and omit them to leave the bound open.

A query that is empty or only whitespace returns an empty result set, not an error; a missing query (or a non-string one) is rejected. Whitespace is trimmed before matching.

expand

expand: N widens each result to a window of up to N chunks on either side of the match. chunkText becomes that window, baseChunkText keeps the matched chunk, and contextStartIndex, contextEndIndex and contextChunkCount describe the window (a start index of 0 is omitted, like every other zero-valued optional field).

group: "document"

One result per page, carrying the page's best-scoring chunk: fusedScore is that chunk's score, chunkIndex is its position, and chunkCount is how many of the page's chunks matched. total counts documents. maxPerPage has no effect here — there is already one row per page.

Facet counting

Counts are document counts over the ranked candidate set, taken before topK but after each retrieval channel has been capped by candidateK. They are wider than the results page and not necessarily exhaustive for the corpus: a query whose lexical channel returns only candidateK hits counts only the documents among those hits. Rules worth knowing:

  • A page counts once per dimension however many of its chunks matched.
  • author counts a page for its creator and for its last modifier, so the buckets can sum to more than total. That matches the author filter, which also matches either name.
  • A page whose metadata has no value for the dimension adds no bucket, so buckets can sum to less than total.
  • Only the dimensions asked for are counted; the confluence.facets tool defaults to space and author.
  • topK does not change the counts — it only changes how many rows come back. candidateK is what bounds them.

Response reference

Every tool returns one JSON document as its text content. The envelope below is this server's; results and facets are the indexer's payloads, unchanged.

Result objects

Each results[] entry is the indexer's Result object: see Output Reference → JSON Output (query --json) for the field list and how the scores are computed. Which fields appear:

FieldWhen
rank, chunkId, documentId, pageId, title, localPath, spaceKey, sourceUrl, chunkText, chunkIndex, lexicalScore, vectorScore, fusedScore, fusionalways
canonicalUrlwhen the crawler recorded one; it is the link to open, never reconstructed from sourceUrl
baseChunkText, contextStartIndex, contextEndIndex, contextChunkCountwhen expand widened the result
chunkCountin group: "document" mode
metadataBoost, metadataFactorsreserved for ranking priors, which this server does not expose yet

An empty result set is [], never null, so a client can iterate it without a nil check. The facets block appears in a search payload only when the call asked for facets.

The request echo

request shows what the server sent to the indexer, not the arguments as you typed them:

  • Keys are the indexer's Go field names — Text, Mode, RRFK, TopK, Limit, Filters.SpaceKey — not the MCP argument names.
  • Embedding overrides are never echoed; the indexer tags them out of its request contract. Priors are excluded the same way, so they will not appear once they are exposed.
  • Filters always carries SpaceKey, PageID, FromDate, ToDate and the internal Candidate limit. The metadata filters (Spaces, Host, Author, CreatedBy, ModifiedBy, DepthMin, DepthMax, SeedOnly, HasAttachments, UpdatedSince) use omitempty, so an unused one is absent rather than empty.
  • Values are as sent: Limit reads 0 even though the effective page size is topK.
  • Group, MaxPerPage and Facets are omitted when unset, so a default call's echo has no trace of them.

Sample payloads

A chunk-level search with default arguments against a three-page fixture index:

{
  "schemaVersion": "1",
  "tool": "confluence.search",
  "dbPath": "/path/to/confluence2md-index.db",
  "count": 2,
  "total": 2,
  "request": {
    "Text": "apple",
    "Mode": "hybrid",
    "Fusion": "weighted",
    "Alpha": 0.7,
    "RRFK": 60,
    "TopK": 10,
    "Offset": 0,
    "Limit": 0,
    "CandidateK": 50,
    "Expand": 0,
    "Filters": {
      "SpaceKey": "",
      "PageID": "",
      "FromDate": "",
      "ToDate": "",
      "Candidate": 0
    }
  },
  "results": [
    {
      "rank": 1,
      "chunkId": "2001:000000",
      "documentId": "2001",
      "pageId": "2001",
      "title": "Apple Runbook",
      "localPath": "apple.md",
      "spaceKey": "ENG",
      "sourceUrl": "https://example.test/pages/2001",
      "canonicalUrl": "https://example.test/pages/2001-canonical",
      "chunkText": "# Apple Runbook\n\nThe apple rotation happens on Mondays.",
      "chunkIndex": 0,
      "lexicalScore": 1,
      "vectorScore": 1,
      "fusedScore": 1,
      "fusion": "weighted"
    },
    {
      "rank": 2,
      "chunkId": "2002:000000",
      "documentId": "2002",
      "pageId": "2002",
      "title": "On-call Handbook",
      "localPath": "oncall.md",
      "spaceKey": "OPS",
      "sourceUrl": "https://example.test/pages/2002",
      "chunkText": "# On-call Handbook\n\nEscalate the apple incident to the ops channel.",
      "chunkIndex": 0,
      "lexicalScore": 0,
      "vectorScore": 0,
      "fusedScore": 0,
      "fusion": "weighted"
    }
  ]
}

The same index with group: "document" and query: "deployment": two chunks of one page matched, so the page appears once and total counts the page.

{
  "count": 1,
  "total": 1,
  "results": [
    {
      "rank": 1,
      "chunkId": "2003:000000",
      "documentId": "2003",
      "pageId": "2003",
      "title": "Deployment Runbook",
      "localPath": "deploy.md",
      "spaceKey": "ENG",
      "sourceUrl": "https://example.test/pages/2003",
      "chunkText": "# Deployment\n\nIntro text.",
      "chunkIndex": 0,
      "lexicalScore": 1,
      "vectorScore": 1,
      "fusedScore": 1,
      "fusion": "weighted",
      "chunkCount": 2
    }
  ]
}

(Abridged: schemaVersion, tool, dbPath and request are as in the sample above, with "Group": "document" added to the echo.)

With expand: 2, the matched chunk arrives inside its window — chunkText is the window, baseChunkText is what actually matched:

{
  "title": "Deployment Runbook",
  "chunkText": "# Deployment\n\nIntro text.\n\n## Rollback\n\nSteps to undo the change.",
  "baseChunkText": "## Rollback\n\nSteps to undo the change.",
  "chunkIndex": 1,
  "contextEndIndex": 1,
  "contextChunkCount": 2
}

confluence.facets for the same query, defaulting to both dimensions. total counts documents and each bucket is a document count:

{
  "schemaVersion": "1",
  "tool": "confluence.facets",
  "dbPath": "/path/to/confluence2md-index.db",
  "total": 2,
  "facets": {
    "space": { "ENG": 1, "OPS": 1 },
    "author": { "Ada Lovelace": 2, "Grace Hopper": 1 }
  },
  "request": {
    "Text": "apple",
    "Mode": "hybrid",
    "Group": "document",
    "Facets": ["space", "author"]
  }
}

(Abridged: request shows only the fields that matter for this call — the rest match the first sample.)

confluence.stats on the same index. The stats object is the indexer's, complete:

{
  "schemaVersion": "1",
  "tool": "confluence.stats",
  "dbPath": "/path/to/confluence2md-index.db",
  "stats": {
    "runs": 1,
    "documents": 3,
    "chunks": 4,
    "embeddings": 4,
    "vectorReady": true,
    "vectorName": "bow-local:fnv1a@256",
    "vectorCapability": "lexical",
    "embeddingModels": [
      { "name": "bow-local:fnv1a@256", "dimension": 256, "chunks": 4 }
    ],
    "metadata": {
      "documents": 3,
      "withAuthors": 3,
      "withLinks": 0,
      "withAttachments": 0,
      "withComments": 0,
      "seeds": 0,
      "nested": 0,
      "hosts": 0
    },
    "corpus": {
      "runId": "21566c45e992fe22",
      "crawlStartedAt": "",
      "crawlCompletedAt": "2026-02-01T00:00:00Z",
      "crawlSucceededAt": "",
      "crawlMode": "",
      "seedCount": 0,
      "pageCount": 3,
      "indexedAt": "2026-10-21T21:20:43.8201126Z"
    }
  }
}

confluence.list_spaces:

{
  "schemaVersion": "1",
  "tool": "confluence.list_spaces",
  "dbPath": "/path/to/confluence2md-index.db",
  "count": 2,
  "spaces": ["ENG", "OPS"]
}

Known limitations

  • Chunk-level text. A result carries a chunk, or a window of chunks with expand; no tool returns a whole page. group: "document" plus expand is the closest equivalent.
  • Attachments and comments are metadata only. The index stores their counts, so a search can filter on them but not read them.
  • No ranking priors yet. Recency, authority, seed, depth and richness weighting exist in the indexer but are not exposed as arguments; see gkoos/confluence2md-indexer#59.
  • No index build or refresh. The server only reads. Building is confluence2md-indexer index <folder>, and stats.corpus.crawlCompletedAt is the crawl timestamp stored at index time — it cannot see a later crawl. To tell whether the index is behind, compare it with the crawler's current metadata.json (last_completed_crawl_completed_at).
  • stdio only. There is no HTTP or SSE transport.
  • confluence.list_spaces is the one direct read. It queries documents.space_key itself because pkg/indexerapi publishes no space list, and it returns keys only — no titles, counts or hierarchy.
  • The caller owns the trust boundary. dbPath opens any SQLite file the process can read, and embeddingBaseURL with embeddingApiKeyEnv can send a named environment variable's value to any host. Where the indexed content is untrusted, treat those two as privileged.

Development

Build

# Linux / macOS / WSL
go build -o bin/confluence2md-mcp .

# Windows
go build -o bin/confluence2md-mcp.exe .

# Cross-compile a Linux binary (PowerShell)
$env:GOOS = "linux"; $env:GOARCH = "amd64"
go build -o bin/confluence2md-mcp-linux-amd64 .

# The same in bash, WSL or macOS
GOOS=linux GOARCH=amd64 go build -o bin/confluence2md-mcp-linux-amd64 .

If module downloads fail with 403, set GOPROXY=direct.

Test

go test ./... -v

The suite builds the server and drives it over stdio. Four offline end-to-end tests install the pinned indexer (go install ...@v0.9.0), build an index from a temporary corpus with the default bow-local provider, and check:

  • the tool list, confluence.list_spaces, and a search narrowed by space;
  • result shaping: group: "document" with chunkCount, maxPerPage, and canonicalUrl;
  • facet counts attached to a search payload;
  • confluence.stats: size, stored embedding identity, metadata coverage and the crawl timestamp; then confluence.facets, including the document total for a page with several matching chunks.

One of the four covers the actionable failure — an index built with a different embedding identity — and a separate smoke test starts the server with no index at all to check the tool list and the missing-database error. Nothing needs an API key or a running service, and the offline tests skip only when the indexer CLI cannot be installed.

Version

Release builds stamp the binary through ldflags; the version is reported in the MCP initialize response and written to the startup log. An unstamped go build reports dev.

Troubleshooting

  • No results: verify CONFLUENCE_INDEX_DB points to a built index containing the chunks_fts and embeddings tables.
  • No results although stats counts documents: a filter is excluding everything — spaceKey is an exact match (read the keys with confluence.list_spaces), updatedSince is relative to now, and depthMin: 1 drops seed pages. Also compare stats.corpus.crawlCompletedAt with stats.corpus.indexedAt: a later completion means the indexing run read a moving corpus. Neither timestamp can see a crawl that happened after indexing, so compare them with the crawler's current metadata.json to judge staleness.
  • facets missing from a search payload: only confluence.facets always returns the block. A search omits it unless the call passes facets.
  • A facet bucket sum that disagrees with total: both numbers can be right. Author buckets count a page once per writer — creator and last modifier — so they sum above total; and the counts come from at most candidateK hits per channel, so they can fall short of the real match count. Raise candidateK to widen the counts; topK only changes how many rows are returned.
  • A filter or limit had no effect: a value that cannot be parsed falls back to its default, and depthMin/depthMax turn an unparseable one into an explicit 0 — see Query semantics.
  • WSL + Windows binary: use the Linux binary with a native Linux DB path — see the WSL note above.
  • Tools not appearing in chat: restart your MCP client after registration.