sprinklr-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +27 -0
- package/README.md +270 -0
- package/package.json +48 -0
- package/server.mjs +384 -0
package/.env.example
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# =====================================================================
|
|
2
|
+
# Sprinklr MCP Server Configuration
|
|
3
|
+
# Copy to .env and fill in all values. NEVER commit .env to git.
|
|
4
|
+
# =====================================================================
|
|
5
|
+
|
|
6
|
+
# --- Sprinklr API Credentials ---
|
|
7
|
+
# Environment: prod, prod2, prod3, prod4, prod8, etc.
|
|
8
|
+
SPRINKLR_ENV=prod4
|
|
9
|
+
|
|
10
|
+
# From Developer Tools in Sprinklr (Manage API Key/Token)
|
|
11
|
+
SPRINKLR_API_KEY=
|
|
12
|
+
SPRINKLR_API_SECRET=
|
|
13
|
+
|
|
14
|
+
# From OAuth flow
|
|
15
|
+
SPRINKLR_ACCESS_TOKEN=
|
|
16
|
+
SPRINKLR_REFRESH_TOKEN=
|
|
17
|
+
|
|
18
|
+
# Must match what you set during app creation
|
|
19
|
+
SPRINKLR_REDIRECT_URI=https://www.google.com
|
|
20
|
+
|
|
21
|
+
# --- Server Configuration ---
|
|
22
|
+
# Public URL where this server is hosted (needed for OAuth metadata)
|
|
23
|
+
# Example: https://sprinklr-mcp-xxxx.up.railway.app
|
|
24
|
+
SERVER_URL=
|
|
25
|
+
|
|
26
|
+
# Port (Railway/Render auto-assign via PORT env var)
|
|
27
|
+
PORT=3000
|
package/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Sprinklr MCP Server
|
|
2
|
+
|
|
3
|
+
An open-source [MCP](https://modelcontextprotocol.io/) server that gives AI assistants **read-only** access to your Sprinklr data. Works with Claude, ChatGPT, Copilot, Cursor, or any MCP-compatible client.
|
|
4
|
+
|
|
5
|
+
**How it works:** You deploy this server with your Sprinklr API credentials. Your AI assistant connects to it via MCP and can query reports, search cases, and call any read-only Sprinklr API endpoint --- using your existing permissions. No new access surface, no data leaves your infrastructure.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [What You Can Do](#what-you-can-do)
|
|
11
|
+
- [Deployment](#deployment)
|
|
12
|
+
- [Full Setup Guide](#full-setup-guide)
|
|
13
|
+
- [Token Lifecycle](#token-lifecycle)
|
|
14
|
+
- [Security](#security)
|
|
15
|
+
- [Troubleshooting](#troubleshooting)
|
|
16
|
+
- [Contributing](#contributing)
|
|
17
|
+
- [Links](#links)
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### Option A: npm package (fastest)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g sprinklr-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Create a `.env` file in your working directory with your Sprinklr credentials (see [`.env.example`](.env.example) for the template), then run:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
sprinklr-mcp
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> **Do not pass credentials as inline environment variables.** They will be saved in your shell history.
|
|
34
|
+
|
|
35
|
+
### Option B: Clone and configure
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/daiict218/sprinklr-mcp.git
|
|
39
|
+
cd sprinklr-mcp
|
|
40
|
+
npm install
|
|
41
|
+
cp .env.example .env # fill in your Sprinklr credentials
|
|
42
|
+
npm test # verify connectivity
|
|
43
|
+
npm start # server runs on port 3000
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then connect your AI client:
|
|
47
|
+
|
|
48
|
+
| Client | How |
|
|
49
|
+
|--------|-----|
|
|
50
|
+
| **Claude.ai** | Settings > Connectors > Add custom connector > `https://your-url/sse` |
|
|
51
|
+
| **Claude Desktop** | Add to config: `{"mcpServers":{"sprinklr":{"url":"http://localhost:3000/sse"}}}` |
|
|
52
|
+
| **Cursor / Others** | Point to `/sse` (SSE) or `/mcp` (Streamable HTTP) |
|
|
53
|
+
|
|
54
|
+
**Need Sprinklr API credentials?** See [Full Setup Guide](#full-setup-guide) below.
|
|
55
|
+
|
|
56
|
+
## What You Can Do
|
|
57
|
+
|
|
58
|
+
| Tool | Description |
|
|
59
|
+
|------|-------------|
|
|
60
|
+
| `sprinklr_report` | Run any reporting dashboard query via API v2 payload |
|
|
61
|
+
| `sprinklr_search_cases` | Search CARE tickets by text, case number, or status |
|
|
62
|
+
| `sprinklr_raw_api` | GET any Sprinklr v2 endpoint (scoped by your token's permissions) |
|
|
63
|
+
| `sprinklr_me` | Check authenticated user profile / verify connectivity |
|
|
64
|
+
| `sprinklr_token_status` | Check connection status and tenant info |
|
|
65
|
+
|
|
66
|
+
**Example:** Open a Sprinklr dashboard > click three dots on a widget > **"Generate API v2 Payload"** > copy the JSON > ask your AI assistant: *"Pull this reporting data: {paste payload}"*
|
|
67
|
+
|
|
68
|
+
## Deployment
|
|
69
|
+
|
|
70
|
+
Deploy to any Node.js host (Render, Railway, Fly.io, AWS, on-prem). Set all env vars from `.env` and run `npm start`.
|
|
71
|
+
|
|
72
|
+
For Render free tier, set `SERVER_URL` to your Render URL --- the server self-pings every 14 minutes to prevent spin-down.
|
|
73
|
+
|
|
74
|
+
**Cost model:** You deploy, you authenticate, you pay for your own LLM subscription. Zero cost on Sprinklr's side.
|
|
75
|
+
|
|
76
|
+
**Note:** This server has no built-in auth --- deploy on a private network or behind a reverse proxy. See [Security](#security).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Full Setup Guide
|
|
81
|
+
|
|
82
|
+
### Prerequisites
|
|
83
|
+
|
|
84
|
+
- Node.js 18+
|
|
85
|
+
- Sprinklr account with API access
|
|
86
|
+
- Admin or platform-level role to create developer apps
|
|
87
|
+
|
|
88
|
+
### Step 1: Find Your Sprinklr Environment
|
|
89
|
+
|
|
90
|
+
Each Sprinklr instance runs on a specific environment. Your API keys and tokens are tied to that environment and cannot be used across others.
|
|
91
|
+
|
|
92
|
+
1. Log into Sprinklr in your browser
|
|
93
|
+
2. Open browser DevTools (**F12** or right-click > **Inspect**)
|
|
94
|
+
3. Press **Ctrl+F** (Windows) or **Cmd+F** (Mac) to search
|
|
95
|
+
4. Search for `sentry-environment`
|
|
96
|
+
5. The value (e.g., `prod4`) is your environment
|
|
97
|
+
|
|
98
|
+
Common environments: `prod`, `prod2`, `prod3`, `prod4`, `prod8`.
|
|
99
|
+
|
|
100
|
+
**Note:** The `prod` environment has **no path prefix** in API URLs. All others include the environment name in the path.
|
|
101
|
+
|
|
102
|
+
### Step 2: Create a Sprinklr Developer App
|
|
103
|
+
|
|
104
|
+
1. Open Sprinklr > **All Settings** > **Manage Customer** > **Developer Apps**
|
|
105
|
+
2. Click **"+ Create App"** and fill in the details
|
|
106
|
+
3. Set the **Callback URL** to `https://www.google.com` (or any URL you control)
|
|
107
|
+
|
|
108
|
+
Alternatively, use the [Developer Portal](https://dev.sprinklr.com): register, go to **Apps** > **+ New App** > fill in the form.
|
|
109
|
+
|
|
110
|
+
### Step 3: Generate API Key and Secret
|
|
111
|
+
|
|
112
|
+
1. In **Developer Apps**, find your app > **three dots** > **"Manage API Key/Token"**
|
|
113
|
+
2. Click **"+ API Key"**
|
|
114
|
+
3. **Copy both the API Key and Secret immediately** --- the Secret is only shown once
|
|
115
|
+
|
|
116
|
+
If you lose the Secret, you must generate a new pair.
|
|
117
|
+
|
|
118
|
+
### Step 4: Ensure Required Permissions
|
|
119
|
+
|
|
120
|
+
The authorizing user needs **Generate Token** and **Generate API v2 Payload** permissions. These are managed in **All Settings > Platform Setup > Governance Console > Workspace/Global Roles**.
|
|
121
|
+
|
|
122
|
+
### Step 5: Generate OAuth Tokens
|
|
123
|
+
|
|
124
|
+
#### Step 5a: Get an Authorization Code
|
|
125
|
+
|
|
126
|
+
Open this URL in your browser (must be logged into Sprinklr):
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
https://api2.sprinklr.com/{ENV}/oauth/authorize?client_id={YOUR_API_KEY}&response_type=code&redirect_uri=https://www.google.com
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
For `prod`, omit `{ENV}/`. The `redirect_uri` must exactly match your app's Callback URL.
|
|
133
|
+
|
|
134
|
+
The browser redirects to `https://www.google.com/?code=XXXXX`. Copy the `code` value.
|
|
135
|
+
|
|
136
|
+
**Codes expire in 10 minutes** --- proceed immediately.
|
|
137
|
+
|
|
138
|
+
#### Step 5b: Exchange the Code for Tokens
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
curl -s -X POST "https://api2.sprinklr.com/{ENV}/oauth/token" \
|
|
142
|
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
143
|
+
-d "client_id={YOUR_API_KEY}" \
|
|
144
|
+
-d "client_secret={YOUR_API_SECRET}" \
|
|
145
|
+
-d "code={YOUR_CODE}" \
|
|
146
|
+
-d "grant_type=authorization_code" \
|
|
147
|
+
-d "redirect_uri=https://www.google.com"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Returns `access_token` and `refresh_token`. Save both.
|
|
151
|
+
|
|
152
|
+
**Alternative:** Generate tokens directly from the Sprinklr UI via **Developer Apps > Your App > Manage API Key/Token > Generate Token**.
|
|
153
|
+
|
|
154
|
+
### Step 6: Clone and Configure
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git clone https://github.com/daiict218/sprinklr-mcp.git
|
|
158
|
+
cd sprinklr-mcp
|
|
159
|
+
npm install
|
|
160
|
+
cp .env.example .env
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Fill in your `.env` with values from the previous steps. See `.env.example` for the template.
|
|
164
|
+
|
|
165
|
+
### Step 7: Test and Start
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npm test # verify Sprinklr connectivity
|
|
169
|
+
npm start # start the server on port 3000
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Endpoints:
|
|
173
|
+
- **SSE:** `GET /sse` + `POST /messages` (Claude.ai connectors)
|
|
174
|
+
- **Streamable HTTP:** `POST/GET/DELETE /mcp`
|
|
175
|
+
- **Health:** `GET /health`
|
|
176
|
+
|
|
177
|
+
## Token Lifecycle
|
|
178
|
+
|
|
179
|
+
| Token | Expiry | Notes |
|
|
180
|
+
|-------|--------|-------|
|
|
181
|
+
| Authorization code | 10 minutes | One-time use |
|
|
182
|
+
| Access token | ~30 days | Tied to environment |
|
|
183
|
+
| Refresh token | No expiry | **Single-use** --- each refresh invalidates the old one |
|
|
184
|
+
|
|
185
|
+
The server auto-refreshes on 401, but stores new tokens **in memory only**. If the server restarts, it re-reads from env vars. Update your env vars after a refresh, or re-run the OAuth flow if tokens go stale.
|
|
186
|
+
|
|
187
|
+
**One token per API key.** If multiple instances share an API key, one refreshing will invalidate the others. Use separate API keys per instance.
|
|
188
|
+
|
|
189
|
+
## Security
|
|
190
|
+
|
|
191
|
+
### Architecture
|
|
192
|
+
|
|
193
|
+
This MCP server is built entirely on top of Sprinklr's existing public REST APIs. It does not create any new access surface, bypass any Sprinklr access controls, or touch internal systems. Every request goes through Sprinklr's standard API gateway with the same authentication, authorization, and rate limiting that applies to any direct API consumer.
|
|
194
|
+
|
|
195
|
+
Because of this:
|
|
196
|
+
|
|
197
|
+
- **No Sprinklr security review required.** This is equivalent to a customer using Sprinklr APIs directly --- same endpoints, same credentials, same access controls.
|
|
198
|
+
- **Customer security teams should review.** As with any API integration, the deploying organization should review the connector as part of their standard security process.
|
|
199
|
+
|
|
200
|
+
### Deployment Model
|
|
201
|
+
|
|
202
|
+
The intended deployment model keeps all sensitive data within the customer's own infrastructure:
|
|
203
|
+
|
|
204
|
+
1. **Customer deploys the server** on their own infrastructure (Render, Railway, AWS, on-prem).
|
|
205
|
+
2. **Customer authenticates with their own Sprinklr credentials.** No credentials are shared with or stored by Sprinklr.
|
|
206
|
+
3. **LLM costs sit with the customer** --- they use their own Claude, ChatGPT, or Copilot subscription.
|
|
207
|
+
|
|
208
|
+
Sprinklr publishes the open-source connector code. Customers deploy, authenticate, and run it themselves. Zero infrastructure or AI cost on Sprinklr's side.
|
|
209
|
+
|
|
210
|
+
### Important: No Built-in Authentication
|
|
211
|
+
|
|
212
|
+
This server does not authenticate incoming MCP client connections. Anyone who can reach the server URL can invoke all tools using the configured Sprinklr credentials. This is by design for simplicity --- the server is intended to run on **private networks, localhost, or behind a reverse proxy with authentication**.
|
|
213
|
+
|
|
214
|
+
**Do not expose this server to the public internet without adding an authentication layer** (e.g., reverse proxy with OAuth, VPN, or firewall rules).
|
|
215
|
+
|
|
216
|
+
### Protections
|
|
217
|
+
|
|
218
|
+
- **Read-only enforcement:** PUT, DELETE, and PATCH are blocked at the API client level. POST is allowlisted only for `/reports/query` and `/case/search`.
|
|
219
|
+
- **SSRF prevention:** All endpoints must start with `/` and are validated against protocol injection (`://`) and path traversal (`..`). Requests always target the configured Sprinklr API domain.
|
|
220
|
+
- **Session expiry:** Inactive MCP sessions are cleaned up after 30 minutes.
|
|
221
|
+
- **No credentials in code:** All secrets are loaded from environment variables. `.env` is gitignored.
|
|
222
|
+
- **Token auto-refresh:** On 401 responses, the server refreshes the access token and stores the new refresh token for subsequent rotations.
|
|
223
|
+
- **Sanitized errors:** Sprinklr API error details are logged server-side only. Clients receive only the HTTP status code, not internal response bodies.
|
|
224
|
+
- **`sprinklr_raw_api` scope:** This tool allows GET requests to any Sprinklr v2 endpoint. Access is intentionally broad to support diverse use cases. The Sprinklr token's own permission scope limits what data is accessible.
|
|
225
|
+
|
|
226
|
+
### Token Storage
|
|
227
|
+
|
|
228
|
+
Tokens are stored **in memory only**. This is a deliberate design choice --- it avoids writing credentials to disk and keeps the attack surface minimal. The tradeoff: if the server restarts, it falls back to the tokens in your environment variables. Update your env vars after a refresh if needed, or re-run the OAuth flow.
|
|
229
|
+
|
|
230
|
+
See [Token Lifecycle](#token-lifecycle) for details on expiry and single-use refresh tokens.
|
|
231
|
+
|
|
232
|
+
## Troubleshooting
|
|
233
|
+
|
|
234
|
+
| Error | Cause | Fix |
|
|
235
|
+
|-------|-------|-----|
|
|
236
|
+
| "Invalid APIKey/ClientID" (401) | API Key doesn't match environment | Verify key belongs to correct environment bundle |
|
|
237
|
+
| "Unauthorized" (401) | Access token expired | Server auto-refreshes, or re-run OAuth flow |
|
|
238
|
+
| "invalid_grant" | Auth code expired/used/redirect mismatch | Get a fresh code, exchange within 10 minutes |
|
|
239
|
+
| Refresh token fails | Already used (single-use) | Re-run full OAuth flow |
|
|
240
|
+
| "Developer Over Rate" (403) | Hit 1,000 calls/hour limit | Wait, or contact Sprinklr Success Manager |
|
|
241
|
+
|
|
242
|
+
## Contributing
|
|
243
|
+
|
|
244
|
+
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
|
|
245
|
+
|
|
246
|
+
1. Fork the repo
|
|
247
|
+
2. Create a branch (`git checkout -b feature/your-feature`)
|
|
248
|
+
3. Make your changes
|
|
249
|
+
4. Test locally (`npm test && npm start`)
|
|
250
|
+
5. Open a PR against `main`
|
|
251
|
+
|
|
252
|
+
**Guidelines:**
|
|
253
|
+
- Keep changes focused --- one concern per PR
|
|
254
|
+
- Follow the existing code style (ES modules, arrow functions)
|
|
255
|
+
- All PRs are reviewed before merge
|
|
256
|
+
- All PRs must target `main` --- direct pushes are blocked
|
|
257
|
+
|
|
258
|
+
**Adding new read-only endpoints:** Add the POST path to `ALLOWED_POST_ENDPOINTS` in `server.mjs`. GET endpoints work automatically via `sprinklr_raw_api`.
|
|
259
|
+
|
|
260
|
+
## Links
|
|
261
|
+
|
|
262
|
+
- [Sprinklr Developer Portal](https://dev.sprinklr.com)
|
|
263
|
+
- [OAuth 2.0 Guide](https://dev.sprinklr.com/oauth-2-0-for-customers)
|
|
264
|
+
- [API Key Generation](https://dev.sprinklr.com/api-key-and-secret-generation)
|
|
265
|
+
- [Authorization Troubleshooting](https://dev.sprinklr.com/authorization-troubleshooting)
|
|
266
|
+
- [REST API Error Codes](https://dev.sprinklr.com/rest-api-error-and-status-codes)
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
ISC
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sprinklr-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open-source MCP server for Sprinklr API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"sprinklr-mcp": "./server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.mjs",
|
|
12
|
+
"README.md",
|
|
13
|
+
".env.example"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node server.mjs",
|
|
17
|
+
"test": "node test.mjs"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"sprinklr",
|
|
22
|
+
"model-context-protocol",
|
|
23
|
+
"claude",
|
|
24
|
+
"ai",
|
|
25
|
+
"chatgpt",
|
|
26
|
+
"copilot",
|
|
27
|
+
"api"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/daiict218/sprinklr-mcp.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/daiict218/sprinklr-mcp",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"author": "Ajay Gaur",
|
|
38
|
+
"license": "ISC",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/express": "^2.0.0-alpha.2",
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
42
|
+
"dotenv": "^17.4.0",
|
|
43
|
+
"express": "^5.2.1",
|
|
44
|
+
"express-rate-limit": "^8.3.2",
|
|
45
|
+
"helmet": "^8.1.0",
|
|
46
|
+
"zod": "^4.3.6"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/express";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import dotenv from "dotenv";
|
|
9
|
+
import helmet from "helmet";
|
|
10
|
+
import rateLimit from "express-rate-limit";
|
|
11
|
+
|
|
12
|
+
dotenv.config();
|
|
13
|
+
|
|
14
|
+
const SPRINKLR_ENV = process.env.SPRINKLR_ENV || "prod4";
|
|
15
|
+
const SPRINKLR_BASE_URL = `https://api2.sprinklr.com/${SPRINKLR_ENV}/api/v2`;
|
|
16
|
+
const SPRINKLR_OAUTH_URL = `https://api2.sprinklr.com/${SPRINKLR_ENV}/oauth`;
|
|
17
|
+
const API_KEY = process.env.SPRINKLR_API_KEY;
|
|
18
|
+
const API_SECRET = process.env.SPRINKLR_API_SECRET;
|
|
19
|
+
let ACCESS_TOKEN = process.env.SPRINKLR_ACCESS_TOKEN;
|
|
20
|
+
let REFRESH_TOKEN = process.env.SPRINKLR_REFRESH_TOKEN;
|
|
21
|
+
const REDIRECT_URI = process.env.SPRINKLR_REDIRECT_URI || "https://www.google.com";
|
|
22
|
+
const PORT = parseInt(process.env.PORT || "3000", 10);
|
|
23
|
+
const SERVER_URL = process.env.SERVER_URL || "";
|
|
24
|
+
|
|
25
|
+
if (!API_KEY || !ACCESS_TOKEN) {
|
|
26
|
+
console.error("ERROR: SPRINKLR_API_KEY and SPRINKLR_ACCESS_TOKEN required");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (REFRESH_TOKEN && !API_SECRET) {
|
|
31
|
+
console.error("ERROR: SPRINKLR_API_SECRET is required when SPRINKLR_REFRESH_TOKEN is set (needed for token refresh)");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!REFRESH_TOKEN) {
|
|
36
|
+
console.warn("WARN: SPRINKLR_REFRESH_TOKEN not set — token auto-refresh is disabled. Server will stop working when the access token expires.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// =====================================================================
|
|
40
|
+
// LOGGING
|
|
41
|
+
// =====================================================================
|
|
42
|
+
|
|
43
|
+
function log(msg, data = {}) {
|
|
44
|
+
console.log(`[${new Date().toISOString()}] ${msg}`, Object.keys(data).length ? JSON.stringify(data) : "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// =====================================================================
|
|
48
|
+
// READ-ONLY ENFORCEMENT
|
|
49
|
+
// =====================================================================
|
|
50
|
+
|
|
51
|
+
const BLOCKED_METHODS = new Set(["PUT", "DELETE", "PATCH"]);
|
|
52
|
+
const ALLOWED_POST_ENDPOINTS = ["/reports/query", "/case/search"];
|
|
53
|
+
|
|
54
|
+
function isReadOnlyRequest(method, endpoint) {
|
|
55
|
+
const m = (method || "GET").toUpperCase();
|
|
56
|
+
if (m === "GET") return true;
|
|
57
|
+
if (BLOCKED_METHODS.has(m)) return false;
|
|
58
|
+
if (m === "POST") return ALLOWED_POST_ENDPOINTS.some((a) => endpoint.endsWith(a));
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// =====================================================================
|
|
63
|
+
// SPRINKLR API CLIENT
|
|
64
|
+
// =====================================================================
|
|
65
|
+
|
|
66
|
+
async function sprinklrFetch(endpoint, options = {}) {
|
|
67
|
+
const { method = "GET", body = null, retried = false } = options;
|
|
68
|
+
|
|
69
|
+
if (!endpoint.startsWith("/")) {
|
|
70
|
+
throw new Error(`BLOCKED: endpoint must start with '/'. Got: ${endpoint}`);
|
|
71
|
+
}
|
|
72
|
+
if (endpoint.includes("://") || endpoint.includes("..")) {
|
|
73
|
+
throw new Error(`BLOCKED: endpoint contains forbidden sequence. Got: ${endpoint}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!isReadOnlyRequest(method, endpoint)) {
|
|
77
|
+
throw new Error(`BLOCKED: ${method} ${endpoint} not permitted.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const headers = { Authorization: `Bearer ${ACCESS_TOKEN}`, key: API_KEY, "Content-Type": "application/json" };
|
|
81
|
+
const fetchOptions = { method, headers };
|
|
82
|
+
if (body) fetchOptions.body = JSON.stringify(body);
|
|
83
|
+
|
|
84
|
+
const url = `${SPRINKLR_BASE_URL}${endpoint}`;
|
|
85
|
+
const response = await fetch(url, fetchOptions);
|
|
86
|
+
|
|
87
|
+
if (response.status === 401 && !retried && REFRESH_TOKEN) {
|
|
88
|
+
log("Sprinklr token expired, refreshing...");
|
|
89
|
+
const refreshed = await refreshAccessToken();
|
|
90
|
+
if (refreshed) return sprinklrFetch(endpoint, { ...options, retried: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const errorText = await response.text();
|
|
95
|
+
log("Sprinklr API error", { status: response.status, body: errorText });
|
|
96
|
+
throw new Error(`Sprinklr API returned HTTP ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
return response.json();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function refreshAccessToken() {
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(`${SPRINKLR_OAUTH_URL}/token`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
106
|
+
body: new URLSearchParams({
|
|
107
|
+
client_id: API_KEY, client_secret: API_SECRET,
|
|
108
|
+
refresh_token: REFRESH_TOKEN, grant_type: "refresh_token", redirect_uri: REDIRECT_URI,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) return false;
|
|
112
|
+
const data = await response.json();
|
|
113
|
+
ACCESS_TOKEN = data.access_token;
|
|
114
|
+
if (data.refresh_token) REFRESH_TOKEN = data.refresh_token;
|
|
115
|
+
log("Sprinklr token refreshed");
|
|
116
|
+
return true;
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// =====================================================================
|
|
121
|
+
// MCP TOOLS (READ-ONLY)
|
|
122
|
+
// =====================================================================
|
|
123
|
+
|
|
124
|
+
function createSprinklrMcpServer() {
|
|
125
|
+
const server = new McpServer({ name: "sprinklr-mcp", version: "0.1.0" });
|
|
126
|
+
|
|
127
|
+
server.tool("sprinklr_me", "Get authenticated user profile from Sprinklr. Verifies connectivity.", {}, async () => {
|
|
128
|
+
log("Tool: sprinklr_me");
|
|
129
|
+
try {
|
|
130
|
+
const result = await sprinklrFetch("/me");
|
|
131
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.tool("sprinklr_report", "Execute Sprinklr Reporting API v2 query from dashboard payload.", {
|
|
138
|
+
payload: z.string().describe("Full reporting API v2 payload as JSON string from 'Generate API v2 Payload'."),
|
|
139
|
+
page_size: z.number().optional().describe("Rows per page. Default 100."),
|
|
140
|
+
}, async ({ payload, page_size }) => {
|
|
141
|
+
log("Tool: sprinklr_report");
|
|
142
|
+
try {
|
|
143
|
+
let p;
|
|
144
|
+
try { p = JSON.parse(payload); } catch {
|
|
145
|
+
return { content: [{ type: "text", text: "Error: invalid JSON payload." }], isError: true };
|
|
146
|
+
}
|
|
147
|
+
if (page_size) p.pageSize = page_size;
|
|
148
|
+
const result = await sprinklrFetch("/reports/query", { method: "POST", body: p });
|
|
149
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
server.tool("sprinklr_search_cases", "Search CARE tickets in Sprinklr. Read-only.", {
|
|
156
|
+
query: z.string().optional().describe("Free text search"),
|
|
157
|
+
case_number: z.string().regex(/^[A-Za-z]+-\d+$/).optional().describe("Case number e.g. CARE-96832"),
|
|
158
|
+
status: z.string().optional().describe("OPEN, IN_PROGRESS, CLOSED"),
|
|
159
|
+
page_size: z.number().optional().describe("Results. Default 20."),
|
|
160
|
+
}, async ({ query, case_number, status, page_size }) => {
|
|
161
|
+
log("Tool: sprinklr_search_cases", { case_number });
|
|
162
|
+
try {
|
|
163
|
+
if (case_number) {
|
|
164
|
+
const result = await sprinklrFetch(`/case/${case_number}`);
|
|
165
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
166
|
+
}
|
|
167
|
+
const sp = { page: 0, pageSize: page_size || 20, sort: { key: "createdTime", order: "DESC" } };
|
|
168
|
+
if (query) sp.query = query;
|
|
169
|
+
if (status) sp.filter = { status: [status] };
|
|
170
|
+
const result = await sprinklrFetch("/case/search", { method: "POST", body: sp });
|
|
171
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.tool("sprinklr_raw_api", "Read-only GET to any Sprinklr v2 endpoint. Access is scoped by the Sprinklr token's permissions.", {
|
|
178
|
+
endpoint: z.string().describe("API path e.g. '/campaign/list'. GET only. Must start with '/'."),
|
|
179
|
+
}, async ({ endpoint }) => {
|
|
180
|
+
log("Tool: sprinklr_raw_api", { endpoint });
|
|
181
|
+
try {
|
|
182
|
+
const result = await sprinklrFetch(endpoint, { method: "GET" });
|
|
183
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
server.tool("sprinklr_token_status", "Check Sprinklr connectivity and tenant info.", {}, async () => {
|
|
190
|
+
log("Tool: sprinklr_token_status");
|
|
191
|
+
try {
|
|
192
|
+
const me = await sprinklrFetch("/me");
|
|
193
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "connected", environment: SPRINKLR_ENV, user: { id: me.id, email: me.email, displayName: me.displayName, clientId: me.clientId } }, null, 2) }] };
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return { content: [{ type: "text", text: JSON.stringify({ status: "disconnected", error: err.message }, null, 2) }], isError: true };
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return server;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// =====================================================================
|
|
203
|
+
// EXPRESS + TRANSPORTS
|
|
204
|
+
// =====================================================================
|
|
205
|
+
|
|
206
|
+
const app = createMcpExpressApp({ host: "0.0.0.0" });
|
|
207
|
+
app.set("trust proxy", 1);
|
|
208
|
+
|
|
209
|
+
// --- Security headers (tuned for API server, not browser app) ---
|
|
210
|
+
app.use(helmet({
|
|
211
|
+
contentSecurityPolicy: false,
|
|
212
|
+
crossOriginResourcePolicy: false,
|
|
213
|
+
crossOriginOpenerPolicy: false,
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
// --- Rate limiting ---
|
|
217
|
+
const globalLimiter = rateLimit({
|
|
218
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
219
|
+
max: 100, // 100 requests per 15 min per IP
|
|
220
|
+
standardHeaders: true,
|
|
221
|
+
legacyHeaders: false,
|
|
222
|
+
message: { error: "Too many requests, please try again later." },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const mcpLimiter = rateLimit({
|
|
226
|
+
windowMs: 60 * 1000, // 1 minute
|
|
227
|
+
max: 30, // 30 requests per minute per IP
|
|
228
|
+
standardHeaders: true,
|
|
229
|
+
legacyHeaders: false,
|
|
230
|
+
message: { error: "Too many MCP requests, please try again later." },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
app.use(globalLimiter);
|
|
234
|
+
app.use("/mcp", mcpLimiter);
|
|
235
|
+
app.use("/messages", mcpLimiter);
|
|
236
|
+
app.use("/sse", mcpLimiter);
|
|
237
|
+
|
|
238
|
+
// Log EVERY incoming request for debugging
|
|
239
|
+
app.use((req, res, next) => {
|
|
240
|
+
log("REQUEST", { method: req.method, path: req.path, headers: { accept: req.headers.accept, "content-type": req.headers["content-type"], "mcp-session-id": req.headers["mcp-session-id"] } });
|
|
241
|
+
next();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// --- Streamable HTTP transport ---
|
|
245
|
+
const transports = {};
|
|
246
|
+
|
|
247
|
+
setInterval(() => {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
for (const [sid, e] of Object.entries(transports)) {
|
|
250
|
+
if (now > e.lastActivity + 30 * 60 * 1000) delete transports[sid];
|
|
251
|
+
}
|
|
252
|
+
}, 60000);
|
|
253
|
+
|
|
254
|
+
app.post("/mcp", async (req, res) => {
|
|
255
|
+
try {
|
|
256
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
257
|
+
if (sessionId && transports[sessionId]) {
|
|
258
|
+
transports[sessionId].lastActivity = Date.now();
|
|
259
|
+
await transports[sessionId].transport.handleRequest(req, res, req.body);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
log("Creating new MCP session");
|
|
264
|
+
const server = createSprinklrMcpServer();
|
|
265
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
|
266
|
+
transport.onclose = () => { const sid = transport.sessionId; if (sid) delete transports[sid]; };
|
|
267
|
+
await server.connect(transport);
|
|
268
|
+
if (transport.sessionId) {
|
|
269
|
+
transports[transport.sessionId] = { transport, lastActivity: Date.now() };
|
|
270
|
+
log("Session created", { sessionId: transport.sessionId });
|
|
271
|
+
}
|
|
272
|
+
await transport.handleRequest(req, res, req.body);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
log("ERROR in POST /mcp", { error: err.message, stack: err.stack });
|
|
275
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
app.get("/mcp", async (req, res) => {
|
|
280
|
+
try {
|
|
281
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
282
|
+
if (sessionId && transports[sessionId]) {
|
|
283
|
+
transports[sessionId].lastActivity = Date.now();
|
|
284
|
+
await transports[sessionId].transport.handleRequest(req, res);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
res.status(400).json({ error: "No session." });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
log("ERROR in GET /mcp", { error: err.message });
|
|
290
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
app.delete("/mcp", async (req, res) => {
|
|
295
|
+
try {
|
|
296
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
297
|
+
if (sessionId && transports[sessionId]) {
|
|
298
|
+
await transports[sessionId].transport.handleRequest(req, res, req.body);
|
|
299
|
+
delete transports[sessionId];
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
res.status(404).json({ error: "Session not found" });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
log("ERROR in DELETE /mcp", { error: err.message });
|
|
305
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// --- SSE transport (fallback for Claude.ai compatibility) ---
|
|
310
|
+
const sseTransports = {};
|
|
311
|
+
|
|
312
|
+
setInterval(() => {
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
for (const [sid, transport] of Object.entries(sseTransports)) {
|
|
315
|
+
if (transport._lastActivity && now > transport._lastActivity + 30 * 60 * 1000) {
|
|
316
|
+
transport.close?.();
|
|
317
|
+
delete sseTransports[sid];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}, 60000);
|
|
321
|
+
|
|
322
|
+
app.get("/sse", async (req, res) => {
|
|
323
|
+
try {
|
|
324
|
+
log("SSE connection requested");
|
|
325
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
326
|
+
const server = createSprinklrMcpServer();
|
|
327
|
+
transport._lastActivity = Date.now();
|
|
328
|
+
sseTransports[transport.sessionId] = transport;
|
|
329
|
+
transport.onclose = () => { delete sseTransports[transport.sessionId]; };
|
|
330
|
+
await server.connect(transport);
|
|
331
|
+
log("SSE session created", { sessionId: transport.sessionId });
|
|
332
|
+
} catch (err) {
|
|
333
|
+
log("ERROR in GET /sse", { error: err.message });
|
|
334
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
app.post("/messages", async (req, res) => {
|
|
339
|
+
try {
|
|
340
|
+
const sessionId = req.query.sessionId;
|
|
341
|
+
log("SSE message received", { sessionId });
|
|
342
|
+
const transport = sseTransports[sessionId];
|
|
343
|
+
if (transport) {
|
|
344
|
+
transport._lastActivity = Date.now();
|
|
345
|
+
await transport.handlePostMessage(req, res, req.body);
|
|
346
|
+
} else {
|
|
347
|
+
res.status(404).json({ error: "SSE session not found" });
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
log("ERROR in POST /messages", { error: err.message });
|
|
351
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// --- Health ---
|
|
356
|
+
app.get("/health", (req, res) => {
|
|
357
|
+
res.json({ status: "ok", server: "sprinklr-mcp", version: "0.1.0", read_only: true, transports: ["streamable-http", "sse"] });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
app.use((req, res) => {
|
|
361
|
+
log("404", { method: req.method, path: req.path });
|
|
362
|
+
res.status(404).json({ error: "not_found" });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// --- Self-ping to prevent Render free tier spin-down ---
|
|
366
|
+
if (SERVER_URL) {
|
|
367
|
+
setInterval(() => {
|
|
368
|
+
fetch(`${SERVER_URL}/health`).catch(() => {});
|
|
369
|
+
}, 14 * 60 * 1000);
|
|
370
|
+
log("Self-ping enabled", { url: SERVER_URL, interval: "14 min" });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- Start ---
|
|
374
|
+
app.listen(PORT, "0.0.0.0", () => {
|
|
375
|
+
log("=== Sprinklr MCP Server Started ===");
|
|
376
|
+
log(`Environment: ${SPRINKLR_ENV}`);
|
|
377
|
+
log(`Port: ${PORT}`);
|
|
378
|
+
log(`Streamable HTTP: POST/GET/DELETE /mcp`);
|
|
379
|
+
log(`SSE: GET /sse + POST /messages`);
|
|
380
|
+
log(`Health: GET /health`);
|
|
381
|
+
log(`Auth: None (deploy behind a reverse proxy or on a private network)`);
|
|
382
|
+
log(`Read-only: Yes`);
|
|
383
|
+
log("===================================");
|
|
384
|
+
});
|