Support per-user identity via request headers

Add multi-user auth: _config reads X-Freshservice-Domain/X-Freshservice-Key
from the per-request headers (via the SDK request_ctx, set inside the tool's
task) and prefers them over the container env. One shared server can now act as
each caller's own Freshservice identity — actions attributed to them, their
permissions, their rate limit — while the env vars remain a single-user
fallback. Verified end-to-end that concurrent clients don't cross-wire and that
a client with no creds errors instead of falling back silently.

Docs/.env.example updated with the multi-user client config and the guidance to
omit env FS creds on a shared deployment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 08:23:52 -06:00
parent ff627b5620
commit 4f9a862c25
3 changed files with 99 additions and 10 deletions

View File

@@ -1,4 +1,11 @@
FRESHSERVICE_DOMAIN=pesco # Shared bearer token every client must send to reach the server (the "gate").
FRESHSERVICE_API_KEY=your_freshservice_api_key # Generate: openssl rand -hex 32
# Shared bearer token the Claude Desktop client must send. Generate: openssl rand -hex 32
MCP_AUTH_TOKEN=replace_with_a_long_random_token MCP_AUTH_TOKEN=replace_with_a_long_random_token
# --- Single-user only -------------------------------------------------------
# Uncomment to bake ONE identity into the server. Every client then acts as
# this Freshservice user. For a shared/multi-user server, LEAVE THESE OUT and
# have each client send its own X-Freshservice-Domain / X-Freshservice-Key
# headers instead (see README → "Multiple users").
# FRESHSERVICE_DOMAIN=pesco
# FRESHSERVICE_API_KEY=your_freshservice_api_key

View File

