Skip to content

jonradoff/markupmarkdown

Repository files navigation

markupmarkdown

CI codecov

Google Docs for markdown — comment on, edit, and ship .md files like it's a real document. Paste a URL, drag-select text, leave a margin comment. Your teammates see it in real time. Edit the raw markdown in a native CodeMirror editor with a formatting toolbar, find-and-replace, and live preview. Resolve threads, then hand them to Claude for an AI-revised version — or push your edits straight back to GitHub as a pull request or direct commit. Agents join the same review through an MCP server: they read what humans read, leave threads humans approve, and (with explicit human sign-off) apply the resolved feedback as a new revision.

Unlike Google Docs, edits happen on the actual markdown — so the file in your repo stays the source of truth and the round-trip back to GitHub is one click.

Live: https://mumd.metavert.io/


The problem

Markdown is where a lot of real thinking lives now — PRDs, design docs, RFCs, release notes, prompts, briefs. But the tools for reviewing it are miserable:

  • GitHub PRs force every discussion through a code-review workflow. Fine for production code, painful for a quick "this paragraph is unclear" on a brainstorming doc you haven't even branched yet.
  • HackMD / Notion / Dropbox Paper lock your content into their format, pull you out of .md, and require everyone to make an account.
  • Pasting into Google Docs drops all your formatting and now you have two sources of truth.

You just want to drag-select a sentence and leave a comment. Like Google Docs. On a real markdown file. And in 2026 you probably also want an agent on your review team — but you don't want to give that agent your whole identity, and you definitely don't want it shipping changes without you signing off.

What this is

A small self-contained web app:

  • Backend: one Go binary (~12 MB Alpine image) talking to MongoDB
  • Frontend: a single React SPA served from the same binary
  • Storage: per-doc copy in Mongo, that's it — no S3, no Redis, no queue
  • Deploy: one Fly machine, scales to zero, ~free for personal use

Everything you'd expect from a Google-Docs-style review experience, in a codebase small enough to read on a Sunday.

What it does

