Skip to main content
Glama
EB-CON-GmbH

EasyTopic MCP Server

Official
by EB-CON-GmbH
README.md
# EasyTopic MCP server

Also mirrored publicly at
[github.com/EB-CON-GmbH/easytopic-mcp-server](https://github.com/EB-CON-GmbH/easytopic-mcp-server)
for reuse outside this monorepo (one-way export via `git subtree split`,
re-run manually after a significant change here — see Topic
`c6seMyvsgYKaQjEOdzt8`).

Lets a Claude Code agent in *any* project pull its work from an EasyTopic
Kanban board instead of a human typing prompts into the console: it picks up
Topics in "ToDo", plans, asks questions via comments, waits for a human
approval column, implements, and closes the ticket.

EasyTopic's data model: `companies/{companyId}/projects/{projectId}/topics/{topicId}`
is the actual task/ticket, gated by **participancy** (a `participantUids: string[]`
array on the Topic, not company employment) — see "Why sign in as a real
Firebase Auth user" below for why that matters for this server specifically.

Standalone package (own `package.json`/`tsconfig.json`, no dependency on any
other part of the EasyTopic app).

## Why sign in as a real Firebase Auth user, not Admin SDK

`firestore.rules` requires `changedBy == request.auth.uid` on history
writes, plus an exact `participantUids` array match against the live
Topic. Only a genuine client-SDK sign-in satisfies that — so this server
authenticates (`signInWithEmailAndPassword`) as a dedicated **bot Firebase
Auth account**, onboarded exactly like a human teammate (Company employee +
Project participant). That also means rules enforcement and Topic History
bookkeeping work automatically, with zero extra code.

Comments are the one write this authenticated client does NOT make
directly: `firestore.rules`' comments `create` rule is `if false` for
every caller (rate-limiting reasons) — `addComment()`
(`src/topics.ts`) instead calls the app's `createTopicComment` Cloud
Function via `client.functions`, the same callable the app's own web UI
uses. The `onTopicCommentCreated` notification fan-out still fires either
way, since it triggers off the resulting Firestore write regardless of
whether that write came from a direct client SDK call or a Cloud
Function.

## One-time EasyTopic setup (per target Company/Project — no app code changes)

All of this is done through the existing app UI (Workflow / Topic / Board
Designer are already shipped):

1. **Bot account** — sign up through the normal app Sign-Up flow using an
   invite code for the target Company (e.g. email `claude-bot@yourcompany.com`).
   Store the password only in `.secrets/` (new file, following this repo's
   existing convention) or your routine host's secret store — **never** in
   a committed `.mcp.json`.
2. **Project participant, role `admin`** (NOT `member` — real bug found
   2026-07-17: `Topic.participantUids` is seeded at creation from the
   project's *current admins only*, and firestore.rules' Topic
   `get`/`list` rule is purely `participantUids`-array-based with no
   `isProjectAdmin` bypass, unlike `update`. A `member` bot never gets
   auto-included in new Topics and can't see old ones either — it would
   structurally never find any work). An existing Project admin adds the
   bot's uid as a participant with `role: "admin"`, **then** fans out
   `participantUids` to every already-existing Topic/Board in the project
   (replicate `useUpdateProjectParticipantRole`'s fan-out,
   `src/hooks/useProjects.ts:272-334` — promoting alone only affects
   *future* Topics).
3. **Workflow "Claude Automation"** (Workflow Designer) — statuses, **in
   this exact array order** (board columns render in `statuses` array
   order, not by any separate sort field — `closed` MUST be last, not
   right after `created` where `seedStatusesAndTransitions()` puts it by
   default, or "Fertig"/"Closed" renders as the first visible column):
   `created (fixed) → todo → planning → planned → approved → in_progress → done → closed (fixed)`.
   `closed` is deliberately the real reserved status key (not a custom
   "done"), so `isCompleted`/admin-only-reopen come for free — `done` is a
   separate, ordinary status just before it (the agent's own "I'm finished"
   signal; `closed` stays the human's exclusive final sign-off, see
   `easytopic_transition_status`'s tool description). Forward
   transitions — **place each `beforeChecks` on the transition that gates
   entry into the NEXT phase, not on the transition leaving that phase**
   (a Topic must not be able to enter a phase it doesn't yet qualify for —
   getting this backwards was an actual bug in the first real setup). The
   live "Claude Automation" Workflow (verified against the real Firestore
   config, not just this original design doc) only actually needs two:
   - `created → todo` — `beforeChecks: [descriptionRequired]`. A Topic
     can't enter the queue at all without a prompt already written —
     otherwise a human could queue an empty Topic and the agent would waste
     a cycle "planning" against nothing.
   - `planned → approved` — `beforeChecks: [requiredFieldsFilled]`
     (gates on the `plan` field being non-empty) — a human must not be able
     to approve a Topic that has no plan on it, or the agent starts
     "In Progress" with nothing to execute. Otherwise human-driven only.
     **The agent must never perform this transition itself** — it's the
     human's sole approval step.
   - Every other forward transition (`todo → planning`, `planning →
     planned`, `approved → in_progress`, `in_progress → done`, `done →
     closed`) has no `beforeChecks` at all.
   Also add backward transitions with empty `beforeChecks`/`afterActions`
   — moving a card back must always be possible (e.g. a plan turns out
   wrong, or an approval was premature). The live setup isn't limited to
   one step back — it also has a few direct shortcuts to an earlier stage
   for convenience: `todo → created`, `planning → todo`, `planned →
   planning`, `planned → todo`, `approved → planned`, `in_progress →
   approved`, `done → in_progress`, `done → todo`, `closed → todo`, plus
   `closed → created` as the dedicated "Reopen" action.
4. **TopicType "Claude Task"** (Topic Designer) — `workflowId` set to the
   above Workflow, plus one multiline field `key: "plan"`, `label: "Plan"`,
   `required: true` (safe — `required` only gates the transitions above, not
   Topic creation/save).
5. **BoardType** (Board Designer) — references the Workflow,
   `hiddenStatusKeys: ["created"]`.
6. **Enable both in Project settings** — add the new TopicType id to
   `Project.settings.allowedTopicTypeIds` and the new BoardType id to
   `Project.settings.allowedBoardTypeIds` (`arrayUnion`, don't overwrite —
   a Project may already have other allowed types). Both are empty by
   default; skipping this step means neither shows up in the app's own
   pickers even though the underlying Workflow/TopicType/BoardType exist.
   Any current Project participant may write `Project.settings` (it's an
   "ordinary field" per firestore.rules) — the bot's own session is enough
   for this one step, no admin needed.
7. **Creating work** — a human creates "Claude Task" Topics with
   `description` = the prompt, and drags them from backlog into `todo` when
   ready.

## Status lifecycle (Workflow "Claude Automation")

A Topic moves through the "Claude Automation" Workflow in one canonical
order:

`created → todo → planning → planned → approved → in_progress → done → closed`

That array order is also the board column order (see setup step 3 for why
that matters and why `closed` has to be last).

Every status has one designated actor who is supposed to set it — the
human and the agent alternate:

| Status | Set by | When / meaning |
|---|---|---|
| `created` | Human | The story is being written: `description` gets the prompt the agent will work from. |
| `todo` | Human | Queues the Topic. From here on the agent may pick it up and plan it. |
| `planning` | Agent | Set the moment the agent starts planning. |
| `planned` | Agent | The plan is written to the `plan` field and is waiting for approval. |
| `approved` | Human | The approval itself. The agent may only set this if the human has explicitly approved — via a Topic comment or directly in conversation — never on its own initiative. |
| `in_progress` | Agent | Set when implementation starts. |
| `done` | Agent | Set once the work is finished *from the agent's own perspective*. |
| `closed` | Human only | The final sign-off, after the human has actually verified the work. The agent never sets this, under any circumstance. |

### Gated transitions

Only two forward transitions carry `beforeChecks` at all:

- `created → todo` — `descriptionRequired`, so an empty Topic can never
  enter the queue and leave the agent planning against nothing.
- `planned → approved` — `requiredFieldsFilled`, which gates on the
  `plan` field being non-empty (it's `required: true` on the "Claude Task"
  TopicType), so nobody can approve a Topic that has no plan on it.

Every other forward transition (`todo → planning`, `planning → planned`,
`approved → in_progress`, `in_progress → done`, `done → closed`) has no
checks. Checks sit by convention on the transition that gates *entry* into
the next phase, not on the one leaving a phase — see setup step 3.

### Going backwards

Backward transitions exist throughout, all with empty
`beforeChecks`/`afterActions`: moving a card back must always be possible
(a plan turns out wrong, an approval was premature). The live setup isn't
limited to one step back — it also has direct shortcuts to an earlier
stage: `todo → created`, `planning → todo`, `planned → planning`,
`planned → todo`, `approved → planned`, `in_progress → approved`,
`done → in_progress`, `done → todo`, `closed → todo`, plus
`closed → created` as the dedicated "Reopen" action.

### `done` vs. `closed`

These two are routinely confused, and they are not interchangeable:

- `done` is an ordinary custom status just before the end. It carries no
  special semantics of its own — it is purely the agent's "I'm finished"
  signal.
- `closed` is the real reserved status key, which is why `isCompleted` and
  the admin-only reopen behaviour apply to it automatically, and it stays
  the human's exclusive closing signature after real verification.

So a Topic in `done` has been *handed in*, not *accepted*.

### Convention, not enforcement

**The actor assignment in the table above is convention only.** It is
enforced exclusively by the prompt text in `easytopic_transition_status`'s
tool description — not by `firestore.rules`, not by any `beforeChecks`,
and not by the role/permission model, which has no notion of "this
transition is human-only". Nothing structurally prevents an agent from
jumping straight to `approved` or `closed`; it relies entirely on the tool
description telling it not to, and on the agent honouring that.

The one gate that *is* enforced in code is the project-admin requirement
for leaving `closed` — and that applies identically to a human and to the
bot.

`agent-worker/src/orchestrate.ts` adds its own deterministic checks on top:
the plan step refuses to run unless the Topic is in `todo`, and the
implement step requires both `approved` *and* a non-empty `plan` field.
That hardens that one worker, though — it is not a server-side guarantee,
and any other client can still move a Topic however it likes.

### Where the keys live

The status *keys* (not the display names) are mirrored in several places:
`EASYTOPIC_*_STATUS_KEY` in the `.mcp.json` `env` block or in
`mcp-server/.env`, and as defaults in `src/config.ts`; `easytopic_whoami`
echoes the resolved values. Renaming a key in the Workflow Designer means
updating these in the same change — a mismatch fails **silently**:
`easytopic_list_topics` simply returns nothing, with no error.

See `loop-prompt-template.md` for the prompt that drives this lifecycle.

## Configuration

Copy `.env.example` to `.env` (local testing) or supply the same variables
through your `/schedule` routine's env/secret mechanism. See
`.env.example` for the full list — the Firebase web config values are
public (same as what ships in the EasyTopic app bundle); `EASYTOPIC_BOT_PASSWORD`
is the one real secret.

`config.ts` also loads `mcp-server/.env` itself as a fallback (fills only
env vars not already set — never overrides a real `.mcp.json` `env` block),
so a self-hosted setup (the MCP server living in the same repo it serves,
like this one does for EasyTopic itself) can point `.mcp.json` at the
bundle with **no `env` block at all**, keeping the committed `.mcp.json`
secret-free.

## App Check

If your Firebase project enforces App Check on Firestore/Storage, this bot
needs `EASYTOPIC_FIREBASE_APPCHECK_DEBUG_TOKEN` set (see `.env.example`) or
every tool call fails with a permission error — a Node process can't do the
real reCAPTCHA/DeviceCheck/Play Integrity attestation a browser or native app
does.

Register a debug token **per bot instance**, not one shared across bots:
Firebase Console → your project → Build → App Check → Apps → (your web app)
→ Manage debug tokens → Add debug token, give it a name that identifies
which bot it's for. Each debug token is independently revocable — if one bot
is ever suspected of misbehaving, delete just its token and it's immediately
locked out of Firestore/Storage, with zero effect on any other bot instance
or on that bot's own Firebase Auth password.

Deliberately **not** using an Admin SDK-minted App Check token here, even
though that's also an officially supported pattern for trusted server
environments: minting a token that way needs a service account with
`roles/firebaseappcheck.admin`, and that role's actual permission set
includes `firebaseappcheck.services.update` (very likely the App Check
enforcement on/off switch itself) and `.debugTokens.update` (manage debug
tokens for *any* app) — no narrower "just mint tokens" permission exists. A
leaked key with that role could disable App Check enforcement project-wide,
not just impersonate one bot. A leaked per-bot debug token, by contrast, can
only ever impersonate that one bot to App Check — a much smaller blast
radius, and it costs nothing to register (no new service account, no IAM
grant).

App Check only answers "is this a request from a client we recognize?" —
never confuse it with a behavioral leash on what a bot can then do. That's
still entirely down to Firestore/Storage security rules (`participantUids`/
role checks) and the bot's own Firebase Auth credentials, both completely
unaffected by any of this.

## Why a committed, dependency-free bundle (`dist/bundle.cjs`), not `lib/`

**Real incident (2026-07-17):** a `/schedule` cloud routine's very first
run failed because the MCP server declared in `.mcp.json` is spawned by
the Claude Code host **at session bootstrap, before the routine's own
prompt-driven Bash steps ever run**. In a fresh git clone, neither
`mcp-server/lib/` (tsc output) nor `mcp-server/node_modules/` exist yet —
both are gitignored — so `node mcp-server/lib/index.js` crashed
immediately with a module-not-found error. By the time the agent's prompt
got around to running `npm install && npm run build`, the already-spawned
(dead) process was never retried — there's no in-session way to restart an
MCP connection.

**Fix:** `npm run bundle` (esbuild, `src/index.ts` → `dist/bundle.cjs`,
`--bundle --platform=node --format=cjs`) produces a single file with every
dependency (`firebase`, `@modelcontextprotocol/sdk`, `zod`) inlined — zero
`node_modules` needed at runtime. **This file is committed to git**
(unlike `lib/`, which stays gitignored dev output) specifically so it
exists immediately in any fresh clone, before any build step could
possibly run. Verified by copying just `dist/bundle.cjs` (+ a `.env`) into
an empty directory with no `node_modules` anywhere nearby and confirming
it starts and serves tool calls correctly. `npm run build` runs `tsc &&
npm run bundle` together — **always re-run and re-commit `dist/bundle.cjs`
after any `src/` change**, or a `/schedule` routine keeps running stale
code indefinitely (a git-ignored build artifact would silently drift;
this one doesn't because it's tracked and reviewable in diffs).

## `.mcp.json` in the target project

```json
{
  "mcpServers": {
    "easytopic": {
      "command": "node",
      "args": ["/absolute/path/to/easytopic/mcp-server/dist/bundle.cjs"],
      "env": {
        "EASYTOPIC_FIREBASE_API_KEY": "...",
        "EASYTOPIC_FIREBASE_AUTH_DOMAIN": "...",
        "EASYTOPIC_FIREBASE_PROJECT_ID": "...",
        "EASYTOPIC_FIREBASE_STORAGE_BUCKET": "...",
        "EASYTOPIC_FIREBASE_MESSAGING_SENDER_ID": "...",
        "EASYTOPIC_FIREBASE_APP_ID": "...",
        "EASYTOPIC_BOT_EMAIL": "...",
        "EASYTOPIC_BOT_PASSWORD": "...",
        "EASYTOPIC_COMPANY_ID": "...",
        "EASYTOPIC_PROJECT_ID": "...",
        "EASYTOPIC_BOARD_ID": "...",
        "EASYTOPIC_TODO_STATUS_KEY": "todo",
        "EASYTOPIC_PLANNING_STATUS_KEY": "planning",
        "EASYTOPIC_PLANNED_STATUS_KEY": "planned",
        "EASYTOPIC_APPROVED_STATUS_KEY": "approved",
        "EASYTOPIC_IN_PROGRESS_STATUS_KEY": "in_progress",
        "EASYTOPIC_DONE_STATUS_KEY": "done",
        "EASYTOPIC_PLAN_FIELD_KEY": "plan"
      }
    }
  }
}
```

**Open point (verify before real use):** a `/schedule` cloud routine runs in
a cloud sandbox, not on this machine — a local absolute `args` path is only
reachable if that sandbox has this `easytopic` repo checked out too, which
isn't guaranteed. If it doesn't, publish this package to an npm registry the
sandbox can reach and use `"command": "npx", "args": ["-y", "@easytopic/mcp-server"]`
instead (`npx` fetches on demand, same "no local build step required"
property as the committed bundle). Also don't commit `EASYTOPIC_BOT_PASSWORD`
in a real `.mcp.json` — reference it as an env var name and inject the
actual value through whatever secret storage the routine host provides.

## Loop prompt

See `loop-prompt-template.md` for the full prompt to give the scheduled
Claude Code agent — implements the pick-up/plan/wait-for-approval/
implement/close lifecycle, including the "never self-approve" and
question/reply detection rules. Since the bundle needs no build step, the
only thing a routine's prompt must still do before its first tool call is
write `mcp-server/.env` (config is loaded lazily per tool call — see
`config.ts` — so the server process itself is already up and running by
the time that file appears).

## Development

```
npm install
npm run build   # tsc (lib/, dev-only) + esbuild bundle (dist/bundle.cjs, committed)
npm start       # runs the stdio MCP server against .env, from lib/
```

## Tool reference

Every Topic tool takes an optional `projectId`; omitted, it uses the
configured `EASYTOPIC_PROJECT_ID`. Passing another Project only works as far
as the Firestore rules let the bot in — that hangs on its role there, not on
the parameter.

| Tool | Purpose |
|---|---|
| `easytopic_whoami` | Auth check, echoes resolved config + the bot's role in the configured Project |
| `easytopic_list_topics({statusKeys})` | Topics filtered by status |
| `easytopic_search_topics({query?, statusKeys?, limit?})` | Title substring search; without `projectId` across every Project the bot participates in |
| `easytopic_get_topic({topicId})` | Topic + comments + legal transitions + parent/children + links + `awaitingHumanReply` |
| `easytopic_add_comment({topicId, body})` | Post a comment |
| `easytopic_write_plan({topicId, planHtml})` | Write the plan custom field (versioned) |
| `easytopic_transition_status({topicId, toStatusKey, comment?})` | Change status via the matching Workflow transition |
| `easytopic_create_topic({title, ...})` | Create a Topic (admin only; optional parent, assignee, custom fields, due date) |
| `easytopic_update_topic({topicId, ...})` | Change title/description/custom fields/due date — versioned, in Topic History |
| `easytopic_set_topic_parent({topicId, parentTopicId})` | Re-hang a Topic in the Project's tree (`null` = top level) |
| `easytopic_link_topics({topicId, linkType, targetProjectId, targetTopicId})` | Link two Topics; the inverse direction is written automatically |
| `easytopic_list_topic_links({topicId})` | The Topic's links, with the `linkId` needed to remove one |
| `easytopic_unlink_topics({topicId, linkId})` | Remove a link (best-effort on the far side) |
| `easytopic_set_topic_assignee({topicId, assignee})` | Assign or clear — this is what the autonomous worker routes on |
| `easytopic_change_topic_type({topicId, newTopicTypeId})` | Switch Topic Type; reports `droppedFields` and any status reset |
| `easytopic_delete_topic({topicId, confirm})` | Permanent delete; direct children are re-hung onto the deleted Topic's parent |

Plus the Documentation-Project tools: `easytopic_list_doc_projects`,
`easytopic_list_doc_tree`, `easytopic_get_doc_page`,
`easytopic_write_doc_page`, `easytopic_create_doc_page`.

Errors from `easytopic_transition_status` use the same vocabulary as the
app's own `useTransitionTopicStatus` hook: a `WorkflowCheckId`
(`assigneeRequired`/`dueDateRequired`/`descriptionRequired`/
`requiredFieldsFilled`), `notProjectAdmin`, `hasActiveConflict`, or
`noMatchingTransition`. The Topic tools answer in the same shape
(`{ "error": "<code>" }`, never a raw permission error):

| Code | Meaning |
|---|---|
| `notProjectAdmin` | Creating a Topic needs the `topic.create` capability, which only the `admin` project role carries. A human has to promote the bot. |
| `topicNotFound` / `parentNotFound` / `targetTopicNotReachable` | The Topic does not exist **or** the bot may not read it. The two are indistinguishable to any client: the `get` rule reads `resource.data.participantUids`, which a missing document does not have, so Firestore answers `permission-denied` either way. |
| `hasActiveConflict` | The race-condition detector has locked the Topic; every client write is refused until a human resolves the conflict in the app. |
| `cycle` | The proposed parent is the Topic itself or one of its own descendants. |
| `topicTypeNotActive` / `noCreatableTopicType` | Topic Type gate, same vocabulary as `functions/src/moveTopic.ts`. |
| `statusNotAllowed` | A Topic Type change would reset the status into one the Workflow's status rights do not let this role enter. |
| `confirmationRequired` | `easytopic_delete_topic` without `confirm: true`. |

Two behaviours worth knowing before using the write tools:

- **`easytopic_link_topics` writes BOTH directions in one call.** A link is
  two mirror documents sharing one id, one under each Topic, and the far
  side gets the inverse type (`blocks` ↔ `isBlockedBy`, `relatesTo` is its
  own inverse). Never call it twice to "build the other side". There is no
  way to change a link's type — remove it and make a new one.
- **`easytopic_set_topic_parent` writes no Topic History entry and does not
  renumber siblings**, exactly like the app: placement in the tree is
  metadata of the same tier as `boardId`, not a tracked field change.

TDQS

A4.4/5.0

Scored across 11 tools

Disambiguation5/5

Tools are clearly separated into topic management and documentation project management groups, with each tool having a distinct purpose (list vs get vs add vs write vs transition vs create). No overlapping functionalities.

Naming Consistency5/5

All tools follow the 'easytopic_verb_noun' pattern with consistent verbs (list, get, add, write, transition, create). The only outlier is 'whoami', which is a standard authentication term and fits the pattern.

Tool Count5/5

With 11 tools covering two main domains (topic management and documentation projects), the number is well-scoped. Each tool serves a necessary role without redundancy.

Completeness3/5

Significant gaps exist: no create_topic or delete_topic for topic management, and no delete_doc_page for documentation. Updates to custom fields beyond the plan field are missing. However, core workflows (list, get, update status, add comments, and documentation CRUD for creation/reading/updating) are covered.

Maintenance

ActivityActive
ResponsivenessNo issues