Metadata-Version: 2.5
Name: activecollab-mcp
Version: 1.20.0
Summary: MCP server for ActiveCollab — exposes projects, tasks, comments, attachments, and time tracking to MCP-compatible LLM clients.
Project-URL: Homepage, https://github.com/devjtv/active-collab-mcp
Project-URL: Issues, https://github.com/devjtv/active-collab-mcp/issues
Author: ActiveCollab MCP Contributors
License: MIT
License-File: LICENSE
Keywords: activecollab,claude,cursor,llm,mcp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Requires-Dist: anyio>=4.4
Requires-Dist: click>=8.1
Requires-Dist: httpx>=0.27
Requires-Dist: mcp<2,>=1.2.0
Requires-Dist: pydantic-settings>=2.3
Requires-Dist: pydantic>=2.7
Requires-Dist: python-dotenv>=1.0
Requires-Dist: starlette>=0.37
Requires-Dist: uvicorn>=0.30
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# activecollab-mcp

A Python MCP server that exposes an ActiveCollab workspace (projects, tasks,
comments, attachments, time tracking) to MCP-compatible LLM clients such as
Cursor and Claude Desktop. Ships as an installable package with a daemon CLI
and version management via `pipx`.

> **Heads up**: this version supersedes the previous Node.js implementation,
> which has been moved to `archive/js/` for reference.

## Features

- **One-line installer** — `pipx install activecollab-mcp && activecollab-mcp setup`, done. `setup` covers login, the background service, registration with installed AI clients (Claude Code, Codex, Grok, Pi, OMP, Gemini CLI, Cursor, opencode), and auto-updates.
- **Interactive `auth login`** — exchanges email + password for an API token via [`/issue-token`](https://developers.activecollab.com/api-documentation/v1/authentication.html) and stores it at `~/.activecollab-mcp/credentials.json` (mode 0600). No env vars required for the common case.
- **Streamable HTTP transport** on `127.0.0.1:8787/mcp` (single, modern MCP transport).
- **Daemon CLI** — `start`, `stop`, `restart`, `status`, `logs`, `config`, `version`.
- **Boot-persistent service** (macOS) — `activecollab-mcp service install` registers a `launchd` LaunchAgent with `RunAtLoad` + `KeepAlive` so the server survives reboots and auto-restarts on crash.
- **Self-update** — `activecollab-mcp update` installs the latest release from PyPI. `--auto` registers a cron entry for daily auto-updates. The running server is **automatically bounced** after a successful update so it picks up the new code.
- **Auto port fallback** — if 8787 is busy, the daemon scans the next 20 ports and binds to the first free one. The actual bound port is written to `~/.activecollab-mcp/server.port` and surfaced by `status`.
- **Read tools** — projects, users, tasks, comments, attachments, time records, name resolution.
- **Write tools** (toggle via env) — create/update/complete/reopen/delete tasks, post comments, log time, upload and attach files, `@`-mention users.
- **First-person identity** — `whoami` and `list_my_tasks` resolve "me" / "my" from the token owner so the LLM doesn't have to ask.
- **Vision-ready image attachments** — `get_image_attachment_content` returns metadata first (including `tempFilePath`) and embeds MCP `ImageContent` for images under the embed budget, so hosts that truncate large image payloads still get a usable file path fallback. Survives ActiveCollab quirks: token-auth-only download endpoint, generic `application/octet-stream` Content-Type responses, and the `{single: {...}, parent: {...}}` wrapper on `/attachments/{id}`.
- **Downloadable attachments of any type** — `download_attachment` fetches a PDF, docx, zip, image or anything else on the agent's behalf and returns a local `tempFilePath`. Attachment records deliberately carry no URL: ActiveCollab's `download_url` holds a literal `--DOWNLOAD-TOKEN--` placeholder and the API route needs the auth header, so no link the server could return would be fetchable without leaking the key. Every list that includes attachments carries a `download_hint` pointing at the tool.
- **Resilient comments + attachments** — comments are read from `/comments/task/{id}`, the route that actually works on ActiveCollab 8.x (the project-scoped one returns 500 "Failed to match path"), and are returned oldest-first so the newest is always last. Attachments fall back to the task payload when their endpoints error, with automatic retry on transient 5xx; routing-miss 500s are remembered for an hour (`endpoint_skipped: true`), so broken endpoints aren't re-probed on every call. A `task_inline_fallback` source flags that a list may be incomplete.
- **Per-tool switches that actually withdraw the tool** — `activecollab-mcp tools` opens a picker for disabling individual tools (say, `post_task_comment`). A disabled tool is never registered, and its name is scrubbed out of every other tool's description and the server instructions, so a client cannot learn it exists from a cross-reference. Re-enabling requires a person at a terminal; `ACTIVECOLLAB_DISABLED_TOOLS` in the launchd plist is a lock the CLI cannot undo at all.
- **Per-tool write confirmation** — put a write tool in the confirm list (`c` in the `activecollab-mcp tools` picker, or `tools confirm <name>`) and every call pauses to show the user the exact change in their MCP client's own dialog; it runs only if they accept, and a decline is final. Nothing confirms by default. Every write attempt, confirmed or not, lands in `~/.activecollab-mcp/writes.log`.
- **Stale-token self-healing** — on a 401 the client re-reads `credentials.json` and retries once with the fresh token, so `auth login` takes effect without restarting the daemon. API keys are redacted from all error output.
- **LLM-friendly response shaping**:
  - `get_task_bundle` — task + comments + deduped attachments in one call; image metadata is returned first with temp-file fallbacks, and small image attachments are embedded as MCP image blocks (`include_image_content`, capped by `max_images`/`max_image_bytes`/`max_embedded_image_bytes`).
  - `get_project_overview` — project metadata + recent open tasks in one call.
  - `describe_workspace` — counts, top projects, top users for orientation.
  - `search_tasks` — multi-field client-side scan when the API's `search` isn't enough.
  - `search_tasks_workspace` — the same scan across every project, for when the user names a task but not its project.
  - List tools default to `format=compact` (drops body HTML and raw attachments); pass `format=full` when you need every raw field.
  - Tasks are enriched with `project_name` and `assignee_name(s)` resolved from cached lookups — no more bare IDs in the LLM output.
  - Cached name resolution (`find_*_by_name`, `list_tasks_by_user_name`) with TTL.
  - Pagination metadata (`hasMore`, `nextPage`, and `totalItems`/`totalPages` when the server reports them) on every list tool. `hasMore` is derived from ActiveCollab's own pagination headers rather than guessed from the page length, so a truncated page is never presented as the complete set. When neither a limit nor server totals are available it is `null` (unknown) rather than a confident `false`.
  - `completed=true` reads ActiveCollab's task **archive**. The plain tasks collection serves open tasks only and ignores a `completed` filter, so it cannot answer "show me finished work".

## Write approval

Telling an agent "get approval before calling" in a tool description is
advice, and agents under pressure ignore advice. The server can enforce it
instead, per tool, for the writes you actually care about.

Nothing asks by default. Mark the tools you want confirmed:

```bash
activecollab-mcp tools                        # picker: c on a write tool, C for all
activecollab-mcp tools confirm delete_task log_time
activecollab-mcp tools trust delete_task      # back to unattended (needs a terminal)
activecollab-mcp tools list                   # 'on ask' marks a confirmed tool
```

A call to a confirmed tool then does this:

1. Builds a plain-text preview of the exact change: target task, changed
   fields, comment text, who gets mentioned, hours and date, file name. It is
   kept to three lines, because that is all Claude Code shows.
2. Sends it to the MCP client as an elicitation. Claude Code (2.1.76+) and
   Cursor render it as a confirm dialog.
3. Runs the write only if the user accepts. Decline or cancel refuses the
   call with a message telling the agent the answer is final and not to
   retry or work around it.

A client that did not advertise elicitation support cannot ask, so a
confirmed tool refuses outright rather than writing unattended. That is the
point of marking it. Unmarked tools keep the "show the user and get their
approval" wording in their descriptions, which is advice and nothing more.

All nine write tools can be confirmed: `create_task`, `update_task`,
`complete_task`, `reopen_task`, `delete_task`, `post_task_comment`,
`set_task_labels`, `upload_attachment`, `log_time`.

For headless installs, `ACTIVECOLLAB_CONFIRM_TOOLS=delete_task,log_time`
merges on top of the saved file and cannot be undone by the CLI — the same
one-way lock as `ACTIVECOLLAB_DISABLED_TOOLS`, and equally safe in the
launchd plist, which `service install` preserves.

Every attempt is appended to `~/.activecollab-mcp/writes.log` as one JSON
line, before the API call, so a declined attempt is as visible as a completed
one:

```json
{"time": "2026-09-08T03:12:44+00:00", "tool": "post_task_comment", "decision": "declined", "policy": "elicit", "client": "claude-code 2.1.261", "target": {"project_id": 7, "task_id": 42}, "preview": "Post a comment on task #42 in project 7\ncomment: ..."}
```

`decision` is one of `approved`, `declined`, `cancelled`, `no_elicitation`,
`elicitation_failed`, or `trusted` (the tool was not marked, so it just ran).

Known caveat: some Claude Code builds auto-decline form elicitations in
particular session types (see anthropics/claude-code#62319). In an affected
session every confirmed write is refused; `tools trust <name>` is the way
out.

## Turning tools off

Not every workspace wants an agent that can post comments or delete tasks.
`activecollab-mcp tools` opens a picker for switching individual tools on and
off:

```bash
activecollab-mcp tools            # interactive picker
```

```
activecollab-mcp tools — 29/39 enabled · 2 ask first  (unsaved)
[ ] not offered to clients at all   (?) asks the user to confirm every call

READ TOOLS
 [x]     batch_get_tasks              Fetch multiple task records in parallel.
 [x]     describe_workspace           Return a high-level overview of the workspace.
 ...
WRITE TOOLS
 [x] (?) delete_task                  Delete (trash) a task.
 [ ]     post_task_comment            Post a comment on a task.

↑/↓ or j/k move    space on/off    c ask first    a all on    n all off
w writes off    C ask on every write    s save and exit    q quit
```

A disabled tool is not registered at all, so it never appears in the client's
tool list and cannot be called — as opposed to being advertised and then
refusing, which still spends tokens on every request. A tool marked `(?)` is
registered normally but asks the user before each call; see
[Write approval](#write-approval).

Non-interactive equivalents, for scripts and for terminals the picker can't
drive:

```bash
activecollab-mcp tools list                              # what's on, off, and asking
activecollab-mcp tools list --json                       # machine-readable
activecollab-mcp tools disable post_task_comment delete_task
activecollab-mcp tools enable post_task_comment
activecollab-mcp tools confirm delete_task               # ask before each call
activecollab-mcp tools trust delete_task                 # stop asking
activecollab-mcp tools reset                             # everything on, nothing asking
```

Choices are saved to `~/.activecollab-mcp/tools.json` (mode 0600) as two
lists: the tools that are **off**, and the write tools that **ask first**.
Storing the off set that way means a tool added by a later release is
available immediately instead of staying invisible until you re-run the
picker, and the file can only ever subtract — nothing in it can hand back a
write tool that `ACTIVECOLLAB_ENABLE_WRITES=false` has already withdrawn.

For headless installs that would rather not carry a state file, set
`ACTIVECOLLAB_DISABLED_TOOLS` instead:

```bash
ACTIVECOLLAB_DISABLED_TOOLS=post_task_comment,delete_task,log_time
```

Those merge on top of the saved file and cannot be re-enabled by the CLI —
`tools list` marks them, and `tools enable` says so rather than pretending to
have worked.

`tools enable`, `tools trust` and `tools reset` refuse to run without a
terminal and ask for confirmation. Turning a tool back on, or stopping it
asking, widens what an AI client may do, and an agent with shell access will
do exactly that if the command is scriptable — one found a tool name in a
cross-reference and went looking for the switch. There is no `--yes`/`--force`
bypass, because a flag an agent can pass is not a control. `tools disable`
and `tools confirm` stay scriptable: the safe direction needs no friction.

For a lock nothing in the CLI can lift, put the names in the launchd plist's
`EnvironmentVariables` and restart the service. `service install` now carries
existing environment entries forward, so a reinstall no longer drops the lock.

### What a client can see

Disabling a tool removes it from `tools/list` **and** from the prose. Before,
`upload_attachment` told every client to pass its code to `post_task_comment`,
which leaked the name of a tool the operator had deliberately withdrawn.
Descriptions and the server instructions are now scrubbed of every absent name
at registration: enumerations are rebuilt (`create_task, update_task, or
post_task_comment` becomes `create_task or update_task`) and a sentence that is
about the missing tool is dropped whole. The same applies to write tools when
`ACTIVECOLLAB_ENABLE_WRITES=false`.

Tools are registered once at startup, so a running server keeps serving the old
set until it restarts. The CLI prints the right command for whatever is running
(`service restart` or `restart`); clients that spawn the server themselves pick
the change up next time they start it.

## Requirements

- Python 3.10+
- An ActiveCollab account (onboarding exchanges your email + password
  for an API token)

`pipx` will be installed automatically by the installer script if it isn't
already on your `PATH`.

## Quick install

Published on PyPI — no git access or SSH keys needed:

```bash
pipx install activecollab-mcp && activecollab-mcp setup
```

`setup` walks through onboarding interactively:

1. Runs `activecollab-mcp auth login`, prompting for your ActiveCollab URL,
   email, and password. It exchanges those for an API token via
   [`POST /issue-token`](https://developers.activecollab.com/api-documentation/v1/authentication.html)
   and stores the token at `~/.activecollab-mcp/credentials.json` (mode 0600).
   Skipped if you're already logged in.
2. Installs the boot-persistent background server (macOS LaunchAgent; ad-hoc
   daemon elsewhere) on `127.0.0.1:8787` — used by HTTP clients like Cursor.
3. Detects the AI clients installed on the machine and offers to register the
   server with each one (default yes, already-registered clients are left
   alone). Supported: **Claude Code, Codex, Grok, Pi, OMP (oh-my-pi),
   Gemini CLI, Cursor, opencode**. CLI-based clients are registered via their
   own `mcp add` command; the rest get a safe merge into their MCP config
   file (existing entries preserved, `.bak` backup written, unparseable
   configs skipped with a warning).
4. Offers to enable the daily auto-update cron (default yes).

Flags: `--all-clients` registers with every detected client without prompts;
`--skip-clients`, `--skip-service`, `--skip-auto-update` skip a step;
`--relogin` re-issues the token.

If you don't have `pipx`, run the bundled `install.sh` instead — it installs
pipx if needed, installs the package from PyPI, and runs `setup`:

```bash
/bin/bash install.sh
```

### Installer script flags

| Flag | Behavior |
| --- | --- |
| `--no-setup` | Install only; skip the interactive onboarding. |
| `--upgrade` | Force-reinstall/upgrade an existing pipx install of `activecollab-mcp`. |
| `--yes`, `-y` | Auto-confirm installing `pipx` if missing. Password prompts during `setup` are still interactive. |
| `--help`, `-h` | Print installer help. |

### Dev install (from a checkout)

```bash
pipx install .                     # production install in an isolated venv
pipx install --force .             # reinstall over an existing version
pip install -e ".[dev]"            # editable dev install in a venv
```

## Authenticate

The fastest way to get started — let the CLI exchange your email and password
for an API token (per the
[ActiveCollab `/issue-token` flow](https://developers.activecollab.com/api-documentation/v1/authentication.html)):

```bash
activecollab-mcp auth login
# ActiveCollab base URL: https://your-company.activecollab.com
# Email: you@example.com
# Password: ************
# Logged in as you@example.com.
# Token stored in /Users/you/.activecollab-mcp/credentials.json (mode 0600).
```

The token is saved to `~/.activecollab-mcp/credentials.json` with `0600`
permissions. From then on you can simply run `activecollab-mcp start` — no env
vars required.

Other auth commands:

```bash
activecollab-mcp auth show     # display stored credentials (token redacted)
activecollab-mcp auth logout   # remove stored credentials
```

Flag overrides if you want to script it:

```bash
activecollab-mcp auth login --url https://your.activecollab.com --email you@example.com
activecollab-mcp auth login --no-verify-tls         # self-signed certs
activecollab-mcp auth login --no-append-api-v1      # URL already includes /api/v1

# Unattended: read the password from stdin (never from argv)
printf '%s' "$AC_PASSWORD" | activecollab-mcp auth login --email you@example.com --password-stdin
```

The password is never accepted as a command-line argument — `argv` is readable
by any process on the machine via `ps` and lands in shell history. Use
`--password-stdin` for scripts, or let it prompt.

## Configure (optional)

`auth login` is enough for most setups. If you'd rather manage credentials
yourself — e.g. for a headless deployment or CI — copy `.env.example` to
`.env` and set the env vars there. Env values **override** anything stored
by `auth login`.

```bash
cp .env.example .env
```

Credential precedence: process env → `.env` file → `~/.activecollab-mcp/credentials.json`.

| Variable | Purpose |
| --- | --- |
| `ACTIVECOLLAB_BASE_URL` | e.g. `https://your-company.activecollab.com` (auto-appends `/api/v1`). Optional when `auth login` has been run. |
| `ACTIVECOLLAB_API_KEY` | Personal API token. Optional when `auth login` has been run. |

Common optional:

| Variable | Default | Notes |
| --- | --- | --- |
| `ACTIVECOLLAB_AUTH_HEADER` | `X-Angie-AuthApiToken` | Override for non-standard auth setups |
| `ACTIVECOLLAB_AUTH_PREFIX` | _(empty)_ | e.g. `Bearer` for custom proxies |
| `ACTIVECOLLAB_APPEND_API_V1` | `true` | Set `false` if your base URL already includes `/api/v1` |
| `ACTIVECOLLAB_ALLOW_SELF_SIGNED` | `false` | Disable TLS verification (self-signed certs) |
| `ACTIVECOLLAB_TIMEOUT_SECONDS` | `15` | HTTP request timeout |
| `ACTIVECOLLAB_ENABLE_WRITES` | `true` | Toggle write tools off for a strictly read-only deployment |
| `ACTIVECOLLAB_DISABLED_TOOLS` | _(empty)_ | Comma-separated tool names to switch off, merged on top of `tools.json`. See [Turning tools off](#turning-tools-off) |
| `ACTIVECOLLAB_CONFIRM_TOOLS` | _(empty)_ | Comma-separated write tools that must ask the user before each call, merged on top of `tools.json`. See [Write approval](#write-approval) |
| `ACTIVECOLLAB_NAME_CACHE_TTL` | `300` | Seconds to cache project/user lists used for fuzzy resolution |
| `ACTIVECOLLAB_MCP_HOST` | `127.0.0.1` | Bind host |
| `ACTIVECOLLAB_MCP_PORT` | `8787` | Bind port |
| `ACTIVECOLLAB_MCP_LOG_LEVEL` | `INFO` | `DEBUG`/`INFO`/`WARNING`/`ERROR` |
| `ACTIVECOLLAB_MCP_LOG_FILE` | `~/.activecollab-mcp/server.log` (daemon mode) | Optional file logging |

Legacy variable names from the JS version (`BASE_URL`, `API_TOKEN`,
`AUTH_HEADER`, `AUTH_PREFIX`) are still accepted as aliases.

## Use the CLI

```bash
activecollab-mcp --help              # list all subcommands
activecollab-mcp version             # print package version
activecollab-mcp setup               # one-shot onboarding (login + service + AI clients + auto-update)
activecollab-mcp auth login          # issue an API token from email+password
activecollab-mcp auth show           # show stored credentials (redacted)
activecollab-mcp auth logout         # remove stored credentials
activecollab-mcp config              # print effective config (api_key redacted)
activecollab-mcp run                 # run in the foreground (Ctrl-C to stop)
activecollab-mcp start               # detach and run as a background process
activecollab-mcp status              # JSON: { serving, served_by, bound_port, mcp_url, service, ... }
activecollab-mcp logs -f             # tail the server log
activecollab-mcp restart             # stop + start
activecollab-mcp stop                # graceful SIGTERM, fallback SIGKILL after 10s
activecollab-mcp update              # install the latest release from PyPI
activecollab-mcp update --check      # check only; don't install
activecollab-mcp update --auto       # also register a daily cron auto-updater
```

## Updating

`activecollab-mcp update` checks PyPI for the latest release and re-runs
`pipx install --force activecollab-mcp==<version>` when a newer one exists.
The update source is recorded in `~/.activecollab-mcp/updater.json`; legacy
pre-1.9 installs that recorded a git URL keep updating from git.

```bash
activecollab-mcp update              # install the latest PyPI release
activecollab-mcp update --check      # exit 0 if an update is available, 4 if up-to-date
activecollab-mcp update --to 1.15.0   # pin to a specific version
activecollab-mcp update --repo git@bitbucket.org:org/fork.git  # force git mode (dev)
```

### Auto-update via cron

```bash
activecollab-mcp update --auto                       # daily at 06:17
activecollab-mcp update --auto --schedule "0 4 * * 0" # weekly, Sunday 04:00
activecollab-mcp update --auto-status                 # show current schedule
activecollab-mcp update --auto-disable                # remove the cron entry
```

`--auto` writes a marker-bracketed block to your crontab so it can be removed
cleanly later. The cron line looks like:

```
# >>> activecollab-mcp auto-update >>>
17 6 * * * /Users/you/.local/bin/activecollab-mcp update --quiet >> /Users/you/.activecollab-mcp/update.log 2>&1
# <<< activecollab-mcp auto-update <<<
```

Output from auto-updates is appended to `~/.activecollab-mcp/update.log`.

### Running server is restarted automatically

After a successful `update`, the running server (if any) is bounced so it
picks up the new code in memory:

- LaunchAgent installed via `service install` → `launchctl kickstart -k`
- Manual daemon started via `activecollab-mcp start` → stop + start

Pass `--no-restart` to skip this (useful when scripting blue/green-style
rollouts or when you want to control the restart timing yourself). If no
server is running at update time, nothing happens — the new code will be
in effect the next time you start it.

The auto-update cron job also restarts the running server by default, so
clients pointed at `http://127.0.0.1:8787/mcp` will reconnect to the
upgraded server without manual intervention.

## Run on boot (macOS LaunchAgent)

`activecollab-mcp start` survives shell exit but **not** a reboot. To keep
the server running across logins and reboots, register a LaunchAgent:

```bash
activecollab-mcp service install      # writes plist + launchctl bootstrap
activecollab-mcp service status       # shows service + any manual daemon
activecollab-mcp service restart      # launchctl kickstart -k
activecollab-mcp service uninstall    # bootout + remove plist
```

What `service install` does:

- Writes `~/Library/LaunchAgents/com.efront.activecollab-mcp.plist` with
  `RunAtLoad=true` (start at login) and `KeepAlive={SuccessfulExit: false}`
  (auto-restart on crash, but not after a clean non-zero exit). The narrower
  form matters: with plain `KeepAlive=true`, a config error — after `auth
  logout`, say — makes launchd relaunch the server every throttle interval
  forever, and each failure appends to a `service.log` that launchd never
  rotates.
- Stops any running `activecollab-mcp start` manual daemon first so the two
  don't fight over the port.
- `launchctl bootstrap gui/<uid> <plist>` loads + starts the service.

Service output is appended to `~/.activecollab-mcp/service.log`. After
install, manage with the `service` subcommands or `launchctl` directly.

> Linux: not yet implemented. Use an `@reboot` crontab entry pointing at
> `activecollab-mcp start`, or write a user-level systemd unit.

## Port handling

By default the server binds to `127.0.0.1:8787`. When another process
(another MCP server, a stale daemon, etc.) is already on that port:

- `activecollab-mcp run` fails fast with a clear error — foreground use
  should surface the conflict immediately. Pass `--auto-port` to opt into
  fallback behavior.
- `activecollab-mcp start` (daemon) defaults to `--auto-port`, so it scans
  the next 20 ports (`port+1` … `port+20`) and binds to the first free one.
  Disable with `--no-auto-port`.

The actually-bound port is written to `~/.activecollab-mcp/server.port`,
returned by `activecollab-mcp status` as `bound_port` / `mcp_url`, and
logged on startup. Set `ACTIVECOLLAB_MCP_AUTO_PORT=true` / `false` and
`ACTIVECOLLAB_MCP_PORT_SCAN_WINDOW=<n>` to configure via env.

### Reading `status`

The server can be supervised two ways, so `status` reports both:

| Field | Meaning |
| --- | --- |
| `serving` | **The one to trust.** A real `/healthz` probe of the bound port. |
| `served_by` | `service` (LaunchAgent), `manual_daemon`, `unknown_process`, or `none`. |
| `running` / `pid` | The **manual daemon only** — `false` is normal and healthy when the LaunchAgent owns the process. |
| `service` | LaunchAgent state (macOS): `loaded`, `pid`, `plist_path`. |

Exit code is 0 when something is serving, 3 when nothing is. Stale pid files
from an exited manual daemon are pruned automatically rather than reported as a
dead pid.

State and credentials live in `~/.activecollab-mcp/`:

```
~/.activecollab-mcp/
├── credentials.json   # base_url + API token (mode 0600), written by `auth login`
├── updater.json       # source repo URL + last-check/install timestamps
├── tools.json         # tools switched off / set to ask, via `activecollab-mcp tools` (mode 0600)
├── server.pid         # PID of the running daemon (manual `start`)
├── server.port        # JSON {port, pid} — the bound port and which process owns it,
│                      #   so an exiting instance never erases another's record
├── start.lock         # held briefly by `start` so two concurrent starts can't both spawn
├── server.log         # rotating log file (5 MB × 5 backups)
├── service.log        # stdout/stderr captured when running under launchd
└── update.log         # auto-update log (appended by the cron entry)
```

The LaunchAgent plist (when installed via `service install`) lives at:

```
~/Library/LaunchAgents/com.efront.activecollab-mcp.plist
```

## Connect from an MCP client

### Cursor

`~/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "activecollab": {
      "url": "http://127.0.0.1:8787/mcp"
    }
  }
}
```

### Claude Desktop

Claude Desktop currently uses stdio MCP servers, so for HTTP transport use a
client that supports it directly. To support stdio in the future you can run
the server inside an HTTP-to-stdio proxy.

### Health check

```bash
curl http://127.0.0.1:8787/healthz
# {"status": "ok", "name": "activecollab-mcp", "version": "0.2.0", "writes_enabled": true}
```

## Tools

### Read

| Tool | What it does |
| --- | --- |
| `health` | Calls `/info`. Use for connectivity smoke tests. |
| `whoami` | Identify who "me" / "I" refers to. Resolved from the token owner stored at `auth login`. Always call this when the user phrases a request in first person. |
| `list_my_tasks` | Convenience: whoami + list_user_tasks in one call. Use for "my tasks" / "what am I working on". |
| `describe_workspace` | Counts + top 10 projects/users for orientation. |
| `list_projects` | Project list with `format=compact` option. |
| `find_project_by_name` | Fuzzy resolve a name to a project ID (cached). |
| `get_project` | Single project by ID. |
| `get_project_overview` | Project + recent open tasks in one call. |
| `list_users` | User list, compact option. |
| `find_user_by_name` | Fuzzy resolve a name or email to a user ID (cached). |
| `get_user` | Single user by ID. |
| `list_user_tasks` | Tasks assigned to a user across projects. |
| `list_tasks` | Tasks in a project with filters. `completed=true` reads the task archive. |
| `list_tasks_by_user_name` | Resolve names + return tasks in one call. |
| `search_tasks` | Client-side multi-field search within a project. |
| `search_tasks_workspace` | The same search across every project, for when the project is unknown. Reports `coverage` so partial scans are visible. |
| `get_task` | Single task. |
| `get_task_bundle` | **Task + comments + deduped attachments in one call.** Image metadata is returned first with temp-file fallbacks; small images are embedded as image content blocks. |
| `batch_get_tasks` | Parallel multi-task fetch with partial-failure reporting. |
| `list_task_comments` | Comments for a task. |
| `list_task_attachments` | Deduped attachment metadata (inline + endpoint sources) plus a `download_hint`. |
| `list_task_image_attachments` | Image attachments only, with comment scan. |
| `get_attachment` | Attachment metadata, with a resolved `{normalized, raw}` summary so the LLM sees `name`, `mimeType`, `isImage`, `size` directly. Metadata-only — use `download_attachment` or `get_image_attachment_content` for the bytes. |
| `download_attachment` | Download any attachment to a local file and return `tempFilePath`, `filename`, `mimeType`, `size`. Capped by `max_bytes` (default 50 MB). Files are swept after 24 hours. |
| `get_image_attachment_content` | Download an image and return metadata first, including `tempFilePath`; image content is embedded only when under `max_embedded_image_bytes`. Falls back through `download_url` / `preview_url` / `thumbnail_url` (with `--DOWNLOAD-TOKEN--` substitution) if the API endpoint fails. |
| `debug_attachment` | Diagnostic: report all download URL candidates and which strategy works for a given attachment, without returning bytes. Use when a download fails. |
| `list_project_time_records` | Time records for a project, filterable by date. |
| `list_user_time_records` | Time records logged by a user. |

### Write (set `ACTIVECOLLAB_ENABLE_WRITES=false` to disable)

Any of these can be made to ask the user before each call; see
[Write approval](#write-approval).

| Tool | What it does |
| --- | --- |
| `create_task` | Create a task with optional assignee, due date, labels, attachments, etc. |
| `update_task` | Partial update of task fields; can attach uploaded files. |
| `complete_task` | Mark complete. Verifies the task really belongs to the given project first — ActiveCollab's completion route is keyed on the task id alone. |
| `reopen_task` | Reopen a completed task. Same project check as `complete_task`. |
| `delete_task` | Move to trash (reversible via UI). |
| `post_task_comment` | Add a comment to a task; can attach uploaded files. |
| `set_task_labels` | Add/remove labels without clobbering the others (read-modify-write). |
| `upload_attachment` | Upload a local file, returns an upload `code` for `attach_uploaded_files`. |
| `log_time` | Create a time record on a project (and optionally a task). |

Attachment flow: `upload_attachment(file_path)` → returns `{code}` →
pass `attach_uploaded_files: ["<code>", …]` to `create_task`, `update_task`,
or `post_task_comment`. Attach codes promptly — ActiveCollab garbage-collects
unattached uploads.

### Labels

`labels` on `create_task` / `update_task` **replaces the entire label set** —
anything not listed is removed. To change individual labels safely, use
`set_task_labels`, which reads the current labels, applies your changes, and
writes the merged set back:

```
list_task_labels(query="progress")                       # confirm exact names
set_task_labels(project_id=5937, task_id=509819,
                add=["COMPLETED ON LOCAL"], remove=["IN PROGRESS"])
```

Removal matches case-insensitively. The tool reports `previous_labels`,
`labels`, `added`, and `removed`, and performs no write when nothing changes.
Workspaces commonly have hundreds of labels, so `list_task_labels` takes a
`query` substring filter and reports whether results were truncated.

### Mentions

Write `@[<user_id>]` inline in a task or comment `body`. The server expands
each token into ActiveCollab's canonical mention anchor, and AC notifies the
mentioned user:

```
post_task_comment(project_id=5937, task_id=509819,
                  body="@[407] can you review this?")
```

Resolve names to ids first with `find_user_by_name` or `list_users`. An
unknown id is a hard error rather than a silently-dropped mention.

Bodies are HTML in ActiveCollab, so plain text is converted automatically —
blank lines become `<p>`, single newlines `<br>`, and `- ` / `* ` lines become
bullet lists. Raw text is HTML-escaped first (no injection), and mention
tokens are expanded after escaping. Pass `body_is_html=true` to supply your
own markup untouched.

> Only `<a class="mention" href="…/users/<id>">` carries the user id through
> ActiveCollab's sanitizer — `<span class="mention">` variants have their id
> attributes stripped and therefore never notify anyone.

## Versioning

This project follows semver. Current release is **v1.20.0**.

`activecollab-mcp update` installs the latest release published to PyPI.
Pin to a specific version with `--to`:

```bash
activecollab-mcp update --to 1.20.0      # downgrade or hold at a version
activecollab-mcp update --check         # see what's available without installing
```

When installing for the first time via pipx with a pinned version:

```bash
pipx install 'activecollab-mcp==1.20.0'
```

Releases are cut with `scripts/release.sh` (tests → build → twine upload →
git tag).

The version is queryable at runtime via `activecollab-mcp version` and the
`/healthz` endpoint.

## Development

```bash
pip install -e ".[dev]"
pytest                # unit tests (none yet — placeholders)
ruff check src
activecollab-mcp run  # runs against your real ActiveCollab via .env
```

## License

MIT