For humans

  • Open any markdown file — paste a URL (raw URL or github.com/.../blob/.../*.md, we auto-rewrite) or upload from your computer. Relative image refs (<img src=".github/logo.svg">) resolve against the source so READMEs render properly.
  • Drag-select text → comment. Click the floating "Comment" button, type, submit. Use @username to mention someone — they get an in-app notification.
  • Threaded replies, mark-as-done, reopen, edit, delete. Same model your team already knows.
  • Realtime sync: every change propagates to every other open tab in <1s via Server-Sent Events.
  • Unread filter pill — shows you only the threads with new activity since your last visit, with a count badge.
  • Step through comments with j / k (or / , or the Prev/Next buttons). The position counter respects whichever filter is active.
  • In-app notifications — bell icon in the header, badge for unread count, dropdown with deep links into the relevant comment.
  • Native markdown editor — click Edit and you get a real CodeMirror 6 editor with syntax highlighting, light/dark theme, and a formatting toolbar (bold, italic, code, H1/H2/H3, lists, task list, blockquote, link, code block, HR). The toolbar stays pinned at the top of the editor as you scroll long documents. ⌘S to save as a new revision, ⌘F for find & replace (regex + case-sensitivity), Esc to cancel. Comments stay anchored to their text spans as you edit; the comment sidebar tracks the same lines in real time. A soft edit lock prevents two people from clobbering each other — whoever clicks Edit second sees a banner with the holder's name. Click Show preview for a side-by-side rendered view.
  • Smart formatting toolbar — selecting **bold** and clicking B removes the markers (toggles off) instead of doubling them up. Same for italic, code, headings. The selection overshoot pattern (**bold** with the markers included) is detected and stripped cleanly.
  • Push changes back to GitHub — for docs cloned from a GitHub blob URL, click Push to GitHub on any revision. Two modes: open a pull request from a new branch (with prefilled title + body that reference the doc), or commit directly to a branch you pick (typically main). Branch-protection rules are enforced on GitHub's side and surfaced verbatim if they reject the push. The OAuth token already in your session is what authenticates — no separate GitHub PAT needed.
  • GitHub source-change detection — for docs cloned from GitHub, we track the upstream blob SHA. When the source file gets new commits, every viewer sees a "Source updated on GitHub" banner. Click Sync from GitHub and we pull the latest content and re-anchor your comments to the new text automatically wherever the original quote still appears. Comments whose quoted text no longer exists surface in a dedicated Comments without anchors section at the bottom of the doc — re-anchor them to new text manually (drag-select, click Re-anchor here), pin them as document-level, or just leave them. Document-level comments (no inline highlight) live in their own sidebar section and survive any source change.
  • Revision tree — every save (manual edit or AI revision) creates a new child document with a parent link. Open the chain breadcrumb to jump between versions; the home list dedups to the latest leaf so your Recents aren't full of intermediate drafts.
  • Soft delete with 30-day recovery — deleted docs sit in Trash and can be restored before the daily purge sweep.
  • Light / dark theme that respects your system pref.
  • Share dialog — copies the link with an explicit note about access (private docs warn you about the GitHub-repo requirement before you send the URL).
  • Per-tab title and Open Graph link unfurls so shared URLs look meaningful in Slack/iMessage. Private docs share a generic card so titles don't leak.
  • Human-readable URLs. Paste mumd.metavert.io/owner/repo/blob/ref/path and it Just Works as the doc URL. mumd.metavert.io/owner/repo opens a repo index; mumd.metavert.io/owner opens a user or org index (auto-detected). When you arrive via a /d/:id or /i/:id permalink, the address bar auto-rewrites to the human form on mount, so copy-paste from the URL bar always gives you the shareable shape.

Markdown indexes — organize a team's docs across all your repos

Indexes are shareable listings of .md files anchored to a GitHub resource. The point: a team has dozens of markdown docs scattered across repos (PRD.md, RFC-*.md, README.md, CLAUDE.md, …), and there's no good way to navigate them as a collection. Paste one URL and you get a curated index your whole team can browse and bookmark.

  • Three URL shapes recognized at the home-page URL bar:
    • github.com/owner/repo → repo index, listing every .md file in the repo's git tree (one round-trip via /git/trees?recursive=1).
    • github.com/owner → user or org index (auto-detected via /users/{name}.type), listing each repo's top-level .md files grouped by repo.
  • Shareable, viewer-scoped access. Each index lives at its own permalink and rewrites to a human URL (mumd.metavert.io/anthropics). Items are computed live on every view using the viewer's GitHub token — different viewers see different listings if their repo access differs. Private-repo indexes re-verify access on every read (same model as private docs); private items in user/org listings are filtered to the original scanner's audience so cached file names don't leak to non-members.
  • Live progress. Org spiders show a real-time activity log (last 8 repos with file counts), progress bar, and X/Y counter. The per-repo fetches fan out across a worker pool of 8 — github.com/beamable (~150 repos) completes in under 10 s instead of 60+.
  • Server-side caching + explicit Refresh. The first scan persists; subsequent visits load instantly from cache. A circular-arrow Refresh button in the page header re-spiders on demand. No accidental re-burning of GitHub rate limits.
  • Filename filter tabs. Save up to 5 case-insensitive substring filters (e.g. claude.md, _PRD, RFC) as named chips along the top. "All" is always present; tabs persist per-(browser, index) in localStorage; the last-active tab reopens on return.
  • Pinned default filter (owner). The index creator can pin one tab as the default view for share-link visitors. First-time visitors land on it; once they pick their own tab their choice takes over.
  • Click → open in markupmarkdown. Each row resolves to a doc via the existing find-or-create flow, so comments aggregate on the same doc across viewers rather than fracturing into N parallel clones.

The home page surfaces Your indexes above Your documents so a saved index becomes the natural jumping-off point for browsing a team's markdown library.

Optional GitHub identity

  • Sign in with GitHub to use your avatar and display name automatically, and to unlock private repo files (the OAuth app needs repo scope).
  • Private docs stay private: if a doc was cloned from a repo that required GitHub auth to read, every subsequent view re-verifies that the current user has GitHub access. No access? No content shown — they can't even see the title.

AI revision

  • Bring your own Anthropic API key (stored encrypted at rest, deletable any time).
  • Click Revise with AI, choose which resolved comments to apply (all by default — uncheck any you want to skip), and Claude Opus 4.7 produces a revised version that incorporates the agreed feedback while changing as little of the rest as possible.
  • The output streams as rendered markdown in real time — you watch headings and paragraphs materialize as Claude writes them.
  • Word-level diff preview before you accept. Saving creates a new document with a parent backlink, so revisions form a tree. The original (and its comments) stay untouched.

Agent collaboration (new — see Agents below)

  • Personal access tokens authenticate scripts and agents to the same REST API humans use.
  • MCP server at /mcp exposes the review primitives as Model Context Protocol tools — any MCP-aware agent (Claude Code, Claude Agent SDK apps, custom tools) can read docs, leave threads, reply to humans, resolve, and trigger AI revisions with human approval.
  • Agent identity badges — comments and replies created via a token marked "for an agent" get a small bot badge so humans can scan a thread and instantly see who's whom.

Lightweight by design

This isn't a SaaS. The whole stack:

fly machine (512 MB)
└── markupmarkdown (Go binary)
    ├── /api/*           — gorilla/mux router (REST)
    ├── /mcp             — Model Context Protocol server for agents
    ├── /SKILL.md        — canonical agent integration guide (raw markdown)
    ├── /                — SPA from /app/web/dist
    └── MongoDB Atlas    — documents, comments, sessions, notifications, tokens

No build-time JavaScript on the server. No background workers besides a daily purge sweep and a bounded view-recording queue. No webhooks. No analytics SDK. No cookie banner because the only cookie is a session ID. Bring your own Anthropic key — your usage, your bill, your data.

Quick start

git clone <this-repo>
cd markupmarkdown

# Backend
cd backend
cp .env.example .env       # fill in MONGODB_URI + ENCRYPTION_KEY
go run ./cmd/markupmarkdown

# Frontend (separate terminal)
cd frontend
npm install && npm run dev

Open http://localhost:4720/.

Generate the encryption master key

openssl rand -hex 32

Set it as MARKUPMARKDOWN_ENCRYPTION_KEY in backend/.env (for dev) and as a Fly secret (for prod). This key encrypts per-user Anthropic API keys at rest using AES-256-GCM — without it, the AI-revision feature is disabled but everything else keeps working. Ciphertexts are prefixed with a version (v1:) so you can rotate by setting MARKUPMARKDOWN_ENCRYPTION_KEY_V2=<old> and updating the primary key.

Optional: GitHub OAuth

Login + private repos are opt-in. Register an OAuth app at https://github.com/settings/applications/new:

Field Value
Homepage URL http://localhost:4720 (dev) / your prod URL
Callback URL http://localhost:4721/api/auth/github/callback (dev) / <your-prod-url>/api/auth/github/callback

Add to env:

GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Scope repo enables reading private files; drop to read:user user:email if you only want public docs.

Deploy to Fly.io

fly launch              # or `fly apps create` if you already have a name
fly secrets set MONGODB_URI="mongodb+srv://..."
fly secrets set MARKUPMARKDOWN_ENCRYPTION_KEY="$(openssl rand -hex 32)"
# optional, after registering the OAuth app:
fly secrets set GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=...
fly deploy

The included Dockerfile is a 3-stage build (frontend → backend → alpine runtime). Final image is ~12 MB.

Agents

markupmarkdown is built for agent + human review on the same doc. The model is simple: agents see what humans see, contribute the same way (threaded comments anchored to text spans), and the human stays in the loop for anything irreversible — including AI revisions.

Personal access tokens

Humans grant agents access by minting Personal access tokens in their avatar menu → Personal access tokens:

  • Format: mmk_<64 hex characters>
  • Stored hashed (SHA-256); plaintext is shown once at creation
  • Tag a token as "for an agent" so every comment/reply it creates gets the bot badge
  • Revocable any time

The agent passes the token as Authorization: Bearer mmk_… on REST and MCP calls.

MCP server

Streamable HTTP transport at /mcp, built on github.com/mark3labs/mcp-go. Authenticate every call with Authorization: Bearer mmk_…. Fourteen tools cover the same surface humans see in the web UI; every one routes through the same access checks, scope enforcement, validation, and rate-limit buckets as REST — there is no agent-only fast path.

Reading (read scope)

Tool Purpose
list_documents Docs the calling identity has touched. Set include_trash: true to include soft-deleted docs.
get_document Full markdown content + metadata, including parent/rootDocument/latestDescendant, source drift state, and revision index.
list_comments Threads on a doc — filter by open / resolved / all, optionally pre-render bodies to HTML via render_html: true.
list_revisions The full revision chain (root → leaf) for a doc with each node's revisionIndex, model, generatedBy, actorKind, and timestamps.

Commenting (write scope)

Tool Purpose
add_comment Anchor a new thread to a verbatim substring of the doc. Pass occurrence: N (1-based) when the substring appears multiple times.
reply Reply to an existing thread.
resolve_comment / reopen_comment Lifecycle. Resolved threads become eligible inputs for revise_with_ai.
patch_anchor Re-anchor an orphan comment, or convert any comment to a document-level pin (doc_level: true). Mine-only.
delete_comment Remove a thread your token authored. Mine-only — same require-mine guard as REST.

Editing the document (admin scope)

Tool Purpose
edit_document Save a new manual revision by sending the full new content. Creates a new child doc; unresolved comments carry forward and are re-anchored against the new content.
revise_with_ai Run Claude Opus 4.7 over the doc + selected resolved threads. Preview-only by default (accept: false); pass accept: true to save as a new child doc. Uses the human user's stored Anthropic key.
merge_from_github Reconcile a doc with its upstream GitHub source via a 3-way Claude merge (ancestor = source the revision was based on, ours = current doc, theirs = new upstream). Persists the merged content and re-anchors comments. Trivial cases bypass Claude.
push_to_github Open a pull request from the doc's current content back to its source repo. PR mode only over MCP — direct-commit is web-UI-only for safety. Only push when a human has explicitly asked.

The full agent guide — conventions, identity model, rate limits, scope hierarchy, when-to-edit-vs-revise-vs-merge, and out-of-scope actions — lives at skills/markupmarkdown/SKILL.md and is served live at https://mumd.metavert.io/SKILL.md.

Examples

All seven examples below are real MCP requests against the live /mcp endpoint. Each "name" matches the tool in the tables above; arguments is the literal payload.

1. Read a doc and its open threads

// MCP request
{ "name": "list_documents", "arguments": {} }
// → [{ id, title, url, ... }, ...]

{ "name": "get_document", "arguments": { "id": "a3f7c2..." } }
// → { id, title, content, sourceUrl, parentId, revisionIndex,
//     rootDocument, latestDescendant, sourceLatestSha, ... }

{
  "name": "list_comments",
  "arguments": {
    "document_id": "a3f7c2...",
    "filter": "all",
    "render_html": true
  }
}
// → [{ id, anchor: {exact, ...}, body, bodyHtml, replies, resolved, ... }, ...]

2. Leave a margin comment anchored to a specific phrase

{
  "name": "add_comment",
  "arguments": {
    "document_id": "a3f7c2...",
    "quoted_text": "we may scale linearly",
    "body": "This claim is unsupported — the benchmark in §4.2 shows sub-linear scaling above 64 cores. Suggest softening to 'scales well up to medium concurrency'."
  }
}

If the quoted text appears multiple times in the doc, the tool returns an error — disambiguate with "occurrence": 2.

3. Reply to a human's thread

{
  "name": "reply",
  "arguments": {
    "comment_id": "c1d2e3...",
    "body": "Sources for the original benchmark: [link]. I can produce a revised graph if useful."
  }
}

4. Apply resolved comments as a new revision (with human approval)

// Preview only — does NOT save:
{
  "name": "revise_with_ai",
  "arguments": {
    "document_id": "a3f7c2...",
    "accept": false
  }
}
// → { originalContent, revisedContent, model, tokensIn, tokensOut, appliedCommentIds }

// After the human approves the diff, save as a new child doc:
{
  "name": "revise_with_ai",
  "arguments": {
    "document_id": "a3f7c2...",
    "accept": true,
    "comment_ids": ["c1d2e3...", "c4d5e6..."]   // optional subset
  }
}
// → { ..., newDocumentId: "b8c4d1..." }

The revision uses the human user's stored Anthropic key — the agent never sees it and never gets billed.

5. Apply a targeted manual edit (no AI revision)

// First read the current content so you can splice in your change:
{ "name": "get_document", "arguments": { "id": "a3f7c2..." } }
// → { content: "...", revisionIndex: 4, ... }

// Then save the edited content as a new revision in the chain.
// Unresolved comments carry forward and re-anchor against the new text.
{
  "name": "edit_document",
  "arguments": {
    "document_id": "a3f7c2...",
    "content": "<the full new markdown>",
    "revision_note": "Fix the §4.2 scaling claim per Jon's review"
  }
}
// → { newDocumentId: "d9e3f1...", revisionIndex: 5, carriedForward: 3, orphaned: 0 }

Use this when you've decided on a specific change yourself, rather than asking Claude to derive it from resolved threads.

6. Walk the revision chain and find the leaf

{
  "name": "list_revisions",
  "arguments": { "document_id": "a3f7c2..." }
}
// → [
//   { id: "a3f7c2...", revisionIndex: 1, model: "manual", generatedBy: "jon", actorKind: "human" },
//   { id: "b8c4d1...", revisionIndex: 2, model: "claude-opus-4-7", generatedBy: "jon", actorKind: "human" },
//   { id: "d9e3f1...", revisionIndex: 3, model: "manual", generatedBy: "claude-code-laptop", actorKind: "agent" }
// ]

get_document's latestDescendant answers the same question with a single call. list_revisions is the right choice when you want the full chain (e.g. to show a version picker).

7. Pull in upstream GitHub edits, then push the resolved revision back as a PR

// 1. The doc's source-drift indicators say upstream changed. Reconcile via 3-way merge:
{
  "name": "merge_from_github",
  "arguments": { "document_id": "a3f7c2..." }
}
// → { merged: true, mergedContent: "...", orphanedComments: 1, trivial: false }

// 2. After the human reviews the merge and resolves any conflicts, push the result
//    back as a pull request. PR mode is the only mode available over MCP — direct
//    commits are intentionally web-UI-only so branch-protection enforcement stays
//    a human decision.
{
  "name": "push_to_github",
  "arguments": {
    "document_id": "a3f7c2...",
    "branch": "claude/wingman-prd-revisions",
    "commit_message": "PRD: tighten §4.2 scaling claim, address resolved review threads",
    "pr_title": "PRD revision: scaling claim + resolved review threads",
    "pr_body": "Applies the comment threads resolved in https://mumd.metavert.io/d/a3f7c2..."
  }
}
// → { mode: "pr", branch: "...", commitSha: "...", commitUrl: "...", prNumber: 142, prUrl: "..." }

Only push when a human has explicitly asked. A resolved comment that says "ship this" counts; a periodic cron does not.

REST fallback

Every MCP tool corresponds to a REST endpoint at /api/... — the same Bearer token authenticates both. Use REST when you need an endpoint MCP doesn't expose (notifications, trash, SSE event streams) or your runtime isn't MCP-aware.

Conventions agents should follow

  1. Always quote verbatim in add_comment — paraphrasing breaks anchoring.
  2. One thread per concern — easier for humans to review, easier for revise_with_ai to apply cleanly.
  3. Use markdown formatting in bodies — humans see it rendered, other agents can fetch HTML via render_html: true.
  4. Mention humans explicitly with @github-login when you want their attention.
  5. Don't resolve your own threads unless the human told you to.
  6. Treat revise_with_ai as privileged — prefer accept: false first, surface the diff, only then call accept: true.
  7. Respect rate limits. Token budgets are per-user; bursts > 30/min get 429s.

Full API surface

# Documents + comments
POST   /api/documents                           { url, title? } | { content, title }
GET    /api/documents                           your recent docs (requires sign-in)
GET    /api/me/trash                            your soft-deleted docs (within 30-day window)
POST   /api/documents/:id/restore               restore a soft-deleted doc
GET    /api/documents/:id
PATCH  /api/documents/:id                       { title }
DELETE /api/documents/:id                       soft delete

GET    /api/documents/:id/comments              ?render=html to include sanitized HTML bodies
POST   /api/documents/:id/comments              { anchor:{start,end,exact}, body, author }
POST   /api/documents/:id/sync                  pull latest source from GitHub + auto re-anchor comments
GET    /api/documents/:id/events                SSE stream — comments-updated + doc-updated events

# Manual editing + GitHub round-trip
POST   /api/documents/:id/manual-revisions      { content } → new child doc (manual edit save)
GET    /api/documents/:id/edit-lock             current soft lock holder
POST   /api/documents/:id/edit-lock             claim the soft edit lock for the doc
DELETE /api/documents/:id/edit-lock             release the soft edit lock
GET    /api/documents/:id/pushback/info         GitHub repo + branch info for the push UI
POST   /api/documents/:id/pushback              { mode: "pr"|"direct", branch, commitMessage, ... }
GET    /api/documents/:id/mention-candidates    people known to this doc, for @-autocomplete
GET    /api/documents/by-source                 ?owner=X&repo=Y&ref=Z&path=W → existing doc id

# Markdown indexes
POST   /api/indexes                             { url }   → meta-only (items stream separately)
GET    /api/indexes/:id                         meta + items (synchronous, cached)
GET    /api/indexes/:id/stream                  SSE — meta / scanning / items / done events
                                                ?refresh=1 forces a fresh GitHub spider
PATCH  /api/indexes/:id                         { title?, defaultFilter? } (creator-only)
DELETE /api/indexes/:id                         soft-delete
POST   /api/indexes/:id/forget                  hide from MY home list only
GET    /api/me/indexes                          your live (non-forgotten) indexes

PATCH  /api/comments/:id                        { body }
DELETE /api/comments/:id
POST   /api/comments/:id/resolve                { author }
POST   /api/comments/:id/reopen
PATCH  /api/comments/:id/anchor                 { start, end, exact } or { docLevel:true }
POST   /api/comments/:id/replies                { body, author }
PATCH  /api/comments/:id/replies/:replyId       { body }
DELETE /api/comments/:id/replies/:replyId

# Auth + identity
GET    /api/auth/config                         { githubEnabled, githubClientId }
GET    /api/auth/me                             { user }
GET    /api/auth/github/login?redirect=/d/...
GET    /api/auth/github/callback
POST   /api/auth/logout

GET    /api/me/notifications                    { unread, notifications }
POST   /api/me/notifications/read               mark all read
POST   /api/me/notifications/:id/read           mark one read

GET    /api/me/tokens                           your active personal access tokens
POST   /api/me/tokens                           { label, isAgent } → token (shown once)
DELETE /api/me/tokens/:id                       revoke

GET    /api/me/anthropic-key                    { hasKey, hint }
PUT    /api/me/anthropic-key                    { key }
DELETE /api/me/anthropic-key

# AI revision (Server-Sent Events streaming)
POST   /api/documents/:id/revise                stream — delta / done / error
POST   /api/documents/:id/revisions             accept a previewed revision

# Agent guide
GET    /SKILL.md                                canonical SKILL.md (raw markdown; /skill.md and /skill also work)

# MCP
*      /mcp                                     streamable HTTP MCP server (Bearer auth)

Architecture notes

  • Comment anchoring uses character offsets into the rendered markdown's textContent. Cloning the source freezes the offsets so they stay valid forever. Agent comments anchor by text-substring; the frontend resolves them to offsets at render time. See frontend/src/utils/anchor.ts.
  • Source-change detection stamps the GitHub blob SHA on each doc at ingest. Every doc-open triggers an async, cached (10-min TTL) re-check against the Contents API — if the upstream SHA differs, the next view sees a Source updated on GitHub banner. The sync flow re-fetches the new content, then walks every comment: those whose quoted text is still present un-orphan and defer to the textContent fallback for highlight positioning; the rest flip to orphan: true with their original quote preserved in originalExact, ready for manual re-anchor. Orphan, doc-level (no inline anchor), and regular anchored comments each occupy their own UI region. See backend/internal/api/source.go.
  • Realtime is a per-document in-memory hub fan-out over SSE. No external pub/sub. Disconnections auto-reconnect via the browser's EventSource.
  • AI revision streams Anthropic's response straight through to the browser, so you see Claude writing in real time. Backend-side timeout is 10 minutes; client-side abort is 5 minutes.
  • API key encryption: AES-256-GCM with a random nonce per encryption. The 32-byte master key lives only in MARKUPMARKDOWN_ENCRYPTION_KEY (env var / Fly secret), never in MongoDB. Encrypted blobs live in a separate user_secrets collection so the field can't accidentally leak via /api/auth/me. Hint = first 10 + last 4 characters. Key versioning supports rotation without re-encryption ceremonies.
  • Personal access tokens are stored as sha256(token); the plaintext is shown once and never logged.
  • SSRF guard: the URL fetcher refuses to dial RFC1918, loopback, link-local, metadata, and CGNAT IPs at both initial connection and every redirect.
  • Rate limits + body caps + concurrency semaphores keep a single Fly machine resilient against burst load and abusive clients. See backend/internal/limits/.
  • MCP server: streamable HTTP at /mcp, Bearer-auth, every tool routes through the same access checks as the REST API.

Build checks

cd backend && go build ./...
cd frontend && npx tsc --noEmit

Testing

The Go backend has a unit + integration test suite that exercises the HTTP handlers against a real MongoDB Atlas instance. Coverage is tracked in Codecov with an 80% project target.

Current coverage: 80.4% on the audited surface (per Codecov, with cmd/, testutil/, the embedded SKILL.md, and the SPA file-server explicitly excluded — see codecov.yml). The raw go tool cover total across every file is 72.7%; both numbers are honest, they just measure different scopes. The audited number is what codecov.yml's 80% gate applies to.

The frontend has a separate suite: Vitest for unit tests on the utility modules, Playwright for end-to-end browser flows.

Backend

cd backend

# Run the full suite (unit + integration), no coverage:
make test

# Just unit tests (no DB needed):
make test-unit

# Coverage report:
make test-coverage           # writes coverage.out + coverage.html

Integration tests connect to a separate MongoDB database named markupmarkdown-test (configured in backend/config/test.yaml). The internal/testutil package hard-refuses to run if the resolved DB name doesn't contain test, so the suite physically cannot touch prod (markupmarkdown) or dev (markupmarkdown-dev) data. Each test starts from an empty database (via DeleteMany rather than Drop, to preserve indexes across packages).

To run integration tests locally, copy backend/.env.test.example to backend/.env.test and fill in MONGODB_URI. If the URI is unset the integration tests are skipped (not failed) so unit-only runs are fast.

Frontend (Playwright)

cd frontend
npm install
npx playwright install --with-deps chromium
npm run test:e2e

E2E tests live in frontend/e2e/. The Playwright config points at http://localhost:4720 by default — start npm run dev (and the backend on port 4721) before running locally.

CI + Codecov

.github/workflows/ci.yml runs the build, type-check, Go tests with coverage, and Playwright tests on every push and PR. Coverage is uploaded to Codecov via CODECOV_TOKEN. The codecov.yml at the repo root configures the 80% project target and excludes packages that are plumbing rather than logic (cmd/, testutil/, embedded assets, the SPA file server). The badges at the top of this README reflect the status of the latest run on master.

License

MIT © 2026 Metavert LLC

About

Google-Docs-style commenting on markdown files, with built-in editing and GitHub integration. Integrates with GitHub. Self-hosted, lightweight, BYOK AI revision.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages