Skip to main content
Glama
nwhistler

tines-cases-mcp

by nwhistler

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-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:

{
  "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.

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

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 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-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).

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. 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

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.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.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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

  • A
    license
    C
    quality
    B
    maintenance
    MCP 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.
    43
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Exposes 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.
    8
    3
    Apache 2.0
  • A
    license
    A
    quality
    D
    maintenance
    A hardened MCP server that exposes OliveTin actions as tools with built-in human-in-the-loop approval for destructive operations.
    18
    1
    MIT

View all related MCP servers

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)

View all MCP Connectors

Latest Blog Posts

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