Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

BusinessPad

go-wiki

Русская версия

Long-term memory for agents that work on a codebase over MCP.
A Markdown wiki with a full revision history, a REST API and a built-in MCP server — the companion to a code graph such as codebase-memory-mcp: the graph knows where things are, the wiki knows what they mean.

Project site · Why · Quick start · REST API · Search · MCP · Related projects

Apache 2.0 Go 1.27 MCP 2025-06-18 no external deps


Why this exists

The project was written as the knowledge half of MCP codebase memory, next to a code graph such as codebase-memory-mcp.

A code graph remembers structure: which functions exist, who calls whom, where a thing lives. What it does not remember is why it is that way. Why this library was picked, what breaks if it is replaced, which agreement stands behind a piece of code that looks strange. That knowledge lives in people's heads and in chat threads, and the agent that comes to edit the code tomorrow never sees it.

go-wiki covers that half: human-readable decisions, versioned and addressable. An agent reads them over the same protocol it uses for the graph, and a person edits them in the browser.

Three properties make a wiki usable as machine memory:

  • Immutable addresses. The pair (path, rev) points at the same text forever: revisions are never rewritten, and restoring one appends a new revision. Such a pair is safe to store in a graph node.
  • Cheap change detection. Every revision carries a sha256, and format=stat answers "did anything change" without transferring the text — so reindexing stays incremental.
  • One source for people and agents. An edit from the UI, from REST and from MCP goes through the same versioning machinery, so agent changes show up in the same history feed and the same diffs.

A single Go binary, no external services: no Postgres, no Redis, no Node.


What it can do

Capability Available in
Markdown page CRUD UI, REST, MCP
Revision history with author and comment UI, REST, MCP
Diff between any two revisions (unified / per-line / side by side) UI, REST, MCP
Restoring an older revision UI, REST, MCP
Full-text search (SQLite FTS5, works with Cyrillic) UI, REST, MCP
Search that also matches other word forms (Russian and English stemming) UI, REST, MCP
Filters by section, author, contributor and dates UI, REST, MCP
Moving a page together with its subtree UI, REST, MCP
Rewriting links to the moved pages across the whole wiki UI, REST, MCP
Walking the tree one level at a time, with subtree counts REST, MCP
Creating a child page UI, REST, MCP
Optimistic locking through base_rev REST, MCP
Light and dark themes, responsive layout UI

Storage model

The key decision: the database always holds the current version only, and every superseded version is written out to its own .md file that the database points at.

data/
├── wiki.db                       # SQLite: current page bodies + revision metadata
└── revisions/
    └── docs/setup/
        ├── r0001.md              # body of revision 1
        └── r0002.md              # body of revision 2
  • pages — one row per page: path, title, the current body, the current revision number, author, comment, timestamps, and the normalized word stems used by search.
  • revisions — one row per superseded version: revision number, title, the path of the .md file, size, sha256, author, comment, when the version was created and when it was archived. Revision bodies are not kept in the database.
  • pages_fts — an FTS5 virtual table over pages, kept in sync by triggers.

Consequences worth relying on:

  • Revision files are plain Markdown with no front matter: you can grep them, diff them and commit them to git as they are.
  • Deleting a page archives its last version first and only then drops the row from pages. The history of a deleted page stays readable through GET /api/v1/revisions?path=….
  • If a page is created again at the same path, revision numbering continues where it left off instead of restarting at one.
  • Restoring a revision does not rewind the history: the body of the old version is written as a new revision. History is strictly append-only.
  • An edit that changes neither the body nor the title creates no revision.

Quick start

go build -o wiki ./cmd/wiki
./wiki -addr :8080 -data ./data

Configuration

Flag Environment variable Default Purpose
-addr WIKI_ADDR :8080 HTTP listen address
-data WIKI_DATA ./data root data directory
-db WIKI_DB <data>/wiki.db SQLite file
-revisions WIKI_REVISIONS <data>/revisions directory of .md revisions
-require-base-rev WIKI_REQUIRE_BASE_REV off reject an edit of an existing page that arrives without base_rev
-auth-tokens WIKI_AUTH_TOKENS token file: token author [read|write] per line
-trust-header WIKI_TRUST_HEADER header a trusted proxy puts the user name into
-trust-header-role WIKI_TRUST_HEADER_ROLE write role granted to clients arriving through the proxy
-stdio-author WIKI_STDIO_AUTHOR name that signs writes in -stdio mode
-stdio off serve MCP over stdio instead of starting the HTTP server
-version print the version

