An MCP (Model Context Protocol) server for GroupOffice groupware, built on FastMCP. It talks to GroupOffice's JMAP-style batch/RPC API and exposes Contacts, Calendars, Tasks, Notes, Projects, Comments, History, Users, Groups, and file attachments as MCP tools.
Defaults to read-only. Mutating tools (create/update/delete/upload) are
rejected before any API call unless you explicitly set GROUPOFFICE_READONLY=false.
- Contacts & Address Books - search, read, create, update, delete
- Calendars & Events - search, read, create, update, delete
- Tasks & Task Lists - search, read, create, update, delete
- Notes - search, read, create, update, delete
- Projects (Projects v3 module, optional) - search, read, create, update, delete
- Comments - search, read, create, update, delete
- History (audit log, read-only) - search
- Users & Groups (read-only - administration is out of scope)
- File attachments - upload/download as GroupOffice blobs
- MCP resources for browsable lists (address books, calendars, task lists, supported-entity registry)
- MCP prompts encoding a "read before write" workflow (daily briefing, contact lookup before creating a duplicate, conflict check before scheduling an event)
- Every tool carries MCP annotations (
readOnlyHint/destructiveHint/idempotentHint/openWorldHint) so clients can reason about risk
pip install -e .
# or, from PyPI:
pip install groupoffice-mcp-serverPublished on PyPI at pypi.org/project/groupoffice-mcp-server.
This project targets the fastmcp build packaged at
github.com/Vitexus/python3-fastmcp
(Debian python3-fastmcp). A generic PyPI fastmcp install may differ.
A Debian package (mcp-server-groupoffice, with a companion
mcprack-mcp-server-groupoffice package that registers it into a local
mcprack MCP catalog) is also
published with each GitHub release.
Copy .env.example to .env and fill in your instance's details, or set these
environment variables directly:
| Variable | Required | Default | Description |
|---|---|---|---|
GROUPOFFICE_URL |
yes | - | Base URL of your GroupOffice instance, e.g. https://groupoffice.example.com |
GROUPOFFICE_API_TOKEN |
yes | - | Bearer token from System Settings -> API Keys (requires the "API key generator" community module) |
GROUPOFFICE_VERIFY_SSL |
no | true |
Verify TLS certificates |
GROUPOFFICE_TIMEOUT |
no | 30 |
HTTP request timeout, seconds |
GROUPOFFICE_MAX_RETRIES |
no | 3 |
Connection-level retries on transient network errors |
GROUPOFFICE_DEBUG |
no | false |
Enable debug logging |
GROUPOFFICE_READONLY |
no | true |
When true, all mutating tools are rejected. Set false to allow writes. |
There is deliberately no default for GROUPOFFICE_URL/GROUPOFFICE_API_TOKEN -
a bundled demo/default host would be a security footgun, so the server refuses
to start without them.
- In GroupOffice, go to System Settings -> Modules, install the community "API key generator" module if it isn't installed yet.
- Go to System Settings -> API Keys -> Add key.
- Give it a name and pick the user it should act as, then save.
- Open the key's menu (⋮) -> View access token (or Copy token to clipboard) to get the bearer token.
Run directly:
groupoffice-mcp-serverOr add it to an MCP client (e.g. Claude Desktop) config:
{
"mcpServers": {
"groupoffice": {
"command": "groupoffice-mcp-server",
"env": {
"GROUPOFFICE_URL": "https://groupoffice.example.com",
"GROUPOFFICE_API_TOKEN": "your-api-token"
}
}
}
}A container image is published to Docker Hub at
docker.io/vitexsoftware/mcp-server-groupoffice,
built from the repo's Containerfile (a two-stage uv-based Python build on
python:3.12-slim). Run it directly - it speaks MCP over stdio, so it must
be launched by an MCP client, not run detached:
podman run --rm -i \
-e GROUPOFFICE_URL=https://groupoffice.example.com \
-e GROUPOFFICE_API_TOKEN=your-api-token \
docker.io/vitexsoftware/mcp-server-groupoffice:0.2.0(or docker run - the image works with either).
A Helm chart lives in helm/. Since the server is stdio-only (no
HTTP port to expose as a Service), the chart deploys a single always-on pod
that an MCP client reaches via kubectl exec, rather than a Service +
Ingress:
helm upgrade --install groupoffice-mcp helm/ \
--set environment.GROUPOFFICE_URL=https://groupoffice.example.com \
--set secrets.GROUPOFFICE_API_TOKEN=your-api-tokenNever put a real token in values.yaml or --set on the command line for
anything beyond ad-hoc testing - pass it via -f with a values file kept out
of version control, or wire the chart's Secret up to your cluster's secret
manager (sealed-secrets, External Secrets, Vault, etc.). See
helm/templates/NOTES.txt (printed after install) for how to reach the pod
once it's running.
Every entity follows the same query_*/get_*/create_*/update_*/delete_*
shape. filter accepts a raw GroupOffice JMAP filter dict for anything beyond
the named convenience parameters.
| Entity | Tools | Notes |
|---|---|---|
| AddressBook | list_addressbooks |
read-only |
| Contact | query_contacts, get_contact, create_contact, update_contact, delete_contact |
addressbook_id filters by addressBookId |
| Calendar | list_calendars |
read-only |
| CalendarEvent | query_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event |
no end property - events use start + duration (ISO 8601, e.g. PT1H) |
| TaskList | list_tasklists |
read-only |
| Task | query_tasks, get_task, create_task, update_task, delete_task |
tasklist_id filters by tasklistId; completion is percentComplete (0-100), not a boolean |
| Note | query_notes, get_note, create_note, update_note, delete_note |
|
| Project3 | query_projects, get_project, create_project, update_project, delete_project |
optional module - errors on instances where it isn't installed |
| Comment | query_comments, get_comment, create_comment, update_comment, delete_comment |
filter by entity (friendly name, e.g. "Contact") + entity_id |
| LogEntry (History) | query_history |
read-only audit log; same entity/entity_id filter as Comment |
| User | query_users, get_user |
read-only - user administration is out of scope |
| Group | query_groups, get_group |
read-only - group administration is out of scope |
| Blob | upload_file, download_file |
upload returns a blob_id to attach via another entity's data |
Instance (multi-tenant administration) is deliberately not exposed - it is
high-privilege and out of scope for this server.
groupoffice://addressbooks,groupoffice://calendars,groupoffice://tasklists- browsable equivalents of the
list_*tools
- browsable equivalents of the
groupoffice://entities- static registry of entities/operations this server exposes (not introspected from/api/doc.php, which is per-instance HTML, not a stable machine-readable contract)
daily_briefing(date=None)- today's calendar events + open taskscontact_lookup(query)- search before ever suggestingcreate_contact, to avoid duplicatesschedule_event_safely(title, start, end, calendar_id=None)- checks for conflicts before suggestingcreate_calendar_event
- Read-only by default.
GROUPOFFICE_READONLY=falseis required to allow any create/update/delete/upload call; this is enforced centrally by a FastMCP middleware hook (ReadOnlyGuardMiddleware) that runs before every tool call, keyed off each tool'sreadOnlyHintannotation - a new tool can't accidentally skip the gate. - The bearer token is passed via environment variable only; it is never logged or written to disk by this server.
User/Groupadministration and theInstance(multi-tenant admin) entity are not exposed by this server at all, regardless of read-only mode, since they carry a much larger blast radius than typical groupware data.
- Covers a curated subset of GroupOffice's 60+ entities, not the full object
model. Adding another entity is mechanical (new
@mcp.toolfunctions inserver.pycalling the existing genericclient.get/query/set) - no transport changes needed. - GroupOffice publishes no OpenAPI/Swagger spec; this server was built and
verified against a live instance's actual (undocumented in places) query
filter behavior. Filter/property names can vary by GroupOffice version -
check your instance's
/api/doc.phpif a named convenience filter (addressbook_id,calendar_id,tasklist_id,entity/entity_id) doesn't behave as expected; the rawfilterdict parameter always works as an escape hatch. download_filereturns base64-encoded content, which inflates size by ~33% - fine for small attachments, not recommended for large files.- Auth is bearer-token-only, matching GroupOffice's documented API; there is no OAuth2 flow (GroupOffice's own "OAuth2 Client" feature is for GroupOffice acting as a client to other services, not for authenticating third parties against GroupOffice itself).
- The Kubernetes Helm chart runs the server as a single always-on pod reached
via
kubectl exec, since MCP-over-stdio has no port to put behind a Service - it is not a horizontally-scaled deployment model.
pip install -e ".[dev]"
pytest tests/ -vTests run entirely offline against a mocked GroupOfficeClient (via
httpx.MockTransport for client-layer tests, and dependency injection for
tool-layer tests) - no live GroupOffice instance is required.
Run the server directly to confirm it starts and connects:
export GROUPOFFICE_URL=https://your-instance.example.com
export GROUPOFFICE_API_TOKEN=your-token
python -m groupoffice_mcp_server.server
# or, once installed: groupoffice-mcp-serverTo call individual tools against a live instance without a full MCP client,
use FastMCP's in-memory Client (the same pattern the test suite uses):
import asyncio
from fastmcp import Client
from groupoffice_mcp_server.config import GroupOfficeConfig
from groupoffice_mcp_server.server import create_server
async def main():
mcp = create_server(GroupOfficeConfig.from_env())
async with Client(mcp) as c:
result = await c.call_tool("query_contacts", {"limit": 5})
print(result.data)
# should be refused - read-only mode is on by default
try:
await c.call_tool("create_contact", {"data": {"firstName": "Test"}})
except Exception as e:
print("blocked as expected:", e)
asyncio.run(main())Only run a mutating call (GROUPOFFICE_READONLY=false) against a
disposable/test instance, never production data.
MIT