tines-cases-mcp
by nwhistler
README.md
# Tines Cases MCP Server
A small, container-based MCP server that exposes Tines' **Cases v2 API**
(create, read, update, close, comment) as tools for MCP clients such as Claude
Code and Claude Desktop. Read-only until you turn writes on, and it runs in a
container, so the only thing it needs from the host is podman or docker.
## Why this exists
Tines' own first-party MCP server at `https://<tenant>.tines.com/mcp` is built
for *authoring stories*. Cases have a full v2 API behind them but no MCP tools,
so the part of Tines I spend my day in, the case queue, was the part I could
not reach from an MCP client.
What makes filling that gap worth the effort is what sits next to it. I run MCP
servers for other tools in my stack alongside this one, so a case can be worked
without leaving the session: pull the case, check the endpoint's detection
history, check the data-loss side, decide, write the outcome back to the case.
No tab-switching, no copying IDs between consoles.
The time saved was the point going in. What I came away with was a much better
sense of my own alerts, which is what happens when you triage a hundred of them
in one sitting with every tool in reach.
## Getting started
```bash
cp .env.example .env # then fill in your tenant and API key
chmod 600 .env
podman build -t tines-cases-mcp .
podman run -i --rm --env-file .env tines-cases-mcp
```
Substitute `docker` for `podman` if that's what you have. It will sit there
waiting on stdin, which is what a working stdio server does. Ctrl-C out and add
it to your MCP client config:
```json
{
"mcpServers": {
"tines-cases": {
"command": "podman",
"args": [
"run", "-i", "--rm",
"--env-file", "/absolute/path/to/.env",
"tines-cases-mcp"
]
}
}
}
```
MCP clients do not inherit your shell `PATH`, so use absolute paths for
`podman`/`docker` if the client can't find them. `--env-file` needs an absolute
path too, since the client runs from its own working directory.
That gets you a **read-only** server: every tool can read, and none can write.
When you're ready to let it write, add `TINES_MCP_READ_ONLY=false` to your
`.env` (or `"-e", "TINES_MCP_READ_ONLY=false"` to the `args` above) and restart
the client.
> **The server starts read-only.** Every write tool (create, update, close,
> comment, metadata, link, unlink, delete) refuses until you set
> `TINES_MCP_READ_ONLY=false` in the server's environment. The server reads case
> content that often originates from external alerts and hands it to a model. On
> a fresh install, that model should not also be able to modify production cases.
>
> Only an exact `false` enables writes. An empty value, a typo, or anything
> unrecognized leaves the server read-only, which is the fail-safe direction.
>
> Treat your MCP client's tool allowlist as defense in depth. A different client
> with the same credentials is not bound by it.
### Configuration
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `TINES_TENANT_DOMAIN` | yes | — | Your tenant host. A bare domain or a full `https://` URL both work. |
| `TINES_API_KEY` | yes | — | Tines API key, sent as a bearer token. |
| `TINES_MCP_READ_ONLY` | no | **`true`** | Read-only is the default. Set to exactly `false` to allow writes. |
| `TINES_MCP_ALLOW_DELETE` | no | `false` | `true` is required before `delete_case` will run at all. |
| `TINES_MCP_TEAM_ID` | no | unset | Restrict every tool to a single Tines team. |
| `TINES_MCP_REDACT_ERRORS` | no | `false` | `true` keeps upstream error bodies out of tool responses, logging them instead. |
| `TINES_ALLOWED_HOST_SUFFIXES` | no | `.tines.com` | Comma-separated host suffixes the tenant domain may use. Change this for self-hosted Tines. |
Only the first two are needed to start. The rest are covered in
[Advanced configuration](#advanced-configuration).
> Tines assigns a **generated** tenant subdomain (something like
> `generated-name-1234.tines.com`). It is not your company name.
### Providing the API key
The server reads plain environment variables and does not care how they get
there. A plain `.env` passed with `--env-file` is the supported path and is
what the rest of this README assumes.
**Write the file unquoted.** `--env-file` does no quote processing in either
podman or docker. Everything after the `=` is the value, so `TINES_API_KEY="abc"`
is read as the seven characters `"abc"`. A quoted tenant fails host validation
with a puzzling "not a valid hostname"; a quoted key just 401s. Spaces around
the `=`, a trailing `# comment`, and trailing whitespace all end up inside the
value too. Copy the shape of `.env.example`.
Then `chmod 600 .env` and keep it out of version control. The included
`.gitignore` covers `.env` and `.env.*`, and unignores only `.env.example`.
Your MCP client may also have its own secret handling. That works too, since
all the server needs is the two variables in its environment. To keep the key
off disk entirely, see
[Keeping the key out of a file](#keeping-the-key-out-of-a-file).
## Tools
### Read
| Tool | What it does |
|---|---|
| `get_case` | Fetch a single case by ID |
| `list_cases` | Search/list cases by team, status, priority, tags, assignees, free text |
| `list_case_comments` | Page through a case's comments |
| `list_case_assignees` | Who is currently assigned (call before an additive reassign) |
| `get_case_metadata` | Read a case's metadata key-value pairs |
| `list_linked_cases` | Cases linked to this one (links are bidirectional) |
### Write
| Tool | What it does |
|---|---|
| `create_case` | Open a new case |
| `update_case` | Patch name, description, priority, status, team, sub-status; replace *or* additively add/remove assignees and tags |
| `close_case` | Set status=closed, optionally posting a closing comment |
| `append_case_description` | Add text to the existing description without overwriting it |
| `add_case_comment` | Post a comment to the case activity feed |
| `set_case_metadata` | Create/update metadata keys (leaves unmentioned keys alone) |
| `link_cases` | Link duplicates into one case to triage; reports per-case success |
| `unlink_case` | Remove a link (both cases themselves untouched) |
| `delete_case` | Permanently delete a case — **disabled by default**, see below |
Deliberately out of scope, to keep the tool surface small: actions, activities,
blocks, bulk, fields/case_inputs, case_statuses, case_templates, files, notes,
pdf, records, subscribers, tasks, webhooks.
## Advanced configuration
### Choosing a key type
Tines actions taken through this server are attributed to the key's owner, and
this shows up in the case audit trail (`resolved_by`, activity entries).
- A **personal key** attributes actions to you. Reasonable when a human is
driving the tools interactively and stays responsible for the outcome, since
the audit trail then names the accountable person. The trade-off is blast
radius: a personal key can reach everything its owner can see.
- A **Service or Team key** is the right choice for anything unattended
(scheduled agents, automated harnesses) and for limiting blast radius. Pair
it with `TINES_MCP_TEAM_ID` to bound the server to one team.
Switching is a key reissue plus an env change, with no code change.
### Restricting to one team
Set `TINES_MCP_TEAM_ID` to a numeric Tines team ID and every tool is bound to
that team: creates are pinned to it, cross-team moves are refused, and reads
withhold anything outside it. This costs one extra `GET` per call on tools
whose response doesn't already carry the team. With the variable unset there is
no extra request and no restriction. See
[Team scoping](#safety-model) for how the enforcement works.
### Enabling deletes
`delete_case` needs `TINES_MCP_ALLOW_DELETE=true` on top of writes being on,
and then three more things at call time. See
[`delete_case` is the nuclear option](#delete_case-is-the-nuclear-option)
before you enable it.
### Redacting upstream errors
By default, upstream error bodies are truncated to 500 characters and returned
to the caller, which is the same caller already authorized to read case data.
If your deployment routes tool errors somewhere with a different trust boundary
(shared logs, telemetry, a third-party client), set
`TINES_MCP_REDACT_ERRORS=true`. The body then goes to the server log and the
tool response carries only the method, path, and status.
### Self-hosted Tines
The tenant host must end in one of `TINES_ALLOWED_HOST_SUFFIXES`, which
defaults to `.tines.com`. A self-hosted deployment sets that variable to its
own suffix, which makes pointing the server somewhere other than Tines' cloud
an explicit opt-in. The value is a comma-separated list.
### Keeping the key out of a file
Most password managers and secret stores can render what amounts to a *virtual*
`.env`: the file holds references, and a launcher command resolves them into
the process environment at run time. The shape is the same whichever tool you
use; only the reference syntax and the launcher's name differ:
```bash
# .env holds references that resolve at run time
<your resolver> --env-file=.env -- podman run -i --rm \
-e TINES_TENANT_DOMAIN -e TINES_API_KEY tines-cases-mcp
```
There are two things to get right in that form. Use the bare `-e VAR` form,
which forwards what the resolver already put in the environment; the
`-e VAR=value` form restates the secret on the command line, where it lands in
shell history and the process list. And pass `--env-file` to the resolver only.
If the container gets it too, it reads the unresolved reference strings straight
off disk and the resolution accomplishes nothing.
Setting this up is yours to own. How your vault is organized, and whether the
resolver is available to your MCP client at launch, are things this project
can't see. If the client can't run your resolver, the plain `.env` path is the
fallback. Keep the tenant and the key on the same vault item either way, so
they stay in sync.
## Safety model
Assume anything an MCP server returns can influence the model calling it. Case
descriptions and comments often originate from external alerts, so treat them as
untrusted input that reaches a model with write capability. The controls below
exist for that reason.
**Identifier validation.** Every ID that reaches a URL path is validated as a
positive decimal integer and re-emitted canonically. This is load-bearing: httpx
resolves dot segments when merging a relative path onto `base_url`, so an
unvalidated `case_id` of `../../../stories` would silently retarget the request
at a completely different endpoint, anything the API key can reach. Non-ASCII
digit forms are rejected too, since `"١٢٣".isdigit()` is true and `int()`
converts it to `123`.
**Read-only by default.** All nine write tools refuse unless
`TINES_MCP_READ_ONLY=false` is set explicitly. Worth staying this way for a few
days before enabling writes, so you can see what the model actually reaches for
before it can change anything.
**Team scoping.** With `TINES_MCP_TEAM_ID` set, no tool touches or returns a
case outside that team. Create is pinned to it, cross-team moves are refused,
and every case-scoped tool verifies the case's team before acting, including
both sides of `link_cases` and `unlink_case`, since links are bidirectional.
That verification costs one extra `GET` per call for tools whose response
doesn't already carry the team. With the variable unset there is no extra
request and no restriction.
For `list_cases`, the `team_id` request filter is treated as an optimization,
and the scope is enforced again on the response. Each returned case's `team.id`
is checked against the scope and out-of-team rows are withheld, including in
`verbose` mode. A case whose team can't be determined is withheld. When rows are
withheld you get a `_scope_note` explaining that `meta.count` is Tines' own
total and may exceed what you see. The redundancy has already earned itself
here: this codebase shipped a filter that Tines silently ignored (see
[the `filters` note below](#tines-api-notes)).
**Tenant host validation.** `TINES_TENANT_DOMAIN` becomes the base URL your
bearer token is sent to, so it is validated before use: userinfo is rejected
outright (`tenant.tines.com@elsewhere.example` is a valid URL whose host is
`elsewhere.example`), as are IP literals, bad ports, and malformed DNS labels.
The host must also end in one of `TINES_ALLOWED_HOST_SUFFIXES`.
**Pagination bounds.** `page >= 1` and `1 <= per_page <= 500`. These responses
land in a model's context window, so an unbounded page size is a
context-exhaustion hazard as much as an API one.
**Fabricated-value guard.** The recurring failure mode for a case tool is a
model inventing an assignee email. Tool docstrings instruct the model to ask,
and `_guard_no_placeholder_emails()` is a runtime backstop rejecting common
placeholders on every write path. It matches whole local parts and whole
domains. A substring check would reject real addresses like
`poweruser@yourcorp.com`, and the caller can't tell a policy refusal from a
typo. The guard covers write paths only, so searching for a real user through
the `list_cases` filter stays unblocked.
**Enum validation.** `status`, `priority`, and `order` are checked against their
documented values before the request goes out, so a typo fails with a message
naming the alternatives, ahead of any 422 from the API. The check covers fields
whose allowed values are actually documented. Length caps for names, comments,
or descriptions would reject valid input on the strength of a guess.
**Error handling.** Upstream error bodies are truncated to 500 characters and
wrapped with the method and path. Non-JSON and unexpected-shape responses raise
a controlled error. Set `TINES_MCP_REDACT_ERRORS=true` to keep bodies out of
tool responses entirely.
**Partial success is reported.** `close_case` sets status before commenting, so
a failed close can't leave an orphan closing comment. If the close succeeds but
the comment fails, you get the closed case plus a `_partial_failure` explaining
that the rationale wasn't recorded. An exception there would imply nothing
happened, inviting a retry that re-closes a closed case. `link_cases` reports
per-case success and keeps confirmed links even if the final read-back fails.
### `delete_case` is the nuclear option
Deleting destroys the case *and its entire audit trail*: comments, activity log,
closure rationale. A closed case with reasoning is evidence that triage
happened; a deleted one looks like it never existed. Deletion is reserved for a
record that should never have existed at all, such as a malformed case, an
accidental duplicate, or a test artifact. **Finishing a case that was
legitimately worked is `close_case`.**
Five delete-specific gates, each covered by a test. Team scoping applies on top
of these when it's configured:
1. `TINES_MCP_READ_ONLY=false` must be set. Read-only outranks everything, and
it is the default.
2. `TINES_MCP_ALLOW_DELETE=true` must be in the environment.
3. `confirm=True` must be passed explicitly.
4. `reason` must be substantive: at least 15 characters, and bare
"cleanup"/"test"/"n/a" are rejected. It is written to stderr *before* the
delete, because that log line becomes the only surviving record once Tines'
own audit trail is gone.
5. `confirm_case_name` must match the live case's actual name. The case is
fetched and compared first, so a **stale or fat-fingered `case_id` fails
here, before it can destroy a different, real case.** Matching is
whitespace- and case-insensitive; a nonexistent ID 404s before anything
destructive is attempted.
Gate 5 compares, then acts. A case renamed between the fetch and the delete
would still be deleted, and Tines exposes no conditional-delete primitive to
close that window.
## Troubleshooting
**The container starts and nothing happens.** That is a working stdio server
waiting on stdin. It only speaks when a client speaks to it. Ctrl-C and wire it
into your MCP client.
**The client reports it can't start the server, or "command not found".** MCP
clients do not inherit your shell `PATH`. Use the absolute path to the
container runtime (`which podman`) as `command`, and an absolute path for
`--env-file`.
**`FATAL: TINES_TENANT_DOMAIN and TINES_API_KEY must be set in the
environment.`** The variables aren't reaching the container. Check that
`--env-file` points at an absolute path that exists. If you're using a secret
resolver, check you haven't passed `--env-file` to both the resolver and the
container.
**`TINES_TENANT_DOMAIN is not a valid hostname: '"acme-1234.tines.com"'`** The
quotes are inside the value. `--env-file` does no quote processing, so write
the file unquoted. The giveaway is in the error itself: the value is printed
with its own quotes still attached.
**Every call 401s.** Same cause, different variable. A quoted `TINES_API_KEY`,
a trailing `# comment`, spaces around the `=`, or trailing whitespace all end
up inside the key. Failing that, the key was revoked or belongs to a different
tenant.
**`TINES_TENANT_DOMAIN must not contain '@'.`** The value carries userinfo,
usually from pasting a full URL that had credentials in it. Use the bare host
or a plain `https://host` form.
**`TINES_TENANT_DOMAIN 'x' does not end with any of .tines.com`** Expected for
self-hosted Tines. Set `TINES_ALLOWED_HOST_SUFFIXES` to your own suffix.
**`This Tines Cases MCP server is read-only.`** Expected on a fresh install.
Set `TINES_MCP_READ_ONLY=false` in the server's environment and restart the
client. Only an exact `false` counts, and the model can't turn it on for you.
**`Case deletion is disabled on this server.`** Set
`TINES_MCP_ALLOW_DELETE=true`, then supply `confirm`, `reason`, and
`confirm_case_name`. If the case was legitimately worked, use `close_case`.
**`Case 123 belongs to team 4, but this server is restricted to team 7.`**
`TINES_MCP_TEAM_ID` is set and doing its job. Unset it, or use a server bound
to the right team.
**`list_cases` returns fewer rows than `meta.count` says exist.** Team scoping
is withholding out-of-team rows, and `meta.count` is Tines' own unfiltered
total. Look for the `_scope_note` in the response.
**A tool refused an email as a placeholder.** The fabricated-value guard caught
a made-up address. Ask for the real one. The guard matches whole local parts
and whole domains, so a real address that merely contains "test" or "example"
as a substring still passes.
**Responses are eating the context window.** See
[Response size](#response-size). Lower `per_page`, avoid `verbose=True`, and
use `get_case(case_id, include_activities=False)` when you only need current
state.
**You changed `.env` and nothing changed.** The MCP client launches the
container once and keeps it. Restart the client.
## Tines API notes
Things the API does that you might not guess, found by probing a live tenant.
These are the reason this wrapper exists in the shape it does.
**Field names on a returned case:**
- The identifier is `case_id`, not `id`.
- Team is a nested object: `"team": {"id": …, "name": …}`. There is no
top-level `team_id` on a *returned* case, even though `team_id` is what you
*send* on create/update.
- `tags` and `assignees` are arrays of objects, not strings.
- `status` and `priority` come back uppercase (`OPEN`, `CRITICAL`) but are sent
lowercase (`open`, `critical`).
- `list_cases` returns `{"cases": [...], "meta": {...}}`. Use `meta.count` for
"how many match", not the length of the page.
**`list_cases` filters go in the request body.** Tines wants `filters` as a JSON
object in the *body* of the GET. Passing it as a query param makes httpx
serialize the dict with Python `repr()` (single quotes), which Tines silently
ignores, so every "filtered" query quietly returns unfiltered results.
**Linked cases** (the v2 docs page for this sub-resource 404s at time of
writing):
- Links are **bidirectional**. `POST /cases/A/linked_cases {"id": B}` makes A
visible from B as well. The data carries no parent/child direction, so a
"master ticket" is a convention about which case you triage.
- The body key is `id`, not `case_id`, and it must be an integer.
- Asymmetric response naming: the POST response returns `linked_case`
(singular object); the GET returns `linked_cases` (plural array).
- Re-linking an existing pair is idempotent.
- `link_cases` links one at a time, so a partial failure reports exactly which
IDs failed. The documented `/batch` endpoint returns one aggregate result.
**Other behavior worth knowing:**
- `tag_names` **creates** a tag that doesn't exist yet, so a typo silently makes
a new tenant-level tag.
- `assignee_emails` on create resolves real emails to Tines user IDs.
- A successful `DELETE` returns an empty body.
- Assignees have two update modes: `assignee_emails` **replaces** the whole
list, while `add_assignee_emails`/`remove_assignee_emails` adjust it in place.
Passing both forms in one call is rejected. Tags are additive-only, with no
whole-list tag replace.
- Metadata is a separate sub-resource. Use `set_case_metadata`, not
`update_case`.
## Response size
Tines embeds each case's **entire activity log**, plus blocks, records, fields,
and per-sub-resource pagination metadata, inside every case object of a *list*
response. At `per_page=25` that measured roughly 84k tokens on a real tenant,
enough to swamp a caller's context in a single call.
So `list_cases` projects each case down to what you need to triage or pick a
row: id, name, status, sub-status, priority, team, tags, assignees, metadata,
timestamps, url, a 600-character description preview, and counts for
activities/records/linked cases. That measured about an 11x reduction.
- Need one case in full, activity log included? `get_case(case_id)`.
- Need the raw list payload anyway? `list_cases(verbose=True)`, and lower
`per_page` when you do.
- Only need current state, not history?
`get_case(case_id, include_activities=False)` drops roughly three quarters of
the payload.
This only shows up when driving the server through a real MCP client. A test
harness that prints selected fields hides it completely.
## Development
```bash
python3 -m venv .venv
.venv/bin/pip install --require-hashes -r requirements-dev.lock
.venv/bin/python -m pytest tests/
```
The full check set, as run in CI (`.github/workflows/ci.yml`):
```bash
.venv/bin/python -m pytest tests/ && .venv/bin/ruff check . && .venv/bin/pylint server.py tests/ && .venv/bin/mypy server.py && .venv/bin/bandit -q -r server.py && .venv/bin/pip-audit -r requirements.lock
```
The suite runs against `httpx.MockTransport` and never touches the network. It
covers every guard described above: ID rejection, pagination bounds, the
placeholder guard, read-only mode, all five delete gates, team scoping on both
reads and writes, tenant host validation, the filters-in-body quirk, and error
handling. **If you add a tool, add its guard tests alongside it.** These guards
are the reason the server is safe to point at a real tenant.
Runtime and dev dependencies are hash-pinned in `requirements.lock` and
`requirements-dev.lock`, and the base image is pinned by digest; regeneration
commands are in the `Dockerfile`.
### Known limitations
- **Scope and delete checks are not atomic.** Team verification and the
`delete_case` name confirmation each read the case, then act. A case moved or
renamed in between would slip through. Tines exposes no conditional-request or
version primitive to close that window, so this is a documented gap.
- **No retry or backoff** for transient `429`/`5xx` responses. Adding it safely
means retrying only idempotent reads, since the API has no idempotency keys.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues