Skip to main content
Glama
llego
by llego
README.md
# Anchor MCP

Implementation plan for a small MCP sidecar that exposes safe Anchor Notes tools to ChatGPT through a tunnel client running in the same Docker Compose stack as Anchor.

Research basis: Anchor upstream repository `ZhFahim/anchor`, default branch `main`, inspected 2026-08-20. Anchor is a Nest.js backend with authenticated REST endpoints under `/api/*`.

## Goal

Run an MCP server next to Anchor so external assistants can list, search, read, create, update, import, and attach files to Anchor notes without exposing Anchor's database or private API directly.

## Current Status

First milestone is implemented:

- Streamable HTTP MCP endpoint at `POST /mcp`.
- Health endpoint at `GET /healthz`.
- Read-only Anchor tools: `anchor_list_notes`, `anchor_search_notes`, `anchor_get_note`, `anchor_list_tags`, `anchor_list_attachments`.
- Optional MCP bearer guard using `ANCHOR_MCP_TOKEN`.
- Anchor API calls use `ANCHOR_TOKEN` and `ANCHOR_BASE_URL`.
- Dockerfile is included.

Write tools are intentionally not implemented yet.

## Development

On NixOS, use `nix-shell` for Node/npm commands:

```bash
nix-shell -p nodejs --run 'npm install'
nix-shell -p nodejs --run 'npm run typecheck'
nix-shell -p nodejs --run 'npm run build'
```

Run locally:

```bash
ANCHOR_BASE_URL=https://anchor.cri.su \
ANCHOR_TOKEN=... \
ANCHOR_MCP_TOKEN=... \
nix-shell -p nodejs --run 'npm run dev'
```

The MCP endpoint is `http://localhost:8000/mcp`. If `ANCHOR_MCP_TOKEN` is set, callers must send `Authorization: Bearer <token>`.

## Deployment Model

The intended stack has three services:

```yaml
services:
  anchor:
    # Existing Anchor service.

  anchor-mcp:
    build: /path/to/anchor-mcp
    environment:
      ANCHOR_BASE_URL: http://anchor:3000
      ANCHOR_TOKEN: ${ANCHOR_TOKEN}
      ANCHOR_MCP_TOKEN: ${ANCHOR_MCP_TOKEN}
    expose:
      - "8000"
    depends_on:
      - anchor

  chatgpt-tunnel-client:
    # Outbound tunnel client.
    environment:
      MCP_TARGET_URL: http://anchor-mcp:8000/mcp
      MCP_TARGET_TOKEN: ${ANCHOR_MCP_TOKEN}
    depends_on:
      - anchor-mcp
```

The MCP server should only be reachable on the Docker network. The tunnel client is the only external bridge.

## Confirmed Anchor API Surface

All endpoints below are guarded by Anchor's `AuthGuard` and expect `Authorization: Bearer <token>`. The guard accepts Anchor tokens that resolve to an active user.

Notes:

- `POST /api/notes`
- `GET /api/notes?search=<query>&tagId=<tagId>&limit=<limit>`
- `GET /api/notes/:id`
- `PATCH /api/notes/:id`
- `DELETE /api/notes/:id`
- `DELETE /api/notes/:id/permanent`
- `PATCH /api/notes/:id/restore`
- `GET /api/notes/trash`
- `GET /api/notes/archive`
- `POST /api/notes/bulk/delete`
- `POST /api/notes/bulk/archive`
- `POST /api/notes/bulk/pin`
- `POST /api/notes/bulk/tags`

Tags:

- `POST /api/tags`
- `GET /api/tags`
- `GET /api/tags/:id`
- `GET /api/tags/:id/notes`
- `PATCH /api/tags/:id`
- `DELETE /api/tags/:id`

Attachments:

- `POST /api/notes/:noteId/attachments`
- `GET /api/notes/:noteId/attachments`
- `GET /api/notes/:noteId/attachments/:id`
- `DELETE /api/notes/:noteId/attachments/:id`
- `PATCH /api/notes/:noteId/attachments/reorder`

Import/export:

- `POST /api/import/notes`
- `POST /api/import/notes/:noteId/attachments`
- `GET /api/export`

Sync API:

- `POST /api/sync`
- `GET /api/sync/events` as server-sent events

Sharing:

- `POST /api/notes/:id/shares`
- `GET /api/notes/:id/shares`
- `PATCH /api/notes/:id/shares/:shareId`
- `DELETE /api/notes/:id/shares/:shareId`

The MCP server should start with normal notes/tags/attachments/import endpoints. The sync API is useful for conflict-aware offline clients, but an MCP sidecar can avoid it initially.

## Data Shapes

Create note body:

```json
{
  "title": "string",
  "content": "optional string",
  "isPinned": false,
  "isArchived": false,
  "background": "optional string",
  "tagIds": ["tag-id"]
}
```

