diff --git a/.gitignore b/.gitignore index 5217409..63e5971 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ .env .DS_Store # Credentials live in ~/.freshservice/credentials, never in the repo. + +# MCP server runtime secrets +mcp-server/.env diff --git a/freshservice-skill.zip b/freshservice-skill.zip new file mode 100644 index 0000000..b29a9c3 Binary files /dev/null and b/freshservice-skill.zip differ diff --git a/mcp-server/.env.example b/mcp-server/.env.example new file mode 100644 index 0000000..b967dd2 --- /dev/null +++ b/mcp-server/.env.example @@ -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 diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 0000000..77efae2 --- /dev/null +++ b/mcp-server/Dockerfile @@ -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"] diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..82f82fa --- /dev/null +++ b/mcp-server/README.md @@ -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 " \ + -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 " } +} +``` + +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. diff --git a/mcp-server/docker-compose.yml b/mcp-server/docker-compose.yml new file mode 100644 index 0000000..60a8a33 --- /dev/null +++ b/mcp-server/docker-compose.yml @@ -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) diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 0000000..a5aa6e2 --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1 @@ +mcp>=1.2.0 diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..2126b72 --- /dev/null +++ b/mcp-server/server.py @@ -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()