Add Freshservice MCP server for Cowork deployment
Cowork runs the agent in an isolated VM, so the uploaded skill can't see host credentials or reach the Freshservice host. Add an HTTP/streamable MCP server (bearer-auth, Dockerfile, compose) to run as a container on the LAN box and connect via mcp-remote like the existing sharepoint-lists server. The skill remains the right tool for the non-sandboxed Claude Code CLI. Also gitignore mcp-server/.env and include the desktop skill install package. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@ __pycache__/
|
||||
.env
|
||||
.DS_Store
|
||||
# Credentials live in ~/.freshservice/credentials, never in the repo.
|
||||
|
||||
# MCP server runtime secrets
|
||||
mcp-server/.env
|
||||
|
||||
BIN
freshservice-skill.zip
Normal file
BIN
freshservice-skill.zip
Normal file
Binary file not shown.
4
mcp-server/.env.example
Normal file
4
mcp-server/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
FRESHSERVICE_DOMAIN=pesco
|
||||
FRESHSERVICE_API_KEY=your_freshservice_api_key
|
||||
# Shared bearer token the Claude Desktop client must send. Generate: openssl rand -hex 32
|
||||
MCP_AUTH_TOKEN=replace_with_a_long_random_token
|
||||
15
mcp-server/Dockerfile
Normal file
15
mcp-server/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY server.py .
|
||||
|
||||
ENV MCP_TRANSPORT=http \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3838
|
||||
|
||||
EXPOSE 3838
|
||||
|
||||
# FRESHSERVICE_DOMAIN, FRESHSERVICE_API_KEY, MCP_AUTH_TOKEN supplied at runtime.
|
||||
CMD ["python", "server.py"]
|
||||
67
mcp-server/README.md
Normal file
67
mcp-server/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Freshservice MCP server (for Cowork / Claude Desktop)
|
||||
|
||||
Cowork runs the agent inside an isolated VM, so a locally-installed *skill*
|
||||
can't see your credentials or reach your Freshservice host. This MCP server
|
||||
runs as a container on the LAN box (`192.168.101.12`) — outside the VM — and
|
||||
Claude Desktop connects to it through `mcp-remote`, exactly like the existing
|
||||
`sharepoint-lists` server. Credentials and network both live on the LAN box.
|
||||
|
||||
(The skill in the parent directory remains the right tool for the Claude Code
|
||||
CLI, which is not sandboxed.)
|
||||
|
||||
## Deploy on 192.168.101.12
|
||||
|
||||
```bash
|
||||
# on the LAN box, in this directory
|
||||
cp .env.example .env
|
||||
# edit .env: FRESHSERVICE_DOMAIN, FRESHSERVICE_API_KEY, MCP_AUTH_TOKEN
|
||||
# token: openssl rand -hex 32
|
||||
docker compose up -d --build
|
||||
docker compose logs -f # confirm it started on :3838 inside the container
|
||||
```
|
||||
|
||||
The container listens on container port 3838, published to host port **3839**
|
||||
(3838 is used by sharepoint-lists). Adjust in `docker-compose.yml` if needed.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
# from the LAN box or any host that can reach it:
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
|
||||
http://192.168.101.12:3839/mcp \
|
||||
-H "Authorization: Bearer <MCP_AUTH_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}'
|
||||
# expect 200 (401 means bad/missing token)
|
||||
```
|
||||
|
||||
## Connect Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` under
|
||||
`mcpServers` (alongside `sharepoint-lists`):
|
||||
|
||||
```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}"
|
||||
],
|
||||
"env": { "MCP_AUTH_HEADER": "Bearer <MCP_AUTH_TOKEN>" }
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop. The Freshservice tools then appear in chat and Cowork.
|
||||
|
||||
## Tools
|
||||
|
||||
Tickets (`list_tickets`, `get_ticket`, `create_ticket`, `update_ticket`,
|
||||
`reply_ticket`, `add_note`, `list_ticket_conversations`), assets
|
||||
(`search_assets`, `get_asset`, `update_asset`), people (`find_requester`,
|
||||
`list_agents`, `list_groups`, `list_departments`), change/problem/release
|
||||
(`list_changes`, `get_change`, `create_change`, `list_problems`, `get_problem`,
|
||||
`list_releases`, `get_release`), and `freshservice_request` for any other v2
|
||||
endpoint.
|
||||
15
mcp-server/docker-compose.yml
Normal file
15
mcp-server/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
freshservice-mcp:
|
||||
build: .
|
||||
image: freshservice-mcp
|
||||
container_name: freshservice-mcp
|
||||
restart: unless-stopped
|
||||
# Host 3839 -> container 3838 (3838 is taken by sharepoint-lists).
|
||||
ports:
|
||||
- "3839:3838"
|
||||
env_file:
|
||||
- .env
|
||||
# .env must define:
|
||||
# FRESHSERVICE_DOMAIN=pesco
|
||||
# FRESHSERVICE_API_KEY=...
|
||||
# MCP_AUTH_TOKEN=... (openssl rand -hex 32)
|
||||
1
mcp-server/requirements.txt
Normal file
1
mcp-server/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
mcp>=1.2.0
|
||||
365
mcp-server/server.py
Normal file
365
mcp-server/server.py
Normal file
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freshservice MCP server (streamable-HTTP, bearer-auth).
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
MCP_TRANSPORT "http" (default here) or "stdio"
|
||||
HOST / PORT bind address (default 0.0.0.0:3838)
|
||||
|
||||
Docs: https://api.freshservice.com
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("freshservice")
|
||||
|
||||
TICKET_STATUS = {2: "Open", 3: "Pending", 4: "Resolved", 5: "Closed"}
|
||||
TICKET_PRIORITY = {1: "Low", 2: "Medium", 3: "High", 4: "Urgent"}
|
||||
|
||||
|
||||
def _config() -> tuple[str, str]:
|
||||
domain = os.environ.get("FRESHSERVICE_DOMAIN", "").strip()
|
||||
api_key = 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."
|
||||
)
|
||||
if "." not in domain:
|
||||
domain = f"{domain}.freshservice.com"
|
||||
return domain, api_key
|
||||
|
||||
|
||||
def _request(method: str, path: str, params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
domain, api_key = _config()
|
||||
path = path if path.startswith("/") else f"/{path}"
|
||||
if not path.startswith("/api/"):
|
||||
path = f"/api/v2{path}"
|
||||
url = f"https://{domain}{path}"
|
||||
if params:
|
||||
clean = {k: v for k, v in params.items() if v is not None}
|
||||
if clean:
|
||||
url += "?" + urllib.parse.urlencode(clean)
|
||||
|
||||
token = base64.b64encode(f"{api_key}:X".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {token}",
|
||||
"Content-Type": "application/json", "Accept": "application/json"}
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method.upper(), headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
raw = resp.read().decode()
|
||||
result: dict[str, Any] = {"ok": True, "status": resp.status,
|
||||
"data": json.loads(raw) if raw else {}}
|
||||
link = resp.headers.get("Link")
|
||||
if link:
|
||||
m = re.search(r'page=(\d+)[^>]*>;\s*rel="next"', link)
|
||||
if m:
|
||||
result["next_page"] = int(m.group(1))
|
||||
rem = resp.headers.get("X-RateLimit-Remaining")
|
||||
if rem is not None:
|
||||
result["rate_limit_remaining"] = rem
|
||||
return result
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode(errors="replace")
|
||||
try:
|
||||
detail = json.loads(detail)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"ok": False, "status": e.code, "error": detail,
|
||||
"url": url, "method": method.upper()}
|
||||
except urllib.error.URLError as e:
|
||||
return {"ok": False, "error": f"Network error: {e.reason}", "url": url}
|
||||
|
||||
|
||||
def _out(result: dict[str, Any]) -> str:
|
||||
return json.dumps(result, indent=2, default=str)
|
||||
|
||||
|
||||
def _coerce_status(v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
for k, name in TICKET_STATUS.items():
|
||||
if name.lower() == v.lower():
|
||||
return k
|
||||
return v
|
||||
|
||||
|
||||
def _coerce_priority(v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
for k, name in TICKET_PRIORITY.items():
|
||||
if name.lower() == v.lower():
|
||||
return k
|
||||
return v
|
||||
|
||||
|
||||
# --- Tickets ---------------------------------------------------------------
|
||||
@mcp.tool()
|
||||
def list_tickets(query: str | None = None, updated_since: str | None = None,
|
||||
order_by: str | None = None, order_type: str | None = None,
|
||||
page: int = 1, per_page: int = 30) -> str:
|
||||
"""List or filter tickets. With `query`, uses /tickets/filter, e.g.
|
||||
"status:2 AND priority:3". Status 2=Open 3=Pending 4=Resolved 5=Closed;
|
||||
priority 1=Low 2=Medium 3=High 4=Urgent."""
|
||||
if query:
|
||||
params = {"query": f'"{query}"', "page": page, "per_page": min(per_page, 100)}
|
||||
return _out(_request("GET", "/tickets/filter", params=params))
|
||||
params = {"updated_since": updated_since, "order_by": order_by,
|
||||
"order_type": order_type, "page": page, "per_page": min(per_page, 100)}
|
||||
return _out(_request("GET", "/tickets", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_ticket(ticket_id: int, include: str | None = None) -> str:
|
||||
"""Get a ticket by id. `include` e.g. "conversations,requester,stats"."""
|
||||
params = {"include": include} if include else None
|
||||
return _out(_request("GET", f"/tickets/{ticket_id}", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_ticket(subject: str, description: str, email: str | None = None,
|
||||
requester_id: int | None = None, priority: Any = 1, status: Any = 2,
|
||||
group_id: int | None = None, agent_id: int | None = None,
|
||||
ticket_type: str | None = None, tags: list[str] | None = None,
|
||||
custom_fields: dict[str, Any] | None = None) -> str:
|
||||
"""Create a ticket. Provide either `email` or `requester_id`."""
|
||||
body: dict[str, Any] = {"subject": subject, "description": description,
|
||||
"priority": _coerce_priority(priority),
|
||||
"status": _coerce_status(status)}
|
||||
if requester_id is not None:
|
||||
body["requester_id"] = requester_id
|
||||
elif email:
|
||||
body["email"] = email
|
||||
if group_id is not None:
|
||||
body["group_id"] = group_id
|
||||
if agent_id is not None:
|
||||
body["responder_id"] = agent_id
|
||||
if ticket_type:
|
||||
body["type"] = ticket_type
|
||||
if tags:
|
||||
body["tags"] = tags
|
||||
if custom_fields:
|
||||
body["custom_fields"] = custom_fields
|
||||
return _out(_request("POST", "/tickets", body=body))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_ticket(ticket_id: int, fields: dict[str, Any]) -> str:
|
||||
"""Update a ticket. Pass only fields to change (status/priority accept names)."""
|
||||
f = dict(fields)
|
||||
if "status" in f:
|
||||
f["status"] = _coerce_status(f["status"])
|
||||
if "priority" in f:
|
||||
f["priority"] = _coerce_priority(f["priority"])
|
||||
return _out(_request("PUT", f"/tickets/{ticket_id}", body=f))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def reply_ticket(ticket_id: int, body: str) -> str:
|
||||
"""Post a public reply (emailed to the requester) on a ticket."""
|
||||
return _out(_request("POST", f"/tickets/{ticket_id}/reply", body={"body": body}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_note(ticket_id: int, body: str, private: bool = True) -> str:
|
||||
"""Add a note to a ticket. private=True is internal-only."""
|
||||
return _out(_request("POST", f"/tickets/{ticket_id}/notes",
|
||||
body={"body": body, "private": private}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_ticket_conversations(ticket_id: int, page: int = 1, per_page: int = 30) -> str:
|
||||
"""List the conversation thread (replies and notes) on a ticket."""
|
||||
return _out(_request("GET", f"/tickets/{ticket_id}/conversations",
|
||||
params={"page": page, "per_page": min(per_page, 100)}))
|
||||
|
||||
|
||||
# --- Assets / CMDB ---------------------------------------------------------
|
||||
@mcp.tool()
|
||||
def search_assets(query: str | None = None, page: int = 1, per_page: int = 30) -> str:
|
||||
"""List or search assets. `query` e.g. "name:'MacBook'" or "asset_type_id:7"."""
|
||||
if query:
|
||||
params = {"filter": f'"{query}"', "page": page, "per_page": min(per_page, 100)}
|
||||
else:
|
||||
params = {"page": page, "per_page": min(per_page, 100)}
|
||||
return _out(_request("GET", "/assets", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_asset(display_id: int, include: str | None = None) -> str:
|
||||
"""Get an asset by display id. `include` e.g. "type_fields"."""
|
||||
params = {"include": include} if include else None
|
||||
return _out(_request("GET", f"/assets/{display_id}", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_asset(display_id: int, fields: dict[str, Any]) -> str:
|
||||
"""Update an asset. Pass only the fields to change."""
|
||||
return _out(_request("PUT", f"/assets/{display_id}", body=fields))
|
||||
|
||||
|
||||
# --- People ----------------------------------------------------------------
|
||||
@mcp.tool()
|
||||
def find_requester(email: str | None = None, query: str | None = None,
|
||||
page: int = 1, per_page: int = 30) -> str:
|
||||
"""Find requesters by email or filter query (e.g. "department_id:123")."""
|
||||
if email:
|
||||
params = {"email": email, "page": page, "per_page": min(per_page, 100)}
|
||||
elif query:
|
||||
params = {"query": f'"{query}"', "page": page, "per_page": min(per_page, 100)}
|
||||
else:
|
||||
params = {"page": page, "per_page": min(per_page, 100)}
|
||||
return _out(_request("GET", "/requesters", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_agents(email: str | None = None, query: str | None = None,
|
||||
page: int = 1, per_page: int = 30) -> str:
|
||||
"""List agents, optionally filtered by email or query (e.g. "group_id:123")."""
|
||||
if email:
|
||||
params = {"email": email, "page": page, "per_page": min(per_page, 100)}
|
||||
elif query:
|
||||
params = {"query": f'"{query}"', "page": page, "per_page": min(per_page, 100)}
|
||||
else:
|
||||
params = {"page": page, "per_page": min(per_page, 100)}
|
||||
return _out(_request("GET", "/agents", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_groups(page: int = 1, per_page: int = 30) -> str:
|
||||
"""List agent groups."""
|
||||
return _out(_request("GET", "/groups",
|
||||
params={"page": page, "per_page": min(per_page, 100)}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_departments(page: int = 1, per_page: int = 30) -> str:
|
||||
"""List departments."""
|
||||
return _out(_request("GET", "/departments",
|
||||
params={"page": page, "per_page": min(per_page, 100)}))
|
||||
|
||||
|
||||
# --- Change / Problem / Release --------------------------------------------
|
||||
@mcp.tool()
|
||||
def list_changes(query: str | None = None, page: int = 1, per_page: int = 30) -> str:
|
||||
"""List or filter changes. `query` e.g. "status:1 AND priority:3"."""
|
||||
if query:
|
||||
params = {"query": f'"{query}"', "page": page, "per_page": min(per_page, 100)}
|
||||
return _out(_request("GET", "/changes/filter", params=params))
|
||||
return _out(_request("GET", "/changes",
|
||||
params={"page": page, "per_page": min(per_page, 100)}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_change(change_id: int, include: str | None = None) -> str:
|
||||
"""Get a change record by id."""
|
||||
params = {"include": include} if include else None
|
||||
return _out(_request("GET", f"/changes/{change_id}", params=params))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_change(subject: str, description: str, requester_id: int | None = None,
|
||||
email: str | None = None, priority: Any = 1, status: int = 1,
|
||||
planned_start_date: str | None = None,
|
||||
planned_end_date: str | None = None,
|
||||
custom_fields: dict[str, Any] | None = None) -> str:
|
||||
"""Create a change record."""
|
||||
body: dict[str, Any] = {"subject": subject, "description": description,
|
||||
"priority": _coerce_priority(priority), "status": status}
|
||||
if requester_id is not None:
|
||||
body["requester_id"] = requester_id
|
||||
elif email:
|
||||
body["email"] = email
|
||||
if planned_start_date:
|
||||
body["planned_start_date"] = planned_start_date
|
||||
if planned_end_date:
|
||||
body["planned_end_date"] = planned_end_date
|
||||
if custom_fields:
|
||||
body["custom_fields"] = custom_fields
|
||||
return _out(_request("POST", "/changes", body=body))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_problems(page: int = 1, per_page: int = 30) -> str:
|
||||
"""List problem records."""
|
||||
return _out(_request("GET", "/problems",
|
||||
params={"page": page, "per_page": min(per_page, 100)}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_problem(problem_id: int) -> str:
|
||||
"""Get a problem record by id."""
|
||||
return _out(_request("GET", f"/problems/{problem_id}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_releases(page: int = 1, per_page: int = 30) -> str:
|
||||
"""List release records."""
|
||||
return _out(_request("GET", "/releases",
|
||||
params={"page": page, "per_page": min(per_page, 100)}))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_release(release_id: int) -> str:
|
||||
"""Get a release record by id."""
|
||||
return _out(_request("GET", f"/releases/{release_id}"))
|
||||
|
||||
|
||||
# --- Generic escape hatch --------------------------------------------------
|
||||
@mcp.tool()
|
||||
def freshservice_request(method: str, path: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None) -> str:
|
||||
"""Call any Freshservice API v2 endpoint directly (solutions/KB, products,
|
||||
vendors, ticket_fields, time_entries, etc.). "/api/v2" is prepended if absent."""
|
||||
return _out(_request(method, path, params=params, body=body))
|
||||
|
||||
|
||||
def _build_http_app():
|
||||
"""Wrap the streamable-HTTP app with a static bearer-token guard."""
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
expected = os.environ.get("MCP_AUTH_TOKEN", "").strip()
|
||||
|
||||
class BearerAuth(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
if expected:
|
||||
header = request.headers.get("authorization", "")
|
||||
token = header[7:].strip() if header.lower().startswith("bearer ") else ""
|
||||
if token != expected:
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
return await call_next(request)
|
||||
|
||||
app = mcp.streamable_http_app()
|
||||
app.add_middleware(BearerAuth)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
transport = os.environ.get("MCP_TRANSPORT", "http").lower()
|
||||
if transport in ("http", "streamable-http", "streamable_http"):
|
||||
import uvicorn
|
||||
|
||||
mcp.settings.host = os.environ.get("HOST", "0.0.0.0")
|
||||
mcp.settings.port = int(os.environ.get("PORT", "3838"))
|
||||
uvicorn.run(_build_http_app(), host=mcp.settings.host, port=mcp.settings.port)
|
||||
else:
|
||||
mcp.run()
|
||||
Reference in New Issue
Block a user