Upgrading a database written by an earlier version

The search index now carries a norm column with the word stems computed for every page (see Search and word forms). A database created before that migrates itself on the first start of the new binary: the column is added, stems are computed for every page, and the full-text index is rebuilt from scratch.

Nothing else is touched — pages, revision rows and the archived .md files stay exactly as they were, and the migration is idempotent, so a repeated start does nothing. The one thing to plan for: on a large wiki the first start takes noticeably longer than usual, because every page body is stemmed and reindexed. Subsequent starts are back to normal.

Who writes to the wiki

With no identity check configured, the author field comes from the client and nothing confirms it: anyone who can reach the service writes anything under anyone's name. For an internal setup with one person that is acceptable; the moment agents on user-reachable machines start writing, it is not.

There are two mechanisms, and they combine:

Per-client tokens. A file of the form

# token               author       role
tok-postanovshchik   постановщик  write
tok-reports          отчёты       read

The client presents the token in Authorization: Bearer … or X-Api-Token. Instead of the token itself the file may hold sha256:<hex>, so no secret sits on disk in the clear. The read role forbids every mutating method.

A header from a trusted proxy. -trust-header X-Auth-User: the name is taken from a header the proxy sets after its own authorization. The service itself must then be unreachable except through the proxy, or anyone can forge that header.

The important property: when a check is enabled, the author sent by the client is ignored and replaced with the verified name — in REST, in the web interface and in MCP. Otherwise the signature under a page means nothing again. A token that is presented but unknown is rejected with 401 rather than silently falling back to the header.

In -stdio mode the identity comes from -stdio-author: the peer there is the local process that started the server, and the protocol has nothing to confirm it with.

HTTP When
401 unauthorized no identity presented, or the token is not recognized
403 forbidden a mutating request arrived from a client with the read role

REST API

The base prefix is /api/v1. Request and response bodies are JSON in UTF-8.

CRUD addresses a page by its path in the URL. Everything that needs a second coordinate (a revision number, a diff range) takes the page as a ?path= parameter — that way routes stay unambiguous even for a page named revisions or diff.

Revision references

Wherever a revision number is expected, these are accepted:

Value Meaning
5 revision number 5
-1 the one before current (-2 — two back)
current, latest, head, empty the current revision
previous, prev the previous revision

Endpoint summary

Method Path Purpose
GET /api/v1/healthz status and page count
GET /api/v1/pages list pages
POST /api/v1/pages create a page
GET /api/v1/pages/{path} the current version of a page
PUT /api/v1/pages/{path} write a page (upsert)
DELETE /api/v1/pages/{path} delete a page
GET /api/v1/revisions?path= the history of a page
GET /api/v1/revisions/{rev}?path= body and metadata of one revision
GET /api/v1/diff?path= compare two revisions
POST /api/v1/restore?path= restore a revision
POST /api/v1/move?path= move a page together with its subtree
GET /api/v1/tree?path=&depth= the children of one node of the tree
GET /api/v1/search?q= search with filters
GET /api/v1/authors authors and their edit counts

GET /api/v1/pages

Parameters: prefix (only pages under it), limit (200 by default, 1000 at most), offset.

curl 'http://localhost:8080/api/v1/pages?prefix=docs&limit=50'
{
  "pages": [
    {"path":"docs/setup","title":"Установка","rev":2,"size":53,"author":"lead","updated_at":"2026-08-25T22:07:36Z"}
  ],
  "count": 1
}

POST /api/v1/pages

Creates a page. If the path is taken — 409 already_exists.

curl -X POST http://localhost:8080/api/v1/pages \
  -H 'Content-Type: application/json' \
  -d '{
        "path": "docs/setup",
        "title": "Установка",
        "content": "# Установка\n\nШаг один.\nШаг два.\n",
        "author": "n36",
        "comment": "первая версия"
      }'

Fields: path (required), content (required), title, author, comment. A missing title is derived from the last path segment. The response is 201 and the page object.

GET /api/v1/pages/{path}

curl http://localhost:8080/api/v1/pages/docs/setup
curl 'http://localhost:8080/api/v1/pages/docs/setup?format=raw'   # Markdown only, text/markdown
{
  "path": "docs/setup",
  "title": "Установка",
  "content": "# Установка\n\nШаг один, уточнённый.\nШаг два.\nШаг три.\n",
  "rev": 2,
  "author": "lead",
  "comment": "добавлен шаг три",
  "created_at": "2026-08-25T22:07:36.372Z",
  "updated_at": "2026-08-25T22:07:36.382Z"
}