@@ -56,6 +56,50 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` under
Restart Claude Desktop. The Freshservice tools then appear in chat and Cowork. Restart Claude Desktop. The Freshservice tools then appear in chat and Cowork.
## Multiple users
The server can act as **each caller's own Freshservice identity** instead of a
single shared one. Each client sends its own credentials as headers on every
request; the server uses them per-call (they take precedence over the env
vars). Actions are then attributed to each person, with their own permissions
and rate limits.
To run the server multi-user:
1. **Omit `FRESHSERVICE_DOMAIN` / `FRESHSERVICE_API_KEY` from `.env`** (keep only
`MCP_AUTH_TOKEN`). With no env fallback, a client that forgets its headers
gets a clean "Missing credentials" error rather than silently acting as
whoever owns the env key.
2. Each user adds the extra headers to **their own** `claude_desktop_config.json`,
with **their own** Freshservice API key (Profile settings → API key):
```json
"freshservice": {
"command": "/opt/homebrew/bin/npx",
"args": [
"-y", "mcp-remote",
"http://192.168.101.12:3839/mcp",
"--allow-http",
"--header", "Authorization:${MCP_AUTH_HEADER}",
"--header", "X-Freshservice-Domain:${FS_DOMAIN}",
"--header", "X-Freshservice-Key:${FS_KEY}"
],
"env": {
"MCP_AUTH_HEADER": "Bearer <shared MCP_AUTH_TOKEN>",
"FS_DOMAIN": "pesco.freshservice.com",
"FS_KEY": "<that user's own Freshservice API key>"
}
}
```
`MCP_AUTH_TOKEN` is the same shared value for everyone (it only gates *reaching*
the server); `FS_KEY` is each person's own identity. The key stays on the
user's machine and travels per-request — the server never stores it.
> Note: the LAN transport is plain HTTP, so these headers travel unencrypted.
> Fine on a trusted network; put a TLS-terminating reverse proxy in front if
> this ever leaves it.
## Tools ## Tools
Tickets (`list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, Tickets (`list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`,

View File

@@ -5,11 +5,22 @@ Runs as a container on the LAN box and is reached from Claude Desktop / Cowork
via mcp-remote, exactly like the sibling sharepoint-lists server. Credentials via mcp-remote, exactly like the sibling sharepoint-lists server. Credentials
live here (in the container), outside the Cowork VM sandbox. live here (in the container), outside the Cowork VM sandbox.
Identity / credentials, in precedence order:
1. Per-request headers (multi-user): each client sends its OWN
X-Freshservice-Domain and X-Freshservice-Key on every call, so the
shared server acts as that caller's identity and actions are attributed
to them. Use this when more than one person shares the server.
2. Env vars (single-user): FRESHSERVICE_DOMAIN / FRESHSERVICE_API_KEY.
A fallback used only when the headers are absent. OMIT these on a
multi-user deployment so a client that forgets its headers errors out
rather than silently acting as whoever owns the env key.
Env: Env:
FRESHSERVICE_DOMAIN "acme" or "acme.freshservice.com" FRESHSERVICE_DOMAIN "acme" or "acme.freshservice.com" (single-user only)
FRESHSERVICE_API_KEY profile API key FRESHSERVICE_API_KEY profile API key (single-user only)
MCP_AUTH_TOKEN shared bearer token the client must send (optional but MCP_AUTH_TOKEN shared bearer token every client must send to reach
strongly recommended; if unset, auth is open) the server (gate); separate from per-user identity.
Strongly recommended; if unset, the server is open.
MCP_TRANSPORT "http" (default here) or "stdio" MCP_TRANSPORT "http" (default here) or "stdio"
HOST / PORT bind address (default 0.0.0.0:3838) HOST / PORT bind address (default 0.0.0.0:3838)
@@ -28,6 +39,7 @@ import urllib.request
from typing import Any from typing import Any
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel.server import request_ctx
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
# The streamable-HTTP transport enables DNS-rebinding protection by default, # The streamable-HTTP transport enables DNS-rebinding protection by default,
@@ -45,12 +57,38 @@ TICKET_STATUS = {2: "Open", 3: "Pending", 4: "Resolved", 5: "Closed"}
TICKET_PRIORITY = {1: "Low", 2: "Medium", 3: "High", 4: "Urgent"} TICKET_PRIORITY = {1: "Low", 2: "Medium", 3: "High", 4: "Urgent"}
def _header_creds() -> tuple[str, str]:
"""Per-request Freshservice creds from headers the client sends on every
call — X-Freshservice-Domain / X-Freshservice-Key — so one shared server
acts as each caller's own identity. Returns ("", "") when unavailable
(stdio transport, or headers absent). Read via the SDK's request_ctx, which
is set inside the tool's own task, so it reflects THIS call's request."""
try:
rc = request_ctx.get()
except LookupError:
return "", ""
req = getattr(rc, "request", None)
if req is None:
return "", ""
h = req.headers
return (h.get("x-freshservice-domain", "").strip(),
h.get("x-freshservice-key", "").strip())
def _config() -> tuple[str, str]: def _config() -> tuple[str, str]:
domain = os.environ.get("FRESHSERVICE_DOMAIN", "").strip() # Per-request header creds take precedence over the container env, so a
api_key = os.environ.get("FRESHSERVICE_API_KEY", "").strip() # shared server acts as each caller's identity. Env is the single-user
# fallback — OMIT FRESHSERVICE_* from a multi-user deployment so a client
# that forgets its headers errors out instead of silently acting as whoever
# owns the env key.
h_domain, h_key = _header_creds()
domain = (h_domain or os.environ.get("FRESHSERVICE_DOMAIN", "")).strip()
api_key = (h_key or os.environ.get("FRESHSERVICE_API_KEY", "")).strip()
if not domain or not api_key: if not domain or not api_key:
raise RuntimeError( raise RuntimeError(
"Missing credentials. Set FRESHSERVICE_DOMAIN and FRESHSERVICE_API_KEY." "Missing credentials. Send X-Freshservice-Domain and "
"X-Freshservice-Key headers (per-user), or set FRESHSERVICE_DOMAIN "
"and FRESHSERVICE_API_KEY in the environment (single-user)."
) )
if "." not in domain: if "." not in domain:
domain = f"{domain}.freshservice.com" domain = f"{domain}.freshservice.com"