Metadata-Version: 2.5
Name: activecollab-mcp
Version: 1.19.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.
- **Writes need a human's yes** — every write tool except `set_task_labels` pauses and asks the user to confirm the exact change through the MCP client's own dialog (MCP elicitation) before anything reaches ActiveCollab. A decline is final and the agent is told so. Clients that cannot show dialogs have their writes refused rather than waved through; `ACTIVECOLLAB_WRITE_APPROVAL=trust` opts out. Every attempt, approved 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. So the server enforces it.
With the default `ACTIVECOLLAB_WRITE_APPROVAL=elicit`, a call to
`create_task`, `update_task`, `complete_task`, `reopen_task`, `delete_task`,
`post_task_comment`, `upload_attachment` or `log_time` 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.
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, cancel, or an unticked
   form 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 its writes
are refused with the same finality. That is deliberate: the alternative is
silently trusting whichever client happens to be connected. If you run such
a client on a workspace where unconfirmed writes are acceptable, set
`ACTIVECOLLAB_WRITE_APPROVAL=trust` and the tools behave as before, with the
"get approval first" wording back in their descriptions. Put it in the
launchd plist to survive `service install`, the same as
`ACTIVECOLLAB_DISABLED_TOOLS`.

`set_task_labels` is never gated; relabelling is cheap to undo and agents
do it constantly.

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\n  comment: ..."}
```

`decision` is one of `approved`, `declined`, `cancelled`, `no_elicitation`,
`elicitation_failed`, `trusted`, or `not_required` (`set_task_labels`).

Known caveat: some Claude Code builds auto-decline form elicitations in
particular session types (see anthropics/claude-code#62319). In an affected
session every guarded write is refused; the refusal message names the
`trust` opt-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/38 enabled  (unsaved)
Disabled tools are not offered to MCP clients at all.

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
 [ ] delete_task                  Delete (trash) a task.
 [ ] post_task_comment            Post a comment on a task.

↑/↓ or j/k move    space toggle    a all on    n all off    w writes off
s save and exit    q quit without saving
```

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.

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

```bash
activecollab-mcp tools list                              # what's on and off
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 reset                             # everything back on
```

Choices are saved to `~/.activecollab-mcp/tools.json` (mode 0600) as the set of
tools that are **off**. Storing it 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` and `tools reset` refuse to run without a terminal and ask for
confirmation. Turning a tool back on 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` stays 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_WRITE_APPROVAL` | `elicit` | `elicit`: confirm every guarded write through the client's dialog; `trust`: run writes as called. 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 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)

All of these except `set_task_labels` ask the user to confirm through the
client before running; 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.19.0**.

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

```bash
activecollab-mcp update --to 1.19.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.19.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