PUT /api/v1/pages/{path}

An idempotent write: it creates the page if it does not exist (201), otherwise it updates it (200). content is the complete new body, not a patch.

curl -X PUT http://localhost:8080/api/v1/pages/docs/setup \
  -H 'Content-Type: application/json' \
  -d '{"content":"# Установка\n\nШаг один, уточнённый.\nШаг два.\nШаг три.\n","author":"lead","comment":"добавлен шаг три","base_rev":1}'

base_rev is optimistic locking: if the page has already moved on, the answer is 409 revision_conflict and nothing is written. For agents this is the main way not to overwrite someone else's edit.

If the service runs with -require-base-rev, an edit of an existing page without base_rev is rejected with 428 base_rev_required. A convention that "every client passes base_rev" is not a mechanism: one agent with a forgotten field silently overwrites another's work. With the flag, the omission becomes an error instead of a lost text. Creating a new page is unaffected — there is nothing to conflict with.

DELETE /api/v1/pages/{path}

curl -X DELETE 'http://localhost:8080/api/v1/pages/docs/setup?author=n36&comment=устарело'

The last version is archived before deletion, so the history stays available.

GET /api/v1/revisions?path=

curl 'http://localhost:8080/api/v1/revisions?path=docs/setup'
{
  "path": "docs/setup",
  "count": 2,
  "revisions": [
    {"path":"docs/setup","rev":2,"title":"Установка","author":"lead","comment":"добавлен шаг три",
     "size":91,"sha256":"d18c09…","current":true,"created_at":"2026-08-25T22:07:36.382Z"},
    {"path":"docs/setup","rev":1,"title":"Установка","author":"n36","comment":"первая версия",
     "size":54,"sha256":"95ef4b…","file":"docs/setup/r0001.md","current":false,
     "created_at":"2026-08-25T22:07:36.372Z","archived_at":"2026-08-25T22:07:36.382Z"}
  ]
}

Newest first. The current revision has current: true and no file field: its body lives in the database. file is a path relative to the revisions directory.

GET /api/v1/revisions/{rev}?path=

curl 'http://localhost:8080/api/v1/revisions/1?path=docs/setup'
curl 'http://localhost:8080/api/v1/revisions/-1?path=docs/setup&format=raw'

Returns the revision metadata plus a content field with the body.

GET /api/v1/diff?path=

Parameter Default Meaning
path required, the page
from to - 1 the left (older) revision
to current the right (newer) revision
format unified unified, json (also lines), side, stat
context 3 lines of context for unified
raw raw=1 together with format=unified returns text/x-diff instead of JSON
# a ready-made patch as text
curl 'http://localhost:8080/api/v1/diff?path=docs/setup&raw=1'
--- docs/setup@1
+++ docs/setup@2
@@ -1,4 +1,5 @@
 # Установка
 
-Шаг один.
+Шаг один, уточнённый.
 Шаг два.
+Шаг три.
# per-line operations — convenient to parse mechanically
curl 'http://localhost:8080/api/v1/diff?path=docs/setup&format=json'
{
  "path": "docs/setup",
  "from": { "rev": 1, "file": "docs/setup/r0001.md", "…": "" },
  "to":   { "rev": 2, "current": true, "…": "" },
  "stat": { "added": 3, "removed": 1 },
  "format": "lines",
  "lines": [
    {"op":"equal","text":"# Установка","old_line":1,"new_line":1},
    {"op":"delete","text":"Шаг один.","old_line":3},
    {"op":"insert","text":"Шаг один, уточнённый.","new_line":3}
  ]
}

format=side returns rows — aligned line pairs with per-character highlighting inside a changed line (kind: equal / replace / insert / delete). format=stat returns the counters only — the cheapest way to learn whether anything changed at all.

POST /api/v1/restore?path=

curl -X POST 'http://localhost:8080/api/v1/restore?path=docs/setup' \
  -H 'Content-Type: application/json' \
  -d '{"rev":1,"author":"n36","comment":"откат неудачной правки"}'

The body of revision 1 is written as a new revision on top of the current one. The response is the page object with the new revision number.

POST /api/v1/move?path=

