@codespar/mcp-rd-station 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @codespar/mcp-rd-station
2
+
3
+ > MCP server for **RD Station** — marketing automation and CRM
4
+
5
+ [![npm](https://img.shields.io/npm/v/@codespar/mcp-rd-station)](https://www.npmjs.com/package/@codespar/mcp-rd-station)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Quick Start
9
+
10
+ ### Claude Desktop
11
+
12
+ Add to `~/.config/claude/claude_desktop_config.json`:
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "rd-station": {
18
+ "command": "npx",
19
+ "args": ["-y", "@codespar/mcp-rd-station"],
20
+ "env": {
21
+ "RD_STATION_TOKEN": "your-token"
22
+ }
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ ### Claude Code
29
+
30
+ ```bash
31
+ claude mcp add rd-station -- npx @codespar/mcp-rd-station
32
+ ```
33
+
34
+ ### Cursor / VS Code
35
+
36
+ Add to `.cursor/mcp.json` or `.vscode/mcp.json`:
37
+
38
+ ```json
39
+ {
40
+ "servers": {
41
+ "rd-station": {
42
+ "command": "npx",
43
+ "args": ["-y", "@codespar/mcp-rd-station"],
44
+ "env": {
45
+ "RD_STATION_TOKEN": "your-token"
46
+ }
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Tools
53
+
54
+ | Tool | Description |
55
+ |------|-------------|
56
+ | `create_contact` | Create a contact in RD Station CRM |
57
+ | `update_contact` | Update a contact by UUID |
58
+ | `get_contact` | Get contact details by UUID or email |
59
+ | `list_contacts` | List contacts with pagination |
60
+ | `create_event` | Create a conversion event for a contact |
61
+ | `list_funnels` | List all sales funnels |
62
+ | `get_funnel` | Get funnel details with stages |
63
+ | `create_opportunity` | Create a sales opportunity in a funnel |
64
+
65
+ ## Authentication
66
+
67
+ RD Station uses a Bearer token for authentication.
68
+
69
+ ## Sandbox / Testing
70
+
71
+ RD Station provides an OAuth sandbox for testing. Use sandbox credentials during development.
72
+
73
+ ### Get your credentials
74
+
75
+ 1. Go to [RD Station Developer Portal](https://developers.rdstation.com)
76
+ 2. Create a developer account
77
+ 3. Register an OAuth application and obtain a token
78
+ 4. Set the `RD_STATION_TOKEN` environment variable
79
+
80
+ ## Environment Variables
81
+
82
+ | Variable | Required | Description |
83
+ |----------|----------|-------------|
84
+ | `RD_STATION_TOKEN` | Yes | Bearer token from RD Station |
85
+
86
+ ## Roadmap
87
+
88
+ ### v0.2 (planned)
89
+ - `list_deals` — List deals in the CRM pipeline
90
+ - `create_deal` — Create a new deal
91
+ - `update_deal` — Update deal details or stage
92
+ - `list_activities` — List activities for a contact or deal
93
+ - `create_task` — Create a task assigned to a user
94
+
95
+ ### v0.3 (planned)
96
+ - `custom_fields` — Manage custom fields for contacts and deals
97
+ - `automation_triggers` — Trigger marketing automation flows
98
+
99
+ Want to contribute? [Open a PR](https://github.com/codespar/mcp-dev-brasil) or [request a tool](https://github.com/codespar/mcp-dev-brasil/issues).
100
+
101
+ ## Links
102
+
103
+ - [RD Station Website](https://rdstation.com)
104
+ - [RD Station API Documentation](https://developers.rdstation.com)
105
+ - [MCP Dev Brasil](https://github.com/codespar/mcp-dev-brasil)
106
+ - [Landing Page](https://codespar.dev/mcp)
107
+
108
+ ## Enterprise
109
+
110
+ Need governance, budget limits, and audit trails for agent payments? [CodeSpar Enterprise](https://codespar.dev/enterprise) adds policy engine, payment routing, and compliance templates on top of these MCP servers.
111
+
112
+ ## License
113
+
114
+ MIT
package/dist/index.js CHANGED
@@ -2,21 +2,33 @@
2
2
  /**
3
3
  * MCP Server for RD Station — Brazilian CRM and marketing automation.
4
4
  *
5
- * Tools:
5
+ * Tools (18):
6
6
  * - create_contact: Create a contact in RD Station
7
7
  * - update_contact: Update a contact by UUID
8
+ * - upsert_contact: Upsert contact by email (Marketing API)
8
9
  * - get_contact: Get contact details by UUID or email
9
10
  * - list_contacts: List contacts with pagination
11
+ * - delete_contact: Delete a contact by UUID
10
12
  * - create_event: Create a conversion event
11
13
  * - list_funnels: List sales funnels
12
14
  * - get_funnel: Get funnel details with stages
15
+ * - list_deal_stages: List deal stages of a pipeline
13
16
  * - create_opportunity: Create a sales opportunity
17
+ * - update_deal: Update a deal/opportunity by ID
18
+ * - get_deal: Get a deal/opportunity by ID
19
+ * - list_deals: List deals with filters
20
+ * - list_segmentations: List contact segmentations
21
+ * - get_segmentation_contacts: List contacts of a segmentation
22
+ * - update_lead_scoring: Mark a contact as lead/opportunity (lead scoring)
23
+ * - create_webhook: Subscribe a webhook to RD Station events
14
24
  *
15
25
  * Environment:
16
26
  * RD_STATION_TOKEN — Bearer token from https://app.rdstation.com/
17
27
  */
18
28
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
29
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
31
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
20
32
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
21
33
  const TOKEN = process.env.RD_STATION_TOKEN || "";
22
34
  const BASE_URL = "https://api.rd.services";
@@ -33,9 +45,20 @@ async function rdStationRequest(method, path, body) {
33
45
  const err = await res.text();
34
46
  throw new Error(`RD Station API ${res.status}: ${err}`);
35
47
  }
36
- return res.json();
48
+ // Some DELETE endpoints return 204 No Content
49
+ if (res.status === 204)
50
+ return { ok: true };
51
+ const text = await res.text();
52
+ if (!text)
53
+ return { ok: true };
54
+ try {
55
+ return JSON.parse(text);
56
+ }
57
+ catch {
58
+ return { raw: text };
59
+ }
37
60
  }
38
- const server = new Server({ name: "mcp-rd-station", version: "0.1.0" }, { capabilities: { tools: {} } });
61
+ const server = new Server({ name: "mcp-rd-station", version: "0.2.0" }, { capabilities: { tools: {} } });
39
62
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
40
63
  tools: [
41
64
  {
@@ -76,6 +99,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
76
99
  required: ["uuid"],
77
100
  },
78
101
  },
102
+ {
103
+ name: "upsert_contact",
104
+ description: "Upsert (create or update) a contact identified by email (Marketing API)",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ email: { type: "string", description: "Identifier email (used in path)" },
109
+ name: { type: "string", description: "Contact name" },
110
+ job_title: { type: "string", description: "Job title" },
111
+ mobile_phone: { type: "string", description: "Mobile phone" },
112
+ tags: { type: "array", items: { type: "string" }, description: "Tags" },
113
+ cf_custom_fields: { type: "object", description: "Custom fields (key-value)" },
114
+ },
115
+ required: ["email"],
116
+ },
117
+ },
79
118
  {
80
119
  name: "get_contact",
81
120
  description: "Get contact details by UUID or email",
@@ -99,6 +138,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
99
138
  },
100
139
  },
101
140
  },
141
+ {
142
+ name: "delete_contact",
143
+ description: "Delete a contact by UUID",
144
+ inputSchema: {
145
+ type: "object",
146
+ properties: {
147
+ uuid: { type: "string", description: "Contact UUID" },
148
+ },
149
+ required: ["uuid"],
150
+ },
151
+ },
102
152
  {
103
153
  name: "create_event",
104
154
  description: "Create a conversion event for a contact",
@@ -138,6 +188,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
138
188
  required: ["id"],
139
189
  },
140
190
  },
191
+ {
192
+ name: "list_deal_stages",
193
+ description: "List deal stages of a pipeline (funnel)",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ deal_pipeline_id: { type: "string", description: "Pipeline (funnel) ID — optional filter" },
198
+ },
199
+ },
200
+ },
141
201
  {
142
202
  name: "create_opportunity",
143
203
  description: "Create a sales opportunity in a funnel",
@@ -153,6 +213,117 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
153
213
  required: ["deal_stage_id", "name"],
154
214
  },
155
215
  },
216
+ {
217
+ name: "update_deal",
218
+ description: "Update a deal/opportunity by ID",
219
+ inputSchema: {
220
+ type: "object",
221
+ properties: {
222
+ id: { type: "string", description: "Deal ID" },
223
+ name: { type: "string", description: "Updated name" },
224
+ amount_total: { type: "number", description: "Total amount" },
225
+ deal_stage_id: { type: "string", description: "Move to this stage" },
226
+ win: { type: "boolean", description: "Mark as won" },
227
+ prediction_date: { type: "string", description: "Updated prediction date (YYYY-MM-DD)" },
228
+ },
229
+ required: ["id"],
230
+ },
231
+ },
232
+ {
233
+ name: "get_deal",
234
+ description: "Get a deal/opportunity by ID",
235
+ inputSchema: {
236
+ type: "object",
237
+ properties: {
238
+ id: { type: "string", description: "Deal ID" },
239
+ },
240
+ required: ["id"],
241
+ },
242
+ },
243
+ {
244
+ name: "list_deals",
245
+ description: "List deals with optional filters and pagination",
246
+ inputSchema: {
247
+ type: "object",
248
+ properties: {
249
+ page: { type: "number", description: "Page number (default 1)" },
250
+ limit: { type: "number", description: "Results per page (default 20)" },
251
+ deal_stage_id: { type: "string", description: "Filter by stage ID" },
252
+ deal_pipeline_id: { type: "string", description: "Filter by pipeline ID" },
253
+ user_id: { type: "string", description: "Filter by owner user ID" },
254
+ win: { type: "string", description: "Filter by win status: true | false | null" },
255
+ },
256
+ },
257
+ },
258
+ {
259
+ name: "list_segmentations",
260
+ description: "List contact segmentations",
261
+ inputSchema: {
262
+ type: "object",
263
+ properties: {
264
+ page: { type: "number", description: "Page number" },
265
+ page_size: { type: "number", description: "Page size" },
266
+ },
267
+ },
268
+ },
269
+ {
270
+ name: "get_segmentation_contacts",
271
+ description: "List contacts inside a given segmentation",
272
+ inputSchema: {
273
+ type: "object",
274
+ properties: {
275
+ segmentation_id: { type: "string", description: "Segmentation ID" },
276
+ page: { type: "number", description: "Page number" },
277
+ page_size: { type: "number", description: "Page size" },
278
+ },
279
+ required: ["segmentation_id"],
280
+ },
281
+ },
282
+ {
283
+ name: "update_lead_scoring",
284
+ description: "Mark a contact as lead, qualified lead, or opportunity (lead scoring)",
285
+ inputSchema: {
286
+ type: "object",
287
+ properties: {
288
+ email: { type: "string", description: "Contact email" },
289
+ status: {
290
+ type: "string",
291
+ enum: ["opportunity", "qualified_lead", "lead", "client"],
292
+ description: "Lifecycle status to apply",
293
+ },
294
+ value: { type: "boolean", description: "Set/unset the status (default true)" },
295
+ },
296
+ required: ["email", "status"],
297
+ },
298
+ },
299
+ {
300
+ name: "create_webhook",
301
+ description: "Subscribe a webhook to RD Station events (WEBHOOK.CONVERTED / WEBHOOK.MARKED_OPPORTUNITY)",
302
+ inputSchema: {
303
+ type: "object",
304
+ properties: {
305
+ entity_type: { type: "string", description: "Entity type (e.g. CONTACT)" },
306
+ event_type: {
307
+ type: "string",
308
+ enum: ["WEBHOOK.CONVERTED", "WEBHOOK.MARKED_OPPORTUNITY"],
309
+ description: "Event type to subscribe to",
310
+ },
311
+ event_identifiers: {
312
+ type: "array",
313
+ items: { type: "string" },
314
+ description: "Optional list of conversion identifiers",
315
+ },
316
+ url: { type: "string", description: "Destination URL" },
317
+ http_method: { type: "string", enum: ["POST", "GET"], description: "HTTP method (default POST)" },
318
+ include_relations: {
319
+ type: "array",
320
+ items: { type: "string" },
321
+ description: "Relations to include (e.g. COMPANY, CONTACT_FUNNEL)",
322
+ },
323
+ },
324
+ required: ["entity_type", "event_type", "url"],
325
+ },
326
+ },
156
327
  ],
157
328
  }));
158
329
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -167,6 +338,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
167
338
  delete body.uuid;
168
339
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("PATCH", `/platform/contacts/${uuid}`, body), null, 2) }] };
169
340
  }
341
+ case "upsert_contact": {
342
+ const email = args?.email;
343
+ const body = { ...args };
344
+ delete body.email;
345
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("PATCH", `/platform/contacts/email:${email}`, body), null, 2) }] };
346
+ }
170
347
  case "get_contact": {
171
348
  if (args?.uuid) {
172
349
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/contacts/${args.uuid}`), null, 2) }] };
@@ -183,14 +360,73 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
183
360
  params.set("query", String(args.query));
184
361
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/contacts?${params}`), null, 2) }] };
185
362
  }
363
+ case "delete_contact":
364
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("DELETE", `/platform/contacts/${args?.uuid}`), null, 2) }] };
186
365
  case "create_event":
187
366
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", "/platform/events", args), null, 2) }] };
188
367
  case "list_funnels":
189
368
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", "/platform/deal_pipelines"), null, 2) }] };
190
369
  case "get_funnel":
191
370
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deal_pipelines/${args?.id}`), null, 2) }] };
371
+ case "list_deal_stages": {
372
+ const params = new URLSearchParams();
373
+ if (args?.deal_pipeline_id)
374
+ params.set("deal_pipeline_id", String(args.deal_pipeline_id));
375
+ const qs = params.toString();
376
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deal_stages${qs ? `?${qs}` : ""}`), null, 2) }] };
377
+ }
192
378
  case "create_opportunity":
193
379
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", "/platform/deals", args), null, 2) }] };
380
+ case "update_deal": {
381
+ const id = args?.id;
382
+ const body = { ...args };
383
+ delete body.id;
384
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("PUT", `/platform/deals/${id}`, body), null, 2) }] };
385
+ }
386
+ case "get_deal":
387
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deals/${args?.id}`), null, 2) }] };
388
+ case "list_deals": {
389
+ const params = new URLSearchParams();
390
+ if (args?.page)
391
+ params.set("page", String(args.page));
392
+ if (args?.limit)
393
+ params.set("limit", String(args.limit));
394
+ if (args?.deal_stage_id)
395
+ params.set("deal_stage_id", String(args.deal_stage_id));
396
+ if (args?.deal_pipeline_id)
397
+ params.set("deal_pipeline_id", String(args.deal_pipeline_id));
398
+ if (args?.user_id)
399
+ params.set("user_id", String(args.user_id));
400
+ if (args?.win !== undefined)
401
+ params.set("win", String(args.win));
402
+ const qs = params.toString();
403
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deals${qs ? `?${qs}` : ""}`), null, 2) }] };
404
+ }
405
+ case "list_segmentations": {
406
+ const params = new URLSearchParams();
407
+ if (args?.page)
408
+ params.set("page", String(args.page));
409
+ if (args?.page_size)
410
+ params.set("page_size", String(args.page_size));
411
+ const qs = params.toString();
412
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/segmentations${qs ? `?${qs}` : ""}`), null, 2) }] };
413
+ }
414
+ case "get_segmentation_contacts": {
415
+ const params = new URLSearchParams();
416
+ if (args?.page)
417
+ params.set("page", String(args.page));
418
+ if (args?.page_size)
419
+ params.set("page_size", String(args.page_size));
420
+ const qs = params.toString();
421
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/segmentations/${args?.segmentation_id}/contacts${qs ? `?${qs}` : ""}`), null, 2) }] };
422
+ }
423
+ case "update_lead_scoring": {
424
+ const status = String(args?.status ?? "lead");
425
+ const value = args?.value === undefined ? true : Boolean(args.value);
426
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", `/platform/contacts/email:${args?.email}/funnels/default`, { lifecycle_stage: status, value }), null, 2) }] };
427
+ }
428
+ case "create_webhook":
429
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", "/integrations/webhooks", args), null, 2) }] };
194
430
  default:
195
431
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
196
432
  }
@@ -200,11 +436,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
200
436
  }
201
437
  });
202
438
  async function main() {
203
- if (!TOKEN) {
204
- console.error("RD_STATION_TOKEN environment variable is required");
205
- process.exit(1);
439
+ if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
440
+ const { default: express } = await import("express");
441
+ const { randomUUID } = await import("node:crypto");
442
+ const app = express();
443
+ app.use(express.json());
444
+ const transports = new Map();
445
+ app.get("/health", (_req, res) => res.json({ status: "ok", sessions: transports.size }));
446
+ app.post("/mcp", async (req, res) => {
447
+ const sid = req.headers["mcp-session-id"];
448
+ if (sid && transports.has(sid)) {
449
+ await transports.get(sid).handleRequest(req, res, req.body);
450
+ return;
451
+ }
452
+ if (!sid && isInitializeRequest(req.body)) {
453
+ const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
454
+ t.onclose = () => { if (t.sessionId)
455
+ transports.delete(t.sessionId); };
456
+ const s = new Server({ name: "mcp-rd-station", version: "0.2.0" }, { capabilities: { tools: {} } });
457
+ server._requestHandlers.forEach((v, k) => s._requestHandlers.set(k, v));
458
+ server._notificationHandlers?.forEach((v, k) => s._notificationHandlers.set(k, v));
459
+ await s.connect(t);
460
+ await t.handleRequest(req, res, req.body);
461
+ return;
462
+ }
463
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
464
+ });
465
+ app.get("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
466
+ await transports.get(sid).handleRequest(req, res);
467
+ else
468
+ res.status(400).send("Invalid session"); });
469
+ app.delete("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
470
+ await transports.get(sid).handleRequest(req, res);
471
+ else
472
+ res.status(400).send("Invalid session"); });
473
+ const port = Number(process.env.MCP_PORT) || 3000;
474
+ app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
475
+ }
476
+ else {
477
+ const transport = new StdioServerTransport();
478
+ await server.connect(transport);
206
479
  }
207
- const transport = new StdioServerTransport();
208
- await server.connect(transport);
209
480
  }
210
481
  main().catch(console.error);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codespar/mcp-rd-station",
3
- "version": "0.1.0",
4
- "description": "MCP server for RD Station — contacts, events, funnels, opportunities",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for RD Station — contacts, events, funnels, deals, segmentations, lead scoring, webhooks",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "bin": {
@@ -25,5 +25,6 @@
25
25
  "crm",
26
26
  "marketing",
27
27
  "brazil"
28
- ]
28
+ ],
29
+ "mcpName": "io.github.codespar/mcp-rd-station"
29
30
  }
package/server.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.codespar/mcp-rd-station",
4
+ "description": "MCP server for RD Station — contacts, events, funnels, deals, segmentations, lead scoring, webhooks",
5
+ "repository": {
6
+ "url": "https://github.com/codespar/mcp-dev-brasil",
7
+ "source": "github",
8
+ "subfolder": "packages/communication/rd-station"
9
+ },
10
+ "version": "0.2.0",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "identifier": "@codespar/mcp-rd-station",
15
+ "version": "0.2.0",
16
+ "transport": {
17
+ "type": "stdio"
18
+ },
19
+ "environmentVariables": [
20
+ {
21
+ "name": "RD_STATION_TOKEN",
22
+ "description": "API key for rd-station",
23
+ "isRequired": true,
24
+ "format": "string",
25
+ "isSecret": true
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ let listToolsHandler: Function;
4
+ let callToolHandler: Function;
5
+
6
+ vi.mock("@modelcontextprotocol/sdk/server/index.js", () => {
7
+ class FakeServer {
8
+ constructor() {}
9
+ setRequestHandler(schema: any, handler: Function) {
10
+ if (JSON.stringify(schema).includes("tools/list")) listToolsHandler = handler;
11
+ if (JSON.stringify(schema).includes("tools/call")) callToolHandler = handler;
12
+ }
13
+ connect() { return Promise.resolve(); }
14
+ }
15
+ return { Server: FakeServer };
16
+ });
17
+
18
+ vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({ StdioServerTransport: class {} }));
19
+
20
+ process.env.RD_STATION_TOKEN = "test-token";
21
+
22
+ const mockFetch = vi.fn();
23
+ global.fetch = mockFetch as any;
24
+
25
+ beforeEach(async () => {
26
+ vi.resetModules();
27
+ listToolsHandler = undefined as any;
28
+ callToolHandler = undefined as any;
29
+ mockFetch.mockReset();
30
+ global.fetch = mockFetch as any;
31
+ await import("../index.js");
32
+ });
33
+
34
+ describe("mcp-rd-station", () => {
35
+ it("should register 8 tools", async () => {
36
+ const result = await listToolsHandler();
37
+ expect(result.tools).toHaveLength(8);
38
+ });
39
+
40
+ it("should call correct API endpoint for create_contact", async () => {
41
+ mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ uuid: "c1" }) });
42
+
43
+ await callToolHandler({
44
+ params: { name: "create_contact", arguments: { name: "Test", email: "test@test.com" } },
45
+ });
46
+
47
+ const [url, opts] = mockFetch.mock.calls[0];
48
+ expect(url).toContain("api.rd.services/platform/contacts");
49
+ expect(opts.method).toBe("POST");
50
+ expect(opts.headers.Authorization).toBe("Bearer test-token");
51
+ });
52
+ });
package/src/index.ts CHANGED
@@ -3,15 +3,25 @@
3
3
  /**
4
4
  * MCP Server for RD Station — Brazilian CRM and marketing automation.
5
5
  *
6
- * Tools:
6
+ * Tools (18):
7
7
  * - create_contact: Create a contact in RD Station
8
8
  * - update_contact: Update a contact by UUID
9
+ * - upsert_contact: Upsert contact by email (Marketing API)
9
10
  * - get_contact: Get contact details by UUID or email
10
11
  * - list_contacts: List contacts with pagination
12
+ * - delete_contact: Delete a contact by UUID
11
13
  * - create_event: Create a conversion event
12
14
  * - list_funnels: List sales funnels
13
15
  * - get_funnel: Get funnel details with stages
16
+ * - list_deal_stages: List deal stages of a pipeline
14
17
  * - create_opportunity: Create a sales opportunity
18
+ * - update_deal: Update a deal/opportunity by ID
19
+ * - get_deal: Get a deal/opportunity by ID
20
+ * - list_deals: List deals with filters
21
+ * - list_segmentations: List contact segmentations
22
+ * - get_segmentation_contacts: List contacts of a segmentation
23
+ * - update_lead_scoring: Mark a contact as lead/opportunity (lead scoring)
24
+ * - create_webhook: Subscribe a webhook to RD Station events
15
25
  *
16
26
  * Environment:
17
27
  * RD_STATION_TOKEN — Bearer token from https://app.rdstation.com/
@@ -19,6 +29,8 @@
19
29
 
20
30
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
21
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
33
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
22
34
  import {
23
35
  CallToolRequestSchema,
24
36
  ListToolsRequestSchema,
@@ -40,11 +52,15 @@ async function rdStationRequest(method: string, path: string, body?: unknown): P
40
52
  const err = await res.text();
41
53
  throw new Error(`RD Station API ${res.status}: ${err}`);
42
54
  }
43
- return res.json();
55
+ // Some DELETE endpoints return 204 No Content
56
+ if (res.status === 204) return { ok: true };
57
+ const text = await res.text();
58
+ if (!text) return { ok: true };
59
+ try { return JSON.parse(text); } catch { return { raw: text }; }
44
60
  }
45
61
 
46
62
  const server = new Server(
47
- { name: "mcp-rd-station", version: "0.1.0" },
63
+ { name: "mcp-rd-station", version: "0.2.0" },
48
64
  { capabilities: { tools: {} } }
49
65
  );
50
66
 
@@ -88,6 +104,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
88
104
  required: ["uuid"],
89
105
  },
90
106
  },
107
+ {
108
+ name: "upsert_contact",
109
+ description: "Upsert (create or update) a contact identified by email (Marketing API)",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ email: { type: "string", description: "Identifier email (used in path)" },
114
+ name: { type: "string", description: "Contact name" },
115
+ job_title: { type: "string", description: "Job title" },
116
+ mobile_phone: { type: "string", description: "Mobile phone" },
117
+ tags: { type: "array", items: { type: "string" }, description: "Tags" },
118
+ cf_custom_fields: { type: "object", description: "Custom fields (key-value)" },
119
+ },
120
+ required: ["email"],
121
+ },
122
+ },
91
123
  {
92
124
  name: "get_contact",
93
125
  description: "Get contact details by UUID or email",
@@ -111,6 +143,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
111
143
  },
112
144
  },
113
145
  },
146
+ {
147
+ name: "delete_contact",
148
+ description: "Delete a contact by UUID",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ uuid: { type: "string", description: "Contact UUID" },
153
+ },
154
+ required: ["uuid"],
155
+ },
156
+ },
114
157
  {
115
158
  name: "create_event",
116
159
  description: "Create a conversion event for a contact",
@@ -150,6 +193,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
150
193
  required: ["id"],
151
194
  },
152
195
  },
196
+ {
197
+ name: "list_deal_stages",
198
+ description: "List deal stages of a pipeline (funnel)",
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ deal_pipeline_id: { type: "string", description: "Pipeline (funnel) ID — optional filter" },
203
+ },
204
+ },
205
+ },
153
206
  {
154
207
  name: "create_opportunity",
155
208
  description: "Create a sales opportunity in a funnel",
@@ -165,6 +218,117 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
165
218
  required: ["deal_stage_id", "name"],
166
219
  },
167
220
  },
221
+ {
222
+ name: "update_deal",
223
+ description: "Update a deal/opportunity by ID",
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {
227
+ id: { type: "string", description: "Deal ID" },
228
+ name: { type: "string", description: "Updated name" },
229
+ amount_total: { type: "number", description: "Total amount" },
230
+ deal_stage_id: { type: "string", description: "Move to this stage" },
231
+ win: { type: "boolean", description: "Mark as won" },
232
+ prediction_date: { type: "string", description: "Updated prediction date (YYYY-MM-DD)" },
233
+ },
234
+ required: ["id"],
235
+ },
236
+ },
237
+ {
238
+ name: "get_deal",
239
+ description: "Get a deal/opportunity by ID",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ id: { type: "string", description: "Deal ID" },
244
+ },
245
+ required: ["id"],
246
+ },
247
+ },
248
+ {
249
+ name: "list_deals",
250
+ description: "List deals with optional filters and pagination",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {
254
+ page: { type: "number", description: "Page number (default 1)" },
255
+ limit: { type: "number", description: "Results per page (default 20)" },
256
+ deal_stage_id: { type: "string", description: "Filter by stage ID" },
257
+ deal_pipeline_id: { type: "string", description: "Filter by pipeline ID" },
258
+ user_id: { type: "string", description: "Filter by owner user ID" },
259
+ win: { type: "string", description: "Filter by win status: true | false | null" },
260
+ },
261
+ },
262
+ },
263
+ {
264
+ name: "list_segmentations",
265
+ description: "List contact segmentations",
266
+ inputSchema: {
267
+ type: "object",
268
+ properties: {
269
+ page: { type: "number", description: "Page number" },
270
+ page_size: { type: "number", description: "Page size" },
271
+ },
272
+ },
273
+ },
274
+ {
275
+ name: "get_segmentation_contacts",
276
+ description: "List contacts inside a given segmentation",
277
+ inputSchema: {
278
+ type: "object",
279
+ properties: {
280
+ segmentation_id: { type: "string", description: "Segmentation ID" },
281
+ page: { type: "number", description: "Page number" },
282
+ page_size: { type: "number", description: "Page size" },
283
+ },
284
+ required: ["segmentation_id"],
285
+ },
286
+ },
287
+ {
288
+ name: "update_lead_scoring",
289
+ description: "Mark a contact as lead, qualified lead, or opportunity (lead scoring)",
290
+ inputSchema: {
291
+ type: "object",
292
+ properties: {
293
+ email: { type: "string", description: "Contact email" },
294
+ status: {
295
+ type: "string",
296
+ enum: ["opportunity", "qualified_lead", "lead", "client"],
297
+ description: "Lifecycle status to apply",
298
+ },
299
+ value: { type: "boolean", description: "Set/unset the status (default true)" },
300
+ },
301
+ required: ["email", "status"],
302
+ },
303
+ },
304
+ {
305
+ name: "create_webhook",
306
+ description: "Subscribe a webhook to RD Station events (WEBHOOK.CONVERTED / WEBHOOK.MARKED_OPPORTUNITY)",
307
+ inputSchema: {
308
+ type: "object",
309
+ properties: {
310
+ entity_type: { type: "string", description: "Entity type (e.g. CONTACT)" },
311
+ event_type: {
312
+ type: "string",
313
+ enum: ["WEBHOOK.CONVERTED", "WEBHOOK.MARKED_OPPORTUNITY"],
314
+ description: "Event type to subscribe to",
315
+ },
316
+ event_identifiers: {
317
+ type: "array",
318
+ items: { type: "string" },
319
+ description: "Optional list of conversion identifiers",
320
+ },
321
+ url: { type: "string", description: "Destination URL" },
322
+ http_method: { type: "string", enum: ["POST", "GET"], description: "HTTP method (default POST)" },
323
+ include_relations: {
324
+ type: "array",
325
+ items: { type: "string" },
326
+ description: "Relations to include (e.g. COMPANY, CONTACT_FUNNEL)",
327
+ },
328
+ },
329
+ required: ["entity_type", "event_type", "url"],
330
+ },
331
+ },
168
332
  ],
169
333
  }));
170
334
 
@@ -181,6 +345,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
181
345
  delete body.uuid;
182
346
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("PATCH", `/platform/contacts/${uuid}`, body), null, 2) }] };
183
347
  }
348
+ case "upsert_contact": {
349
+ const email = args?.email;
350
+ const body = { ...args } as Record<string, unknown>;
351
+ delete body.email;
352
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("PATCH", `/platform/contacts/email:${email}`, body), null, 2) }] };
353
+ }
184
354
  case "get_contact": {
185
355
  if (args?.uuid) {
186
356
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/contacts/${args.uuid}`), null, 2) }] };
@@ -194,14 +364,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
194
364
  if (args?.query) params.set("query", String(args.query));
195
365
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/contacts?${params}`), null, 2) }] };
196
366
  }
367
+ case "delete_contact":
368
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("DELETE", `/platform/contacts/${args?.uuid}`), null, 2) }] };
197
369
  case "create_event":
198
370
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", "/platform/events", args), null, 2) }] };
199
371
  case "list_funnels":
200
372
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", "/platform/deal_pipelines"), null, 2) }] };
201
373
  case "get_funnel":
202
374
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deal_pipelines/${args?.id}`), null, 2) }] };
375
+ case "list_deal_stages": {
376
+ const params = new URLSearchParams();
377
+ if (args?.deal_pipeline_id) params.set("deal_pipeline_id", String(args.deal_pipeline_id));
378
+ const qs = params.toString();
379
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deal_stages${qs ? `?${qs}` : ""}`), null, 2) }] };
380
+ }
203
381
  case "create_opportunity":
204
382
  return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", "/platform/deals", args), null, 2) }] };
383
+ case "update_deal": {
384
+ const id = args?.id;
385
+ const body = { ...args } as Record<string, unknown>;
386
+ delete body.id;
387
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("PUT", `/platform/deals/${id}`, body), null, 2) }] };
388
+ }
389
+ case "get_deal":
390
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deals/${args?.id}`), null, 2) }] };
391
+ case "list_deals": {
392
+ const params = new URLSearchParams();
393
+ if (args?.page) params.set("page", String(args.page));
394
+ if (args?.limit) params.set("limit", String(args.limit));
395
+ if (args?.deal_stage_id) params.set("deal_stage_id", String(args.deal_stage_id));
396
+ if (args?.deal_pipeline_id) params.set("deal_pipeline_id", String(args.deal_pipeline_id));
397
+ if (args?.user_id) params.set("user_id", String(args.user_id));
398
+ if (args?.win !== undefined) params.set("win", String(args.win));
399
+ const qs = params.toString();
400
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/deals${qs ? `?${qs}` : ""}`), null, 2) }] };
401
+ }
402
+ case "list_segmentations": {
403
+ const params = new URLSearchParams();
404
+ if (args?.page) params.set("page", String(args.page));
405
+ if (args?.page_size) params.set("page_size", String(args.page_size));
406
+ const qs = params.toString();
407
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/segmentations${qs ? `?${qs}` : ""}`), null, 2) }] };
408
+ }
409
+ case "get_segmentation_contacts": {
410
+ const params = new URLSearchParams();
411
+ if (args?.page) params.set("page", String(args.page));
412
+ if (args?.page_size) params.set("page_size", String(args.page_size));
413
+ const qs = params.toString();
414
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("GET", `/platform/segmentations/${args?.segmentation_id}/contacts${qs ? `?${qs}` : ""}`), null, 2) }] };
415
+ }
416
+ case "update_lead_scoring": {
417
+ const status = String(args?.status ?? "lead");
418
+ const value = args?.value === undefined ? true : Boolean(args.value);
419
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", `/platform/contacts/email:${args?.email}/funnels/default`, { lifecycle_stage: status, value }), null, 2) }] };
420
+ }
421
+ case "create_webhook":
422
+ return { content: [{ type: "text", text: JSON.stringify(await rdStationRequest("POST", "/integrations/webhooks", args), null, 2) }] };
205
423
  default:
206
424
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
207
425
  }
@@ -211,12 +429,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
211
429
  });
212
430
 
213
431
  async function main() {
214
- if (!TOKEN) {
215
- console.error("RD_STATION_TOKEN environment variable is required");
216
- process.exit(1);
432
+ if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
433
+ const { default: express } = await import("express");
434
+ const { randomUUID } = await import("node:crypto");
435
+ const app = express();
436
+ app.use(express.json());
437
+ const transports = new Map<string, StreamableHTTPServerTransport>();
438
+ app.get("/health", (_req: any, res: any) => res.json({ status: "ok", sessions: transports.size }));
439
+ app.post("/mcp", async (req: any, res: any) => {
440
+ const sid = req.headers["mcp-session-id"] as string | undefined;
441
+ if (sid && transports.has(sid)) { await transports.get(sid)!.handleRequest(req, res, req.body); return; }
442
+ if (!sid && isInitializeRequest(req.body)) {
443
+ const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
444
+ t.onclose = () => { if (t.sessionId) transports.delete(t.sessionId); };
445
+ const s = new Server({ name: "mcp-rd-station", version: "0.2.0" }, { capabilities: { tools: {} } }); (server as any)._requestHandlers.forEach((v: any, k: any) => (s as any)._requestHandlers.set(k, v)); (server as any)._notificationHandlers?.forEach((v: any, k: any) => (s as any)._notificationHandlers.set(k, v)); await s.connect(t);
446
+ await t.handleRequest(req, res, req.body); return;
447
+ }
448
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
449
+ });
450
+ app.get("/mcp", async (req: any, res: any) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req, res); else res.status(400).send("Invalid session"); });
451
+ app.delete("/mcp", async (req: any, res: any) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req, res); else res.status(400).send("Invalid session"); });
452
+ const port = Number(process.env.MCP_PORT) || 3000;
453
+ app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
454
+ } else {
455
+ const transport = new StdioServerTransport();
456
+ await server.connect(transport);
217
457
  }
218
- const transport = new StdioServerTransport();
219
- await server.connect(transport);
220
458
  }
221
459
 
222
460
  main().catch(console.error);
package/tsconfig.json CHANGED
@@ -6,6 +6,7 @@
6
6
  "outDir": "./dist",
7
7
  "rootDir": "./src",
8
8
  "strict": true,
9
+ "skipLibCheck": true,
9
10
  "esModuleInterop": true,
10
11
  "declaration": true
11
12
  },