From 4f9a862c25ccff4938a0dc1e25b184be17f53b40 Mon Sep 17 00:00:00 2001 From: spencerm Date: Fri, 10 Jul 2026 08:23:52 -0600 Subject: [PATCH] Support per-user identity via request headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mcp-server/.env.example | 13 ++++++++--- mcp-server/README.md | 44 ++++++++++++++++++++++++++++++++++ mcp-server/server.py | 52 +++++++++++++++++++++++++++++++++++------ 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/mcp-server/.env.example b/mcp-server/.env.example index b967dd2..9f8e77a 100644 --- a/mcp-server/.env.example +++ b/mcp-server/.env.example @@ -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 diff --git a/mcp-server/README.md b/mcp-server/README.md index 82f82fa..599e203 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -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 ", + "FS_DOMAIN": "pesco.freshservice.com", + "FS_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`, diff --git a/mcp-server/server.py b/mcp-server/server.py index 6f32b1f..6cc857c 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -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"