Renames a page and moves it elsewhere in the tree. The place in the tree is the path itself: every segment but the last is a parent.

curl -X POST 'http://localhost:8080/api/v1/move?path=продукт/cardflow' \
  -H 'Content-Type: application/json' \
  -d '{"to":"продукт/каталог/cardflow","author":"n36"}'
{
  "from": "продукт/cardflow",
  "to": "продукт/каталог/cardflow",
  "moved": 3,
  "pages": ["продукт/cardflow/интеграции/1c", "продукт/cardflow/карточки", "продукт/cardflow/обзор"],
  "relinked": 2,
  "relinked_pages": ["продукт/каталог/cardflow/карточки", "реестр/домены"]
}

Nested pages travel with their parent. History follows them: revision rows are re-pointed and the archived .md files are physically moved to the new directory. pages lists the old addresses of everything that moved.

A section without a page of its own can be moved too. In the example above продукт/cardflow has no page — only its children do — and renaming a domain means renaming everything under it. What matters is that the subtree is not empty; an empty node is 404.

Links are rewritten across the whole wiki. A move renames pages but leaves the text of every other page alone, so without this step the registries that referred to the subtree keep pointing at addresses that no longer exist. Every reference of the form ](/w/old/path) is repointed, in each of the shapes it can be written in — with an anchor, in a reference definition, and percent-encoded, which is what a browser produces for Cyrillic paths:

- [CardFlow](/w/продукт/каталог/cardflow/обзор) — карточки товаров
- [Обмен с 1С](/w/%D0%BF%D1%80%D0%BE%D0%B4%D1%83%D0%BA%D1%82/%D0%BA%D0%B0%D1%82%D0%B0%D0%BB%D0%BE%D0%B3/cardflow/…)
- [Раздел про карточки][cards]

[cards]: /w/продукт/каталог/cardflow/карточки#создание

A link is rewritten in the same shape it was written in: a percent-encoded address stays percent-encoded, because changing it to raw text would change what the reader copied.

The move itself still creates no revision, because the text of the moved pages does not change and a revision with identical content would be indistinguishable from a real edit. The link rewrite does create one — it is a genuine change of text — signed by whoever performed the move and commented перенос A → B:

{"path":"реестр/домены","rev":2,"author":"n36",
 "comment":"перенос продукт/cardflow → продукт/каталог/cardflow",
 "size":453,"current":true,"created_at":"2026-08-25T22:07:36.960Z"}

That is what the author field in the request body is for: without it those revisions would be unsigned. Under -require-base-rev the rewrite works as well — the server supplies base_rev for its own edit and retries on a conflicting concurrent edit.

Failures: 404 — nothing under the source path; 409 — the target path is taken; 400 — an attempt to move a page into its own subtree. All checks run before the first write, so a partial move cannot happen.

GET /api/v1/tree?path=&depth=

Returns the children of one node instead of a flat list of every page. A client drawing a tree of thousands of pages should not have to pull all of them to find out what lies one level down.

Parameter Default Meaning
path empty the node to look under; empty means the top of the tree
depth 1 how many levels to expand, 5 at most
curl -G http://localhost:8080/api/v1/tree \
  --data-urlencode 'path=продукт' --data-urlencode 'depth=2'
{
  "path": "продукт",
  "node": {"path":"продукт","name":"продукт","title":"Продукт","exists":false,"pages":3,"children":1},
  "nodes": [
    {
      "path": "продукт/cardflow", "name": "cardflow", "title": "Cardflow",
      "exists": false, "pages": 3, "children": 3,
      "nodes": [
        {"path":"продукт/cardflow/интеграции","name":"интеграции","title":"Интеграции",
         "exists":false,"pages":1,"children":1},
        {"path":"продукт/cardflow/карточки","name":"карточки","title":"Карточки товаров",
         "exists":true,"rev":1,"author":"постановщик","size":121,
         "updated_at":"2026-08-25T22:07:36.403Z","pages":1,"children":0},
        {"path":"продукт/cardflow/обзор","name":"обзор","title":"CardFlow: обзор",
         "exists":true,"rev":1,"author":"постановщик","size":123,
         "updated_at":"2026-08-25T22:07:36.391Z","pages":1,"children":0}
      ]
    }
  ],
  "count": 1
}

node describes the node that was asked about, nodes are its children, and count is how many children came back. Per node:

