tines-cases-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@tines-cases-mcpshow me my open cases"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
In the first week I cleared a backlog of over 100 open cases in a few hours. Most were false positives. Some needed enrichment, some needed real work. The same pass turned up two things the queue had been hiding: a SIEM parser that needed fixing, and a missed attack on an endpoint belonging to a SaaS tool that had been locking accounts out at random for long enough that people had stopped asking why.
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.
Related MCP server: hermes-gpt
Getting started
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-mcpSubstitute 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:
{
"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=falsein 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
falseenables 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 |
| yes | — | Your tenant host. A bare domain or a full |
| yes | — | Tines API key, sent as a bearer token. |
| no |
| Read-only is the default. Set to exactly |
| no |
|
|
| no | unset | Restrict every tool to a single Tines team. |
| no |
|
|
| no |
| 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.
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.
Tools
Read
Tool | What it does |
| Fetch a single case by ID |
| Search/list cases by team, status, priority, tags, assignees, free text |
| Page through a case's comments |
| Who is currently assigned (call before an additive reassign) |
| Read a case's metadata key-value pairs |
| Cases linked to this one (links are bidirectional) |
Write
Tool | What it does |
| Open a new case |
| Patch name, description, priority, status, team, sub-status; replace or additively add/remove assignees and tags |
| Set status=closed, optionally posting a closing comment |
| Add text to the existing description without overwriting it |
| Post a comment to the case activity feed |
| Create/update metadata keys (leaves unmentioned keys alone) |
| Link duplicates into one case to triage; reports per-case success |
| Remove a link (both cases themselves untouched) |
| 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_IDto 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 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
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:
# .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-mcpThere 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).
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:
TINES_MCP_READ_ONLY=falsemust be set. Read-only outranks everything, and it is the default.TINES_MCP_ALLOW_DELETE=truemust be in the environment.confirm=Truemust be passed explicitly.reasonmust 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.confirm_case_namemust match the live case's actual name. The case is fetched and compared first, so a stale or fat-fingeredcase_idfails 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. 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, notid.Team is a nested object:
"team": {"id": …, "name": …}. There is no top-levelteam_idon a returned case, even thoughteam_idis what you send on create/update.tagsandassigneesare arrays of objects, not strings.statusandprioritycome back uppercase (OPEN,CRITICAL) but are sent lowercase (open,critical).list_casesreturns{"cases": [...], "meta": {...}}. Usemeta.countfor "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, notcase_id, and it must be an integer.Asymmetric response naming: the POST response returns
linked_case(singular object); the GET returnslinked_cases(plural array).Re-linking an existing pair is idempotent.
link_caseslinks one at a time, so a partial failure reports exactly which IDs failed. The documented/batchendpoint returns one aggregate result.
Other behavior worth knowing:
tag_namescreates a tag that doesn't exist yet, so a typo silently makes a new tenant-level tag.assignee_emailson create resolves real emails to Tines user IDs.A successful
DELETEreturns an empty body.Assignees have two update modes:
assignee_emailsreplaces the whole list, whileadd_assignee_emails/remove_assignee_emailsadjust 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, notupdate_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 lowerper_pagewhen 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
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):
.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.lockThe 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_casename 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/5xxresponses. Adding it safely means retrying only idempotent reads, since the API has no idempotency keys.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseAqualityBmaintenanceMCP server for TheHive that enables AI agents to manage cases, alerts, observables, and tasks.11
- AlicenseCqualityBmaintenanceMCP sidecar for Hermes Agent exposing operator tools (cron, skills, config, workspace) with tiered read-only/operator/owner modes and dry-run by default for safe local development.43MIT
- AlicenseAqualityAmaintenanceExposes 79 cybersecurity skills and 12 orchestrator agents over MCP, with a typed 11-field output contract, an enforced resolvable-evidence gate (no verdict without a resolvable source), and human-approval gating for every mutating action. Apache-2.0, stdlib-only.83Apache 2.0
- AlicenseAqualityDmaintenanceA hardened MCP server that exposes OliveTin actions as tools with built-in human-in-the-loop approval for destructive operations.181MIT
Related MCP Connectors
MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.
MCP server for managing Prisma Postgres.
ClickUp MCP — wraps the ClickUp REST API v2 (BYO API key)
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/nwhistler/tines-cases-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server