diff --git a/.env.example b/.env.example index 56d1395..6e68b95 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # Service Desk Plus OAuth Configuration -SDP_CLIENT_ID=1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU -SDP_CLIENT_SECRET=5752f7060c587171f81b21d58c5b8d0019587ca999 +SDP_CLIENT_ID=YOUR_CLIENT_ID +SDP_CLIENT_SECRET=YOUR_CLIENT_SECRET SDP_PORTAL_NAME=your-portal-name SDP_DATA_CENTER=US diff --git a/.mcp.json b/.mcp.json index e05b841..c984e8f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,8 +4,8 @@ "type": "sse", "url": "http://10.212.0.7:3456/sse", "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea4f2fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,179 @@ +# Changelog + +All notable changes to the SDP MCP Server are documented here. + +## [Unreleased] — 2026-05-19 (session 5) + +### New Features +- **`list_requests` accepts `technician_email` and `requester_email` filters** (`src/tools/requests.cjs`, + `src/sdp-api-client-v2.cjs`) — Copilot Studio's AI cannot construct the structured `criteria` array + required by `advanced_search_requests` from natural language ("pull up all my assigned requests that are + open"), so the two most common person-based filters are now exposed as simple string parameters on + `list_requests`. Both are forwarded to `listRequests` as `technicianEmail` / `requesterEmail`, which + already builds the correct `search_criteria` objects internally. + +## [Unreleased] — 2026-05-19 (session 4) + +### Bug Fixes +- **Fixed `advancedSearchRequests` criteria normalisation** (`sdp-api-client-v2.cjs`) — four + issues corrected against the SDP v3 API documentation: + 1. Single-element criteria array now unwrapped to a plain object before sending — the API + expects an object for single criteria, not a one-element array. + 2. `logical_operator` is now stripped from the first criterion in multi-element arrays — + the API rejects queries where the first item carries `logical_operator`. + 3. `page` parameter replaced with `start_index: (page-1) * rowCount` — aligns with + `listRequests` and the primary documented pagination parameter. + 4. `display_id` lookup in `get_request` now passes the value as an integer (`parseInt`) — + `display_id` is type `long` in the API; sending a string caused a type mismatch. + +## [Unreleased] — 2026-05-19 (session 3) + +### New Features +- **`get_request` accepts display_id** (`src/tools/requests.cjs`) — the tool now resolves + short human-readable ticket numbers (e.g. `31230`) to the internal 17-digit ID + automatically via `advanced_search_requests` before fetching full details. IDs of 10 + digits or fewer are treated as display_ids; longer IDs are passed directly as before. + The schema description is updated to document both formats. + +## [Unreleased] — 2026-05-19 (session 2) + +### Refactoring +- **Modularised `working-sse-server.cjs`** — slimmed from 1769 to 246 lines using a factory + function pattern (`makeImplementations(sdpClient)`). Tool implementations extracted into + three focused modules: + - `src/tools/requests.cjs` — 16 request tools (list, get, create, update, close, notes, + replies, search, delete, attachment, conversation) + - `src/tools/technicians.cjs` — 3 technician tools (list, get, find) + - `src/tools/metadata.cjs` — get_metadata, get_usage_guide, claude_code_command +- **Extracted MCP Resources** into `src/mcp-resources.cjs` — server now uses + `listResources()` / `readResource()` from this module; `metadata.cjs` uses the same + module for `get_usage_guide`, eliminating the inline `SDP_RESOURCES` constant. + +### New Features +- **MCP Prompts** (`src/mcp-prompts.cjs`) — four prompt templates wired to `prompts/list` + and `prompts/get` protocol handlers; `prompts: {}` declared in `initialize` capabilities: + - `resolve_request` — get → update → reply → close workflow + - `triage_request` — get → get_metadata → update → reply workflow + - `escalate_request` — get → find_technician → update → add_private_note → reply workflow + - `follow_up_request` — get_request_conversation → reply workflow + All templates use "request" terminology throughout (not "ticket"). +- **Input validation at tool layer** (`src/tools/requests.cjs`) — three validators applied + before any API call: + - `subject` truncation prevented: error thrown if > 250 characters + - `impact_details` truncation prevented: error thrown if > 250 characters + - `closure_code` validated against known enum (Resolved, Cancelled, Duplicate, Closed, + On Hold, Open) with a clear error message listing valid values +- **Richer `/health` endpoint** — now async; performs a live OAuth token probe via + `sdpClient.testConnection()` and returns `auth_status` (ok / failed / error / + not_configured), `instance`, `base_url`, and `data_center` alongside existing fields. + +### Dead Code Removal +- Deleted `src/sdp-api-client.cjs` — superseded by `sdp-api-client-v2.cjs` +- Deleted `src/sdp-api-client-enhanced.cjs` — no importers +- Deleted `src/simple-sse-server.cjs` — superseded by `working-sse-server.cjs` +- Deleted `src/index-sse-simple.ts` — unused TypeScript entry point +- Deleted `src/index.ts` — unused TypeScript entry point + +## [Unreleased] — 2026-05-19 + +### Bug Fixes +- **Fixed API base URL** (`sdp-api-client-v2.cjs`, `sdp-api-metadata.cjs`) — the path + `/app/{portalName}` was incorrectly prepended to all API calls. The SDP v3 API base path + is `{SDP_BASE_URL}/api/v3` with no portal name segment. Corrected in both files. +- **Fixed infinite 401 retry loop** (`sdp-api-client-v2.cjs`) — the axios response + interceptor re-entered itself on a token-refresh retry, producing an unbound loop on + authentication failures. Fixed with an `_retry` flag on the request config that caps + the refresh at one attempt per request. +- **Fixed `testConnection()` calling an unreachable endpoint** (`sdp-api-client-v2.cjs`) + — startup connection test was calling `GET /priorities`, which returns a Tomcat HTML 401 + on some SDP instances. Changed to OAuth token acquisition only; if a valid token is + returned the connection is considered healthy. + +### Environment / Configuration +- **Unified `SDP_INSTANCE_NAME` into `SDP_PORTAL_NAME`** — both variables referred to the + same value. `SDP_INSTANCE_NAME` has been removed; `SDP_PORTAL_NAME` now serves both the + portal name and instance name roles across all three source files. +- **Corrected OAuth env var names in `.env.example` and `README.md`** — references to + `SDP_OAUTH_CLIENT_ID`, `SDP_OAUTH_CLIENT_SECRET`, and `SDP_OAUTH_REFRESH_TOKEN` replaced + with the correct names: `SDP_CLIENT_ID`, `SDP_CLIENT_SECRET`, `SDP_REFRESH_TOKEN`. +- **Azure / cloud deployment support** (`working-sse-server.cjs`) — server now binds to + `process.env.PORT` so it works on Azure App Service (which injects `PORT=8080`) + without manual configuration. +- **Added startup environment diagnostics** (`working-sse-server.cjs`) — logs + `SDP_BASE_URL`, `SDP_PORTAL_NAME`, `SDP_CLIENT_ID` (set/not set), `SDP_REFRESH_TOKEN` + (set/not set), and `SDP_DATA_CENTER` at boot for faster misconfiguration diagnosis. + +### Documentation +- **Added `SDP-MCP_Usage.md`** — comprehensive AI context reference covering all tools, + field formats, error codes, action sequences, and the tool decision matrix. Intended as + a system prompt supplement for agents consuming this MCP server. +- **Updated `README.md`** — corrected environment variable names, documented the API URL + fix, added Azure deployment troubleshooting section, updated status date. + +### New Features +- **Implemented MCP Resources** (`working-sse-server.cjs`) — server now advertises six + named resources sourced from `SDP-MCP_Usage.md`: + - `sdp://usage/api-rules` — Critical API Rules + - `sdp://usage/field-formats` — Field Format Reference + - `sdp://usage/tool-reference` — Full Tool Reference + - `sdp://usage/error-codes` — Error Code Reference + - `sdp://usage/action-sequences` — Common Action Sequences + - `sdp://usage/decision-matrix` — Tool Decision Matrix + Handlers added for `resources/list` and `resources/read`. The `resources: {}` + capability is declared in the `initialize` response. +- **Added `get_usage_guide` tool** (`working-sse-server.cjs`) — MCP tool that returns any + of the six resource sections as both a `text` and a typed `resource` content item, + satisfying the Microsoft Copilot Studio requirement that resources be surfaced as tool + outputs. + +## [Unreleased] — 2026-05-15 + +### Security +- **Removed hardcoded credentials from `CLAUDE.md`** — plaintext PostgreSQL passwords replaced with `(set via SDP_DB_PASSWORD environment variable)` and `(set via SDP_DB_ROOT_PASSWORD environment variable)`. +- **Removed hardcoded OAuth credentials from `sdp-oauth-client.cjs`** — constructor no longer contains fallback client ID/secret strings; all credential values must come from environment variables (`SDP_CLIENT_ID`, `SDP_CLIENT_SECRET`, `SDP_OAUTH_REFRESH_TOKEN`). +- **Removed hardcoded customer-specific fallback values** from `sdp-api-client-v2.cjs`, `sdp-api-metadata.cjs`, and `working-sse-server.cjs` — portal name (`kaltentech`), custom domain (`https://helpdesk.pttg.com`), and instance name (`itdesk`) are no longer hardcoded; all must be supplied via environment variables (`SDP_PORTAL_NAME`, `SDP_BASE_URL`, `SDP_INSTANCE_NAME`). +- **Removed hardcoded server IP** (`10.212.0.7`) from startup log in `working-sse-server.cjs` — replaced with `process.env.SERVER_HOST || 'localhost'`. +- **Removed hardcoded local paths** (`/Users/kalten/projects/SDP-MCP`) from `working-sse-server.cjs` claude_code_command handler — replaced with `process.cwd()`. + +### Runtime +- **Updated Node.js engine requirement** in `package.json` from `>=20.0.0` to `>=22.0.0` to match the current LTS target and remove the deprecated runtime warning. +- **Updated `@types/node`** dev dependency from `^20.12.0` to `^22.0.0` to match. + +### Bug Fixes +- **Fixed `closure_code` 400 error on close request** (`sdp-api-client-v2.cjs`) — the SDP v3 API requires all reference fields to be objects. `closure_code` was being sent as a plain string (`"Resolved"`); corrected to `{ name: "Resolved" }`. +- **Fixed `resolution` field format** — `resolution` was being sent as a plain string in both `updateRequest` and `closeRequest`. Corrected to always send as `{ content: "..." }` per the API spec. Normalisation applied: string input is automatically wrapped; object input is passed through unchanged. +- **Fixed `start_index` pagination** in `listRequests` and `searchRequests` — was incorrectly set to `1` when offset was `0`. The SDP v3 API uses 0-based `start_index`; corrected to pass `offset` directly. +- **Fixed priority name regression** in `listRequests` and `updateRequest` — `'z - Medium'` was incorrectly changed to `'2 - Medium'` during a prior edit. Reverted to `'z - Medium'`, which is the actual priority name configured in this SDP instance (confirmed via `sdp-api-metadata.cjs` which maps `p.name === 'z - Medium'`). + +### New Features + +#### API Client (`sdp-api-client-v2.cjs`) +- **Added `deleteRequest(requestId)`** — sends `DELETE /api/v3/requests/{id}`. +- **Added `addAttachment(requestId, filePath, fileName)`** — sends `POST /api/v3/requests/{id}/attachments` using a raw multipart/form-data body constructed from a file `Buffer`; no new runtime dependencies. +- **Added shared `PRIORITY_NAMES` constant** at module level — single source of truth for the priority name map (`low` → `1 - Low`, `medium` → `z - Medium`, `high` → `3 - High`, `urgent` → `4 - Critical`). All three previously duplicated inline `priorityMap` objects in `listRequests` (×2) and `updateRequest` have been replaced with references to this constant. +- **Re-enabled `requester` field on `createRequest`** — previously skipped with a comment about validation errors. Now sends `requester: { email_id }` when `requester_email` is provided, `{ name }` when `requester_name` is provided, or passes through an object directly. +- **Re-enabled `priority` on `createRequest`** — previously commented out. Now included using `PRIORITY_NAMES` lookup. +- **Removed hardcoded subcategory default** — `createRequest` previously always injected `subcategory: { name: 'Not in list' }` (with customer-specific category ID checks) when no subcategory was supplied. The field is now omitted entirely when not provided by the caller, letting SDP apply its own defaults. + +#### SSE Server (`working-sse-server.cjs`) +- **Added `delete_request` tool** — exposes `deleteRequest` to MCP clients. Required field: `request_id`. +- **Added `add_attachment` tool** — exposes `addAttachment` to MCP clients. Required fields: `request_id`, `file_path`. Optional: `file_name`. +- **Added `resolution` field** to `close_request` tool schema and handler. +- **Expanded `closure_code` enum** on `close_request` — added `Closed`, `On Hold`, and `Open` to the existing `Resolved`, `Cancelled`, `Duplicate` options. +- **Wired technician tools to real `SDPUsersAPI`** — `list_technicians`, `get_technician`, and `find_technician` were returning hardcoded stub responses claiming the `/users` endpoint does not exist. They now call `sdpClient.users.listTechnicians()`, `sdpClient.users.getTechnician()`, and `sdpClient.users.findTechnician()` respectively (the `SDPUsersAPI` class in `sdp-api-users.cjs` was already fully implemented). +- **Expanded `create_request` schema** — added: `requester_name`, `urgency`, `impact`, `level`, `mode`, `request_type`, `group`, `site`, `template`, `due_by_time`, `impact_details`, `email_ids_to_notify`. +- **Expanded `update_request` schema** — added: `update_reason`, `due_by_time`, `urgency`, `impact`, `level`, `group`, `site`, `scheduled_start_time`, `scheduled_end_time`; status enum extended with `'in progress'` and `'on hold'`. +- **Added `advanced_search_requests` tool** — exposes `advancedSearchRequests` to MCP clients. Accepts a structured `criteria` array (field, condition, value, logical_operator), with `limit`, `page`, `sort_by`, and `sort_order` options. Enables complex multi-field queries (e.g., filter by requester + date range + priority in a single call). + +#### Metadata Client (`sdp-api-metadata.cjs`) +- **`getStatuses()` now tries the API first** — previously always returned a hardcoded list because the endpoint was assumed to return 404. It now attempts `GET /statuses`; uses the API response when successful, falls back to the hardcoded list on error or empty response. + +### API Conformance +Audited all SDP v3 API calls against the official documentation. Key findings applied: +- All reference fields (priority, status, category, subcategory, closure_code, requester, technician, mode, request_type, urgency, impact, level) are sent as objects (`{ name: "..." }` or `{ id: "..." }`), not plain strings. +- `input_data` is sent as a URL query parameter on all request methods (GET, POST, PUT, DELETE), with a `null` body on POST/PUT. +- `Authorization` header uses `Zoho-oauthtoken ` format (not `Bearer`). +- `start_index` is 0-based. +- `row_count` maximum is 100 per API limits. +- Close request uses `POST /api/v3/requests/{id}/close`, not PUT. +- Subject and `impact_details` fields are validated to 250-character maximum before sending. diff --git a/CLAUDE.md b/CLAUDE.md index 4fa681f..a35a7ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -655,8 +655,8 @@ The current implementation stores OAuth tokens in environment variables. Databas - Port: 5433 (non-standard to avoid conflicts) - Database: sdp_mcp - User: sdpmcpservice -- Password: *jDE1Bj%IPXKMe%Z -- Root user: root / 16vOp$BeC!&9SCqv +- Password: (set via SDP_DB_PASSWORD environment variable) +- Root user: (set via SDP_DB_ROOT_PASSWORD environment variable) ### Environment Variables ```bash diff --git a/README.md b/README.md index e440769..1fade22 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Model Context Protocol (MCP) server that integrates with Service Desk Plus Cloud API, enabling AI assistants to perform CRUD operations on all Service Desk Plus entities. -## 🚀 Current Status (January 2025) +## 🚀 Current Status (May 2026) 🎉 **PRODUCTION READY** - Complete Service Desk Plus MCP Server ✅ **ALL 16 TOOLS WORKING PERFECTLY** (100% Success Rate) @@ -10,14 +10,20 @@ A Model Context Protocol (MCP) server that integrates with Service Desk Plus Clo ✅ **Email Communication** - Reply to requesters with ticket conversation integration ✅ **Zero OAuth Issues** - Bulletproof token management with rate limit protection ✅ **Complete Testing** - All tools validated through comprehensive client testing -✅ **Production Ready** - Robust error handling and business rule compliance +✅ **Production Ready** - Robust error handling and business rule compliance +✅ **Azure/Cloud Ready** - Respects `PORT` environment variable for cloud deployments ### Recent Improvements +- 🔧 Fixed API URL — path is `{SDP_BASE_URL}/api/v3` directly (no `/app/{name}` segment) +- 🔧 Fixed infinite 401 retry loop — axios interceptor now caps at one token refresh per request +- 🔧 Fixed `testConnection()` — verifies OAuth token only at startup (no API endpoint call) +- 🔧 Unified `SDP_INSTANCE_NAME` into `SDP_PORTAL_NAME` — single variable serves both roles +- 🔧 Sanitized hardcoded credentials from source files +- 🔧 Server now respects `PORT` environment variable for Azure/cloud deployments - 🔧 Fixed Authorization header format from Bearer to Zoho-oauthtoken - 🔧 Added subcategory as mandatory field for request creation - 🔧 Implemented proper list_info structure with search_criteria - 🔧 Added advanced search capabilities with complex criteria -- 🔧 Created comprehensive OAuth and search documentation - 🔧 Mock API now perfectly replicates real API behaviors - 🔧 **NEW**: Email communication tools for requester replies - 🔧 **NEW**: Private notes and first response functionality @@ -202,20 +208,24 @@ The mock API: ### Required Environment Variables ```bash # Service Desk Plus Configuration -SDP_BASE_URL=https://helpdesk.yourdomain.com # Custom domain -SDP_INSTANCE_NAME=itdesk # Instance name -SDP_PORTAL_NAME=yourportal # Portal name -SDP_DATA_CENTER=US # Data center (US, EU, IN, AU, JP, UK, CA, CN) +SDP_BASE_URL=https://sdpondemand.manageengine.com # Base URL (or your custom domain) +SDP_PORTAL_NAME=yourportal # Portal/instance name (used for both roles) +SDP_DATA_CENTER=US # Data center: US, EU, IN, AU, JP, UK, CA, CN # OAuth Credentials -SDP_OAUTH_CLIENT_ID=your_client_id -SDP_OAUTH_CLIENT_SECRET=your_client_secret_here -SDP_OAUTH_REFRESH_TOKEN=your_permanent_refresh_token_here +SDP_CLIENT_ID=your_client_id +SDP_CLIENT_SECRET=your_client_secret +SDP_REFRESH_TOKEN=your_permanent_refresh_token # Optional: Use mock API for testing SDP_USE_MOCK_API=false + +# Optional: Override listen port (set automatically by Azure/cloud platforms) +# PORT=8080 ``` +> **Note**: `SDP_PORTAL_NAME` replaces the former `SDP_INSTANCE_NAME` — a single variable now covers both the portal name and instance name roles. The `SDP_OAUTH_*` variable prefix has been dropped; use `SDP_CLIENT_ID`, `SDP_CLIENT_SECRET`, and `SDP_REFRESH_TOKEN` directly. + ### OAuth Setup Steps 1. Create a self-client OAuth app in Service Desk Plus 2. Generate authorization code with required scopes @@ -244,21 +254,30 @@ When MCP protocol evolves to support stateless connections: ### Common Issues -1. **OAuth Rate Limiting** +1. **API URL / 404 on all endpoints** + - Cause: Incorrect base URL format — the API path does not include `/app/{name}` + - Solution: Set `SDP_BASE_URL` to the root of your SDP instance (e.g. `https://sdpondemand.manageengine.com`) — the client appends `/api/v3` automatically + +2. **OAuth Rate Limiting** - Error: "You have made too many requests continuously" - - Solution: Wait 5-15 minutes, server implements proper token reuse + - Solution: Wait 5-15 minutes; server implements singleton OAuth client with proper token reuse + +3. **Authentication Errors (401) / Infinite retry loop** + - Error: "UNAUTHORISED" repeated in logs + - Solution: Verify `SDP_CLIENT_ID`, `SDP_CLIENT_SECRET`, and `SDP_REFRESH_TOKEN` are set correctly. The server now caps token refresh at one attempt per request to prevent loops. -2. **Field Validation Errors (4012)** +4. **Field Validation Errors (4012)** - Error: Missing mandatory fields - Solution: Check instance configuration for required fields -3. **Priority Update Errors (403)** +5. **Priority Update Errors (403)** - Error: "Cannot give value for priority" - - Solution: This is an API limitation, priority may not be updatable + - Solution: This is an API limitation, priority may not be updatable via this endpoint -4. **Authentication Errors (401)** - - Error: "UNAUTHORISED" - - Solution: Verify OAuth tokens and custom domain configuration +6. **Azure / Cloud Deployment** + - The server automatically reads the `PORT` environment variable injected by Azure App Service (default 8080) + - Set all OAuth variables under **Settings → Environment Variables** in the Azure portal + - The startup log prints `SDP env check:` diagnostics to confirm variables are loaded ### Debug Mode ```bash diff --git a/REMOTE_SETUP.md b/REMOTE_SETUP.md index 67045dd..260dcc1 100644 --- a/REMOTE_SETUP.md +++ b/REMOTE_SETUP.md @@ -44,8 +44,8 @@ Add this configuration: "http://studio:3456/sse" ], "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/SDP-MCP_Usage.md b/SDP-MCP_Usage.md new file mode 100644 index 0000000..72a38cd --- /dev/null +++ b/SDP-MCP_Usage.md @@ -0,0 +1,393 @@ +# SDP-MCP Usage Guide +## Service Desk Plus MCP Server — AI Context Reference + +This document is intended as context for AI agents using the SDP-MCP server +to interact with Service Desk Plus Cloud. It covers all available tools, when +to use them, required field formats, and known API behaviours. + +--- + +## Critical API Rules + +These apply to every tool call without exception. + +- **All reference fields must be objects**, never plain strings. + Correct: `{ "name": "Hardware" }` — Wrong: `"Hardware"` +- **Resolution field format**: `{ "content": "your text here" }` +- **Closure code format**: `{ "name": "Resolved" }` (not a plain string) +- **Authorization header**: `Zoho-oauthtoken ` — NOT `Bearer` +- **input_data** is sent as a URL query parameter on all methods +- **start_index** is 0-based for pagination +- **row_count** maximum is 100 per request +- **Subject** field: 250 character maximum +- **impact_details** field: 250 character maximum +- You cannot update a closed ticket — always check status first with `get_request` + +--- + +## Tool Reference + +### 1. `get_request` +**Purpose:** Retrieve full details of a single request by ID. + +**When to use:** +- Before executing any action sequence to confirm the request exists and is not already closed +- When you need current field values before an update +- To verify the requester, status, or assigned technician + +**Required fields:** +- `request_id` — the numeric SDP request ID + +**Important:** Always call this first. Never assume a request is open. + +--- + +### 2. `list_requests` +**Purpose:** List requests with optional filters and pagination. + +**When to use:** +- Browsing open requests for a technician or site +- Checking recent activity +- Fetching requests by status + +**Key parameters:** +- `status` — e.g. `"Open"`, `"In Progress"`, `"Resolved"`, `"Closed"` +- `technician_email` — filter by assigned technician +- `row_count` — max 100 per page +- `start_index` — 0-based page offset +- `sort_by` / `sort_order` — field name and `"asc"` or `"desc"` + +**Note:** Statuses `"Cancelled"`, `"Closed"`, and `"Resolved"` are all treated +as closed by the API. + +--- + +### 3. `search_requests` +**Purpose:** Search requests using simple criteria. + +**When to use:** +- Finding requests by requester, subject keyword, or category +- Narrower than list_requests but less complex than advanced_search_requests + +**Key parameters:** +- `search_fields` — object with field/value pairs to match +- `row_count`, `start_index` — pagination + +--- + +### 4. `advanced_search_requests` +**Purpose:** Complex multi-field search with logical operators. + +**When to use:** +- Queries that require AND/OR logic across multiple fields +- Filtering by requester AND date range AND priority in a single call +- Any search that list_requests or search_requests cannot express + +**Key parameters:** +- `criteria` — array of `{ field, condition, value, logical_operator }` objects +- `limit`, `page`, `sort_by`, `sort_order` + +**Example criteria:** +```json +[ + { "field": "status.name", "condition": "is", "value": "Open", "logical_operator": "AND" }, + { "field": "technician.email_id", "condition": "is", "value": "jthomas@gmfus.org" } +] +``` + +--- + +### 5. `create_request` +**Purpose:** Create a new service desk request. + +**When to use:** +- Only when creating a brand new ticket, not for updates + +**Required fields:** +- `subject` — max 250 characters + +**Optional but recommended:** +- `description` — HTML supported +- `requester_email` or `requester_name` +- `category` — as `{ "name": "..." }` +- `subcategory` — as `{ "name": "..." }` — omit if unknown, SDP applies defaults +- `site` — as `{ "name": "..." }` +- `technician` — as `{ "email_id": "..." }` +- `priority` — as `{ "name": "..." }` — valid names: `"1 - Low"`, `"z - Medium"`, `"3 - High"`, `"4 - Critical"` +- `urgency`, `impact`, `level`, `group`, `mode`, `request_type` + +--- + +### 6. `update_request` +**Purpose:** Update fields on an existing open request. + +**When to use:** +- Changing category, subcategory, site, technician, or status +- Any field update that is not a closure or a reply + +**Required fields:** +- `request_id` + +**Updatable fields (all as objects):** +- `category` → `{ "name": "Software / Platform" }` +- `subcategory` → `{ "name": "MS Office" }` +- `site` → `{ "name": "Berlin" }` +- `technician` → `{ "email_id": "jthomas@gmfus.org" }` +- `status` → `{ "name": "In Progress" }` +- `priority` → `{ "name": "3 - High" }` +- `group`, `urgency`, `impact`, `level` — all as `{ "name": "..." }` + +**Known limitation:** Priority updates may return 403 in some SDP configurations. +This is an API-level restriction, not a bug. + +**Do not use update_request to set the resolution field before closing.** +Pass resolution directly to `close_request` instead. + +--- + +### 7. `close_request` +**Purpose:** Close a request with a resolution and closure code. + +**When to use:** +- Only when response_type is `"solution"` and the fix is self-service +- Never use if response_type is `"follow_up"` or `"escalate"` + +**Required fields:** +- `request_id` +- `resolution` → `{ "content": "one-sentence summary of resolution" }` +- `closure_code` → `{ "name": "Resolved" }` + +**Available closure codes:** `"Resolved"`, `"Cancelled"`, `"Duplicate"`, +`"Closed"`, `"On Hold"`, `"Open"` + +**Sequence:** `close_request` handles both the resolution and closure in a +single API call (`POST /api/v3/requests/{id}/close`). Do not call +`update_request` first to set resolution separately. + +--- + +### 8. `delete_request` +**Purpose:** Permanently delete a request. + +**When to use:** +- Only when explicitly instructed. This is irreversible. +- Never use as part of a standard action sequence. + +**Required fields:** +- `request_id` + +--- + +### 9. `reply_to_requester` +**Purpose:** Send an email reply to the requester that appears in the +ticket conversation thread. + +**When to use:** +- ALWAYS use this tool when responding to a requester — not `add_note` +- `add_note` does NOT send an email. `reply_to_requester` does. + +**Required fields:** +- `request_id` +- `reply_content` — the message body (HTML supported) + +--- + +### 10. `send_first_response` +**Purpose:** Send the first formal response on a ticket with email notification. + +**When to use:** +- Only for the very first response on a brand new ticket where SLA + first-response time is being tracked +- For all subsequent replies use `reply_to_requester` instead + +**Required fields:** +- `request_id` +- `response_content` + +--- + +### 11. `add_note` +**Purpose:** Add a public note to a request visible to the requester. + +**When to use:** +- Adding a visible note to the ticket record without sending an email +- If you need to email the requester, use `reply_to_requester` instead + +**Required fields:** +- `request_id` +- `note_content` + +--- + +### 12. `add_private_note` +**Purpose:** Add an internal note to a request that is NOT visible to the requester. + +**When to use:** +- Recording escalation reasons +- Adding technician-facing context (e.g. internal_note from upstream agent) +- Any note that should not be seen by the requester + +**Required fields:** +- `request_id` +- `note_content` + +--- + +### 13. `get_request_conversation` +**Purpose:** Retrieve the full conversation history of a request including +all replies and notes. + +**When to use:** +- Reviewing prior communication before responding +- Checking whether a requester has already been contacted + +**Required fields:** +- `request_id` + +--- + +### 14. `list_technicians` +**Purpose:** List all available technicians. + +**When to use:** +- Browsing available technicians when no specific assignment is known + +--- + +### 15. `get_technician` +**Purpose:** Get detailed information about a specific technician by ID. + +**When to use:** +- When you have a technician ID and need their full profile + +**Required fields:** +- `technician_id` + +--- + +### 16. `find_technician` +**Purpose:** Look up a technician by name or email address. + +**When to use:** +- When you need to resolve a name or email to a technician ID +- Not required if the upstream agent already supplies `assigned_technician_email` + and the API accepts email directly + +**Required fields:** +- `name` or `email` + +--- + +### 17. `get_metadata` +**Purpose:** Retrieve valid dropdown values for SDP fields such as category, +subcategory, status, priority, closure codes, and sites. + +**When to use:** +- Before calling `update_request` or `create_request` if you are unsure + whether a field value is valid for this SDP instance +- To validate category/subcategory combinations before writing + +--- + +### 18. `add_attachment` +**Purpose:** Attach a file to an existing request. + +**When to use:** +- Only when explicitly instructed to attach a file + +**Required fields:** +- `request_id` +- `file_path` — path to the file on the server + +**Optional:** +- `file_name` — display name for the attachment + +--- + +### 19. `claude_code_command` +**Purpose:** Execute a Claude Code command on the MCP server host. + +**When to use:** +- Never use this tool as part of any request fulfilment workflow. + It is a developer utility only. + +--- + +## Tool Decision Matrix + +| Task | Tool to use | +|---|---| +| Check if a request exists / get current state | `get_request` | +| Update category, subcategory, site, technician | `update_request` | +| Send a reply the requester receives by email | `reply_to_requester` | +| Add an internal note for the technician only | `add_private_note` | +| Add a note visible to the requester (no email) | `add_note` | +| Close a resolved ticket | `close_request` | +| Find requests matching criteria | `search_requests` or `advanced_search_requests` | +| Validate a field value before writing | `get_metadata` | +| Check prior communication | `get_request_conversation` | + +--- + +## Common Action Sequences + +### Standard Solution +``` +get_request → update_request → reply_to_requester → close_request +``` + +### Follow-Up Question +``` +get_request → update_request → reply_to_requester +``` + +### Escalation +``` +get_request → update_request (reassign technician) → add_private_note → reply_to_requester +``` + +--- + +## Error Code Reference + +| Code | Meaning | Action | +|---|---|---| +| 401 / UNAUTHORISED | OAuth token invalid or expired | Token refresh handled automatically | +| 403 | Permission denied (e.g. priority update blocked) | API limitation, skip the field | +| 400 / 4012 | Mandatory field missing | Check required fields for the operation | +| 4000 | General failure | Read the `error_messages` array for detail | +| 4002 | Unauthorised | Verify portal name, instance name, and custom domain | + +--- + +## Field Format Reference + +| Field | Correct format | +|---|---| +| Category | `{ "name": "Software / Platform" }` | +| Subcategory | `{ "name": "MS Office" }` | +| Site | `{ "name": "Berlin" }` | +| Technician | `{ "email_id": "jthomas@gmfus.org" }` | +| Status | `{ "name": "In Progress" }` | +| Priority | `{ "name": "z - Medium" }` | +| Closure code | `{ "name": "Resolved" }` | +| Resolution | `{ "content": "Issue resolved by resetting the user's password." }` | + +### Priority Name Map +| Level | API value | +|---|---| +| Low | `1 - Low` | +| Medium | `z - Medium` | +| High | `3 - High` | +| Critical | `4 - Critical` | + +--- + +## Out of Scope Tools + +Never call the following tools as part of a request fulfilment workflow: + +- `claude_code_command` — developer utility only +- `delete_request` — irreversible, requires explicit instruction +- `send_first_response` — only for first response SLA tracking, not general replies +- `create_request` — fulfilment agents do not create tickets diff --git a/claude-desktop-config-10.212.json b/claude-desktop-config-10.212.json index 03ae274..c79b988 100644 --- a/claude-desktop-config-10.212.json +++ b/claude-desktop-config-10.212.json @@ -8,8 +8,8 @@ "--allow-http" ], "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/claude-desktop-config-fixed.json b/claude-desktop-config-fixed.json index 03ae274..c79b988 100644 --- a/claude-desktop-config-fixed.json +++ b/claude-desktop-config-fixed.json @@ -8,8 +8,8 @@ "--allow-http" ], "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/claude-desktop-config.json b/claude-desktop-config.json index e989553..109b05b 100644 --- a/claude-desktop-config.json +++ b/claude-desktop-config.json @@ -6,8 +6,8 @@ "/Users/kalten/projects/SDP-MCP/src/mcp-sse-server.js" ], "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/claude-desktop-mcp-remote-config.json b/claude-desktop-mcp-remote-config.json index dd9dfbc..4c9679e 100644 --- a/claude-desktop-mcp-remote-config.json +++ b/claude-desktop-mcp-remote-config.json @@ -7,8 +7,8 @@ "http://studio:3456/sse" ], "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/claude-desktop-wrapper-config.json b/claude-desktop-wrapper-config.json index 1af2a94..c56c2d2 100644 --- a/claude-desktop-wrapper-config.json +++ b/claude-desktop-wrapper-config.json @@ -8,8 +8,8 @@ "env": { "REMOTE_HOST": "192.168.2.10", "REMOTE_PORT": "3456", - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/claude_desktop_config.json b/claude_desktop_config.json index dd9dfbc..4c9679e 100644 --- a/claude_desktop_config.json +++ b/claude_desktop_config.json @@ -7,8 +7,8 @@ "http://studio:3456/sse" ], "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/example/.mcp.json b/example/.mcp.json index 430ab0b..791dba8 100644 --- a/example/.mcp.json +++ b/example/.mcp.json @@ -4,8 +4,8 @@ "type": "sse", "url": "http://10.212.0.7:3456/sse", "env": { - "SDP_CLIENT_ID": "1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU", - "SDP_CLIENT_SECRET": "5752f7060c587171f81b21d58c5b8d0019587ca999" + "SDP_CLIENT_ID": "YOUR_CLIENT_ID", + "SDP_CLIENT_SECRET": "YOUR_CLIENT_SECRET" } } } diff --git a/ignore.md b/ignore.md index 706dbac..6e918cd 100644 --- a/ignore.md +++ b/ignore.md @@ -2,9 +2,9 @@ id -1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU +YOUR_CLIENT_ID secret -5752f7060c587171f81b21d58c5b8d0019587ca999 \ No newline at end of file +YOUR_CLIENT_SECRET \ No newline at end of file diff --git a/scripts/quick-oauth-setup.js b/scripts/quick-oauth-setup.js index 61328d3..9b3ca43 100755 --- a/scripts/quick-oauth-setup.js +++ b/scripts/quick-oauth-setup.js @@ -27,8 +27,8 @@ async function main() { try { const oauth = new SDPOAuthClient({ - clientId: process.env.SDP_CLIENT_ID || '1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU', - clientSecret: process.env.SDP_CLIENT_SECRET || '5752f7060c587171f81b21d58c5b8d0019587ca999', + clientId: process.env.SDP_CLIENT_ID || 'YOUR_CLIENT_ID', + clientSecret: process.env.SDP_CLIENT_SECRET || 'YOUR_CLIENT_SECRET', dataCenter: process.env.SDP_DATA_CENTER || 'US' }); diff --git a/sdp-mcp-server/.env.example b/sdp-mcp-server/.env.example index c0e6ea7..9b21a78 100644 --- a/sdp-mcp-server/.env.example +++ b/sdp-mcp-server/.env.example @@ -2,7 +2,8 @@ NODE_ENV=development PORT=3000 HOST=0.0.0.0 -SERVER_ENDPOINTS=studio,studio.pttg.loc,192.168.2.10,10.212.0.7,localhost +SERVER_ENDPOINTS=localhost +SERVER_HOST=localhost # Hostname used in client connection strings (set to server IP/hostname for remote access) # Security ENCRYPTION_KEY=your-32-character-encryption-key-here @@ -14,7 +15,7 @@ DB_HOST=localhost DB_PORT=5433 DB_NAME=sdp_mcp DB_USER=sdpmcpservice -DB_PASSWORD=*jDE1Bj%IPXKMe%Z +DB_PASSWORD=YOUR_DB_PASSWORD DB_SSL=false DB_POOL_MIN=2 DB_POOL_MAX=10 @@ -22,7 +23,7 @@ DB_POOL_MAX=10 # Redis Configuration REDIS_HOST=localhost REDIS_PORT=6380 -REDIS_PASSWORD=R3d1s$3cur3P@ss +REDIS_PASSWORD=your-redis-password REDIS_TLS=false REDIS_DB=0 @@ -35,17 +36,13 @@ SDP_RETRY_ATTEMPTS=3 SDP_RETRY_DELAY_MS=1000 # Service Desk Plus OAuth Configuration -# Note: For custom domains (e.g., https://helpdesk.pttg.com), the instance URL -# will be stored per tenant in the database. These are default/fallback values. -SDP_BASE_URL=https://helpdesk.pttg.com # Custom domain URL (if using custom domain) -SDP_INSTANCE_NAME=itdesk # Your SDP instance name (e.g., 'itdesk' from https://helpdesk.pttg.com/app/itdesk/api/v3/) -SDP_PORTAL_NAME=kaltentech # Your portal name -SDP_DATA_CENTER=US # Options: US, EU, IN, AU, JP, CN -SDP_OAUTH_CLIENT_ID=your-client-id-from-self-client -SDP_OAUTH_CLIENT_SECRET=your-client-secret-from-self-client -SDP_OAUTH_REFRESH_TOKEN=your-permanent-refresh-token # Get this from OAuth setup -SDP_OAUTH_REDIRECT_URI=https://localhost:3000/callback -SDP_TENANT_ID=default # Use a unique ID for each tenant +# Variable names must match exactly — the server reads these specific keys. +SDP_BASE_URL=https://your-domain.com # Custom domain URL (e.g. https://helpdesk.example.com) +SDP_PORTAL_NAME=your-portal-name # Portal/instance name (segment after /app/ in the URL; also used as Zoho portal name) +SDP_DATA_CENTER=US # Options: US, EU, IN, AU, JP, UK, CA, CN +SDP_CLIENT_ID=your-client-id # OAuth self-client Client ID +SDP_CLIENT_SECRET=your-client-secret # OAuth self-client Client Secret +SDP_REFRESH_TOKEN=your-permanent-refresh-token # Permanent refresh token from OAuth setup # Simple SSE Server Configuration SDP_HTTP_PORT=3456 # Port for SSE server diff --git a/sdp-mcp-server/.sdp-tokens.json b/sdp-mcp-server/.sdp-tokens.json deleted file mode 100644 index ae216d5..0000000 --- a/sdp-mcp-server/.sdp-tokens.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "accessToken": "1000.cb7e5b982a6d416ded99cd9b3708b85b.1282a983f8d4dd9ad90ee22b854d89db", - "tokenExpiry": "2025-07-09T20:58:48.276Z", - "refreshToken": "1000.58376be1b900c8dba9c8cb277e07ab31.0766efe7060d6208a7c71b0b9d057936", - "savedAt": "2025-07-09T19:58:48.276Z" -} \ No newline at end of file diff --git a/sdp-mcp-server/OAUTH_SCOPE_COMBINATIONS.md b/sdp-mcp-server/OAUTH_SCOPE_COMBINATIONS.md index 6c9354b..ba82105 100644 --- a/sdp-mcp-server/OAUTH_SCOPE_COMBINATIONS.md +++ b/sdp-mcp-server/OAUTH_SCOPE_COMBINATIONS.md @@ -89,7 +89,7 @@ When generating the OAuth code: 1. Go to: https://accounts.zoho.com/oauth/v2/auth 2. Use these parameters: - - client_id: 1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU + - client_id: YOUR_CLIENT_ID - redirect_uri: https://localhost:3000/callback - response_type: code - access_type: offline @@ -98,7 +98,7 @@ When generating the OAuth code: 3. Example URL for combination #1: ``` -https://accounts.zoho.com/oauth/v2/auth?client_id=1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU&redirect_uri=https://localhost:3000/callback&response_type=code&access_type=offline&prompt=consent&scope=SDPOnDemand.requests.ALL,SDPOnDemand.setup.READ +https://accounts.zoho.com/oauth/v2/auth?client_id=YOUR_CLIENT_ID&redirect_uri=https://localhost:3000/callback&response_type=code&access_type=offline&prompt=consent&scope=SDPOnDemand.requests.ALL,SDPOnDemand.setup.READ ``` ## Notes diff --git a/sdp-mcp-server/RECOMMENDED_OAUTH_SCOPE.md b/sdp-mcp-server/RECOMMENDED_OAUTH_SCOPE.md index 5792857..75d6fdf 100644 --- a/sdp-mcp-server/RECOMMENDED_OAUTH_SCOPE.md +++ b/sdp-mcp-server/RECOMMENDED_OAUTH_SCOPE.md @@ -64,7 +64,7 @@ SDPOnDemand.requests.ALL,SDPOnDemand.problems.ALL,SDPOnDemand.changes.ALL,SDPOnD ## OAuth URL ``` -https://accounts.zoho.com/oauth/v2/auth?client_id=1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU&redirect_uri=https://localhost:3000/callback&response_type=code&access_type=offline&prompt=consent&scope=SDPOnDemand.requests.ALL,SDPOnDemand.problems.ALL,SDPOnDemand.changes.ALL,SDPOnDemand.solutions.ALL,SDPOnDemand.assets.READ,SDPOnDemand.setup.READ,SDPOnDemand.users.READ +https://accounts.zoho.com/oauth/v2/auth?client_id=YOUR_CLIENT_ID&redirect_uri=https://localhost:3000/callback&response_type=code&access_type=offline&prompt=consent&scope=SDPOnDemand.requests.ALL,SDPOnDemand.problems.ALL,SDPOnDemand.changes.ALL,SDPOnDemand.solutions.ALL,SDPOnDemand.assets.READ,SDPOnDemand.setup.READ,SDPOnDemand.users.READ ``` ## If This Fails diff --git a/sdp-mcp-server/package.json b/sdp-mcp-server/package.json index a5bf98a..dc099d6 100644 --- a/sdp-mcp-server/package.json +++ b/sdp-mcp-server/package.json @@ -67,7 +67,7 @@ "@types/express": "^4.17.0", "@types/jest": "^29.5.0", "@types/jsonwebtoken": "^9.0.0", - "@types/node": "^20.12.0", + "@types/node": "^22.0.0", "@types/pg": "^8.11.0", "@types/supertest": "^6.0.0", "@types/uuid": "^9.0.0", @@ -83,6 +83,6 @@ "typescript": "^5.4.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } } diff --git a/sdp-mcp-server/scripts/exchange-code.js b/sdp-mcp-server/scripts/exchange-code.js index 80921bc..05ed979 100644 --- a/sdp-mcp-server/scripts/exchange-code.js +++ b/sdp-mcp-server/scripts/exchange-code.js @@ -18,8 +18,8 @@ dotenv.config({ path: path.join(__dirname, '..', '.env') }); // Configuration const config = { - clientId: process.env.SDP_OAUTH_CLIENT_ID || '1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU', - clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET || '5752f7060c587171f81b21d58c5b8d0019587ca999', + clientId: process.env.SDP_OAUTH_CLIENT_ID || 'YOUR_CLIENT_ID', + clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET || 'YOUR_CLIENT_SECRET', portalName: process.env.SDP_PORTAL_NAME || 'kaltentech', dataCenter: process.env.SDP_DATA_CENTER || 'US', redirectUri: process.env.SDP_OAUTH_REDIRECT_URI || 'https://localhost:3000/callback' diff --git a/sdp-mcp-server/scripts/oauth-flow.js b/sdp-mcp-server/scripts/oauth-flow.js index eb16389..527d75b 100755 --- a/sdp-mcp-server/scripts/oauth-flow.js +++ b/sdp-mcp-server/scripts/oauth-flow.js @@ -22,8 +22,8 @@ dotenv.config({ path: path.join(__dirname, '..', '.env') }); // Configuration const config = { - clientId: process.env.SDP_OAUTH_CLIENT_ID || '1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU', - clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET || '5752f7060c587171f81b21d58c5b8d0019587ca999', + clientId: process.env.SDP_OAUTH_CLIENT_ID || 'YOUR_CLIENT_ID', + clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET || 'YOUR_CLIENT_SECRET', portalName: process.env.SDP_PORTAL_NAME || 'kaltentech', dataCenter: process.env.SDP_DATA_CENTER || 'US', redirectUri: 'http://localhost:8080/callback' // Local callback diff --git a/sdp-mcp-server/scripts/test-api-direct.js b/sdp-mcp-server/scripts/test-api-direct.js index 98bcf38..62d82ef 100755 --- a/sdp-mcp-server/scripts/test-api-direct.js +++ b/sdp-mcp-server/scripts/test-api-direct.js @@ -18,8 +18,8 @@ dotenv.config({ path: path.join(__dirname, '..', '.env') }); // Configuration from environment const config = { - clientId: process.env.SDP_OAUTH_CLIENT_ID || '1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU', - clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET || '5752f7060c587171f81b21d58c5b8d0019587ca999', + clientId: process.env.SDP_OAUTH_CLIENT_ID || 'YOUR_CLIENT_ID', + clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET || 'YOUR_CLIENT_SECRET', portalName: process.env.SDP_PORTAL_NAME || 'kaltentech', dataCenter: process.env.SDP_DATA_CENTER || 'US', redirectUri: process.env.SDP_OAUTH_REDIRECT_URI || 'https://localhost:3000/callback' diff --git a/sdp-mcp-server/src/index-sse-simple.ts b/sdp-mcp-server/src/index-sse-simple.ts deleted file mode 100644 index 0e7a7be..0000000 --- a/sdp-mcp-server/src/index-sse-simple.ts +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env node - -/** - * Simplified SSE Server for Service Desk Plus MCP - * Based on the working implementation from src/mcp-sse-sdp-integrated.js - */ - -import express from 'express'; -import cors from 'cors'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, -} from '@modelcontextprotocol/sdk/types.js'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import dotenv from 'dotenv'; -import { logger } from './monitoring/simpleLogging.js'; -import { SDPClient } from './sdp/simpleClient.js'; -import { getSimpleConfig } from './utils/simpleConfig.js'; - -// Load environment variables -dotenv.config(); - -// Get simple config -const config = getSimpleConfig(); - -// Create Express app -const app = express(); -app.use(cors()); -app.use(express.json()); - -// Initialize SDP client (simplified for now - single tenant) -const sdpClient = new SDPClient({ - baseUrl: config.sdp.baseUrl, - instanceName: config.sdp.instanceName, - clientId: process.env.SDP_OAUTH_CLIENT_ID!, - clientSecret: process.env.SDP_OAUTH_CLIENT_SECRET!, - refreshToken: process.env.SDP_OAUTH_REFRESH_TOKEN!, -}); - -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ - status: 'ok', - service: 'sdp-mcp-server', - version: '1.0.0', - environment: process.env.NODE_ENV || 'development' - }); -}); - -// SSE endpoint for MCP -app.get('/sse', async (req, res) => { - logger.info('New SSE connection established'); - - // Set SSE headers - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering - - // Initialize MCP server for this connection - const server = new Server( - { - name: "service-desk-plus", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // Import tools and handlers - const { tools } = await import('./server/tools/index.js'); - const { createSimpleToolHandler } = await import('./server/handlers/simpleToolHandler.js'); - - // Register tool handlers - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: tools.map(tool => ({ - name: tool.name, - description: tool.description, - inputSchema: zodToJsonSchema(tool.schema), - })), - }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - const tool = tools.find((t) => t.name === name); - if (!tool) { - throw new Error(`Tool not found: ${name}`); - } - - try { - // Validate arguments - const validatedArgs = tool.schema.parse(args); - - // Create and execute handler - const handler = createSimpleToolHandler(name, sdpClient); - const result = await handler(validatedArgs); - - return { - content: [ - { - type: "text", - text: typeof result === 'string' ? result : JSON.stringify(result, null, 2), - }, - ], - }; - } catch (error: any) { - logger.error(`Error executing tool ${name}:`, error); - return { - content: [ - { - type: "text", - text: `Error: ${error.message || 'Unknown error occurred'}` - } - ], - isError: true, - }; - } - }); - - // Create SSE transport - const transport = new SSEServerTransport('/sse', res); - - // Connect server to transport - await server.connect(transport); - - // Keep connection alive - const keepAlive = setInterval(() => { - res.write(':keepalive\n\n'); - }, 30000); - - // Handle client disconnect - req.on('close', () => { - logger.info('SSE connection closed'); - clearInterval(keepAlive); - server.close(); - }); -}); - -// Error handling middleware -app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.error('Server error:', err); - res.status(500).json({ error: 'Internal server error' }); -}); - -// Start server -const PORT = config.server.port || 3456; -const HOST = config.server.host || '0.0.0.0'; - -app.listen(PORT, HOST, () => { - logger.info(`🚀 SDP MCP Server (SSE) running at http://${HOST}:${PORT}`); - logger.info(`📡 SSE endpoint: http://${HOST}:${PORT}/sse`); - logger.info(`🏥 Health check: http://${HOST}:${PORT}/health`); -}); - -// Handle graceful shutdown -process.on('SIGINT', () => { - logger.info('👋 Shutting down server...'); - process.exit(0); -}); - -process.on('SIGTERM', () => { - logger.info('👋 Shutting down server...'); - process.exit(0); -}); \ No newline at end of file diff --git a/sdp-mcp-server/src/index.ts b/sdp-mcp-server/src/index.ts deleted file mode 100644 index 255e760..0000000 --- a/sdp-mcp-server/src/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node - -import dotenv from 'dotenv'; -import { createSDPMCPServer } from './server/index.js'; -import { logger } from './monitoring/logging.js'; -import { validateEnvironment } from './utils/config.js'; -import { connectDatabase } from './database/connection.js'; -import { connectRedis } from './utils/redis.js'; -import { runMigrations } from './database/migrations.js'; - -// Load environment variables -dotenv.config(); - -/** - * Main application entry point - */ -async function main(): Promise { - try { - // Validate environment configuration - logger.info('Validating environment configuration...'); - const config = validateEnvironment(); - - // Connect to PostgreSQL - logger.info('Connecting to PostgreSQL...'); - await connectDatabase(config.database); - - // Run database migrations - logger.info('Running database migrations...'); - await runMigrations(); - - // Connect to Redis - logger.info('Connecting to Redis...'); - await connectRedis(config.redis); - - // Create and start the MCP server - logger.info('Starting MCP server...'); - const server = createSDPMCPServer({ - port: config.server.port || 3000, - path: config.server.path || '/mcp', - cors: config.server.cors, - maxConnections: config.server.maxConnections || 100, - heartbeatInterval: config.server.heartbeatInterval || 30000, - }); - - // Start server with specified transport - const transport = config.server.transport as 'stdio' | 'sse' || 'sse'; - await server.start(transport); - - // Setup graceful shutdown - const shutdown = async (signal: string): Promise => { - logger.info(`Received ${signal}, initiating graceful shutdown...`); - - try { - await server.stop(); - logger.info('Server stopped successfully'); - process.exit(0); - } catch (error) { - logger.error('Error during shutdown:', error); - process.exit(1); - } - }; - - // Register shutdown handlers - process.on('SIGTERM', () => void shutdown('SIGTERM')); - process.on('SIGINT', () => void shutdown('SIGINT')); - - // Handle uncaught errors - process.on('uncaughtException', (error) => { - logger.error('Uncaught exception:', error); - void shutdown('uncaughtException'); - }); - - process.on('unhandledRejection', (reason, promise) => { - logger.error('Unhandled rejection at:', promise, 'reason:', reason); - void shutdown('unhandledRejection'); - }); - - logger.info('SDP MCP Server started successfully'); - logger.info(`Server endpoints: ${config.server.endpoints.join(', ')}`); - - } catch (error) { - logger.error('Failed to start server:', error); - process.exit(1); - } -} - -// Start the application -void main(); \ No newline at end of file diff --git a/sdp-mcp-server/src/mcp-prompts.cjs b/sdp-mcp-server/src/mcp-prompts.cjs new file mode 100644 index 0000000..81a1f97 --- /dev/null +++ b/sdp-mcp-server/src/mcp-prompts.cjs @@ -0,0 +1,115 @@ +'use strict'; + +const PROMPTS = [ + { + name: 'resolve_request', + description: 'Guide through the full resolution workflow for a Service Desk request: retrieve current state, update fields, send a reply to the requester, then close with a resolution.', + arguments: [ + { name: 'request_id', description: 'The SDP request ID to resolve', required: true }, + { name: 'resolution_summary', description: 'One-sentence summary of how the request was resolved', required: true } + ] + }, + { + name: 'triage_request', + description: 'Guide through request triage: retrieve the request, fetch valid field values, assign a technician and category, then send an acknowledgement reply to the requester.', + arguments: [ + { name: 'request_id', description: 'The SDP request ID to triage', required: true } + ] + }, + { + name: 'escalate_request', + description: 'Guide through escalating a request: retrieve current state, reassign to a new technician or group, add an internal private note with escalation context, then send a reply to the requester.', + arguments: [ + { name: 'request_id', description: 'The SDP request ID to escalate', required: true }, + { name: 'escalation_reason', description: 'Reason for escalation (used in the private note)', required: true } + ] + }, + { + name: 'follow_up_request', + description: 'Guide through following up on a request: retrieve the conversation history to review prior communication, then send a follow-up reply to the requester.', + arguments: [ + { name: 'request_id', description: 'The SDP request ID to follow up on', required: true } + ] + } +]; + +function listPrompts() { + return PROMPTS; +} + +function getPrompt(name, args = {}) { + const prompt = PROMPTS.find(p => p.name === name); + if (!prompt) return null; + + const messages = { + resolve_request: () => [{ + role: 'user', + content: { + type: 'text', + text: `Resolve Service Desk request #${args.request_id}. + +Step 1 — Call get_request with request_id="${args.request_id}" to confirm it is open and retrieve current field values. +Step 2 — Call update_request if any fields need correcting (category, subcategory, technician, site). +Step 3 — Call reply_to_requester with a clear explanation of the resolution: "${args.resolution_summary || 'Issue has been resolved.'}" +Step 4 — Call close_request with: + - resolution: { "content": "${args.resolution_summary || 'Issue has been resolved.'}" } + - closure_code: { "name": "Resolved" } + +Do not call update_request to set the resolution field separately — pass it directly to close_request.` + } + }], + + triage_request: () => [{ + role: 'user', + content: { + type: 'text', + text: `Triage Service Desk request #${args.request_id}. + +Step 1 — Call get_request with request_id="${args.request_id}" to review the subject, description, and current field values. +Step 2 — Call get_metadata to retrieve valid categories, priorities, and technician options if needed. +Step 3 — Call update_request to set the appropriate category, subcategory, priority, and assigned technician based on the request content. +Step 4 — Call reply_to_requester to send an acknowledgement to the requester confirming the request has been received and is being worked on.` + } + }], + + escalate_request: () => [{ + role: 'user', + content: { + type: 'text', + text: `Escalate Service Desk request #${args.request_id}. + +Escalation reason: ${args.escalation_reason || '(not specified)'} + +Step 1 — Call get_request with request_id="${args.request_id}" to confirm current assignee and status. +Step 2 — Call find_technician or list_technicians to identify the correct escalation target. +Step 3 — Call update_request to reassign the technician and/or group to the escalation target. +Step 4 — Call add_private_note with the escalation context: "${args.escalation_reason || 'Escalated due to complexity or urgency.'}" — this note is NOT visible to the requester. +Step 5 — Call reply_to_requester to inform the requester that their request has been escalated and is receiving priority attention.` + } + }], + + follow_up_request: () => [{ + role: 'user', + content: { + type: 'text', + text: `Follow up on Service Desk request #${args.request_id}. + +Step 1 — Call get_request_conversation with request_id="${args.request_id}" to review all prior communication and notes. +Step 2 — Review the conversation to understand what has already been communicated and what outstanding questions exist. +Step 3 — Call reply_to_requester with a follow-up message that addresses any outstanding items or provides a status update. + +Do not repeat information already communicated. Keep the reply concise and action-oriented.` + } + }] + }; + + const messageFn = messages[name]; + if (!messageFn) return null; + + return { + description: prompt.description, + messages: messageFn() + }; +} + +module.exports = { listPrompts, getPrompt }; diff --git a/sdp-mcp-server/src/mcp-resources.cjs b/sdp-mcp-server/src/mcp-resources.cjs new file mode 100644 index 0000000..425dd5f --- /dev/null +++ b/sdp-mcp-server/src/mcp-resources.cjs @@ -0,0 +1,215 @@ +'use strict'; + +const SDP_RESOURCES = { + 'sdp://usage/api-rules': { + uri: 'sdp://usage/api-rules', + name: 'Critical API Rules', + description: 'Mandatory rules that apply to every SDP tool call — field object formats, character limits, and constraints', + mimeType: 'text/plain', + text: `CRITICAL API RULES — apply to every tool call without exception + +REQUEST ID — CRITICAL: +- ALWAYS use the full internal ID (e.g. 97837000038081358) — never the short display ID (e.g. 29257) +- The display ID is the short number shown in the SDP portal — it is NOT the request_id field +- Obtain the internal ID from get_request or list_requests before calling any mutating tool +- If you only have a display ID, call get_request first (it resolves display IDs automatically) + +FIELD FORMATS: +- All reference fields must be objects, never plain strings. + Correct: { "name": "Hardware" } Wrong: "Hardware" +- Resolution field format: { "content": "your text here" } +- Closure code format: { "name": "Resolved" } (not a plain string) +- Authorization header: Zoho-oauthtoken — NOT Bearer +- input_data is sent as a URL query parameter on all methods +- start_index is 1-based for pagination +- row_count maximum is 100 per request +- Subject field: 250 character maximum +- impact_details field: 250 character maximum +- You cannot update a closed request — always check status first with get_request + +STATUS CHANGE COMMENTS: +- Setting status to "On Hold" or "Cancelled" requires status_change_comments — always include it` + }, + + 'sdp://usage/field-formats': { + uri: 'sdp://usage/field-formats', + name: 'Field Format Reference', + description: 'Correct object format for every SDP field, including priority name map and closure codes', + mimeType: 'text/plain', + text: `FIELD FORMAT REFERENCE + +REQUEST ID +- Full internal ID: 97837000038081358 ← use this in all request_id fields +- Short display ID: 29257 ← shown in portal, NEVER pass as request_id +- If you only have a display ID, call get_request first to resolve it + +Field | Correct format +-------------|------------------------------------------------ +request_id | "97837000038081358" (17-digit internal ID) +Category | { "name": "Software / Platform" } +Subcategory | { "name": "MS Office" } +Site | { "name": "Berlin" } +Technician | { "email_id": "jthomas@example.com" } +Status | { "name": "In Progress" } +Priority | { "name": "z - Medium" } +Closure code | { "name": "Resolved" } +Resolution | { "content": "Issue resolved by resetting the user's password." } + +PRIORITY NAME MAP +Low → "1 - Low" +Medium → "z - Medium" +High → "3 - High" +Critical → "4 - Critical" + +AVAILABLE CLOSURE CODES: Resolved, Cancelled, Duplicate, Closed, On Hold, Open + +STATUS CHANGE COMMENTS +- "On Hold" and "Cancelled" statuses require status_change_comments in the same call` + }, + + 'sdp://usage/tool-reference': { + uri: 'sdp://usage/tool-reference', + name: 'Tool Reference', + description: 'All SDP tools with purpose, when-to-use guidance, and required fields', + mimeType: 'text/plain', + text: `SDP TOOL REFERENCE + +get_request — Retrieve full details of a single request by ID. + Required: request_id (full internal ID preferred; display IDs are resolved automatically) + Use: Before any action sequence. Always use the returned .id value as request_id in subsequent calls. + IMPORTANT: The id field in the response is the internal ID to use — not display_id. + +list_requests — List requests with optional filters and pagination. + Params: status, technician_email, row_count (max 100), start_index, sort_by, sort_order + +search_requests — Search requests using simple criteria. + Params: query (keyword string), limit + +advanced_search_requests — Complex multi-field search with AND/OR logic. + Params: criteria array of { field, condition, value, logical_operator } + +create_request — Create a new service desk request. + Required: subject (max 250 chars) + Optional: description, requester_email, category, subcategory, site, technician, priority + +update_request — Update fields on an existing open request. + Required: request_id (full internal ID — NOT the display ID) + Updatable: category, subcategory, site, technician, status, priority, group, urgency, impact, level + Note: status "On Hold" or "Cancelled" also requires status_change_comments + +close_request — Close a request with resolution and closure code. + Required: request_id, resolution { "content": "..." }, closure_code { "name": "Resolved" } + Note: Handles both resolution and closure in one call. Do not call update_request first. + +delete_request — Permanently delete a request. Irreversible. Explicit instruction only. + Required: request_id + +reply_to_requester — Send email reply visible in the request conversation thread. + Required: request_id, reply_message + Note: ALWAYS use this to email the requester — not add_note. + +send_first_response — First formal response for SLA first-response tracking. + Required: request_id, response_message + Note: Only for the very first response. Use reply_to_requester for all subsequent replies. + +add_note — Add a public note visible to the requester (no email sent). + Required: request_id, note_content + +add_private_note — Add an internal note NOT visible to the requester. + Required: request_id, note_content + +get_request_conversation — Retrieve full conversation history. + Required: request_id + +list_technicians — List all available technicians. + +get_technician — Get detailed technician information by ID. + Required: technician_id + +find_technician — Look up a technician by name or email. + Required: search_term + +get_metadata — Retrieve valid dropdown values for SDP fields. + Use: Before create/update if unsure whether a field value is valid. + +add_attachment — Attach a file to an existing request. + Required: request_id, file_path + +get_usage_guide — Retrieve a specific section of the SDP usage guide as context. + Params: section (api-rules | tool-reference | field-formats | error-codes | action-sequences | decision-matrix)` + }, + + 'sdp://usage/error-codes': { + uri: 'sdp://usage/error-codes', + name: 'Error Code Reference', + description: 'SDP API error codes and recommended actions', + mimeType: 'text/plain', + text: `ERROR CODE REFERENCE + +Code | Meaning | Action +-----------------|---------------------------------------|---------------------------------- +401/UNAUTHORISED | OAuth token invalid or expired | Token refresh handled automatically +403 | Permission denied (e.g. priority) | API limitation — skip the field +400/4012 | Mandatory field missing | Check required fields for the operation +4000 | General failure | Read the error_messages array for detail +4002 | Unauthorised | Verify portal name, instance name, and domain` + }, + + 'sdp://usage/action-sequences': { + uri: 'sdp://usage/action-sequences', + name: 'Common Action Sequences', + description: 'Standard tool call sequences for typical ITSM workflows', + mimeType: 'text/plain', + text: `COMMON ACTION SEQUENCES + +Standard Solution: + get_request → update_request → reply_to_requester → close_request + +Follow-Up Question: + get_request → update_request → reply_to_requester + +Escalation: + get_request → update_request (reassign technician) → add_private_note → reply_to_requester` + }, + + 'sdp://usage/decision-matrix': { + uri: 'sdp://usage/decision-matrix', + name: 'Tool Decision Matrix', + description: 'Which tool to use for each ITSM task, including out-of-scope tools to avoid', + mimeType: 'text/plain', + text: `TOOL DECISION MATRIX + +Task | Tool +--------------------------------------------------|--------------------------- +Check if a request exists / get current state | get_request +Update category, subcategory, site, technician | update_request +Send a reply the requester receives by email | reply_to_requester +Add an internal note for the technician only | add_private_note +Add a note visible to the requester (no email) | add_note +Close a resolved request | close_request +Find requests matching criteria | search_requests or advanced_search_requests +Validate a field value before writing | get_metadata +Check prior communication | get_request_conversation + +OUT OF SCOPE — never call as part of request fulfilment: +- claude_code_command — developer utility only +- delete_request — irreversible, requires explicit instruction +- send_first_response — only for first-response SLA tracking +- create_request — fulfilment agents do not create requests` + } +}; + +function listResources() { + return Object.values(SDP_RESOURCES).map(r => ({ + uri: r.uri, + name: r.name, + description: r.description, + mimeType: r.mimeType + })); +} + +function readResource(uri) { + return SDP_RESOURCES[uri] || null; +} + +module.exports = { SDP_RESOURCES, listResources, readResource }; diff --git a/sdp-mcp-server/src/sdp-api-client-enhanced.cjs b/sdp-mcp-server/src/sdp-api-client-enhanced.cjs deleted file mode 100644 index e1b4a26..0000000 --- a/sdp-mcp-server/src/sdp-api-client-enhanced.cjs +++ /dev/null @@ -1,331 +0,0 @@ -/** - * Enhanced Service Desk Plus API Client - * Fixed for proper API field names and better error handling - */ - -const axios = require('axios'); -const { SDPOAuthClient } = require('./sdp-oauth-client.cjs'); - -class SDPAPIClientEnhanced { - constructor(config = {}) { - // Use the correct portal configuration - this.portalName = config.portalName || process.env.SDP_PORTAL_NAME || 'kaltentech'; - this.dataCenter = config.dataCenter || process.env.SDP_DATA_CENTER || 'US'; - this.customDomain = config.customDomain || process.env.SDP_BASE_URL || 'https://helpdesk.pttg.com'; - this.instanceName = config.instanceName || process.env.SDP_INSTANCE_NAME || 'itdesk'; - - // Initialize OAuth client - this.oauth = new SDPOAuthClient(config); - - // API base URLs by data center - this.apiEndpoints = { - US: 'https://sdpondemand.manageengine.com', - EU: 'https://sdpondemand.manageengine.eu', - IN: 'https://sdpondemand.manageengine.in', - AU: 'https://sdpondemand.manageengine.com.au', - JP: 'https://sdpondemand.manageengine.jp', - UK: 'https://sdpondemand.manageengine.uk', - CA: 'https://sdpondemand.manageengine.ca', - CN: 'https://sdpondemand.manageengine.cn' - }; - - // Create axios instance - use custom domain if available - const baseURL = this.customDomain - ? `${this.customDomain}/app/${this.instanceName}/api/v3` - : `${this.getAPIEndpoint()}/app/${this.portalName}/api/v3`; - - this.client = axios.create({ - baseURL, - timeout: 30000, - headers: { - 'Accept': 'application/vnd.manageengine.sdp.v3+json' - } - }); - - // Add request interceptor for auth - this.client.interceptors.request.use( - async (config) => { - const token = await this.oauth.getAccessToken(); - config.headers['Authorization'] = `Bearer ${token}`; - console.error(`API Request: ${config.method.toUpperCase()} ${config.baseURL}${config.url}`); - if (config.params?.input_data) { - console.error('Request payload:', config.params.input_data); - } - return config; - }, - (error) => Promise.reject(error) - ); - - // Add response interceptor for error handling - this.client.interceptors.response.use( - (response) => { - console.error(`API Response: ${response.status} ${response.statusText}`); - return response; - }, - async (error) => { - if (error.response?.status === 401) { - // Token expired, try to refresh and retry - console.error('Got 401, attempting token refresh...'); - await this.oauth.refreshAccessToken(); - - // Retry the original request - const originalRequest = error.config; - const token = await this.oauth.getAccessToken(); - originalRequest.headers['Authorization'] = `Bearer ${token}`; - return this.client(originalRequest); - } - - // Log detailed error information - if (error.response) { - console.error('API Error Response:', { - status: error.response.status, - statusText: error.response.statusText, - data: JSON.stringify(error.response.data, null, 2) - }); - } - - return Promise.reject(this.formatError(error)); - } - ); - } - - /** - * Get API endpoint for the configured data center - */ - getAPIEndpoint() { - return this.apiEndpoints[this.dataCenter] || this.apiEndpoints.US; - } - - /** - * Format API errors for better handling - */ - formatError(error) { - if (error.response) { - const { status, data } = error.response; - - // Handle specific SDP error formats - if (data.response_status) { - const messages = data.response_status.messages || []; - const message = messages.map(m => m.message).join('; ') || 'API Error'; - return { - code: data.response_status.status_code || status, - message, - details: data, - statusCode: data.response_status.status_code - }; - } - - return { - code: status, - message: data.message || 'API Error', - details: data - }; - } - return { - code: 'NETWORK_ERROR', - message: error.message, - details: error - }; - } - - /** - * List requests with filters - */ - async listRequests(options = {}) { - const { limit = 10, offset = 0, status, priority, sortBy = 'created_time', sortOrder = 'desc' } = options; - - const listInfo = { - row_count: limit, - start_index: offset, - sort_field: sortBy, - sort_order: sortOrder - }; - - // Add filters if provided - if (status || priority) { - listInfo.search_fields = {}; - if (status) listInfo.search_fields.status = { name: status }; - if (priority) listInfo.search_fields.priority = { name: priority }; - } - - const params = { - input_data: JSON.stringify({ list_info: listInfo }) - }; - - const response = await this.client.get('/requests', { params }); - return { - requests: response.data.requests || [], - total_count: response.data.list_info?.total_count || 0, - has_more: response.data.list_info?.has_more_rows || false - }; - } - - /** - * Get request details - */ - async getRequest(requestId) { - const response = await this.client.get(`/requests/${requestId}`); - return response.data.request; - } - - /** - * Create a new request with proper field validation - */ - async createRequest(requestData) { - const { subject, description, priority = 'medium', category, requester_email, requester_name } = requestData; - - // Validate required fields - if (!subject) { - throw new Error('Subject is required for creating a request'); - } - - const request = { - subject, - description: description || '' - }; - - // Add optional fields only if provided - if (priority) { - request.priority = { name: priority }; - } - - if (category) { - request.category = { name: category }; - } - - // Handle requester - SDP expects specific format - if (requester_email || requester_name) { - request.requester = {}; - if (requester_email) request.requester.email_id = requester_email; - if (requester_name) request.requester.name = requester_name; - } - - const params = { - input_data: JSON.stringify({ request }) - }; - - try { - const response = await this.client.post('/requests', null, { params }); - return response.data.request; - } catch (error) { - // Provide more helpful error messages - if (error.statusCode === 4000) { - throw new Error(`Failed to create request: ${error.message}. Check if all required fields are provided and category/priority values are valid.`); - } - throw error; - } - } - - /** - * Update a request - */ - async updateRequest(requestId, updates) { - const request = {}; - - // Map common update fields - if (updates.subject) request.subject = updates.subject; - if (updates.description) request.description = updates.description; - if (updates.status) request.status = { name: updates.status }; - if (updates.priority) request.priority = { name: updates.priority }; - if (updates.category) request.category = { name: updates.category }; - - const params = { - input_data: JSON.stringify({ request }) - }; - - const response = await this.client.put(`/requests/${requestId}`, null, { params }); - return response.data.request; - } - - /** - * Close a request - */ - async closeRequest(requestId, closeData) { - const { closure_comments, closure_code = 'Resolved' } = closeData; - - const request = { - status: { name: 'Closed' }, - closure_info: { - closure_code: { name: closure_code }, - closure_comments: closure_comments || 'Request closed' - } - }; - - const params = { - input_data: JSON.stringify({ request }) - }; - - const response = await this.client.put(`/requests/${requestId}`, null, { params }); - return response.data.request; - } - - /** - * Add a note to a request - Fixed for proper API format - */ - async addNote(requestId, noteContent, isPublic = true) { - // SDP API expects 'request_note' not 'note' - const request_note = { - note_text: noteContent, // Changed from 'description' to 'note_text' - show_to_requester: isPublic - }; - - const params = { - input_data: JSON.stringify({ request_note }) - }; - - try { - const response = await this.client.post(`/requests/${requestId}/notes`, null, { params }); - return response.data.note || response.data.request_note; - } catch (error) { - // If that doesn't work, try the alternate format - if (error.statusCode === 4000 && error.message.includes('EXTRA_KEY_FOUND')) { - console.error('Trying alternate note format...'); - - // Try with just 'note' object - const note = { - description: noteContent, - show_to_requester: isPublic - }; - - const altParams = { - input_data: JSON.stringify({ note }) - }; - - const response = await this.client.post(`/requests/${requestId}/notes`, null, { params: altParams }); - return response.data.note; - } - throw error; - } - } - - /** - * Search requests - */ - async searchRequests(query, options = {}) { - const { limit = 10, offset = 0 } = options; - - const listInfo = { - row_count: limit, - start_index: offset, - search_criteria: [ - { - field: 'subject', - condition: 'contains', - value: query - } - ] - }; - - const params = { - input_data: JSON.stringify({ list_info: listInfo }) - }; - - const response = await this.client.get('/requests', { params }); - return { - requests: response.data.requests || [], - total_count: response.data.list_info?.total_count || 0 - }; - } -} - -module.exports = { SDPAPIClientEnhanced }; \ No newline at end of file diff --git a/sdp-mcp-server/src/sdp-api-client-v2.cjs b/sdp-mcp-server/src/sdp-api-client-v2.cjs index 52e27b1..efd7a1f 100644 --- a/sdp-mcp-server/src/sdp-api-client-v2.cjs +++ b/sdp-mcp-server/src/sdp-api-client-v2.cjs @@ -9,27 +9,37 @@ const { SDPMetadataClient } = require('./sdp-api-metadata.cjs'); const { SDPUsersAPI } = require('./sdp-api-users.cjs'); const errorLogger = require('./utils/error-logger.cjs'); +// Canonical priority name map for this SDP instance. +// 'z - Medium' is the actual name configured in this tenant — do not change to '2 - Medium'. +const PRIORITY_NAMES = { + 'low': '1 - Low', + 'medium': 'z - Medium', + 'high': '3 - High', + 'urgent': '4 - Critical' +}; + class SDPAPIClientV2 { constructor(config = {}) { // Configuration - this.portalName = config.portalName || process.env.SDP_PORTAL_NAME || 'kaltentech'; + this.portalName = config.portalName || process.env.SDP_PORTAL_NAME; this.dataCenter = config.dataCenter || process.env.SDP_DATA_CENTER || 'US'; - this.customDomain = config.customDomain || process.env.SDP_BASE_URL || 'https://helpdesk.pttg.com'; - this.instanceName = config.instanceName || process.env.SDP_INSTANCE_NAME || 'itdesk'; - + this.customDomain = config.customDomain || process.env.SDP_BASE_URL; + this.instanceName = config.instanceName || process.env.SDP_PORTAL_NAME; + // Initialize clients (use singleton OAuth client) this.oauth = SDPOAuthClient.getInstance(config); this.metadata = new SDPMetadataClient(config); - + // Create axios instance // Check if we should use mock API for testing const useMock = process.env.SDP_USE_MOCK === 'true' || process.env.SDP_USE_MOCK_API === 'true'; const baseURL = useMock - ? `${process.env.SDP_BASE_URL || 'http://localhost:3457'}/app/${this.instanceName}/api/v3` - : this.customDomain - ? `${this.customDomain}/app/${this.instanceName}/api/v3` - : `https://sdpondemand.manageengine.com/app/${this.portalName}/api/v3`; + ? `${process.env.SDP_BASE_URL || 'http://localhost:3457'}/api/v3` + : this.customDomain + ? `${this.customDomain}/api/v3` + : `https://sdpondemand.manageengine.com/api/v3`; + console.error(`SDP API baseURL: ${baseURL}`); if (useMock) { console.error('🧪 Using MOCK Service Desk Plus API:', baseURL); } @@ -40,7 +50,13 @@ class SDPAPIClientV2 { baseURL, timeout: 30000, headers: { - 'Accept': 'application/vnd.manageengine.sdp.v3+json' + 'Accept': 'application/vnd.manageengine.sdp.v3+json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + paramsSerializer: { + serialize: (params) => Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') } }); @@ -58,6 +74,8 @@ class SDPAPIClientV2 { console.error(`API Request: ${config.method.toUpperCase()} ${config.url}`); if (config.params?.input_data) { console.error('Payload:', JSON.stringify(JSON.parse(config.params.input_data), null, 2)); + const encodedQS = `input_data=${encodeURIComponent(String(config.params.input_data))}`; + console.error(`Full URL: ${config.baseURL}${config.url}?${encodedQS}`); } return config; }, @@ -67,9 +85,23 @@ class SDPAPIClientV2 { // Response interceptor this.client.interceptors.response.use( (response) => { + // Log response summary for list endpoints so empty-result searches are visible + if (response.data?.list_info !== undefined) { + const li = response.data.list_info; + const itemCount = li.total_count ?? + response.data.requests?.length ?? response.data.notes?.length ?? + response.data.technicians?.length ?? response.data.problems?.length ?? 'n/a'; + console.error(`API Response: ${response.status} — total_count=${itemCount} has_more=${li.has_more_rows ?? false} start_index=${li.start_index ?? 'n/a'}`); + const hasResults = response.data.requests?.length || response.data.notes?.length || + response.data.technicians?.length || response.data.problems?.length; + if ((li.total_count === 0 || !hasResults) && response.config?.params?.input_data) { + console.error('Empty result — search_criteria sent:', JSON.stringify( + JSON.parse(response.config.params.input_data)?.list_info?.search_criteria ?? 'none' + )); + } + } // Some responses have response_status array instead of object if (Array.isArray(response.data?.response_status)) { - // This is actually a successful response with data return response; } return response; @@ -124,17 +156,24 @@ class SDPAPIClientV2 { errorMessage.toLowerCase().includes(pattern) ); + // Log the actual 401 response body to diagnose auth failures + console.error('401 response body:', JSON.stringify(errorData)); + if (!shouldSkipRefresh) { + // Guard against infinite retry loop — only retry once per request + if (error.config._retry) { + console.error('Got 401 again after token refresh — token is valid but API still rejects. Check portal name and scopes.'); + return Promise.reject(error); + } + error.config._retry = true; console.error('Got 401 Unauthorized, attempting token refresh...'); try { await this.oauth.refreshAccessToken(); - const originalRequest = error.config; const token = await this.oauth.getAccessToken(); - originalRequest.headers['Authorization'] = `Zoho-oauthtoken ${token}`; - return this.client(originalRequest); + error.config.headers['Authorization'] = `Zoho-oauthtoken ${token}`; + return this.client(error.config); } catch (refreshError) { console.error('Token refresh failed:', refreshError.message); - // Don't retry if refresh fails return Promise.reject(error); } } else { @@ -260,20 +299,20 @@ class SDPAPIClientV2 { */ async listRequests(options = {}) { const { limit = 10, offset = 0, status, priority, sortBy = 'created_time', sortOrder = 'desc' } = options; - + await this.ensureMetadata(); - + // Enforce API maximum of 100 rows per request const rowCount = Math.min(limit, 100); - + const listInfo = { row_count: rowCount, start_index: offset, sort_field: sortBy, sort_order: sortOrder, - get_total_count: true // Request total count for pagination + get_total_count: true }; - + // Add filters - try filter_by for simple filters first if (status && !priority) { // Single status filter - use filter_by @@ -291,50 +330,25 @@ class SDPAPIClientV2 { }; } else if (priority && !status) { // Single priority filter - use filter_by - const priorityMap = { - 'low': '1 - Low', - 'medium': 'z - Medium', - 'high': '3 - High', - 'urgent': '4 - Critical' - }; - const priorityName = priorityMap[priority.toLowerCase()] || priority; + const priorityName = PRIORITY_NAMES[priority.toLowerCase()] || priority; listInfo.filter_by = { name: 'priority.name', value: priorityName }; } else if (status && priority) { - // Multiple filters - use search_criteria - const searchCriteria = []; - + // Multiple filters — use children nested format (flat array causes Tomcat 400) const statusMap = { - 'open': 'Open', - 'closed': 'Closed', - 'pending': 'On Hold', - 'resolved': 'Resolved', - 'in progress': 'In Progress' + 'open': 'open', 'closed': 'closed', 'pending': 'on hold', + 'resolved': 'resolved', 'in progress': 'in progress' }; - const statusName = statusMap[status.toLowerCase()] || status; - searchCriteria.push({ + const statusName = statusMap[status.toLowerCase()] || status.toLowerCase(); + const priorityName = PRIORITY_NAMES[priority.toLowerCase()] || priority; + listInfo.search_criteria = { field: 'status.name', condition: 'is', - value: statusName - }); - - const priorityMap = { - 'low': '1 - Low', - 'medium': 'z - Medium', - 'high': '3 - High', - 'urgent': '4 - Critical' + value: statusName, + children: [{ field: 'priority.name', condition: 'is', value: priorityName, logical_operator: 'AND' }] }; - const priorityName = priorityMap[priority.toLowerCase()] || priority; - searchCriteria.push({ - field: 'priority.name', - condition: 'is', - value: priorityName, - logical_operator: 'AND' - }); - - listInfo.search_criteria = searchCriteria; } const params = { @@ -393,9 +407,9 @@ class SDPAPIClientV2 { */ async createRequest(requestData) { const { - subject, - description, - priority = 'medium', + subject, + description, + priority, category, subcategory, requester, @@ -439,165 +453,65 @@ class SDPAPIClientV2 { await this.ensureMetadata(); - const request = { - subject, - description: description || '', - // All required fields based on API error - mode: mode ? { name: mode } : { name: 'Web Form' }, - request_type: request_type ? { name: request_type } : { name: 'Incident' }, - urgency: urgency ? { name: urgency } : { name: '2 - General Concern' }, // Valid urgency - level: level ? { name: level } : { name: '1 - Frontline' }, // Valid level - impact: impact ? { name: impact } : { name: '1 - Affects User' }, - category: category ? { name: category } : { name: 'Software' }, // Default category - status: { name: 'Open' } - }; - - // Add optional fields - if (impact_details) { - request.impact_details = impact_details; - } - - if (email_ids_to_notify && Array.isArray(email_ids_to_notify)) { - request.email_ids_to_notify = email_ids_to_notify; - } - - if (due_by_time) { - request.due_by_time = due_by_time; - } - - if (first_response_due_by_time) { - request.first_response_due_by_time = first_response_due_by_time; - } - - if (assets && Array.isArray(assets)) { - request.assets = assets; - } - - if (configuration_items && Array.isArray(configuration_items)) { - request.configuration_items = configuration_items; - } - - if (udf_fields && typeof udf_fields === 'object') { - request.udf_fields = udf_fields; - } - - if (template) { - request.template = typeof template === 'string' ? { name: template } : template; - } - - if (site) { - request.site = typeof site === 'string' ? { name: site } : site; - } - - if (group) { - request.group = typeof group === 'string' ? { name: group } : group; - } - - if (service_category) { - request.service_category = typeof service_category === 'string' ? { name: service_category } : service_category; - } - - if (service_approvers) { - request.service_approvers = service_approvers; - } - - if (resources) { - request.resources = resources; + // Only include fields that were explicitly provided — let SDP apply its own + // template defaults rather than injecting values that may be wrong for this instance. + const request = { subject }; + + if (description) request.description = description; + if (mode) request.mode = { name: mode }; + if (request_type) request.request_type = { name: request_type }; + if (urgency) request.urgency = { name: urgency }; + if (level) request.level = { name: level }; + if (impact) request.impact = { name: impact }; + if (impact_details) request.impact_details = impact_details; + if (due_by_time) request.due_by_time = due_by_time; + if (first_response_due_by_time) request.first_response_due_by_time = first_response_due_by_time; + if (assets?.length) request.assets = assets; + if (configuration_items?.length) request.configuration_items = configuration_items; + if (udf_fields) request.udf_fields = udf_fields; + if (template) request.template = typeof template === 'string' ? { name: template } : template; + if (site) request.site = typeof site === 'string' ? { name: site } : site; + if (group) request.group = typeof group === 'string' ? { name: group } : group; + if (service_category) request.service_category = typeof service_category === 'string' ? { name: service_category } : service_category; + if (service_approvers) request.service_approvers = service_approvers; + if (resources) request.resources = resources; + if (email_ids_to_notify?.length) request.email_ids_to_notify = email_ids_to_notify; + + if (priority) { + request.priority = { name: PRIORITY_NAMES[priority.toLowerCase()] || priority }; } - - // SKIP priority on creation - business rules prevent it (error 4002) - // Let SDP use its default priority setting - // Priority can be updated after creation if needed - // if (priority) { - // const priorityMap = { - // 'low': '1 - Low', - // 'medium': '2 - Normal', - // 'high': '3 - High', - // 'urgent': '4 - Critical' - // }; - // const priorityName = priorityMap[priority.toLowerCase()] || priority; - // request.priority = { name: priorityName }; - // console.error(`Using priority name: "${priorityName}"`); - // } - console.error('Skipping priority on creation due to business rules - will use SDP default'); - - // Use category ID + if (category) { - // Handle both object and string formats if (typeof category === 'object' && category.id) { request.category = category; - } else if (typeof category === 'string') { + } else { const categoryId = this.metadata.getCategoryId(category); - // Only set if we got a valid ID, not the same string back - if (categoryId && categoryId !== category) { - request.category = { id: categoryId }; - } else { - console.error(`Warning: Could not find category ID for "${category}"`); - // Use name format as fallback - request.category = { name: category }; - } + request.category = (categoryId && categoryId !== category) ? { id: categoryId } : { name: category }; } } - - // Add subcategory - this is often required + if (subcategory) { - // Handle both object and string formats - if (typeof subcategory === 'object' && subcategory.id) { - request.subcategory = subcategory; - } else if (typeof subcategory === 'string') { - // Map common subcategory names to valid ones - const subcategoryMap = { - 'printer': 'Printer/Scanner', - 'printers': 'Printer/Scanner', - 'scanner': 'Printer/Scanner', - 'scanners': 'Printer/Scanner' - }; - const mappedSubcategory = subcategoryMap[subcategory.toLowerCase()] || subcategory; - request.subcategory = { name: mappedSubcategory }; - } - } else if (request.category) { - // Default subcategory - always add one since it's often required - // IMPORTANT: Use validated subcategories that exist in the system - const categoryName = request.category.name || ''; - const categoryId = request.category.id || '0'; - - if (categoryId === '216826000000006689' || categoryName === 'Software') { - // Use a known valid subcategory for Software - request.subcategory = { name: 'Not in list' }; - console.error('Using default Software subcategory: Not in list'); - } else if (categoryId === '216826000000288100' || categoryName === 'Hardware') { - // Use a valid subcategory for Hardware - request.subcategory = { name: 'Not in list' }; - console.error('Using default Hardware subcategory: Not in list'); - } else { - // Generic default subcategory - use Not in list as it's commonly available - request.subcategory = { name: 'Not in list' }; - console.error('Using default subcategory: Not in list'); - } + request.subcategory = typeof subcategory === 'object' ? subcategory : { name: subcategory }; } - - // Skip requester field for now to avoid validation issues - // The API user will be used as the requester automatically - // This prevents error 4001 with invalid email addresses - if (requester_email || requester_name || requester) { - console.error('Note: Requester field skipped to avoid validation errors - API user will be used as requester'); - } else { - console.error('No requester specified, SDP will use API user as requester'); + + if (requester_email) { + request.requester = { email_id: requester_email.toLowerCase() }; + } else if (requester_name) { + request.requester = { name: requester_name }; + } else if (requester) { + request.requester = typeof requester === 'string' ? { name: requester } : requester; } - - // Add technician assignment if provided + if (technician_id) { request.technician = { id: technician_id }; } else if (technician_email) { - // Use technician email directly - the /users endpoint doesn't exist in SDP Cloud API - console.error(`Using technician email directly: ${technician_email}`); - request.technician = { email_id: technician_email }; + request.technician = { email_id: technician_email.toLowerCase() }; } - + const params = { input_data: JSON.stringify({ request }) }; - + try { const response = await this.client.post('/requests', null, { params }); return response.data.request; @@ -724,7 +638,9 @@ class SDPAPIClientV2 { } if (updates.resolution) { - request.resolution = updates.resolution; + request.resolution = typeof updates.resolution === 'string' + ? { content: updates.resolution } + : updates.resolution; } if (updates.closure_info) { @@ -770,18 +686,20 @@ class SDPAPIClientV2 { } } + if (updates.status_change_comments) { + const resolvedStatus = request.status?.name?.toLowerCase(); + if (resolvedStatus === 'on hold') { + request.onhold_scheduler = { comments: updates.status_change_comments }; + } else { + request.status_change_comments = updates.status_change_comments; + } + } + if (updates.priority) { - // Use priority name format - const priorityMap = { - 'low': '1 - Low', - 'medium': 'z - Medium', - 'high': '3 - High', - 'urgent': '4 - Critical' - }; - const priorityName = priorityMap[updates.priority.toLowerCase()] || updates.priority; + const priorityName = PRIORITY_NAMES[updates.priority.toLowerCase()] || updates.priority; request.priority = { name: priorityName }; } - + if (updates.category) { const categoryId = this.metadata.getCategoryId(updates.category); if (categoryId && categoryId !== updates.category) { @@ -800,9 +718,8 @@ class SDPAPIClientV2 { if (updates.technician_id) { request.technician = { id: updates.technician_id }; } else if (updates.technician_email) { - // Use technician email directly - the /users endpoint doesn't exist in SDP Cloud API console.error(`Using technician email directly: ${updates.technician_email}`); - request.technician = { email_id: updates.technician_email }; + request.technician = { email_id: updates.technician_email.toLowerCase() }; } const params = { @@ -948,8 +865,11 @@ class SDPAPIClientV2 { */ async getRequestConversation(requestId) { try { - const response = await this.client.get(`/requests/${requestId}/notes`); - return response.data.request_notes || []; + const params = { + input_data: JSON.stringify({ list_info: { row_count: '100', sort_field: 'created_time', sort_order: 'asc' } }) + }; + const response = await this.client.get(`/requests/${requestId}/notes`, { params }); + return response.data.notes || []; } catch (error) { console.error('Failed to get request conversation:', error.message); throw error; @@ -968,38 +888,158 @@ class SDPAPIClientV2 { * @returns {Promise} The closed request object */ async closeRequest(requestId, closeData) { - await this.ensureMetadata(); - - const { closure_comments, closure_code, status = 'Closed', resolution } = closeData; - - // Try closing with just closure_comments and status change - // Skip closure_code as it's causing validation errors + const { resolution, closure_code } = closeData; + + // PUT /requests/{id} rejects closure_code as a field — use it to + // determine the target status instead of sending it directly + const STATUS_FROM_CLOSURE = { + 'Resolved': 'Resolved', + 'Cancelled': 'Cancelled', + 'Duplicate': 'Closed', + 'Closed': 'Closed', + 'On Hold': 'On Hold', + 'Open': 'Open' + }; + const statusName = STATUS_FROM_CLOSURE[closure_code] || 'Closed'; + + // closure_comments is plain text only — strip HTML tags, then truncate to 250 chars + const MAX_CLOSURE_COMMENTS = 250; + let safeClosureComments = (resolution || 'Request closed') + .replace(/<[^>]*>/g, '') // strip tags + .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') + .trim(); + if (safeClosureComments.length > MAX_CLOSURE_COMMENTS) { + console.error(`Warning: closure_comments truncated from ${safeClosureComments.length} to ${MAX_CLOSURE_COMMENTS} chars`); + safeClosureComments = safeClosureComments.substring(0, MAX_CLOSURE_COMMENTS - 3) + '...'; + } + const request = { - closure_info: { - closure_comments: closure_comments || 'Request closed' - }, - // Use the name format for status - status: { name: status } + closure_info: { closure_comments: safeClosureComments }, + status: { name: statusName } }; - - // Add resolution if provided + if (resolution) { - request.resolution = resolution; - } - - // Add closure_code if provided (may cause validation errors in some instances) - if (closure_code) { - request.closure_info.closure_code = closure_code; + request.resolution = { content: resolution }; } - + const params = { input_data: JSON.stringify({ request }) }; - + + const response = await this.client.put(`/requests/${requestId}`, null, { params }); + return response.data.request; + } + + async setResolution(requestId, resolutionText) { + const params = { + input_data: JSON.stringify({ + request: { + resolution: { content: resolutionText } + } + }) + }; + const response = await this.client.put(`/requests/${requestId}`, null, { params }); + return response.data.request; + } + + async updateStatus(requestId, statusName, comments) { + const request = { status: { name: statusName } }; + if (comments) { + // On Hold uses onhold_scheduler.comments; other status changes use status_change_comments + if (statusName.toLowerCase() === 'on hold') { + request.onhold_scheduler = { comments }; + } else { + request.status_change_comments = comments; + } + } + const params = { input_data: JSON.stringify({ request }) }; const response = await this.client.put(`/requests/${requestId}`, null, { params }); return response.data.request; } + /** + * Test connectivity to the SDP API and return tenant/instance info. + * Makes a minimal GET /priorities call (1 row) to verify auth and reachability. + * + * @returns {Promise} { success, instance, baseUrl, dataCenter, message, error? } + */ + async testConnection() { + const instance = this.instanceName || '(not set)'; + const baseUrl = this.customDomain || 'https://sdpondemand.manageengine.com'; + const dataCenter = this.dataCenter || 'US'; + + try { + const token = await this.oauth.getAccessToken(); + if (!token) throw new Error('No access token returned'); + return { + success: true, + instance, + baseUrl, + dataCenter, + message: `OAuth token obtained successfully for "${instance}" at ${baseUrl}` + }; + } catch (error) { + return { + success: false, + instance, + baseUrl, + dataCenter, + message: `Failed to obtain OAuth token for "${instance}" at ${baseUrl}`, + error: error.message + }; + } + } + + /** + * Delete a request permanently + * + * @param {string} requestId - ID of the request to delete + * @returns {Promise} API response status + */ + async deleteRequest(requestId) { + const response = await this.client.delete(`/requests/${requestId}`); + return response.data; + } + + /** + * Add a file attachment to a request + * + * @param {string} requestId - ID of the request + * @param {string} filePath - Absolute path to the file on the server filesystem + * @param {string} [fileName] - Display name for the attachment (defaults to basename of filePath) + * @returns {Promise} API response with attachment details + */ + async addAttachment(requestId, filePath, fileName) { + const fs = require('fs'); + const path = require('path'); + + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + const resolvedName = fileName || path.basename(filePath); + const boundary = `----SDPBoundary${Date.now()}`; + const fileData = fs.readFileSync(filePath); + + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${resolvedName}"\r\nContent-Type: application/octet-stream\r\n\r\n` + ), + fileData, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + + const response = await this.client.post( + `/requests/${requestId}/attachments`, + body, + { + headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, + maxBodyLength: Infinity, + } + ); + return response.data; + } + /** * Search requests with proper format * @@ -1029,8 +1069,8 @@ class SDPAPIClientV2 { // Use search_criteria for searching (object format, not array) // Service Desk Plus requires object format for single criteria const listInfo = { - row_count: rowCount, - start_index: offset || 1, // SDP uses 1-based indexing + row_count: String(rowCount), + start_index: String(offset + 1), sort_field: sortBy, sort_order: sortOrder, get_total_count: getTotalCount, @@ -1040,6 +1080,7 @@ class SDPAPIClientV2 { value: query } }; + const params = { input_data: JSON.stringify({ list_info: listInfo }) @@ -1066,39 +1107,124 @@ class SDPAPIClientV2 { * @returns {Promise} Search results with requests array and pagination info */ async advancedSearchRequests(criteria, options = {}) { - const { - limit = 10, - page = 1, - sortBy = 'created_time', + const { + limit = 10, + page = 1, + sortBy = 'created_time', sortOrder = 'desc', - getTotalCount = true + getTotalCount = true } = options; - - // Enforce API maximum of 100 rows per request + const rowCount = Math.min(limit, 100); - + + // Normalise criteria — single object passed directly; array uses children nested format. + // Flat array format causes Tomcat 400 on this instance. + let normalisedCriteria; + if (Array.isArray(criteria)) { + if (criteria.length === 1) { + const { logical_operator, ...rest } = criteria[0]; + normalisedCriteria = rest; + } else { + const [first, ...rest] = criteria; + normalisedCriteria = { + ...first, + children: rest.map(c => ({ ...c, logical_operator: 'AND' })) + }; + } + } else { + normalisedCriteria = criteria; + } + const listInfo = { - row_count: rowCount, - page: page, // Use page instead of start_index for easier pagination + row_count: String(rowCount), + start_index: String((page - 1) * rowCount + 1), sort_field: sortBy, sort_order: sortOrder, get_total_count: getTotalCount, - search_criteria: criteria + search_criteria: normalisedCriteria }; - + const params = { input_data: JSON.stringify({ list_info: listInfo }) }; - + + console.error(`advancedSearchRequests: sending search_criteria=${JSON.stringify(normalisedCriteria)}`); const response = await this.client.get('/requests', { params }); + console.error(`advancedSearchRequests: HTTP ${response.status}, response_code=${response.data?.response_status?.status_code}, requests=${response.data?.requests?.length ?? 0}, total_count=${response.data?.list_info?.total_count}`); + if (response.data?.response_status?.status_code !== 2000 && response.data?.response_status?.messages) { + console.error(`advancedSearchRequests: API messages: ${JSON.stringify(response.data.response_status.messages)}`); + } return { requests: response.data.requests || [], total_count: response.data.list_info?.total_count || 0, has_more: response.data.list_info?.has_more_rows || false, - page: response.data.list_info?.page || page, start_index: response.data.list_info?.start_index }; } + + async searchRequestsFlat(filters = {}, options = {}) { + const { + technician_email, requester_email, subject_contains, + category, group, + created_after, created_before, due_before, + status, priority + } = filters; + + const { limit = 10, page = 1, sortBy = 'created_time', sortOrder = 'desc' } = options; + + const rowCount = Math.min(limit, 100); + const listInfo = { + row_count: String(rowCount), + start_index: String((page - 1) * rowCount + 1), + sort_field: sortBy, + sort_order: sortOrder, + get_total_count: true + }; + + const STATUS_MAP = { + 'open': 'open', 'closed': 'closed', 'pending': 'on hold', + 'resolved': 'resolved', 'cancelled': 'cancelled', + 'in progress': 'in progress', 'on hold': 'on hold' + }; + + // Build search_criteria using nested children format: + // { field:A, condition, value, children: [{ field:B, condition, value, logical_operator:"AND" }] } + // The first criterion is the parent object; all additional criteria go in children[]. + const criteriaList = []; + if (technician_email) criteriaList.push({ field: 'technician.email_id', condition: 'is', value: technician_email.toLowerCase() }); + if (requester_email) criteriaList.push({ field: 'requester.email_id', condition: 'is', value: requester_email.toLowerCase() }); + if (subject_contains) criteriaList.push({ field: 'subject', condition: 'contains', value: subject_contains }); + if (category) criteriaList.push({ field: 'category.name', condition: 'is', value: category }); + if (group) criteriaList.push({ field: 'group.name', condition: 'is', value: group }); + if (created_after) criteriaList.push({ field: 'created_time', condition: 'greater than', value: created_after }); + if (created_before) criteriaList.push({ field: 'created_time', condition: 'less than', value: created_before }); + if (due_before) criteriaList.push({ field: 'due_by_time', condition: 'less than', value: due_before }); + if (status) criteriaList.push({ field: 'status.name', condition: 'is', value: STATUS_MAP[status.toLowerCase()] || status }); + if (priority) criteriaList.push({ field: 'priority.name', condition: 'is', value: PRIORITY_NAMES[priority.toLowerCase()] || priority }); + + if (criteriaList.length === 1) { + listInfo.search_criteria = criteriaList[0]; + } else if (criteriaList.length > 1) { + const [first, ...rest] = criteriaList; + listInfo.search_criteria = { + ...first, + children: rest.map(c => ({ ...c, logical_operator: 'AND' })) + }; + } + + console.error(`searchRequestsFlat: list_info=${JSON.stringify(listInfo)}`); + const params = { input_data: JSON.stringify({ list_info: listInfo }) }; + const response = await this.client.get('/requests', { params }); + console.error(`searchRequestsFlat: HTTP ${response.status}, requests=${response.data?.requests?.length ?? 0}, total=${response.data?.list_info?.total_count}`); + if (response.data?.response_status?.status_code !== 2000 && response.data?.response_status?.messages) { + console.error(`searchRequestsFlat: API error: ${JSON.stringify(response.data.response_status.messages)}`); + } + return { + requests: response.data.requests || [], + total_count: response.data.list_info?.total_count || 0, + has_more: response.data.list_info?.has_more_rows || false + }; + } /** * Add email notifications to a request @@ -1217,7 +1343,7 @@ class SDPAPIClientV2 { const criteria = { field: 'requester.email_id', condition: 'is', - value: requesterEmail + value: requesterEmail.toLowerCase() }; return await this.advancedSearchRequests(criteria, options); @@ -1234,7 +1360,7 @@ class SDPAPIClientV2 { const criteria = { field: 'technician.email_id', condition: 'is', - value: technicianEmail + value: technicianEmail.toLowerCase() }; return await this.advancedSearchRequests(criteria, options); diff --git a/sdp-mcp-server/src/sdp-api-client.cjs b/sdp-mcp-server/src/sdp-api-client.cjs deleted file mode 100644 index af819d2..0000000 --- a/sdp-mcp-server/src/sdp-api-client.cjs +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Service Desk Plus API Client - * Handles all API interactions with proper error handling - */ - -const axios = require('axios'); -const { SDPOAuthClient } = require('./sdp-oauth-client.cjs'); - -class SDPAPIClient { - constructor(config = {}) { - // Use the correct portal configuration - this.portalName = config.portalName || process.env.SDP_PORTAL_NAME || 'kaltentech'; - this.dataCenter = config.dataCenter || process.env.SDP_DATA_CENTER || 'US'; - this.customDomain = config.customDomain || process.env.SDP_BASE_URL || 'https://helpdesk.pttg.com'; - this.instanceName = config.instanceName || process.env.SDP_INSTANCE_NAME || 'itdesk'; - - // Initialize OAuth client - this.oauth = new SDPOAuthClient(config); - - // API base URLs by data center - this.apiEndpoints = { - US: 'https://sdpondemand.manageengine.com', - EU: 'https://sdpondemand.manageengine.eu', - IN: 'https://sdpondemand.manageengine.in', - AU: 'https://sdpondemand.manageengine.com.au', - JP: 'https://sdpondemand.manageengine.jp', - UK: 'https://sdpondemand.manageengine.uk', - CA: 'https://sdpondemand.manageengine.ca', - CN: 'https://sdpondemand.manageengine.cn' - }; - - // Create axios instance - use custom domain if available - const baseURL = this.customDomain - ? `${this.customDomain}/app/${this.instanceName}/api/v3` - : `${this.getAPIEndpoint()}/app/${this.portalName}/api/v3`; - - this.client = axios.create({ - baseURL, - timeout: 30000, - headers: { - 'Accept': 'application/vnd.manageengine.sdp.v3+json' - } - }); - - // Add request interceptor for auth - this.client.interceptors.request.use( - async (config) => { - const token = await this.oauth.getAccessToken(); - config.headers['Authorization'] = `Bearer ${token}`; - return config; - }, - (error) => Promise.reject(error) - ); - - // Add response interceptor for error handling - this.client.interceptors.response.use( - (response) => response, - async (error) => { - if (error.response?.status === 401) { - // Token expired, try to refresh and retry - console.error('Got 401, attempting token refresh...'); - await this.oauth.refreshAccessToken(); - - // Retry the original request - const originalRequest = error.config; - const token = await this.oauth.getAccessToken(); - originalRequest.headers['Authorization'] = `Bearer ${token}`; - return this.client(originalRequest); - } - - return Promise.reject(this.formatError(error)); - } - ); - } - - /** - * Get API endpoint for the configured data center - */ - getAPIEndpoint() { - return this.apiEndpoints[this.dataCenter] || this.apiEndpoints.US; - } - - /** - * Format API errors for better handling - */ - formatError(error) { - if (error.response) { - const { status, data } = error.response; - return { - code: status, - message: data.response_status?.messages?.[0]?.message || data.message || 'API Error', - details: data - }; - } - return { - code: 'NETWORK_ERROR', - message: error.message, - details: error - }; - } - - /** - * List requests with filters - */ - async listRequests(options = {}) { - const { limit = 10, offset = 0, status, priority, sortBy = 'created_time', sortOrder = 'desc' } = options; - - const listInfo = { - row_count: limit, - start_index: offset, - sort_field: sortBy, - sort_order: sortOrder - }; - - // Add filters if provided - if (status || priority) { - listInfo.search_fields = {}; - if (status) listInfo.search_fields.status = { name: status }; - if (priority) listInfo.search_fields.priority = { name: priority }; - } - - const params = { - input_data: JSON.stringify({ list_info: listInfo }) - }; - - const response = await this.client.get('/requests', { params }); - return { - requests: response.data.requests || [], - total_count: response.data.list_info?.total_count || 0, - has_more: response.data.list_info?.has_more_rows || false - }; - } - - /** - * Get request details - */ - async getRequest(requestId) { - const response = await this.client.get(`/requests/${requestId}`); - return response.data.request; - } - - /** - * Create a new request - */ - async createRequest(requestData) { - const { subject, description, priority = 'medium', category, requester_email } = requestData; - - const request = { - subject, - description, - priority: { name: priority } - }; - - if (category) request.category = { name: category }; - if (requester_email) request.requester = { email_id: requester_email }; - - const params = { - input_data: JSON.stringify({ request }) - }; - - const response = await this.client.post('/requests', null, { params }); - return response.data.request; - } - - /** - * Update a request - */ - async updateRequest(requestId, updates) { - const request = {}; - - // Map common update fields - if (updates.subject) request.subject = updates.subject; - if (updates.description) request.description = updates.description; - if (updates.status) request.status = { name: updates.status }; - if (updates.priority) request.priority = { name: updates.priority }; - if (updates.category) request.category = { name: updates.category }; - - const params = { - input_data: JSON.stringify({ request }) - }; - - const response = await this.client.put(`/requests/${requestId}`, null, { params }); - return response.data.request; - } - - /** - * Close a request - */ - async closeRequest(requestId, closeData) { - const { closure_comments, closure_code = 'Resolved' } = closeData; - - const request = { - status: { name: 'Closed' }, - closure_info: { - closure_code: { name: closure_code }, - closure_comments - } - }; - - const params = { - input_data: JSON.stringify({ request }) - }; - - const response = await this.client.put(`/requests/${requestId}`, null, { params }); - return response.data.request; - } - - /** - * Add a note to a request - */ - async addNote(requestId, noteContent, isPublic = true) { - const note = { - description: noteContent, - show_to_requester: isPublic - }; - - const params = { - input_data: JSON.stringify({ note }) - }; - - const response = await this.client.post(`/requests/${requestId}/notes`, null, { params }); - return response.data.note; - } - - /** - * Search requests - */ - async searchRequests(query, options = {}) { - const { limit = 10, offset = 0 } = options; - - const listInfo = { - row_count: limit, - start_index: offset, - search_criteria: { - field: 'subject', - condition: 'contains', - value: query - } - }; - - const params = { - input_data: JSON.stringify({ list_info: listInfo }) - }; - - const response = await this.client.get('/requests', { params }); - return { - requests: response.data.requests || [], - total_count: response.data.list_info?.total_count || 0 - }; - } -} - -module.exports = { SDPAPIClient }; \ No newline at end of file diff --git a/sdp-mcp-server/src/sdp-api-metadata.cjs b/sdp-mcp-server/src/sdp-api-metadata.cjs index 782a892..704d419 100644 --- a/sdp-mcp-server/src/sdp-api-metadata.cjs +++ b/sdp-mcp-server/src/sdp-api-metadata.cjs @@ -8,25 +8,31 @@ const { SDPOAuthClient } = require('./sdp-oauth-client.cjs'); class SDPMetadataClient { constructor(config = {}) { - this.portalName = config.portalName || process.env.SDP_PORTAL_NAME || 'kaltentech'; + this.portalName = config.portalName || process.env.SDP_PORTAL_NAME; this.dataCenter = config.dataCenter || process.env.SDP_DATA_CENTER || 'US'; - this.customDomain = config.customDomain || process.env.SDP_BASE_URL || 'https://helpdesk.pttg.com'; - this.instanceName = config.instanceName || process.env.SDP_INSTANCE_NAME || 'itdesk'; - + this.customDomain = config.customDomain || process.env.SDP_BASE_URL; + this.instanceName = config.instanceName || process.env.SDP_PORTAL_NAME; + this.oauth = SDPOAuthClient.getInstance(config); - + // Check if we should use mock API const baseURL = process.env.SDP_USE_MOCK_API === 'true' - ? `${process.env.SDP_BASE_URL || 'http://localhost:3457'}/app/${this.instanceName}/api/v3` - : this.customDomain - ? `${this.customDomain}/app/${this.instanceName}/api/v3` - : `https://sdpondemand.manageengine.com/app/${this.portalName}/api/v3`; + ? `${process.env.SDP_BASE_URL || 'http://localhost:3457'}/api/v3` + : this.customDomain + ? `${this.customDomain}/api/v3` + : `https://sdpondemand.manageengine.com/api/v3`; this.client = axios.create({ baseURL, timeout: 30000, headers: { - 'Accept': 'application/vnd.manageengine.sdp.v3+json' + 'Accept': 'application/vnd.manageengine.sdp.v3+json', + 'Content-Type': 'application/x-www-form-urlencoded' + }, + paramsSerializer: { + serialize: (params) => Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&') } }); @@ -55,46 +61,42 @@ class SDPMetadataClient { */ async getPriorities() { if (this.cache.priorities) return this.cache.priorities; - + + const FALLBACK_PRIORITIES = [ + { id: null, name: '1 - Low' }, + { id: null, name: 'z - Medium' }, + { id: null, name: '3 - High' }, + { id: null, name: '4 - Critical' } + ]; + try { const params = { input_data: JSON.stringify({ - list_info: { - row_count: 100, - start_index: 0 - } + list_info: { row_count: '100', start_index: '1' } }) }; - + const response = await this.client.get('/priorities', { params }); - this.cache.priorities = response.data.priorities || []; - - // Create mapping for easy lookup - const priorityMap = {}; - this.cache.priorities.forEach(p => { - priorityMap[p.name.toLowerCase()] = p.id; - priorityMap[p.id] = p.name; - // Add common aliases for priorities - if (p.name === '1 - Low') { - priorityMap['low'] = p.id; - } else if (p.name === '2 - Normal') { - priorityMap['normal'] = p.id; - } else if (p.name === '3 - High') { - priorityMap['high'] = p.id; - } else if (p.name === '4 - Critical') { - priorityMap['critical'] = p.id; - priorityMap['urgent'] = p.id; // alias - } else if (p.name === 'z - Medium') { - priorityMap['medium'] = p.id; - } - }); - this.cache.priorityMap = priorityMap; - - return this.cache.priorities; + const apiPriorities = response.data.priorities || []; + this.cache.priorities = apiPriorities.length > 0 ? apiPriorities : FALLBACK_PRIORITIES; } catch (error) { - console.error('Failed to fetch priorities:', error.message); - return []; + console.error('Failed to fetch priorities from API, using fallback list:', error.message); + this.cache.priorities = FALLBACK_PRIORITIES; } + + const priorityMap = {}; + this.cache.priorities.forEach(p => { + priorityMap[p.name.toLowerCase()] = p.id; + if (p.id) priorityMap[p.id] = p.name; + if (p.name === '1 - Low') priorityMap['low'] = p.id; + else if (p.name === '2 - Normal') priorityMap['normal'] = p.id; + else if (p.name === '3 - High') priorityMap['high'] = p.id; + else if (p.name === '4 - Critical') { priorityMap['critical'] = p.id; priorityMap['urgent'] = p.id; } + else if (p.name === 'z - Medium') priorityMap['medium'] = p.id; + }); + this.cache.priorityMap = priorityMap; + + return this.cache.priorities; } /** @@ -102,10 +104,8 @@ class SDPMetadataClient { */ async getStatuses() { if (this.cache.statuses) return this.cache.statuses; - - // Since the statuses endpoint returns 404, use hardcoded common statuses - // Note: These don't have real IDs - we'll use names instead - this.cache.statuses = [ + + const FALLBACK_STATUSES = [ { id: null, name: 'Open' }, { id: null, name: 'On Hold' }, { id: null, name: 'In Progress' }, @@ -113,15 +113,28 @@ class SDPMetadataClient { { id: null, name: 'Closed' }, { id: null, name: 'Cancelled' } ]; - - // Create mapping - map to names since we don't have IDs + + try { + const params = { + input_data: JSON.stringify({ + list_info: { row_count: '100', start_index: '1' } + }) + }; + const response = await this.client.get('/statuses', { params }); + const apiStatuses = response.data.statuses || []; + this.cache.statuses = apiStatuses.length > 0 ? apiStatuses : FALLBACK_STATUSES; + } catch (error) { + console.error('Failed to fetch statuses from API, using fallback list:', error.message); + this.cache.statuses = FALLBACK_STATUSES; + } + const statusMap = {}; this.cache.statuses.forEach(s => { - statusMap[s.name.toLowerCase()] = s.name; // Map to name, not ID - statusMap[s.name.toLowerCase().replace(/\s+/g, '')] = s.name; // Handle no spaces + statusMap[s.name.toLowerCase()] = s.name; + statusMap[s.name.toLowerCase().replace(/\s+/g, '')] = s.name; }); this.cache.statusMap = statusMap; - + return this.cache.statuses; } @@ -134,13 +147,10 @@ class SDPMetadataClient { try { const params = { input_data: JSON.stringify({ - list_info: { - row_count: 200, - start_index: 0 - } + list_info: { row_count: '200', start_index: '1' } }) }; - + const response = await this.client.get('/categories', { params }); this.cache.categories = response.data.categories || []; @@ -168,13 +178,10 @@ class SDPMetadataClient { try { const params = { input_data: JSON.stringify({ - list_info: { - row_count: 100, - start_index: 0 - } + list_info: { row_count: '100', start_index: '1' } }) }; - + const response = await this.client.get('/request_templates', { params }); this.cache.templates = response.data.request_templates || []; @@ -195,11 +202,21 @@ class SDPMetadataClient { this.getCategories(), this.getTemplates() ]); - + + // Fetch subcategories for all categories in parallel + const subcategoryResults = await Promise.all( + categories.map(c => this.getSubcategories(c.id).catch(() => [])) + ); + return { priorities: priorities.map(p => ({ id: p.id, name: p.name, color: p.color })), statuses: statuses.map(s => ({ id: s.id, name: s.name, color: s.color })), - categories: categories.map(c => ({ id: c.id, name: c.name, description: c.description })), + categories: categories.map((c, i) => ({ + id: c.id, + name: c.name, + description: c.description, + subcategories: subcategoryResults[i].map(sc => ({ id: sc.id, name: sc.name })) + })), templates: templates.map(t => ({ id: t.id, name: t.name, description: t.description })), mappings: { priority: this.cache.priorityMap, @@ -244,13 +261,10 @@ class SDPMetadataClient { try { const params = { input_data: JSON.stringify({ - list_info: { - row_count: 100, - start_index: 1 - } + list_info: { row_count: '100', start_index: '1' } }) }; - + const response = await this.client.get(`/categories/${categoryId}/subcategories`, { params }); const subcategories = response.data.subcategories || []; diff --git a/sdp-mcp-server/src/sdp-api-users.cjs b/sdp-mcp-server/src/sdp-api-users.cjs index 68f91cc..fa7b2ab 100644 --- a/sdp-mcp-server/src/sdp-api-users.cjs +++ b/sdp-mcp-server/src/sdp-api-users.cjs @@ -16,54 +16,35 @@ class SDPUsersAPI { */ async listTechnicians(options = {}) { const { limit = 25, offset = 0, searchTerm } = options; - - // Enforce API maximum of 100 rows per request + const rowCount = Math.min(limit, 100); - + const listInfo = { - row_count: rowCount, - start_index: offset, + row_count: String(rowCount), + start_index: String(offset + 1), sort_field: 'name', sort_order: 'asc', get_total_count: true }; - - // Add search using search_criteria if provided - // IMPORTANT: Cannot use both search_criteria and filter_by - causes 400 error + if (searchTerm) { - listInfo.search_criteria = [{ - field: 'name', - condition: 'contains', - value: searchTerm - }]; + listInfo.search_fields = { name: searchTerm }; } - + const params = { input_data: JSON.stringify({ list_info: listInfo }) }; - + try { - // Only try /users endpoint - /technicians doesn't exist in SDP Cloud - const response = await this.client.get('/users', { params }); - - // Filter technicians from users list based on properties - // Look for users who have technician-specific properties - const allUsers = response.data.users || []; - const technicians = allUsers.filter(user => { - // Check various indicators that user is a technician - return user.is_technician === true || - user.is_vip_user === true || - user.employee_id || - user.department?.name; - }); - + const response = await this.client.get('/technicians', { params }); + const technicians = response.data.technicians || []; return { - technicians: technicians, - total_count: technicians.length, + technicians, + total_count: response.data.list_info?.total_count || technicians.length, has_more: response.data.list_info?.has_more_rows || false }; } catch (error) { - console.error('Failed to list users/technicians:', error.message); + console.error('Failed to list technicians:', error.message); throw error; } } @@ -73,94 +54,93 @@ class SDPUsersAPI { */ async getTechnician(technicianId) { try { - // Only use /users endpoint - const response = await this.client.get(`/users/${technicianId}`); - return response.data.user; + const response = await this.client.get(`/technicians/${technicianId}`); + return response.data.technician; } catch (error) { - console.error('Failed to get user/technician:', error.message); + console.error('Failed to get technician:', error.message); throw error; } } /** - * List users (requesters) + * List requesters */ async listUsers(options = {}) { const { limit = 25, offset = 0, searchTerm, includeInactive = false } = options; - - // Enforce API maximum of 100 rows per request + const rowCount = Math.min(limit, 100); - + const listInfo = { - row_count: rowCount, - start_index: offset, + row_count: String(rowCount), + start_index: String(offset + 1), sort_field: 'name', sort_order: 'asc', get_total_count: true }; - - // Add search using search_criteria if provided - // IMPORTANT: Cannot use both search_criteria and filter_by - causes 400 error + if (searchTerm) { - listInfo.search_criteria = [{ - field: 'name', - condition: 'contains', - value: searchTerm - }]; + listInfo.search_fields = { name: searchTerm }; } - + const params = { input_data: JSON.stringify({ list_info: listInfo }) }; - - const response = await this.client.get('/users', { params }); - - // Filter results after getting them if needed - let users = response.data.users || []; + + const response = await this.client.get('/requesters', { params }); + + let users = response.data.requesters || []; if (!includeInactive) { - // Filter out inactive users client-side users = users.filter(user => user.is_active !== false); } - + return { - users: users, + users, total_count: users.length, has_more: response.data.list_info?.has_more_rows || false }; } /** - * Get user details + * Get requester details */ async getUser(userId) { - const response = await this.client.get(`/users/${userId}`); - return response.data.user; + const response = await this.client.get(`/requesters/${userId}`); + return response.data.requester; } /** * Search for technician by name or email */ async findTechnician(searchTerm) { - // First try exact email match try { - const result = await this.listTechnicians({ - searchTerm, - limit: 5 - }); - - if (result.technicians.length > 0) { - // Return best match - const exactMatch = result.technicians.find( + const isEmail = searchTerm.includes('@'); + const listInfo = { + row_count: '10', + start_index: '1', + get_total_count: true, + search_fields: isEmail + ? { email_id: searchTerm } + : { name: searchTerm } + }; + + const params = { + input_data: JSON.stringify({ list_info: listInfo }) + }; + + const response = await this.client.get('/technicians', { params }); + const technicians = response.data.technicians || []; + + if (technicians.length > 0) { + const exactMatch = technicians.find( t => t.email_id?.toLowerCase() === searchTerm.toLowerCase() || t.name?.toLowerCase() === searchTerm.toLowerCase() ); - - return exactMatch || result.technicians[0]; + return exactMatch || technicians[0]; } } catch (error) { console.error('Failed to find technician:', error.message); } - + return null; } diff --git a/sdp-mcp-server/src/sdp-oauth-client.cjs b/sdp-mcp-server/src/sdp-oauth-client.cjs index 1ac9da5..b51c30f 100644 --- a/sdp-mcp-server/src/sdp-oauth-client.cjs +++ b/sdp-mcp-server/src/sdp-oauth-client.cjs @@ -16,9 +16,9 @@ let globalRefreshPromise = null; class SDPOAuthClient { constructor(config = {}) { // Use the refresh token from sdp-mcp-server's .env if available - this.clientId = config.clientId || process.env.SDP_CLIENT_ID || '1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU'; - this.clientSecret = config.clientSecret || process.env.SDP_CLIENT_SECRET || '5752f7060c587171f81b21d58c5b8d0019587ca999'; - this.refreshToken = config.refreshToken || process.env.SDP_OAUTH_REFRESH_TOKEN || process.env.SDP_REFRESH_TOKEN || '1000.58376be1b900c8dba9c8cb277e07ab31.0766efe7060d6208a7c71b0b9d057936'; + this.clientId = config.clientId || process.env.SDP_CLIENT_ID; + this.clientSecret = config.clientSecret || process.env.SDP_CLIENT_SECRET; + this.refreshToken = config.refreshToken || process.env.SDP_OAUTH_REFRESH_TOKEN || process.env.SDP_REFRESH_TOKEN; this.dataCenter = config.dataCenter || process.env.SDP_DATA_CENTER || 'US'; // Token storage diff --git a/sdp-mcp-server/src/simple-sse-server.cjs b/sdp-mcp-server/src/simple-sse-server.cjs deleted file mode 100644 index 6f1150c..0000000 --- a/sdp-mcp-server/src/simple-sse-server.cjs +++ /dev/null @@ -1,425 +0,0 @@ -#!/usr/bin/env node - -/** - * Simple SSE MCP Server for Service Desk Plus - * JavaScript version that works immediately - * Based on working implementation patterns - */ - -const express = require('express'); -const cors = require('cors'); -const { Server } = require('@modelcontextprotocol/sdk/server/index.js'); -const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js'); -const { - CallToolRequestSchema, - ListToolsRequestSchema, -} = require('@modelcontextprotocol/sdk/types.js'); -const axios = require('axios'); -const dotenv = require('dotenv'); - -// Load environment variables -dotenv.config(); - -// Validate required environment variables -const required = [ - 'SDP_BASE_URL', - 'SDP_INSTANCE_NAME', - 'SDP_OAUTH_CLIENT_ID', - 'SDP_OAUTH_CLIENT_SECRET', - 'SDP_OAUTH_REFRESH_TOKEN', -]; - -const missing = required.filter(key => !process.env[key]); -if (missing.length > 0) { - console.error(`❌ Missing required environment variables: ${missing.join(', ')}`); - process.exit(1); -} - -// Simple logger -const logger = { - info: (msg, meta) => console.log(`[${new Date().toISOString()}] INFO: ${msg}`, meta || ''), - error: (msg, error) => console.error(`[${new Date().toISOString()}] ERROR: ${msg}`, error || ''), - debug: (msg, meta) => process.env.LOG_LEVEL === 'debug' && console.log(`[${new Date().toISOString()}] DEBUG: ${msg}`, meta || ''), -}; - -// SDP API Client -class SDPClient { - constructor() { - this.baseURL = `${process.env.SDP_BASE_URL}/app/${process.env.SDP_INSTANCE_NAME}/api/v3`; - this.accessToken = null; - this.tokenExpiresAt = 0; - - this.client = axios.create({ - baseURL: this.baseURL, - timeout: 30000, - headers: { - 'Accept': 'application/vnd.manageengine.sdp.v3+json', - }, - }); - - // Setup interceptors - this.client.interceptors.request.use(async (config) => { - const token = await this.getAccessToken(); - config.headers['Authorization'] = `Bearer ${token}`; - logger.debug(`API Request: ${config.method.toUpperCase()} ${config.url}`); - return config; - }); - - this.client.interceptors.response.use( - (response) => response.data, - async (error) => { - if (error.response?.status === 401) { - logger.info('Access token expired, refreshing...'); - this.accessToken = null; - this.tokenExpiresAt = 0; - - const token = await this.getAccessToken(); - const originalRequest = error.config; - originalRequest.headers['Authorization'] = `Bearer ${token}`; - - return this.client(originalRequest); - } - throw error; - } - ); - } - - async getAccessToken() { - if (this.accessToken && Date.now() < this.tokenExpiresAt) { - return this.accessToken; - } - - const tokenData = await this.refreshAccessToken(); - this.accessToken = tokenData.access_token; - this.tokenExpiresAt = Date.now() + (tokenData.expires_in - 300) * 1000; - - return this.accessToken; - } - - async refreshAccessToken() { - const dataCenter = process.env.SDP_DATA_CENTER || 'US'; - const oauthUrls = { - US: 'https://accounts.zoho.com/oauth/v2/token', - EU: 'https://accounts.zoho.eu/oauth/v2/token', - IN: 'https://accounts.zoho.in/oauth/v2/token', - }; - - const oauthUrl = oauthUrls[dataCenter] || oauthUrls.US; - - const params = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: process.env.SDP_OAUTH_REFRESH_TOKEN, - client_id: process.env.SDP_OAUTH_CLIENT_ID, - client_secret: process.env.SDP_OAUTH_CLIENT_SECRET, - scope: 'SDPOnDemand.requests.ALL SDPOnDemand.problems.ALL SDPOnDemand.changes.ALL SDPOnDemand.projects.ALL SDPOnDemand.assets.ALL', - }); - - try { - const response = await axios.post(oauthUrl, params, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }); - logger.info('Access token refreshed successfully'); - return response.data; - } catch (error) { - logger.error('Failed to refresh access token', error.response?.data || error.message); - throw new Error('Failed to refresh access token'); - } - } - - async listRequests(params = {}) { - const requestParams = { - list_info: { - row_count: params.limit || 10, - start_index: params.offset || 0, - }, - }; - - if (params.status) { - requestParams.list_info.search_criteria = [{ - field: 'status.name', - condition: 'is', - value: params.status, - }]; - } - - return this.client.get('/requests', { - params: { input_data: JSON.stringify(requestParams) }, - }); - } - - async getRequest(id) { - return this.client.get(`/requests/${id}`); - } - - async searchRequests(params = {}) { - const searchParams = { - list_info: { - row_count: params.limit || 10, - start_index: params.offset || 0, - }, - }; - - if (params.query) { - searchParams.list_info.search_criteria = [{ - field: 'subject', - condition: 'contains', - value: params.query, - }]; - } - - return this.client.get('/requests', { - params: { input_data: JSON.stringify(searchParams) }, - }); - } - - async getMetadata(entityType) { - const endpoints = { - priorities: '/priorities', - statuses: '/statuses', - categories: '/categories', - urgencies: '/urgencies', - impacts: '/impacts', - }; - - const endpoint = endpoints[entityType]; - if (!endpoint) { - throw new Error(`Unknown metadata type: ${entityType}`); - } - - return this.client.get(endpoint, { - params: { - input_data: JSON.stringify({ - list_info: { row_count: 100, start_index: 0 }, - }), - }, - }); - } -} - -// Initialize SDP client -const sdpClient = new SDPClient(); - -// MCP Tools -const tools = [ - { - name: 'list_requests', - description: 'List service desk requests with optional filters', - inputSchema: { - type: 'object', - properties: { - limit: { type: 'number', description: 'Maximum number of requests to return' }, - offset: { type: 'number', description: 'Number of requests to skip' }, - status: { type: 'string', description: 'Filter by status' }, - }, - }, - }, - { - name: 'get_request', - description: 'Get details of a specific request by ID', - inputSchema: { - type: 'object', - properties: { - request_id: { type: 'string', description: 'The ID of the request to retrieve' }, - }, - required: ['request_id'], - }, - }, - { - name: 'search_requests', - description: 'Search requests by subject or other criteria', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query for request subjects' }, - limit: { type: 'number', description: 'Maximum number of results' }, - offset: { type: 'number', description: 'Number of results to skip' }, - }, - required: ['query'], - }, - }, - { - name: 'get_metadata', - description: 'Get metadata for entity types (priorities, statuses, categories, etc.)', - inputSchema: { - type: 'object', - properties: { - entity_type: { - type: 'string', - enum: ['priorities', 'statuses', 'categories', 'urgencies', 'impacts'], - description: 'Type of metadata to retrieve', - }, - }, - required: ['entity_type'], - }, - }, -]; - -// Tool handlers -const toolHandlers = { - async list_requests(args) { - logger.info('Executing list_requests', args); - const result = await sdpClient.listRequests(args); - return { - requests: result.requests || [], - total_count: result.response_status?.total_count || 0, - }; - }, - - async get_request(args) { - logger.info('Executing get_request', args); - if (!args.request_id) { - throw new Error('request_id is required'); - } - return sdpClient.getRequest(args.request_id); - }, - - async search_requests(args) { - logger.info('Executing search_requests', args); - if (!args.query) { - throw new Error('query is required'); - } - const result = await sdpClient.searchRequests(args); - return { - requests: result.requests || [], - total_count: result.response_status?.total_count || 0, - }; - }, - - async get_metadata(args) { - logger.info('Executing get_metadata', args); - if (!args.entity_type) { - throw new Error('entity_type is required'); - } - return sdpClient.getMetadata(args.entity_type); - }, -}; - -// Create Express app -const app = express(); -app.use(cors()); -app.use(express.json()); - -// Health check endpoint -app.get('/health', (req, res) => { - res.json({ - status: 'ok', - service: 'sdp-mcp-server', - version: '1.0.0', - environment: process.env.NODE_ENV || 'development', - }); -}); - -// SSE endpoint for MCP -app.get('/sse', async (req, res) => { - logger.info('New SSE connection established'); - - // Set SSE headers - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - - // Initialize MCP server for this connection - const server = new Server( - { - name: "service-desk-plus", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - } - ); - - // Register tool handlers - server.setRequestHandler(ListToolsRequestSchema, async () => { - logger.info('Tools list requested'); - return { tools }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - - const handler = toolHandlers[name]; - if (!handler) { - throw new Error(`Tool not found: ${name}`); - } - - try { - const result = await handler(args); - - return { - content: [ - { - type: "text", - text: typeof result === 'string' ? result : JSON.stringify(result, null, 2), - }, - ], - }; - } catch (error) { - logger.error(`Error executing tool ${name}:`, error); - return { - content: [ - { - type: "text", - text: `Error: ${error.message || 'Unknown error occurred'}` - } - ], - isError: true, - }; - } - }); - - // Create SSE transport - const transport = new SSEServerTransport(res); - - // Connect server to transport - try { - await server.connect(transport); - logger.info('MCP server connected successfully'); - } catch (error) { - logger.error('Failed to connect MCP server:', error); - res.end(); - return; - } - - // Keep connection alive - const keepAlive = setInterval(() => { - res.write(':keepalive\n\n'); - }, 30000); - - // Handle client disconnect - req.on('close', () => { - logger.info('SSE connection closed'); - clearInterval(keepAlive); - server.close(); - }); -}); - -// Error handling middleware -app.use((err, req, res, next) => { - logger.error('Server error:', err); - res.status(500).json({ error: 'Internal server error' }); -}); - -// Start server -const PORT = process.env.SDP_HTTP_PORT || 3456; -const HOST = process.env.SDP_HTTP_HOST || '0.0.0.0'; - -app.listen(PORT, HOST, () => { - logger.info(`🚀 SDP MCP Server (SSE) running at http://${HOST}:${PORT}`); - logger.info(`📡 SSE endpoint: http://${HOST}:${PORT}/sse`); - logger.info(`🏥 Health check: http://${HOST}:${PORT}/health`); -}); - -// Handle graceful shutdown -process.on('SIGINT', () => { - logger.info('👋 Shutting down server...'); - process.exit(0); -}); - -process.on('SIGTERM', () => { - logger.info('👋 Shutting down server...'); - process.exit(0); -}); \ No newline at end of file diff --git a/sdp-mcp-server/src/tools/metadata.cjs b/sdp-mcp-server/src/tools/metadata.cjs new file mode 100644 index 0000000..027109d --- /dev/null +++ b/sdp-mcp-server/src/tools/metadata.cjs @@ -0,0 +1,73 @@ +'use strict'; + +const { readResource, listResources } = require('../mcp-resources.cjs'); + +function makeImplementations(sdpClient) { + return { + + async get_metadata() { + console.error('Fetching SDP metadata...'); + const metadata = await sdpClient.getMetadata(); + return { + content: [{ + type: 'text', + text: JSON.stringify({ + message: 'Valid values for Service Desk Plus fields', + priorities: metadata.priorities, + statuses: metadata.statuses, + categories: metadata.categories.slice(0, 20), + templates: metadata.templates.slice(0, 10), + usage_tips: { + priority: 'Use values like: low, medium, high, urgent', + status: 'Use values like: open, closed, pending, resolved', + category: 'Use exact category names from the list above' + } + }, null, 2) + }] + }; + }, + + async get_usage_guide(params) { + const { section = 'tool-reference' } = params || {}; + const uri = `sdp://usage/${section}`; + const resource = readResource(uri); + if (!resource) { + const valid = listResources().map(r => r.uri.replace('sdp://usage/', '')).join(', '); + throw new Error(`Unknown section "${section}". Valid sections: ${valid}`); + } + return { + content: [ + { type: 'text', text: `Usage guide section: ${resource.name}\n\n${resource.text}` }, + { type: 'resource', resource: { uri: resource.uri, mimeType: resource.mimeType, text: resource.text } } + ] + }; + }, + + }; +} + +const schemas = [ + { + name: 'get_metadata', + description: 'Get valid values for priorities, statuses, categories, and templates. Use before create/update if unsure whether a field value is valid.', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'get_usage_guide', + description: 'Retrieve a specific section of the SDP usage guide to inform tool usage. Returns structured context about API rules, field formats, tool reference, error codes, action sequences, or the decision matrix.', + inputSchema: { + type: 'object', + properties: { + section: { + type: 'string', + description: 'Which section of the guide to retrieve', + enum: ['api-rules', 'tool-reference', 'field-formats', 'error-codes', 'action-sequences', 'decision-matrix'], + default: 'tool-reference' + } + }, + required: [] + } + }, +]; + +module.exports = { makeImplementations, schemas }; diff --git a/sdp-mcp-server/src/tools/requests.cjs b/sdp-mcp-server/src/tools/requests.cjs new file mode 100644 index 0000000..35c863f --- /dev/null +++ b/sdp-mcp-server/src/tools/requests.cjs @@ -0,0 +1,571 @@ +'use strict'; + +const VALID_CLOSURE_CODES = new Set(['Resolved', 'Cancelled', 'Duplicate', 'Closed', 'On Hold', 'Open']); + +function validateRequestId(request_id) { + if (!request_id) throw new Error('request_id is required'); +} + +function validateSubject(subject) { + if (!subject) throw new Error('subject is required'); + if (subject.length > 250) throw new Error(`subject exceeds 250 character limit (${subject.length} chars)`); +} + +function validateImpactDetails(impact_details) { + if (impact_details && impact_details.length > 250) { + throw new Error(`impact_details exceeds 250 character limit (${impact_details.length} chars)`); + } +} + +function validateClosureCode(closure_code) { + if (!closure_code) return; + const name = typeof closure_code === 'object' ? closure_code?.name : closure_code; + if (name && !VALID_CLOSURE_CODES.has(name)) { + throw new Error(`Invalid closure_code "${name}". Valid values: ${[...VALID_CLOSURE_CODES].join(', ')}`); + } +} + +function makeImplementations(sdpClient) { + return { + + async list_requests(params) { + const { limit = 10, status, priority, sort_by, sort_order } = params; + const result = await sdpClient.listRequests({ limit, status, priority, sortBy: sort_by, sortOrder: sort_order }); + const requests = result.requests.map(req => ({ + id: req.id, + subject: req.subject, + status: req.status?.name, + priority: req.priority?.name, + requester: req.requester?.name || req.requester?.email_id, + created_time: req.created_time?.display_value, + due_date: req.due_by_time?.display_value, + category: req.category?.name, + subcategory: req.subcategory?.name, + technician: req.technician?.name + })); + return { content: [{ type: 'text', text: JSON.stringify({ requests, total_count: result.total_count, has_more: result.has_more }, null, 2) }] }; + }, + + async get_request(params) { + const { request_id } = params; + validateRequestId(request_id); + + // Resolve display_id (short number e.g. 31230) to internal ID (17-digit e.g. 97837000038081358) + let internalId = String(request_id); + if (/^\d{1,10}$/.test(internalId)) { + console.error(`Treating ${request_id} as display_id — resolving to internal ID`); + const result = await sdpClient.advancedSearchRequests( + [{ field: 'display_id', condition: 'is', value: parseInt(internalId, 10) }], + { limit: 1 } + ); + if (!result.requests || result.requests.length === 0) { + throw new Error(`No request found with display_id ${request_id}`); + } + internalId = result.requests[0].id; + console.error(`Resolved display_id ${request_id} → ${internalId}`); + } + + console.error(`Fetching request: ${internalId}`); + const req = await sdpClient.getRequest(internalId); + const formatted = { + id: req.id, + subject: req.subject, + description: req.description, + status: req.status?.name, + priority: req.priority?.name, + requester: { name: req.requester?.name, email: req.requester?.email_id, phone: req.requester?.phone }, + category: req.category?.name, + subcategory: req.subcategory?.name, + item: req.item?.name, + technician: req.technician?.name, + group: req.group?.name, + created_time: req.created_time?.display_value, + due_date: req.due_by_time?.display_value, + completed_time: req.completed_time?.display_value, + time_elapsed: req.time_elapsed, + resolution: req.resolution?.content, + closure_info: req.closure_info, + has_notes: req.has_notes, + has_attachments: req.has_attachments + }; + return { content: [{ type: 'text', text: JSON.stringify(formatted, null, 2) }] }; + }, + + async create_request(params) { + const { subject, description, priority, category, subcategory, requester_email, technician_id, technician_email, impact_details } = params; + validateSubject(subject); + validateImpactDetails(impact_details); + console.error(`Creating request: ${subject}`); + const data = { subject, description: description || '' }; + if (priority) data.priority = priority; + if (category) data.category = category; + if (subcategory) data.subcategory = subcategory; + if (requester_email) data.requester_email = requester_email; + if (technician_id) data.technician_id = technician_id; + if (technician_email) data.technician_email = technician_email; + if (impact_details) data.impact_details = impact_details; + // Pass through any remaining optional params + const extra = ['requester_name', 'urgency', 'impact', 'level', 'mode', 'request_type', 'group', 'site', 'template', 'due_by_time', 'email_ids_to_notify']; + extra.forEach(k => { if (params[k] !== undefined) data[k] = params[k]; }); + const req = await sdpClient.createRequest(data); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id: req.id, subject: req.subject, + status: req.status?.name, message: `Request #${req.id} created successfully` + }, null, 2) }] + }; + }, + + async update_request(params) { + const { request_id, ...updates } = params; + validateRequestId(request_id); + if (updates.subject !== undefined) validateSubject(updates.subject); + if (updates.impact_details !== undefined) validateImpactDetails(updates.impact_details); + console.error(`Updating request ${request_id}`); + const req = await sdpClient.updateRequest(request_id, updates); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id: req.id, + updated_fields: Object.keys(updates), message: `Request #${req.id} updated successfully` + }, null, 2) }] + }; + }, + + async close_request(params) { + const { request_id, resolution, closure_code = 'Resolved' } = params; + validateRequestId(request_id); + validateClosureCode(closure_code); + console.error(`Closing request ${request_id}`); + const req = await sdpClient.closeRequest(request_id, { + resolution: resolution || 'Request resolved', + closure_code + }); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id: req?.id, status: req?.status?.name, + closed_time: req?.completed_time?.display_value, message: `Request #${request_id} closed successfully` + }, null, 2) }] + }; + }, + + async closure_resolution(params) { + const { request_id, resolution } = params; + validateRequestId(request_id); + if (!resolution) throw new Error('resolution is required'); + console.error(`Setting resolution on request ${request_id}`); + const req = await sdpClient.setResolution(request_id, resolution); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id: req?.id, message: `Resolution set on request #${request_id}` + }, null, 2) }] + }; + }, + + async update_status(params) { + const { request_id, status, status_change_comments } = params; + validateRequestId(request_id); + if (!status) throw new Error('status is required'); + console.error(`Updating status of request ${request_id} to "${status}"`); + const req = await sdpClient.updateStatus(request_id, status, status_change_comments); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id: req?.id, status: req?.status?.name, + message: `Status updated to "${status}" on request #${request_id}` + }, null, 2) }] + }; + }, + + async add_note(params) { + const { request_id, note_content, is_public = true } = params; + validateRequestId(request_id); + if (!note_content) throw new Error('note_content is required'); + console.error(`Adding note to request ${request_id}`); + const note = await sdpClient.addNote(request_id, note_content, is_public); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, note_id: note.id, request_id, + added_time: note.created_time?.display_value, message: `Note added to request #${request_id}` + }, null, 2) }] + }; + }, + + async add_private_note(params) { + const { request_id, note_content, notify_technician = true } = params; + validateRequestId(request_id); + if (!note_content) throw new Error('note_content is required'); + console.error(`Adding private note to request ${request_id}`); + const note = await sdpClient.addPrivateNote(request_id, note_content, notify_technician); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, note_id: note.id, request_id, is_private: true, + technician_notified: notify_technician, message: `Private note added to request #${request_id}` + }, null, 2) }] + }; + }, + + async reply_to_requester(params) { + const { request_id, reply_message, mark_first_response = false } = params; + validateRequestId(request_id); + if (!reply_message) throw new Error('reply_message is required'); + console.error(`Replying to requester for request ${request_id}`); + const note = await sdpClient.replyToRequester(request_id, reply_message, mark_first_response); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, note_id: note.id, request_id, reply_sent: true, + first_response: mark_first_response, message: `Email reply sent to requester for request #${request_id}` + }, null, 2) }] + }; + }, + + async send_first_response(params) { + const { request_id, response_message } = params; + validateRequestId(request_id); + if (!response_message) throw new Error('response_message is required'); + console.error(`Sending first response for request ${request_id}`); + const note = await sdpClient.sendFirstResponse(request_id, response_message); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, note_id: note.id, request_id, first_response: true, + email_sent: true, message: `First response sent to requester for request #${request_id}` + }, null, 2) }] + }; + }, + + async get_request_conversation(params) { + const { request_id } = params; + validateRequestId(request_id); + console.error(`Getting conversation for request ${request_id}`); + const conversation = await sdpClient.getRequestConversation(request_id); + const formatted = conversation.map(note => ({ + id: note.id, + content: note.description, + created_time: note.created_time?.display_value, + author: note.added_by?.name || note.added_by?.email_id, + visible_to_requester: note.show_to_requester, + is_first_response: note.mark_first_response + })); + return { + content: [{ type: 'text', text: JSON.stringify({ + request_id, conversation: formatted, total_notes: conversation.length, + message: `Retrieved ${conversation.length} conversation entries for request #${request_id}` + }, null, 2) }] + }; + }, + + async search_requests(params) { + const { query, limit = 10 } = params; + if (!query) throw new Error('query is required'); + console.error(`Searching requests: ${query}`); + const result = await sdpClient.searchRequests(query, { limit }); + const requests = result.requests.map(req => ({ + id: req.id, subject: req.subject, status: req.status?.name, + priority: req.priority?.name, requester: req.requester?.name || req.requester?.email_id, + created_time: req.created_time?.display_value + })); + return { content: [{ type: 'text', text: JSON.stringify({ query, results: requests, total_count: result.total_count }, null, 2) }] }; + }, + + async advanced_search_requests(params) { + const { + technician_email, requester_email, subject_contains, + category, group, created_after, created_before, due_before, + status, priority, + limit = 10, page = 1, sort_by = 'created_time', sort_order = 'desc' + } = params; + + console.error(`advanced_search_requests: technician_email=${technician_email || '-'}, requester_email=${requester_email || '-'}, status=${status || '-'}, priority=${priority || '-'}, subject_contains=${subject_contains || '-'}`); + + const result = await sdpClient.searchRequestsFlat( + { technician_email, requester_email, subject_contains, category, group, created_after, created_before, due_before, status, priority }, + { limit, page, sortBy: sort_by, sortOrder: sort_order } + ); + + console.error(`advanced_search_requests: returned ${result.requests?.length ?? 0} requests (total=${result.total_count})`); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + }, + + async delete_request(params) { + const { request_id } = params; + validateRequestId(request_id); + console.error(`Deleting request ${request_id}`); + await sdpClient.deleteRequest(request_id); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id, message: `Request #${request_id} permanently deleted` + }, null, 2) }] + }; + }, + + async add_attachment(params) { + const { request_id, file_path, file_name } = params; + validateRequestId(request_id); + if (!file_path) throw new Error('file_path is required'); + console.error(`Attaching "${file_path}" to request ${request_id}`); + await sdpClient.addAttachment(request_id, file_path, file_name); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, request_id, + file_name: file_name || require('path').basename(file_path), + message: `File attached to request #${request_id}` + }, null, 2) }] + }; + } + + }; +} + +const schemas = [ + { + name: 'list_requests', + description: 'List service desk requests with optional filters', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Maximum number of requests to return (max 100)', default: 10, maximum: 100 }, + status: { type: 'string', description: 'Filter by status', enum: ['open', 'closed', 'pending', 'resolved', 'cancelled'] }, + priority: { type: 'string', description: 'Filter by priority name as configured in the instance (e.g. "High", "Medium", "Low")' }, + sort_by: { type: 'string', enum: ['created_time', 'due_by_time', 'subject', 'priority'], default: 'created_time' }, + sort_order: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } + } + } + }, + { + name: 'get_request', + description: 'Get detailed information about a specific service desk request', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'The request ID — accepts either the short display_id (e.g. "31230") or the full internal ID (e.g. "97837000038081358"). Display IDs are resolved automatically.' } + }, + required: ['request_id'] + } + }, + { + name: 'create_request', + description: 'Create a new service desk request', + inputSchema: { + type: 'object', + properties: { + subject: { type: 'string', description: 'Subject of the request (max 250 chars)' }, + description: { type: 'string', description: 'Detailed description (HTML supported)' }, + priority: { type: 'string', description: 'Priority name as configured in the instance (e.g. "High", "Medium", "Low")', default: 'medium' }, + category: { type: 'string', description: 'Category name' }, + subcategory: { type: 'string', description: 'Subcategory name' }, + requester_email: { type: 'string', description: 'Email of the requester' }, + requester_name: { type: 'string', description: 'Name of the requester' }, + technician_id: { type: 'string', description: 'ID of technician to assign' }, + technician_email: { type: 'string', description: 'Email of technician to assign' }, + urgency: { type: 'string', description: 'Urgency level (e.g., "2 - General Concern")' }, + impact: { type: 'string', description: 'Impact level (e.g., "1 - Affects User")' }, + level: { type: 'string', description: 'Support level (e.g., "1 - Frontline")' }, + mode: { type: 'string', description: 'Creation mode (e.g., "Web Form", "Email")' }, + request_type: { type: 'string', description: 'Type of request (e.g., "Incident", "Service Request")' }, + group: { type: 'string', description: 'Group name to assign the request to' }, + site: { type: 'string', description: 'Site name for the request' }, + template: { type: 'string', description: 'Template name to use' }, + due_by_time: { type: 'object', description: 'Due date/time as { value: }' }, + impact_details: { type: 'string', description: 'Impact description (max 250 chars)' }, + email_ids_to_notify: { type: 'array', items: { type: 'string' }, description: 'Additional email addresses to notify' } + }, + required: ['subject'] + } + }, + { + name: 'update_request', + description: 'Update fields on an existing open service desk request', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + subject: { type: 'string', description: 'New subject (max 250 chars)' }, + description: { type: 'string', description: 'New description' }, + status: { type: 'string', enum: ['open', 'pending', 'resolved', 'closed', 'in progress', 'on hold'] }, + priority: { type: 'string', description: 'Priority name as configured in the instance (e.g. "High", "Medium", "Low")' }, + category: { type: 'string', description: 'New category' }, + subcategory: { type: 'string', description: 'New subcategory' }, + technician_id: { type: 'string', description: 'ID of technician to assign' }, + technician_email: { type: 'string', description: 'Email of technician to assign' }, + resolution: { type: 'string', description: 'Resolution text' }, + update_reason: { type: 'string', description: 'Reason for this update' }, + due_by_time: { type: 'object', description: 'New due date/time as { value: }' }, + urgency: { type: 'string', description: 'New urgency level' }, + impact: { type: 'string', description: 'New impact level' }, + impact_details: { type: 'string', description: 'New impact details (max 250 chars)' }, + level: { type: 'string', description: 'New support level' }, + group: { type: 'string', description: 'New group name' }, + site: { type: 'string', description: 'New site name' }, + scheduled_start_time: { type: 'object', description: 'Scheduled start time as { value: }' }, + scheduled_end_time: { type: 'object', description: 'Scheduled end time as { value: }' }, + status_change_comments: { type: 'string', description: 'Reason or comment for the status change. Required by SDP when setting status to "On Hold" or "Cancelled".' } + }, + required: ['request_id'] + } + }, + { + name: 'close_request', + description: 'Close a service desk request. Provide a resolution summary and an optional closure code (defaults to Resolved). Handles both the resolution text and the closure in a single call.', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + resolution: { type: 'string', description: 'Resolution summary (what was done to fix the issue)' }, + closure_code: { + type: 'string', + description: 'Closure code', + enum: ['Resolved', 'Cancelled', 'Duplicate', 'Closed', 'On Hold', 'Open'], + default: 'Resolved' + } + }, + required: ['request_id'] + } + }, + { + name: 'closure_resolution', + description: 'Set the resolution text on a request (separate from closing). Can be called before or after close_request.', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + resolution: { type: 'string', description: 'Resolution text to record on the request' } + }, + required: ['request_id', 'resolution'] + } + }, + { + name: 'update_status', + description: 'Update the status of a request without closing it (e.g., set to On Hold, In Progress)', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + status: { type: 'string', enum: ['Open', 'On Hold', 'In Progress', 'Resolved', 'Closed', 'Cancelled'] }, + status_change_comments: { type: 'string', description: 'Reason or comment for the status change. Required by SDP when setting status to "On Hold" or "Cancelled".' } + }, + required: ['request_id', 'status'] + } + }, + { + name: 'add_note', + description: 'Add a public note to a request that is visible to the requester in the portal. Does not send an email — use reply_to_requester to email the requester.', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + note_content: { type: 'string', description: 'Content of the note' }, + is_public: { type: 'boolean', description: 'Visible to requester', default: true } + }, + required: ['request_id', 'note_content'] + } + }, + { + name: 'add_private_note', + description: 'Add an internal note to a request (NOT visible to the requester)', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + note_content: { type: 'string', description: 'Content of the private note' }, + notify_technician: { type: 'boolean', description: 'Whether to notify the assigned technician', default: true } + }, + required: ['request_id', 'note_content'] + } + }, + { + name: 'reply_to_requester', + description: 'Send an email reply to the requester that appears in the request conversation thread. Always use this to email the requester — not add_note.', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + reply_message: { type: 'string', description: 'The reply message to send to the requester' }, + mark_first_response: { type: 'boolean', description: 'Mark as first response for SLA tracking', default: false } + }, + required: ['request_id', 'reply_message'] + } + }, + { + name: 'send_first_response', + description: 'Send the first formal response on a request for SLA first-response tracking. Only for the very first response — use reply_to_requester for all subsequent replies.', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + response_message: { type: 'string', description: 'The first response message content' } + }, + required: ['request_id', 'response_message'] + } + }, + { + name: 'get_request_conversation', + description: 'Retrieve the full conversation history of a request including all replies and notes', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' } + }, + required: ['request_id'] + } + }, + { + name: 'search_requests', + description: 'Full-text keyword search across request subjects and descriptions. Use list_requests instead when filtering by status, priority, technician, or requester.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Maximum results (max 100)', default: 10, maximum: 100 } + }, + required: ['query'] + } + }, + { + name: 'advanced_search_requests', + description: 'Search requests by technician, requester, subject, category, group, or date ranges — optionally combined with status and priority. Use this instead of list_requests when filtering by anything other than status or priority alone.', + inputSchema: { + type: 'object', + properties: { + technician_email: { type: 'string', description: 'Filter by assigned technician email address (e.g. "jsmith@example.com")' }, + requester_email: { type: 'string', description: 'Filter by requester email address' }, + subject_contains: { type: 'string', description: 'Filter requests whose subject contains this text' }, + category: { type: 'string', description: 'Filter by category name (exact match)' }, + group: { type: 'string', description: 'Filter by group or team name (exact match)' }, + created_after: { type: 'number', description: 'Return requests created after this Unix epoch millisecond timestamp' }, + created_before: { type: 'number', description: 'Return requests created before this Unix epoch millisecond timestamp' }, + due_before: { type: 'number', description: 'Return requests due before this Unix epoch millisecond timestamp' }, + status: { type: 'string', description: 'Filter by status', enum: ['open', 'closed', 'pending', 'resolved', 'cancelled', 'in progress', 'on hold'] }, + priority: { type: 'string', description: 'Filter by priority name as configured in the instance (e.g. "High", "Medium", "Low")' }, + limit: { type: 'number', description: 'Maximum results (max 100)', default: 10, maximum: 100 }, + page: { type: 'number', description: 'Page number (1-based)', default: 1 }, + sort_by: { type: 'string', enum: ['created_time', 'due_by_time', 'subject', 'priority'], default: 'created_time' }, + sort_order: { type: 'string', enum: ['asc', 'desc'], default: 'desc' } + }, + required: [] + } + }, + { + name: 'delete_request', + description: 'Permanently delete a service desk request. This action is irreversible — only use when explicitly instructed.', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' } + }, + required: ['request_id'] + } + }, + { + name: 'add_attachment', + description: 'Attach a file to a service desk request', + inputSchema: { + type: 'object', + properties: { + request_id: { type: 'string', description: 'Full internal request ID (e.g. "97837000038081358") — NOT the short display ID (e.g. "29257"). Obtain from get_request or list_requests results.' }, + file_path: { type: 'string', description: 'Absolute path to the file on the server' }, + file_name: { type: 'string', description: 'Display name for the attachment (defaults to filename from file_path)' } + }, + required: ['request_id', 'file_path'] + } + } +]; + +module.exports = { makeImplementations, schemas }; diff --git a/sdp-mcp-server/src/tools/technicians.cjs b/sdp-mcp-server/src/tools/technicians.cjs new file mode 100644 index 0000000..d4fb76c --- /dev/null +++ b/sdp-mcp-server/src/tools/technicians.cjs @@ -0,0 +1,66 @@ +'use strict'; + +function makeImplementations(sdpClient) { + return { + + async list_technicians(params) { + const { limit = 25, offset = 0, search_term } = params; + const result = await sdpClient.users.listTechnicians({ limit, offset, searchTerm: search_term }); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + }, + + async get_technician(params) { + const { technician_id } = params; + if (!technician_id) throw new Error('technician_id is required'); + const technician = await sdpClient.users.getTechnician(technician_id); + return { content: [{ type: 'text', text: JSON.stringify(technician, null, 2) }] }; + }, + + async find_technician(params) { + const { search_term } = params; + if (!search_term) throw new Error('search_term is required'); + const clean = search_term.replace(/^mailto:/i, ''); + const technician = await sdpClient.users.findTechnician(clean); + return { content: [{ type: 'text', text: JSON.stringify({ found: !!technician, technician: technician || null }, null, 2) }] }; + } + + }; +} + +const schemas = [ + { + name: 'list_technicians', + description: 'List available technicians for request assignment', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Maximum number of technicians to return (max 100)', default: 25, maximum: 100 }, + search_term: { type: 'string', description: 'Filter by name or email' } + } + } + }, + { + name: 'get_technician', + description: 'Get detailed information about a specific technician by ID', + inputSchema: { + type: 'object', + properties: { + technician_id: { type: 'string', description: 'The ID of the technician' } + }, + required: ['technician_id'] + } + }, + { + name: 'find_technician', + description: 'Find a technician by name or email address (returns best match)', + inputSchema: { + type: 'object', + properties: { + search_term: { type: 'string', description: 'Name or email to search for' } + }, + required: ['search_term'] + } + } +]; + +module.exports = { makeImplementations, schemas }; diff --git a/sdp-mcp-server/src/working-sse-server.cjs b/sdp-mcp-server/src/working-sse-server.cjs index 828c6fb..e1d189f 100644 --- a/sdp-mcp-server/src/working-sse-server.cjs +++ b/sdp-mcp-server/src/working-sse-server.cjs @@ -1,19 +1,30 @@ #!/usr/bin/env node -/** - * MCP SSE Server with Service Desk Plus Integration - * Full implementation with real API calls - */ +'use strict'; + +require('dotenv').config(); const express = require('express'); const cors = require('cors'); const { SDPAPIClientV2 } = require('./sdp-api-client-v2.cjs'); +const { listResources, readResource } = require('./mcp-resources.cjs'); +const { listPrompts, getPrompt } = require('./mcp-prompts.cjs'); +const { makeImplementations: makeRequestImpls, schemas: requestSchemas } = require('./tools/requests.cjs'); +const { makeImplementations: makeTechImpls, schemas: techSchemas } = require('./tools/technicians.cjs'); +const { makeImplementations: makeMetaImpls, schemas: metaSchemas } = require('./tools/metadata.cjs'); const app = express(); app.use(cors()); app.use(express.json()); -// Initialize SDP API client +// Diagnostic: log env vars at startup so misconfiguration is immediately visible +console.error('SDP env check:'); +console.error(` SDP_BASE_URL = ${process.env.SDP_BASE_URL || '(not set)'}`); +console.error(` SDP_PORTAL_NAME = ${process.env.SDP_PORTAL_NAME || '(not set)'}`); +console.error(` SDP_CLIENT_ID = ${process.env.SDP_CLIENT_ID ? '(set)' : '(not set)'}`); +console.error(` SDP_REFRESH_TOKEN= ${process.env.SDP_REFRESH_TOKEN? '(set)' : '(not set)'}`); +console.error(` SDP_DATA_CENTER = ${process.env.SDP_DATA_CENTER || 'US (default)'}`); + let sdpClient; try { sdpClient = new SDPAPIClientV2({ @@ -27,24 +38,51 @@ try { console.error('Failed to initialize SDP client:', error.message); } +// Wire all tool implementations and schemas +const toolImplementations = sdpClient ? { + ...makeRequestImpls(sdpClient), + ...makeTechImpls(sdpClient), + ...makeMetaImpls(sdpClient) +} : {}; + +const tools = [...requestSchemas, ...techSchemas, ...metaSchemas]; + // Active SSE connections const connections = new Map(); let sessionCounter = 0; -// Health check -app.get('/health', (req, res) => { - res.json({ - status: 'ok', +// Health check — includes OAuth token probe when sdpClient is available +app.get('/health', async (req, res) => { + const health = { + status: 'ok', service: 'mcp-sse-sdp', connections: connections.size, - sdp_configured: !!sdpClient - }); + sdp_configured: !!sdpClient, + instance: process.env.SDP_PORTAL_NAME || null, + base_url: process.env.SDP_BASE_URL || null, + data_center: process.env.SDP_DATA_CENTER || 'US' + }; + + if (sdpClient) { + try { + const conn = await sdpClient.testConnection(); + health.auth_status = conn.success ? 'ok' : 'failed'; + if (!conn.success) health.auth_error = conn.error; + } catch (e) { + health.auth_status = 'error'; + health.auth_error = e.message; + } + } else { + health.auth_status = 'not_configured'; + } + + res.json(health); }); // SSE endpoint app.get('/sse', (req, res) => { console.error('New SSE connection established'); - + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -56,14 +94,9 @@ app.get('/sse', (req, res) => { connections.set(sessionId, res); console.error(`Session created: ${sessionId}`); - res.write(`data: ${JSON.stringify({ - type: 'connection', - sessionId: sessionId - })}\n\n`); + res.write(`data: ${JSON.stringify({ type: 'connection', sessionId })}\n\n`); - const keepAlive = setInterval(() => { - res.write(':keepalive\n\n'); - }, 30000); + const keepAlive = setInterval(() => { res.write(':keepalive\n\n'); }, 30000); req.on('close', () => { console.error(`Session closed: ${sessionId}`); @@ -72,1122 +105,149 @@ app.get('/sse', (req, res) => { }); }); -// Tool implementations with real SDP API -const toolImplementations = { - async claude_code_command(params) { - try { - const { command, project_path, args = [] } = params; - - console.error(`Executing Claude Code command: ${command}`); - - // Map of allowed Claude Code commands - const allowedCommands = { - 'open_project': 'Open a project in Claude Code', - 'create_file': 'Create a new file', - 'read_file': 'Read file contents', - 'write_file': 'Write content to file', - 'list_files': 'List files in directory', - 'run_command': 'Run a shell command', - 'git_status': 'Check git status', - 'git_commit': 'Create a git commit' - }; - - if (!allowedCommands[command]) { - throw new Error(`Unknown command: ${command}. Available: ${Object.keys(allowedCommands).join(', ')}`); - } - - // For now, return instructions on how to use Claude Code - let result = { - command, - status: 'instructions', - message: `To execute '${command}' in Claude Code: -` - }; - - switch (command) { - case 'open_project': - result.message += `1. Open Claude Code -2. Navigate to: ${project_path || '/Users/kalten/projects/SDP-MCP'} -3. The MCP server project is at: /Users/kalten/projects/SDP-MCP`; - break; - - case 'create_file': - result.message += `1. Use the 'Write' tool to create: ${args[0] || 'filename.js'} -2. Add content with the MCP integration`; - break; - - case 'read_file': - result.message += `1. Use the 'Read' tool for: ${args[0] || 'filename'} -2. The file content will be displayed`; - break; - - case 'list_files': - result.message += `1. Use the 'LS' tool for: ${project_path || '.'} -2. Shows all files and directories`; - break; - - case 'run_command': - result.message += `1. Use the 'Bash' tool -2. Command: ${args.join(' ') || 'npm test'} -3. See output in Claude Code terminal`; - break; - - case 'git_status': - result.message += `1. Use 'Bash' tool with: git status -2. Shows current git state`; - break; - - case 'git_commit': - result.message += `1. Stage files: git add . -2. Commit: git commit -m "${args[0] || 'Update from MCP'}" -3. Use Bash tool for both`; - break; - - default: - result.message = `Command '${command}' recognized but not implemented yet`; - } - - // Add project context - result.project_info = { - main_project: '/Users/kalten/projects/SDP-MCP', - server_file: '/Users/kalten/projects/SDP-MCP/src/mcp-sse-sdp-integrated.js', - api_client: '/Users/kalten/projects/SDP-MCP/src/sdp-api-client-v2.js', - current_directory: process.cwd() - }; - - return { - content: [{ - type: 'text', - text: JSON.stringify(result, null, 2) - }] - }; - } catch (error) { - throw new Error(`Claude Code command failed: ${error.message}`); - } - }, - - async get_metadata(params) { - try { - console.error('Fetching SDP metadata...'); - - const metadata = await sdpClient.getMetadata(); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - message: 'Valid values for Service Desk Plus fields', - priorities: metadata.priorities, - statuses: metadata.statuses, - categories: metadata.categories.slice(0, 20), // Limit for readability - templates: metadata.templates.slice(0, 10), - usage_tips: { - priority: 'Use values like: low, medium, high, urgent', - status: 'Use values like: open, closed, pending, resolved', - category: 'Use exact category names from the list above' - } - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to get metadata: ${error.message}`); - } - }, - - async list_requests(params) { - try { - const { limit = 10, status, priority, sort_by, sort_order } = params; - - console.error(`Fetching requests: limit=${limit}, status=${status}, priority=${priority}`); - - const result = await sdpClient.listRequests({ - limit, - status, - priority, - sortBy: sort_by, - sortOrder: sort_order - }); - - // Format the response - const formattedRequests = result.requests.map(req => ({ - id: req.id, - subject: req.subject, - status: req.status?.name, - priority: req.priority?.name, - requester: req.requester?.name || req.requester?.email_id, - created_time: req.created_time?.display_value, - due_date: req.due_by_time?.display_value, - category: req.category?.name, - subcategory: req.subcategory?.name, - technician: req.technician?.name - })); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - requests: formattedRequests, - total_count: result.total_count, - has_more: result.has_more - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to list requests: ${error.message}`); - } - }, - - async get_request(params) { - try { - const { request_id } = params; - - if (!request_id) { - throw new Error('request_id is required'); - } - - console.error(`Fetching request details for ID: ${request_id}`); - - const request = await sdpClient.getRequest(request_id); - - // Format detailed response - const formatted = { - id: request.id, - subject: request.subject, - description: request.description, - status: request.status?.name, - priority: request.priority?.name, - requester: { - name: request.requester?.name, - email: request.requester?.email_id, - phone: request.requester?.phone - }, - category: request.category?.name, - subcategory: request.subcategory?.name, - item: request.item?.name, - technician: request.technician?.name, - group: request.group?.name, - created_time: request.created_time?.display_value, - due_date: request.due_by_time?.display_value, - completed_time: request.completed_time?.display_value, - time_elapsed: request.time_elapsed, - resolution: request.resolution?.content, - closure_info: request.closure_info, - has_notes: request.has_notes, - has_attachments: request.has_attachments - }; - - return { - content: [{ - type: 'text', - text: JSON.stringify(formatted, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to get request: ${error.message}`); - } - }, - - async create_request(params) { - try { - const { subject, description, priority, category, subcategory, requester_email, technician_id, technician_email } = params; - - if (!subject) { - throw new Error('subject is required'); - } - - console.error(`Creating new request: ${subject}`); - - const requestData = { - subject, - description: description || '' - }; - - // Only add optional fields if they're provided - if (priority) requestData.priority = priority; - if (category) requestData.category = category; - if (subcategory) requestData.subcategory = subcategory; - if (requester_email) requestData.requester_email = requester_email; - if (technician_id) requestData.technician_id = technician_id; - if (technician_email) requestData.technician_email = technician_email; - - const request = await sdpClient.createRequest(requestData); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - request_id: request.id, - subject: request.subject, - status: request.status?.name, - message: `Request #${request.id} created successfully` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to create request: ${error.message}`); - } - }, - - async update_request(params) { - try { - const { request_id, ...updates } = params; - - if (!request_id) { - throw new Error('request_id is required'); - } - - console.error(`Updating request ${request_id}:`, updates); - - const request = await sdpClient.updateRequest(request_id, updates); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - request_id: request.id, - updated_fields: Object.keys(updates), - message: `Request #${request.id} updated successfully` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to update request: ${error.message}`); - } - }, - - async close_request(params) { - try { - const { request_id, closure_comments, closure_code } = params; - - if (!request_id) { - throw new Error('request_id is required'); - } - - console.error(`Closing request ${request_id}`); - - const request = await sdpClient.closeRequest(request_id, { - closure_comments: closure_comments || 'Request resolved', - closure_code: closure_code || 'Resolved' - }); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - request_id: request.id, - status: request.status?.name, - closed_time: request.completed_time?.display_value, - message: `Request #${request.id} closed successfully` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to close request: ${error.message}`); - } - }, - - async add_note(params) { - try { - const { request_id, note_content, is_public = true } = params; - - if (!request_id || !note_content) { - throw new Error('request_id and note_content are required'); - } - - console.error(`Adding note to request ${request_id}`); - - const note = await sdpClient.addNote(request_id, note_content, is_public); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - note_id: note.id, - request_id, - added_time: note.created_time?.display_value, - message: `Note added to request #${request_id}` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to add note: ${error.message}`); - } - }, - - async search_requests(params) { - try { - const { query, limit = 10 } = params; - - if (!query) { - throw new Error('query is required'); - } - - console.error(`Searching requests for: ${query}`); - - const result = await sdpClient.searchRequests(query, { limit }); - - const formattedRequests = result.requests.map(req => ({ - id: req.id, - subject: req.subject, - status: req.status?.name, - priority: req.priority?.name, - requester: req.requester?.name || req.requester?.email_id, - created_time: req.created_time?.display_value - })); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - query, - results: formattedRequests, - total_count: result.total_count - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to search requests: ${error.message}`); - } - }, - - async reply_to_requester(params) { - try { - const { request_id, reply_message, mark_first_response = false } = params; - - if (!request_id || !reply_message) { - throw new Error('request_id and reply_message are required'); - } - - console.error(`Replying to requester for request ${request_id}`); - - const note = await sdpClient.replyToRequester(request_id, reply_message, mark_first_response); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - note_id: note.id, - request_id, - reply_sent: true, - first_response: mark_first_response, - message: `Email reply sent to requester for request #${request_id}` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to reply to requester: ${error.message}`); - } - }, - - async add_private_note(params) { - try { - const { request_id, note_content, notify_technician = true } = params; - - if (!request_id || !note_content) { - throw new Error('request_id and note_content are required'); - } - - console.error(`Adding private note to request ${request_id}`); - - const note = await sdpClient.addPrivateNote(request_id, note_content, notify_technician); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - note_id: note.id, - request_id, - is_private: true, - technician_notified: notify_technician, - message: `Private note added to request #${request_id}` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to add private note: ${error.message}`); - } - }, - - async send_first_response(params) { - try { - const { request_id, response_message } = params; - - if (!request_id || !response_message) { - throw new Error('request_id and response_message are required'); - } - - console.error(`Sending first response for request ${request_id}`); - - const note = await sdpClient.sendFirstResponse(request_id, response_message); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - note_id: note.id, - request_id, - first_response: true, - email_sent: true, - message: `First response sent to requester for request #${request_id}` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to send first response: ${error.message}`); - } - }, - - async get_request_conversation(params) { - try { - const { request_id } = params; - - if (!request_id) { - throw new Error('request_id is required'); - } - - console.error(`Getting conversation for request ${request_id}`); - - const conversation = await sdpClient.getRequestConversation(request_id); - - const formattedConversation = conversation.map(note => ({ - id: note.id, - content: note.description, - created_time: note.created_time?.display_value, - author: note.added_by?.name || note.added_by?.email_id, - visible_to_requester: note.show_to_requester, - is_first_response: note.mark_first_response - })); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - request_id, - conversation: formattedConversation, - total_notes: conversation.length, - message: `Retrieved ${conversation.length} conversation entries for request #${request_id}` - }, null, 2) - }] - }; - } catch (error) { - throw new Error(`Failed to get request conversation: ${error.message}`); - } - }, - - async list_technicians(params) { - // The /users endpoint doesn't exist in Service Desk Plus Cloud API v3 - // Return empty result to prevent 401 errors and token refresh loops - console.error('Warning: Technician listing not available - /users endpoint does not exist in SDP Cloud API'); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - message: 'Technician listing is not available in Service Desk Plus Cloud API v3', - technicians: [], - total_count: 0, - has_more: false, - usage_tip: 'To assign tickets, use known technician IDs or email addresses directly', - note: 'The /users endpoint does not exist in the current API. Technician information is embedded in request objects.' - }, null, 2) - }] - }; - }, - - async get_technician(params) { - // The /users endpoint doesn't exist in Service Desk Plus Cloud API v3 - console.error('Warning: Technician details not available - /users endpoint does not exist in SDP Cloud API'); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - message: 'Technician details are not available in Service Desk Plus Cloud API v3', - error: 'The /users/{id} endpoint does not exist in the current API', - suggestion: 'Technician information is embedded in request objects when retrieved' - }, null, 2) - }] - }; - }, - - async find_technician(params) { - // The /users endpoint doesn't exist in Service Desk Plus Cloud API v3 - console.error('Warning: Technician search not available - /users endpoint does not exist in SDP Cloud API'); - - const { search_term } = params; - const cleanSearchTerm = search_term ? search_term.replace(/^mailto:/i, '') : ''; - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - found: false, - message: 'Technician search is not available in Service Desk Plus Cloud API v3', - search_term: cleanSearchTerm, - error: 'The /users endpoint does not exist in the current API', - suggestion: 'Use known technician email addresses directly when assigning tickets (e.g., "cmeuth@pttg.com")' - }, null, 2) - }] - }; - } -}; - -// Tool definitions -const tools = [ - { - name: 'claude_code_command', - description: 'Execute Claude Code commands or get instructions for Claude Code integration', - inputSchema: { - type: 'object', - properties: { - command: { - type: 'string', - description: 'Command to execute', - enum: ['open_project', 'create_file', 'read_file', 'write_file', 'list_files', 'run_command', 'git_status', 'git_commit'] - }, - project_path: { - type: 'string', - description: 'Path to project or file', - default: '/Users/kalten/projects/SDP-MCP' - }, - args: { - type: 'array', - items: { type: 'string' }, - description: 'Additional arguments for the command' - } - }, - required: ['command'] - } - }, - { - name: 'get_metadata', - description: 'Get valid values for priorities, statuses, categories, and templates', - inputSchema: { - type: 'object', - properties: {} - } - }, - { - name: 'list_requests', - description: 'List service desk requests with optional filters', - inputSchema: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Maximum number of requests to return (max 100 per API limits)', - default: 10, - maximum: 100 - }, - status: { - type: 'string', - description: 'Filter by status (e.g., open, closed, pending)', - enum: ['open', 'closed', 'pending', 'resolved', 'cancelled'] - }, - priority: { - type: 'string', - description: 'Filter by priority', - enum: ['low', 'medium', 'high', 'urgent'] - }, - sort_by: { - type: 'string', - description: 'Sort field', - enum: ['created_time', 'due_by_time', 'subject', 'priority'], - default: 'created_time' - }, - sort_order: { - type: 'string', - description: 'Sort order', - enum: ['asc', 'desc'], - default: 'desc' - } - } - } - }, - { - name: 'get_request', - description: 'Get detailed information about a specific request', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'The ID of the request to retrieve' - } - }, - required: ['request_id'] - } - }, - { - name: 'create_request', - description: 'Create a new service desk request', - inputSchema: { - type: 'object', - properties: { - subject: { - type: 'string', - description: 'Subject/title of the request' - }, - description: { - type: 'string', - description: 'Detailed description of the request' - }, - priority: { - type: 'string', - description: 'Priority level', - enum: ['low', 'medium', 'high', 'urgent'], - default: 'medium' - }, - category: { - type: 'string', - description: 'Category of the request' - }, - subcategory: { - type: 'string', - description: 'Subcategory of the request (often required)' - }, - requester_email: { - type: 'string', - description: 'Email of the requester' - }, - technician_id: { - type: 'string', - description: 'ID of technician to assign' - }, - technician_email: { - type: 'string', - description: 'Email of technician to assign (will lookup ID automatically)' - } - }, - required: ['subject'] - } - }, - { - name: 'update_request', - description: 'Update an existing request', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request to update' - }, - subject: { - type: 'string', - description: 'New subject' - }, - description: { - type: 'string', - description: 'New description' - }, - status: { - type: 'string', - description: 'New status', - enum: ['open', 'pending', 'resolved', 'closed'] - }, - priority: { - type: 'string', - description: 'New priority', - enum: ['low', 'medium', 'high', 'urgent'] - }, - category: { - type: 'string', - description: 'New category' - }, - subcategory: { - type: 'string', - description: 'New subcategory' - }, - technician_id: { - type: 'string', - description: 'ID of technician to assign' - }, - technician_email: { - type: 'string', - description: 'Email of technician to assign (will lookup ID)' - } - }, - required: ['request_id'] - } - }, - { - name: 'close_request', - description: 'Close a request with resolution details', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request to close' - }, - closure_comments: { - type: 'string', - description: 'Resolution/closure comments' - }, - closure_code: { - type: 'string', - description: 'Closure code', - enum: ['Resolved', 'Cancelled', 'Duplicate'], - default: 'Resolved' - } - }, - required: ['request_id'] - } - }, - { - name: 'add_note', - description: 'Add a note/comment to a request', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request' - }, - note_content: { - type: 'string', - description: 'Content of the note' - }, - is_public: { - type: 'boolean', - description: 'Whether the note is visible to requester', - default: true - } - }, - required: ['request_id', 'note_content'] - } - }, - { - name: 'search_requests', - description: 'Search requests by keyword', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query' - }, - limit: { - type: 'number', - description: 'Maximum results (max 100 per API limits)', - default: 10, - maximum: 100 - } - }, - required: ['query'] - } - }, - { - name: 'list_technicians', - description: 'List available technicians for ticket assignment', - inputSchema: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Maximum number of technicians to return (max 100 per API limits)', - default: 25, - maximum: 100 - }, - search_term: { - type: 'string', - description: 'Search by name or email' - } - } - } - }, - { - name: 'get_technician', - description: 'Get detailed information about a specific technician', - inputSchema: { - type: 'object', - properties: { - technician_id: { - type: 'string', - description: 'The ID of the technician' - } - }, - required: ['technician_id'] - } - }, - { - name: 'find_technician', - description: 'Find a technician by name or email (returns best match)', - inputSchema: { - type: 'object', - properties: { - search_term: { - type: 'string', - description: 'Name or email to search for' - } - }, - required: ['search_term'] - } - }, - { - name: 'reply_to_requester', - description: 'Send an email reply to the requester that appears in the ticket conversation', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request to reply to' - }, - reply_message: { - type: 'string', - description: 'The reply message content to send to the requester' - }, - mark_first_response: { - type: 'boolean', - description: 'Whether to mark this as the first response to the ticket', - default: false - } - }, - required: ['request_id', 'reply_message'] - } - }, - { - name: 'add_private_note', - description: 'Add a private note to a request (not visible to requester)', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request to add private note to' - }, - note_content: { - type: 'string', - description: 'Content of the private note' - }, - notify_technician: { - type: 'boolean', - description: 'Whether to notify the assigned technician', - default: true - } - }, - required: ['request_id', 'note_content'] - } - }, - { - name: 'send_first_response', - description: 'Send the first response to a requester (marks as first response and sends email)', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request to send first response to' - }, - response_message: { - type: 'string', - description: 'The first response message content' - } - }, - required: ['request_id', 'response_message'] - } - }, - { - name: 'get_request_conversation', - description: 'Get the full conversation/notes history for a request', - inputSchema: { - type: 'object', - properties: { - request_id: { - type: 'string', - description: 'ID of the request to get conversation for' - } - }, - required: ['request_id'] - } - } -]; - -// Handle JSON-RPC messages -function handleJsonRpcMessage(message, sseConnection) { - const { method, params, id, jsonrpc } = message; +// JSON-RPC message handler +function handleJsonRpcMessage(message) { + const { method, params, id } = message; const isNotification = id === undefined; - + console.error(`Received ${isNotification ? 'notification' : 'request'}: ${method}`); - - if (isNotification) { - console.error(`Ignoring notification: ${method}`); - return null; - } - + + if (isNotification) return null; + try { let result; - + switch (method) { case 'initialize': result = { protocolVersion: '2024-11-05', - capabilities: { - tools: {} - }, - serverInfo: { - name: 'service-desk-plus', - version: '2.0.0' - } + capabilities: { tools: {}, resources: {}, prompts: {} }, + serverInfo: { name: 'service-desk-plus', version: '2.0.0' } }; break; - + case 'tools/list': result = { tools }; break; - + case 'resources/list': - // No resources provided by this server - result = { resources: [] }; + result = { resources: listResources() }; break; - + + case 'resources/read': { + const uri = (params || {}).uri; + const resource = readResource(uri); + if (!resource) { + return { jsonrpc: '2.0', error: { code: -32602, message: `Unknown resource URI: ${uri}` }, id }; + } + result = { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.text }] }; + break; + } + case 'prompts/list': - // No prompts provided by this server - result = { prompts: [] }; + result = { prompts: listPrompts() }; + break; + + case 'prompts/get': { + const { name, arguments: args } = params || {}; + const prompt = getPrompt(name, args || {}); + if (!prompt) { + return { jsonrpc: '2.0', error: { code: -32602, message: `Unknown prompt: ${name}` }, id }; + } + result = prompt; break; - - case 'tools/call': + } + + case 'tools/call': { const { name, arguments: args } = params || {}; - + console.error(`Tool: ${name}`); + if (!sdpClient) { throw new Error('SDP client not initialized. Please check OAuth configuration.'); } - - const implementation = toolImplementations[name]; - if (!implementation) { + + const impl = toolImplementations[name]; + if (!impl) { throw new Error(`Unknown tool: ${name}`); } - - // Execute tool asynchronously - return implementation(args || {}).then(toolResult => ({ - jsonrpc: '2.0', - result: toolResult, - id + + return impl(args || {}).then(toolResult => ({ + jsonrpc: '2.0', result: toolResult, id })).catch(error => ({ - jsonrpc: '2.0', - error: { - code: -32603, - message: error.message - }, - id + jsonrpc: '2.0', error: { code: -32603, message: error.message }, id })); - + } + default: - return { - jsonrpc: '2.0', - error: { - code: -32601, - message: `Method not found: ${method}` - }, - id - }; + return { jsonrpc: '2.0', error: { code: -32601, message: `Method not found: ${method}` }, id }; } - - return { - jsonrpc: '2.0', - result, - id - }; - + + return { jsonrpc: '2.0', result, id }; + } catch (error) { - return { - jsonrpc: '2.0', - error: { - code: -32603, - message: error.message - }, - id - }; + return { jsonrpc: '2.0', error: { code: -32603, message: error.message }, id }; } } -// Message endpoint +// Message endpoint — used by SSE clients app.post('/messages', async (req, res) => { const sessionId = req.query.sessionId || req.headers['x-session-id']; const sseConnection = connections.get(sessionId); - + if (!sseConnection) { return res.status(404).json({ error: 'Session not found' }); } - const responsePromise = handleJsonRpcMessage(req.body, sseConnection); - - if (responsePromise && responsePromise.then) { - // Handle async response - const response = await responsePromise; - if (response) { - sseConnection.write(`data: ${JSON.stringify(response)}\n\n`); - } - } else if (responsePromise) { - // Handle sync response - sseConnection.write(`data: ${JSON.stringify(responsePromise)}\n\n`); + const response = handleJsonRpcMessage(req.body); + + if (response && response.then) { + const resolved = await response; + if (resolved) sseConnection.write(`data: ${JSON.stringify(resolved)}\n\n`); + } else if (response) { + sseConnection.write(`data: ${JSON.stringify(response)}\n\n`); } - + res.json({ status: 'ok' }); }); -// Direct POST to /sse endpoint +// Direct POST to /sse — used by some MCP clients app.post('/sse', async (req, res) => { console.error('Direct SSE POST received'); - - const responsePromise = handleJsonRpcMessage(req.body, null); - - if (!responsePromise) { - return res.status(200).end(); - } - - if (responsePromise.then) { - const response = await responsePromise; - res.json(response); + const response = handleJsonRpcMessage(req.body); + if (!response) return res.status(200).end(); + if (response.then) { + res.json(await response); } else { - res.json(responsePromise); + res.json(response); } }); const PORT = process.env.PORT || 3456; app.listen(PORT, '0.0.0.0', () => { - console.error(`MCP SSE Server with SDP Integration running on port ${PORT}`); - console.error(`SSE endpoint: http://0.0.0.0:${PORT}/sse`); + console.error(`MCP SSE Server running on port ${PORT}`); + console.error(`SSE: http://0.0.0.0:${PORT}/sse`); console.error(`Health: http://0.0.0.0:${PORT}/health`); - console.error(`\nIntegrated Service Desk Plus tools:`); - console.error('Request Management:'); - console.error('- list_requests: List service desk requests'); - console.error('- get_request: Get request details'); - console.error('- create_request: Create new request'); - console.error('- update_request: Update existing request'); - console.error('- close_request: Close request'); - console.error('- add_note: Add note to request'); - console.error('- search_requests: Search requests'); - console.error('\nEmail Communication:'); - console.error('- reply_to_requester: Send email reply to requester'); - console.error('- add_private_note: Add private note (not visible to requester)'); - console.error('- send_first_response: Send first response with email notification'); - console.error('- get_request_conversation: Get full conversation history'); - console.error('\nUtilities:'); - console.error('- get_metadata: Get valid field values'); - console.error('- claude_code_command: Claude Code integration'); - - console.error('\n🪟 Windows VS Code Configuration:'); - console.error('Create .vscode/mcp.json or %USERPROFILE%\\.mcp.json:'); - console.error(JSON.stringify({ - servers: { - 'service-desk-plus': { - type: 'stdio', - command: 'npx', - args: ['-y', 'mcp-remote', 'http://10.212.0.7:' + PORT + '/sse', '--allow-http'] - } - } - }, null, 2)); - + console.error(`Tools: ${tools.length} registered`); + if (!sdpClient) { - console.error('\n⚠️ SDP client not initialized. Please configure OAuth credentials.'); + console.error('WARNING: SDP client not initialized — check OAuth credentials.'); + return; } -}); \ No newline at end of file + + sdpClient.testConnection().then(result => { + if (result.success) { + console.error('\n✅ SDP API connection successful'); + console.error(` Instance : ${result.instance}`); + console.error(` URL : ${result.baseUrl}`); + console.error(` Region : ${result.dataCenter}`); + } else { + console.error('\n❌ SDP API connection failed'); + console.error(` Instance : ${result.instance}`); + console.error(` URL : ${result.baseUrl}`); + console.error(` Error : ${result.error}`); + console.error(' Check SDP_BASE_URL, SDP_CLIENT_ID, SDP_CLIENT_SECRET, SDP_REFRESH_TOKEN'); + } + }).catch(err => { + console.error('\n❌ SDP API connection test error:', err.message); + }); +}); diff --git a/src/sdp-oauth-client.js b/src/sdp-oauth-client.js index 41a7a88..ea76be2 100644 --- a/src/sdp-oauth-client.js +++ b/src/sdp-oauth-client.js @@ -10,9 +10,9 @@ const path = require('path'); class SDPOAuthClient { constructor(config = {}) { // Use the refresh token from sdp-mcp-server's .env if available - this.clientId = config.clientId || process.env.SDP_CLIENT_ID || '1000.U38EZ7R0KMO9DQZHYGKE83FG4OVUEU'; - this.clientSecret = config.clientSecret || process.env.SDP_CLIENT_SECRET || '5752f7060c587171f81b21d58c5b8d0019587ca999'; - this.refreshToken = config.refreshToken || process.env.SDP_REFRESH_TOKEN || '1000.58376be1b900c8dba9c8cb277e07ab31.0766efe7060d6208a7c71b0b9d057936'; + this.clientId = config.clientId || process.env.SDP_CLIENT_ID || 'YOUR_CLIENT_ID'; + this.clientSecret = config.clientSecret || process.env.SDP_CLIENT_SECRET || 'YOUR_CLIENT_SECRET'; + this.refreshToken = config.refreshToken || process.env.SDP_REFRESH_TOKEN || 'YOUR_REFRESH_TOKEN'; this.dataCenter = config.dataCenter || process.env.SDP_DATA_CENTER || 'US'; // Token storage