Field Meaning
path, name the full path and the last segment
exists whether the node has a page of its own
pages how many pages lie in this subtree, the node's own page included
children how many direct children the node has
rev, author, size, updated_at present only when exists is true
nodes the next level, when depth > 1

A section without a page of its own is a node too ("exists": false). Without that a tree like продукт/<domain>/обзор would break in the middle: продукт/cardflow has no page, and skipping it would hide everything below. Such a node gets its title derived from its name, exactly the way page creation derives one.

depth is capped at 5 and the answer as a whole at 2000 nodes, because the point of this endpoint is that clients stop pulling the entire wiki to draw a tree — it must not become a way to do exactly that. Both counters, pages and children, are computed in SQL: counting them in Go would mean reading every path in the subtree, which is the cost this endpoint exists to avoid.

An empty subtree is 404 — the root (path empty) is always a valid node.

GET /api/v1/search?q=

curl -G http://localhost:8080/api/v1/search --data-urlencode 'q=карточку'
{
  "query": "карточку",
  "count": 4,
  "hits": [
    {"path":"продукт/cardflow/карточки","title":"Карточки товаров","rev":1,
     "snippet":"# Карточки товаров\n\nКарточки создаются импортом и правятся вручную. …",
     "author":"постановщик","size":121,"updated_at":"2026-08-25T22:07:36.403Z"}
  ]
}

Every word of the query is looked up twice: as a prefix over the path, the title and the body, and as a stem over the normalized column — so карточку finds a page that only ever says карточки. Punctuation is escaped, so arbitrary user input cannot break the query. The search runs over the current versions of pages; archived revisions are not indexed. See Search and word forms for what the stemmer does and does not manage.

Filters. Any of them works without q as well — then it is simply a selection:

Parameter Meaning
prefix only pages of the given subtree
author the author of the current revision
contributor the author of any revision of the page, however old
created_after, created_before bounds on creation time
updated_after, updated_before bounds on the time of the last edit
sort relevance, updated, -updated, created, -created, path, title
limit, offset pagination

Dates are accepted as 2026-08-25 or as full RFC3339. The default sort is by relevance when there is a query and newest-first without one.

# what one person edited over the last week
curl 'http://localhost:8080/api/v1/search?author=n36&updated_after=2026-08-18&sort=-updated'

# every page of a section they have ever touched
curl 'http://localhost:8080/api/v1/search?contributor=n36&prefix=docs'

GET /api/v1/pages accepts the same filters: with them the listing behaves like a search.

GET /api/v1/authors

Everyone who is on record as the author of a page or of a revision, with edit counts — a hint for the author filter.

{ "authors": [{"author": "n36", "edits": 3}, {"author": "постановщик", "edits": 3},
            {"author": "lead", "edits": 1}], "count": 3 }

Errors

{ "error": { "code": "revision_conflict", "message": "page is at revision 4, not 2" } }
HTTP code When
400 bad_path the path is empty, contains .., control characters or forbidden signs
400 bad_request unparsable JSON or an unknown format
404 not_found no such page, revision or tree node
409 already_exists POST to a taken path, including two simultaneous creations
428 base_rev_required the server runs with -require-base-rev and the edit arrived without it
409 revision_conflict base_rev did not match the current revision
500 internal everything else

Search and word forms

Full-text search is FTS5 with the unicode61 tokenizer, which compares word forms letter by letter and can only match prefixes. That is enough for Latin text and hopeless for Russian: карточк found both карточки and карточку, but карточку found neither — prefix matching only works in one direction, and the words a person types are inflected.

The fix is a second index column. When a page is written, Go reduces its title and body to word stems and stores them in the norm column, which is part of the full-text index. Every word of a query is stemmed by the same code, so both sides of the comparison end up in one normal form. A query term therefore produces two conditions joined by OR: the literal prefix over path, title and body, and the stem over norm. Terms are joined by AND.

  • Russian words go through the Snowball (Porter) algorithm, English words through a light suffix stripper; digits, mixed-script tokens (utf8, v2) and words shorter than three letters are left alone. ё is folded to е before anything else, because both spellings occur in the same texts.
  • The path is deliberately left out of the normalization. It is made of slugs, it is already searched as a prefix in its own column, and it would have to be recomputed on every move.
  • Highlighting in snippet still comes from literal matches in the body, so a hit found only through its stem comes back without brackets around the word.

What the stemmer does not do