Update note body is a partial create body plus optional optimistic lock:

```json
{
  "title": "optional string",
  "content": "optional string",
  "isPinned": false,
  "isArchived": false,
  "background": "optional string",
  "tagIds": ["tag-id"],
  "baseVersion": 1
}
```

Anchor returns transformed notes with these important fields:

```json
{
  "id": "uuid",
  "title": "string",
  "content": "string or null",
  "version": 1,
  "isPinned": false,
  "isArchived": false,
  "background": null,
  "state": "active",
  "createdAt": "iso timestamp",
  "updatedAt": "iso timestamp",
  "userId": "uuid",
  "tagIds": ["tag-id"],
  "permission": "owner",
  "attachmentCount": 0,
  "imagePreviewIds": []
}
```

Import notes body:

```json
{
  "notes": [
    {
      "ref": "external stable reference, max 256 chars",
      "id": "optional uuid",
      "title": "string",
      "content": "stringified Quill Delta JSON",
      "isPinned": false,
      "isArchived": false,
      "isTrashed": false,
      "background": "optional background id",
      "tagNames": ["tag name"],
      "createdAt": "iso timestamp",
      "updatedAt": "iso timestamp"
    }
  ],
  "tags": [{ "name": "tag", "color": "#8B5CF6" }],
  "skipExisting": true
}
```

Import result shape:

```json
{
  "results": [
    {
      "ref": "external reference",
      "status": "created | skipped | remapped | failed",
      "noteId": "uuid",
      "warning": "optional string",
      "error": "optional string"
    }
  ],
  "tags": { "created": 0, "reused": 0 }
}
```

Attachment upload shapes:

- Normal note upload: multipart `file` field to `POST /api/notes/:noteId/attachments`.
- Import attachment upload: multipart `file` plus `position` form field to `POST /api/import/notes/:noteId/attachments`.
- Attachment response includes `id`, `noteId`, `type`, `originalFilename`, `mimeType`, `fileSize`, `position`, `uploadedByUserId`, and `createdAt`.

## Limits And Validation

Notes list limit:

- `GET /api/notes` clamps `limit` to `1..200`.

Bulk limits:

- `noteIds`: max 200.
- `tagIds`: max 50.

Import limits:

- Notes per batch: 50.
- Stringified Delta content length: 1,000,000 bytes/chars.
- Title length: 1000.
- Tags per note: 50.
- Tags per import batch: 500.
- Tag name length: 100.

Attachment limits:

- Max file size: 50 MB.
- Allowed images: `image/jpeg`, `image/png`, `image/webp`, `image/gif`.
- Allowed audio: `audio/mpeg`, `audio/wav`, `audio/mp4`, `audio/x-m4a`, `audio/ogg`, `audio/aac`, `audio/webm`.
- PDFs, JSON, ZIP, and generic `application/octet-stream` are rejected by current source.

Background IDs allowed by import:

- `color_red`, `color_orange`, `color_yellow`, `color_green`, `color_teal`, `color_blue`, `color_dark_blue`, `color_purple`, `color_pink`, `color_brown`.
- `pattern_dots`, `pattern_grid`, `pattern_lines`, `pattern_waves`, `pattern_groceries`, `pattern_music`, `pattern_travel`, `pattern_code`.

## Content Format

Anchor stores note `content` as a string. Existing import work confirms this should be stringified Quill Delta JSON for rich-text import.

The MCP server should expose Markdown-friendly tools and convert Markdown to Quill Delta internally. It can also expose expert-mode native Delta tools later.

Recommended conversion policy:

- `anchor_create_note` accepts Markdown, converts to Delta, calls `POST /api/notes`.
- `anchor_update_note` accepts Markdown, converts to Delta, calls `PATCH /api/notes/:id` with optional `baseVersion`.
- `anchor_import_notes` accepts Markdown or native Delta, batches through `POST /api/import/notes`.
- `anchor_get_note` returns raw content plus a best-effort text/Markdown projection for LLM readability.

## Authentication Model

Anchor source uses bearer-token extraction from `Authorization: Bearer <token>`. The MCP sidecar should therefore maintain two auth layers:

- `ANCHOR_TOKEN`: token used by `anchor-mcp` when calling Anchor.
- `ANCHOR_MCP_TOKEN`: token expected from the tunnel client before any MCP request is served.

The MCP server should never forward arbitrary caller tokens to Anchor.

## Source References

Primary files inspected upstream:

- `server/src/notes/controllers/notes.controller.ts`
- `server/src/notes/controllers/note-attachments.controller.ts`
- `server/src/notes/controllers/note-shares.controller.ts`
- `server/src/tags/tags.controller.ts`
- `server/src/import-export/import.controller.ts`
- `server/src/import-export/export.controller.ts`
- `server/src/sync/sync.controller.ts`
- `server/src/sync/sync-events.controller.ts`
- `server/src/notes/dto/create-note.dto.ts`
- `server/src/notes/dto/update-note.dto.ts`
- `server/src/import-export/dto/import-notes.dto.ts`
- `server/src/import-export/dto/import-attachment.dto.ts`
- `server/src/notes/constants/notes.constants.ts`
- `server/src/import-export/constants/import.constants.ts`
- `server/src/notes/utils/note-transformer.util.ts`
- `server/src/notes/utils/attachment-storage.util.ts`

## MCP Tools

Phase 1 read tools:

- `anchor_list_notes(limit, offset)`
- `anchor_search_notes(query, limit)`
- `anchor_get_note(note_id)`
- `anchor_list_tags()`
- `anchor_list_attachments(note_id)`

Implemented tool details:

- `anchor_list_notes` supports `limit`, `offset`, `include_content`, and `tag_id`. Because Anchor only exposes limit-based listing, `offset + limit` must be at most 200.
- `anchor_search_notes` supports `query`, `limit`, `include_content`, and `tag_id`.
- `anchor_get_note` supports `note_id` and `include_content`.
- `anchor_list_tags` takes no input.
- `anchor_list_attachments` returns metadata only and does not download attachment bytes.

Phase 2 write tools:

- `anchor_create_note(title, markdown)`
- `anchor_update_note(note_id, markdown, base_version)`
- `anchor_import_notes(notes)`
- `anchor_create_tag(name, color)`
- `anchor_upload_attachment(note_id, file, filename, mime_type)`

Phase 3 management tools:

- `anchor_archive_notes(note_ids)`
- `anchor_pin_notes(note_ids, is_pinned)`
- `anchor_add_tags(note_ids, tag_ids)`
- `anchor_export()` if the tunnel client can handle a streamed archive.

Avoid or gate destructive tools:

- `anchor_delete_note(note_id, confirm)` maps to soft delete and should require `confirm=true`.
- `anchor_permanent_delete_note(note_id, confirm)` should be omitted initially.
- `anchor_delete_tag(tag_id, confirm)` should be omitted initially.
- Do not expose a raw arbitrary HTTP proxy tool.

## Security

- Store `ANCHOR_TOKEN` only in the Docker stack environment or `.env`; do not bake it into the image.
- Add a separate `ANCHOR_MCP_TOKEN` for calls from the tunnel client to `anchor-mcp`.
- Bind the MCP server to the container network only; do not add Traefik labels unless intentionally exposing it.
- Keep tools narrow and typed. Do not allow callers to choose arbitrary Anchor API paths.
- Log request metadata, not note content or tokens.
- Default to read-only tools until the tunnel auth path is verified.
- Require explicit `confirm=true` for soft-delete and bulk destructive actions.
- Refuse permanent deletion unless a separate `ENABLE_DANGEROUS_TOOLS=true` setting is present.

## Implementation Phases

1. Create a minimal TypeScript MCP HTTP server.
2. Add configuration from environment: `ANCHOR_BASE_URL`, `ANCHOR_TOKEN`, `ANCHOR_MCP_TOKEN`, bind host/port.
3. Implement `/healthz` for Docker and tunnel diagnostics.
4. Implement a small Anchor API client with typed methods and no arbitrary path escape hatch.
5. Implement `anchor_list_notes`, `anchor_search_notes`, `anchor_get_note`, and `anchor_list_tags`.
6. Add response shaping that strips heavy fields unless explicitly requested.
7. Implement Markdown-to-Delta conversion helpers and tests.
8. Implement create/update with optional optimistic locking via `baseVersion`.
9. Implement import batching with the known import limits.
10. Implement attachment upload for allowed images/audio only.
11. Add Dockerfile and Compose example including the tunnel client placeholder.
12. Add tests with mocked Anchor responses and validation failures.
13. Add operational docs for rotating tokens and wiring the ChatGPT tunnel client.

## Open Questions

- Exact tunnel-client image, environment variables, and auth header format.
- Whether Anchor can be configured or patched to allow PDFs and other file types.
- Whether note content should be accepted as Markdown and converted to Quill Delta, or whether the MCP should expose Anchor's native content format directly.
- Whether the tunnel client can pass binary payloads well enough for attachment upload and export download.
- Whether `offset` should be simulated client-side because `GET /api/notes` only exposes `limit`, not offset pagination.

## Recommended First Milestone

Build a read-only MCP server with `anchor_list_notes`, `anchor_search_notes`, `anchor_get_note`, and `anchor_list_tags`. Deploy it privately in the Anchor stack behind the tunnel client. Add create/update/import only after the read path and authentication model are verified.