forked from spencerm/freshservice-claude
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:
@@ -1,4 +1,11 @@
|
||||
FRESHSERVICE_DOMAIN=pesco
|
||||
FRESHSERVICE_API_KEY=your_freshservice_api_key
|
||||
# Shared bearer token the Claude Desktop client must send. Generate: openssl rand -hex 32
|
||||
# Shared bearer token every client must send to reach the server (the "gate").
|
||||
# Generate: openssl rand -hex 32
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Tickets (`list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`,
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
FRESHSERVICE_DOMAIN "acme" or "acme.freshservice.com"
|
||||
FRESHSERVICE_API_KEY profile API key
|
||||
MCP_AUTH_TOKEN shared bearer token the client must send (optional but
|
||||
strongly recommended; if unset, auth is open)
|
||||
FRESHSERVICE_DOMAIN "acme" or "acme.freshservice.com" (single-user only)
|
||||
FRESHSERVICE_API_KEY profile API key (single-user only)
|
||||
MCP_AUTH_TOKEN shared bearer token every client must send to reach
|
||||
the server (gate); separate from per-user identity.
|
||||
Strongly recommended; if unset, the server is open.
|
||||
MCP_TRANSPORT "http" (default here) or "stdio"
|
||||
HOST / PORT bind address (default 0.0.0.0:3838)
|
||||
|
||||
@@ -28,6 +39,7 @@ import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.lowlevel.server import request_ctx
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
|
||||
# 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"}
|
||||
|
||||
|
||||
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]:
|
||||
domain = os.environ.get("FRESHSERVICE_DOMAIN", "").strip()
|
||||
api_key = os.environ.get("FRESHSERVICE_API_KEY", "").strip()
|
||||
# Per-request header creds take precedence over the container env, so a
|
||||
# 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:
|
||||
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:
|
||||
domain = f"{domain}.freshservice.com"
|
||||
|
||||
Reference in New Issue
Block a user