It is a stemmer, not a lemmatizer: it strips endings by rule and knows neither a dictionary nor the alternations inside a root. Two honest limits, both pinned by tests in internal/morph/morph_test.go:

  • A fleeting vowel. карточек (genitive plural) does not reduce to карточк, because -ек is not an ending here — there is nothing to strip. The same goes for проверок, ссылок.
  • An alternation in the root. переносить stems to перенос, while перенести gives перенест and перенесённый gives перенесен. The algorithm has no way to know these are one word.

Live, on the same wiki: карточку and карточки both return 4 pages, while карточек returns only the 2 pages that literally contain that form. The trade-off is deliberate — precision is exchanged for having no dictionaries and no dependencies.


MCP

The server announces itself as go-wiki and comes up on two transports at once:

Transport Address When to use it
stdio wiki -stdio -data ./data a local agent starts the wiki as a subprocess
streamable HTTP http://<host>:8080/mcp the wiki is already deployed and the agent reaches it over the network

The protocol is 2025-06-18, the SDK is github.com/modelcontextprotocol/go-sdk.

Connecting

Claude Code, locally over stdio:

claude mcp add go-wiki -- /path/to/wiki -stdio -data /var/lib/go-wiki

Over HTTP to a deployed service — and, next to it, the code graph the wiki is meant to be read together with:

claude mcp add --transport http go-wiki http://wiki.internal:8080/mcp
claude mcp add codebase-memory -- codebase-memory-mcp

By configuration file (.mcp.json / claude_desktop_config.json):

{
  "mcpServers": {
    "go-wiki": {
      "command": "/usr/local/bin/wiki",
      "args": ["-stdio", "-data", "/var/lib/go-wiki"]
    },
    "go-wiki-http": {
      "type": "http",
      "url": "http://wiki.internal:8080/mcp"
    }
  }
}

Tools (12)

Page paths are slash-separated everywhere (docs/setup) and normalized on input. Revision references take the same syntax as REST (5, -1, current, previous).

wiki_list_pages

Pages without bodies — a cheap way to build a table of contents.

Parameter Type Req. Meaning
prefix string no only pages under this prefix
limit int no 200 by default
offset int no for paging through

Returns: { pages: [{path,title,rev,size,author,updated_at}], count }.

wiki_search

Search with filters. Any filter works without a query, so "pages by author X over the last week" is a plain call rather than a workaround.

Parameter Type Req. Meaning
query string no query words; each is matched as a prefix and as a stem
prefix string no only the given subtree
author string no the author of the current revision
contributor string no the author of any revision of the page
created_after, created_before string no 2026-08-25 or RFC3339
updated_after, updated_before string no the same for edit time
sort string no relevance, updated, -updated, created, -created, path, title
limit, offset int no pagination

Returns: { hits: [{path,title,rev,snippet,author,size,created_at,updated_at}], count }.

wiki_get_page

Read a page or one specific revision.

Parameter Type Req. Meaning
path string yes page path
rev string no revision reference; current by default

Returns: { path, title, rev, current, author, comment, content, file, modified }. file is filled in only for archived revisions and points at the .md on disk.

wiki_create_page

Create a new page. Fails if the path is taken.

Parameter Type Req. Meaning
path string yes page path
content string yes Markdown body
title string no title; otherwise derived from the path
author string no who is editing
comment string no what the edit is

Returns: { path, title, rev, created, size }.

wiki_update_page

Replace the body of a page with a new revision. Creates the page if it does not exist.

Parameter Type Req. Meaning
path string yes page path
content string yes the complete new body, not a patch
title string no new title; otherwise the old one is kept
author string no who is editing
comment string no what the edit is
base_rev int no refuse the write if the page has been changed meanwhile

Returns: { path, title, rev, created, size }.

wiki_delete_page

Delete a page; its last version is archived first.

Parameter Type Req. Meaning
path string yes page path
author string no who is deleting
comment string no why

Returns: { path, deleted }.

wiki_list_revisions

The history of a page, newest first.

Parameter Type Req. Meaning
path string yes page path

Returns: { path, revisions: [{rev,title,author,comment,size,sha256,file,current,created_at,archived_at}], count }.

wiki_diff

Compare two revisions.

Parameter Type Req. Meaning
path string yes page path
from string no the older revision; the one before to by default
to string no the newer revision; current by default
format string no unified (default), lines, stat
context int no lines of context for unified, 3 by default

Returns: { path, from, to, stat:{added,removed}, format, unified?, lines? }.

An example format=unified answer:

{
  "path": "docs/setup", "from": 1, "to": 2,
  "stat": { "added": 3, "removed": 1 },
  "format": "unified",
  "unified": "--- docs/setup@1\n+++ docs/setup@2\n@@ -1,4 +1,5 @@\n # Установка\n \n-Шаг один.\n+Шаг один, уточнённый.\n Шаг два.\n+Шаг три.\n"
}

wiki_move_page

Move or rename a page together with its subtree.

Parameter Type Req. Meaning
from string yes the current path
to string yes the new path; the parent segments set the place in the tree
author string no who is moving; signs the revisions that repoint links

Returns: { from, to, moved, pages, relinked, relinked_pages }. History follows the pages and the move itself creates no revision; repointing the links to them does, on every page it touches. A section that has no page of its own can be moved as well.

wiki_tree

Walk the page tree one level at a time.

Parameter Type Req. Meaning
path string no the node to look under; empty means the top of the tree
depth int no how many levels to expand, 1 by default and 5 at most

Returns { path, exists, pages, children, nodes, count }: the counters of the node that was asked about, then its descendants. Unlike the REST answer the nodes come back flat, in depth-first order, each carrying parent and depth instead of a nested nodes array — the SDK's JSON-schema generator does not build a schema for a self-referencing type, it panics on one and takes the whole server down with it. count is therefore the size of the flat list across all levels, not the number of direct children.

{
  "path": "продукт", "exists": false, "pages": 3, "children": 1, "count": 2,
  "nodes": [
    {"path":"продукт/каталог","name":"каталог","title":"Каталог","parent":"продукт",
     "depth":1,"exists":false,"pages":3,"children":1},
    {"path":"продукт/каталог/cardflow","name":"cardflow","title":"Cardflow",
     "parent":"продукт/каталог","depth":2,"exists":false,"pages":3,"children":3}
  ]
}

A node with a page of its own also carries rev, author, size and updated (RFC3339 — the REST field is spelled updated_at). Sections with no page of their own are nodes too (exists: false), so the tree never breaks in the middle. This is the call to use instead of wiki_list_pages when the question is "what is under this node", not "give me everything".

wiki_list_authors

The authors with their edit counts — so a filter value is not a guess.

Returns: { authors: [{author, edits}], count }.

wiki_restore_revision

Bring an old version back as a new revision.

Parameter Type Req. Meaning
path string yes page path
rev int yes the revision whose body becomes current
author string no who is restoring
comment string no why

Returns: { path, title, rev, created, size }.

Checking by hand

curl -sS -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
                 "clientInfo":{"name":"curl","version":"1"}}}'

The response carries Mcp-Session-Id in the headers; it has to be passed along in the tools/list and tools/call requests that follow.


Integration with MCP codebase memory

"Why this exists" covers the motivation; this section is the mechanics of pairing the wiki with codebase-memory-mcp or any other codebase indexer.

The division of labour is the point. codebase-memory-mcp answers where things are: it indexes a repository into a graph of files, functions, classes and routes connected by CALLS, IMPORTS and similar edges, and answers questions about callers, dependencies and dead code. go-wiki answers what it is about and why it was decided that way: statements of work, contracts, domain decisions — with a revision history that says who changed a decision and when. One agent talks to both servers in the same session: the structure comes from the graph, the meaning comes from the wiki.

{
  "mcpServers": {
    "codebase-memory": {
      "command": "codebase-memory-mcp"
    },
    "go-wiki": {
      "type": "http",
      "url": "http://wiki.internal:8080/mcp"
    }
  }
}

With both connected, a question like "why does the card import go through a queue" turns into two calls: search_graph finds the code that does it, wiki_search finds the page that explains why it was built that way — and wiki_list_revisions shows when that reasoning last changed.

What makes the wiki side usable from an indexer:

  • Stable addresses. The pair (path, rev) identifies a text forever: revisions are immutable, and restoring one appends a new revision instead of editing an old one. Such a pair is safe to store in a graph node.
  • Checksums. Every revision has a sha256 of its body, so you can tell whether the content changed without fetching it.
  • Cheap change detection. GET /api/v1/diff?path=…&format=stat or wiki_diff with format=stat answer with counters and no text — enough for incremental reindexing.
  • Files right there. Archived revisions are ordinary .md files under <data>/revisions/<path>/rNNNN.md — an indexer can read them straight off disk, bypassing HTTP, and take the metadata from the database.
  • Tree instead of a flat list. wiki_tree walks the wiki by domain the way a graph is walked by module, without pulling every page first.
  • One history. A write through MCP is indistinguishable from a write through the UI, so agent edits appear in the same revision feed and the same diffs.
  • Race protection. Pass base_rev on automated writes — then a person's concurrent edit is not silently overwritten but comes back as 409.

A typical reindexing cycle:

wiki_list_pages            → the list of (path, rev)
compare rev with the one stored in the graph
wiki_diff format=stat      → did anything change in substance
wiki_get_page              → fetch the body only for what changed

Related projects

Neighbouring tools of the same workflow. Nothing links them automatically — there is no code between go-wiki and either of them; they are separate servers an agent or a person happens to use together.

  • codebase-memory-mcp — an MCP server that indexes a repository into a code knowledge graph and answers structural questions: who calls what, what a change touches, where a symbol is defined. It is the "where things are" half of the pair described above.
  • kaneo — a fork of the usekaneo/kaneo task board that renders the board in three dimensions with camera controls (live demo). That is where a task lives as a card and moves between statuses; go-wiki is where the statement of work behind that card is written down and kept with its history.

Web interface

  • / — pages as cards
  • /w/<path> — reading (Markdown is rendered with GFM: tables, task lists, strikethrough)
  • /edit/<path>, /new — the editor
  • /history/<path> — revision history
  • /diff/<path>?from=&to=&view=side|unified — comparison, two columns or a patch
  • /search?q= — search with a filter panel: section, author, contributor, dates, sorting

The tree is edited right in the interface: a page has a "Child page" button (which creates a nested page), and the editor has a "Move in the tree" block that moves the page along with its whole subtree — links to it are repointed the same way as through the API.

The styling follows the BusinessPad design system: colour, typography, spacing and radius tokens are taken from @business-pad/theme and laid out as CSS custom properties (the React packages are not available to a Go server). Both light and dark themes are supported plus a "system" mode: the choice is a button in the header and is kept in localStorage. The layout is responsive across the theme's breakpoints (481 / 769 / 1025 / 1441 / 1921 px): below md the left menu moves into an overlay, the diff table scrolls inside its own container, and lists turn into cards.

Raw HTML inside Markdown is not rendered — page bodies arrive from agents as well, and none of it counts as trusted markup.


Importing from Yandex Wiki

scripts/import_yandex_wiki.py moves pages along with their subtrees: it converts Yandex Flavored Markdown to plain Markdown, rewrites links between the imported pages to local ones, and creates index pages for empty sections so the tree does not fall apart.

export WIKI_CLOUD_ORG_ID=...      # the IAM token comes from `yc iam create-token`
python3 scripts/import_yandex_wiki.py \
    --root homepage/business-pad \
    --match 'obekty-ucheta|obekt-uchjota' \
    --target-prefix objekty-ucheta \
    --wiki http://127.0.0.1:8080 --dry-run

Selection goes by slug (Latin): walking the tree yields only identifiers and slugs, and checking titles would mean a separate request for each of thousands of pages. Exact roots can be listed with --slug.

Re-running is idempotent: pages are written with PUT, and go-wiki creates no revision when the text has not changed.

What the import does not do: images stay as links to wiki.yandex.ru — go-wiki has no attachment storage, and without access to Yandex Wiki they will not open. mermaid blocks are kept as code: rendering them is not supported yet.

Development

go test ./...        # storage, stemmer, diff engine and API tests
go vet ./...
go build ./cmd/wiki

Layout:

cmd/wiki/          entry point: flags, routing, graceful shutdown
internal/wiki/     page path normalization, link rewriting on a move
internal/morph/    word stemming for the search index
internal/store/    SQLite + the .md revision archive
internal/diffx/    per-line diff, unified patch, two columns, inline highlighting
internal/api/      REST
internal/mcpsrv/   MCP tools
internal/web/      HTML templates, CSS with BusinessPad tokens

License

Apache License 2.0. Copyright 2026 BusinessPad.

The design tokens (palette, typography, grid, radii) come from the BusinessPad design system. The BusinessPad name and logo are trademarks and are not covered by the Apache license, see NOTICE.

About

Markdown wiki with full revision history, REST API and a built-in MCP server. Long-term codebase memory for coding agents — the companion layer to codebase-memory-mcp: the graph knows where code lives, go-wiki knows what it is for. Single Go binary.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages