outx-mcp-server 1.2.1 → 1.7.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.

Potentially problematic release.


This version of outx-mcp-server might be problematic. Click here for more details.

@@ -25,8 +25,8 @@ export function registerLinkedInTools(
25
25
  );
26
26
 
27
27
  server.tool(
28
- "fetch_linkedin_posts",
29
- "Fetch recent posts from LinkedIn profiles by URN. Use fetch_linkedin_profile first to get the profile URN. Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
28
+ "fetch_linkedin_profile_posts",
29
+ "Fetch recent posts from one or more LinkedIn profiles by URN. Use fetch_linkedin_profile first to get the profile URN. Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
30
30
  {
31
31
  profile_urns: z
32
32
  .array(z.string())
@@ -45,8 +45,8 @@ export function registerLinkedInTools(
45
45
  );
46
46
 
47
47
  server.tool(
48
- "linkedin_like_post",
49
- "Like a LinkedIn post by its activity URN. This uses the LinkedIn Data API (direct LinkedIn action). Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
48
+ "like_linkedin_post",
49
+ "Like a LinkedIn post by its activity URN (direct LinkedIn action). Use this when you have a `social_urn` and don't need a watchlist. For liking a post that came from one of your watchlists (using its OutX `post_id`), use `like_watchlist_post` instead. Requires the OutX Chrome extension to be active. Polls automatically.",
50
50
  {
51
51
  social_urn: z
52
52
  .string()
@@ -63,8 +63,8 @@ export function registerLinkedInTools(
63
63
  );
64
64
 
65
65
  server.tool(
66
- "linkedin_comment_on_post",
67
- "Comment on a LinkedIn post by its activity URN. This uses the LinkedIn Data API (direct LinkedIn action). Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
66
+ "comment_on_linkedin_post",
67
+ "Comment on a LinkedIn post by its activity URN (direct LinkedIn action). Use this when you have a `social_urn` and don't need a watchlist. For commenting on a post that came from one of your watchlists (using its OutX `post_id`), use `comment_on_watchlist_post` instead. Requires the OutX Chrome extension to be active. Polls automatically.",
68
68
  {
69
69
  social_urn: z
70
70
  .string()
@@ -119,8 +119,8 @@ export function registerLinkedInTools(
119
119
  );
120
120
 
121
121
  server.tool(
122
- "linkedin_send_message",
123
- "Send a LinkedIn direct message to a 1st-degree connection. Requires the recipient's person URN (get it from fetch_linkedin_profile or linkedin_search_profiles). Message is sent from your team's oldest admin member's LinkedIn account. Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
122
+ "send_linkedin_message",
123
+ "Send a LinkedIn direct message to a 1st-degree connection. Requires the recipient's person URN (get it from fetch_linkedin_profile or search_linkedin_profiles). Message is sent from your team's oldest admin member's LinkedIn account. Requires the OutX Chrome extension to be active. Polls automatically.",
124
124
  {
125
125
  recipient_urn: z
126
126
  .string()
@@ -141,8 +141,33 @@ export function registerLinkedInTools(
141
141
  );
142
142
 
143
143
  server.tool(
144
- "linkedin_search_profiles",
145
- "Search LinkedIn profiles by keywords, title, company, location, industry, and more. Returns matching profiles with name, headline, location, profile slug, and connection degree. At least one filter is required. Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
144
+ "send_linkedin_connection_request",
145
+ "Send a LinkedIn connection request to a profile, with an optional note. Requires the person URN (get it from fetch_linkedin_profile or search_linkedin_profiles). Sent from your team's oldest admin member's LinkedIn account. Requires the OutX Chrome extension to be active. Polls automatically.",
146
+ {
147
+ profile_urn: z
148
+ .string()
149
+ .describe("LinkedIn person URN to connect with (e.g. 'urn:li:person:ACoAABCDEFG')"),
150
+ note: z
151
+ .string()
152
+ .optional()
153
+ .describe("Optional connection note (max 300 characters)."),
154
+ },
155
+ async (params) => {
156
+ const body: Record<string, unknown> = { profile_urn: params.profile_urn };
157
+ if (params.note) body.note = params.note;
158
+ const response = (await client.post(
159
+ "/linkedin-agent/send-connection-request",
160
+ body
161
+ )) as { api_agent_task_id: string };
162
+
163
+ const result = await client.pollTask(response.api_agent_task_id);
164
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
165
+ }
166
+ );
167
+
168
+ server.tool(
169
+ "search_linkedin_profiles",
170
+ "Search LinkedIn profiles by keywords, title, company, location, industry, and more. Returns matching profiles with name, headline, location, profile slug, and connection degree. At least one filter is required. Requires the OutX Chrome extension to be active. Polls automatically.",
146
171
  {
147
172
  keywords: z.string().optional().describe("General keyword search across name, headline, and profile text"),
148
173
  title: z.string().optional().describe("Filter by job title (e.g. 'VP of Engineering')"),
@@ -182,8 +207,8 @@ export function registerLinkedInTools(
182
207
  );
183
208
 
184
209
  server.tool(
185
- "linkedin_fetch_connections",
186
- "Fetch your 1st-degree LinkedIn connections. Optionally filter by keyword, sort by recently added, and paginate. Connections are from your team's oldest admin member's LinkedIn account. Requires the OutX Chrome extension to be active. This tool handles async polling automatically.",
210
+ "fetch_linkedin_connections",
211
+ "Fetch your 1st-degree LinkedIn connections. Optionally filter by keyword, sort by recently added, and paginate. Connections are from your team's oldest admin member's LinkedIn account. Requires the OutX Chrome extension to be active. Polls automatically.",
187
212
  {
188
213
  keyword: z.string().optional().describe("Filter connections by name (e.g. 'John')"),
189
214
  sort_type: z.string().optional().describe("Sort order. Pass 'RECENTLY_ADDED' for newest first"),
@@ -208,9 +233,9 @@ export function registerLinkedInTools(
208
233
 
209
234
  server.tool(
210
235
  "get_task_status",
211
- "Check the status of an async LinkedIn Data API task. Use this to manually check tasks that timed out. Status values: pending, processing, completed, failed.",
236
+ "Recovery tool. Every other LinkedIn Data API tool polls automatically for ~150 seconds and returns the result directly, so you almost never need this. Use it ONLY when another tool returns a 'did not complete within 150s' error: pass the `api_agent_task_id` from that error message to check whether the task has since finished. Calling the original tool again would create a duplicate task and consume another LinkedIn-side daily quota slot. Status values: pending, processing, completed, failed.",
212
237
  {
213
- task_id: z.string().describe("The api_agent_task_id to check"),
238
+ task_id: z.string().describe("The `api_agent_task_id` from the timeout error message of another LinkedIn Data tool."),
214
239
  },
215
240
  async (params) => {
216
241
  const result = await client.get("/linkedin-agent/get-task-status", {
@@ -13,9 +13,9 @@ export function registerPostTools(
13
13
  watchlist_id: z.string().optional().describe("Filter by watchlist ID (omit for all)"),
14
14
  search_term: z.string().optional().describe("Search within post content"),
15
15
  sort_by: z
16
- .enum(["recent_first", "oldest_first", "engagement"])
16
+ .enum(["recent", "popular_first", "engagement", "relevance_first"])
17
17
  .optional()
18
- .describe("Sort order (default: recent_first)"),
18
+ .describe("Sort order (default: recent)"),
19
19
  page: z.string().optional().describe("Page number (default: 1)"),
20
20
  start_date: z.string().optional().describe("Filter posts after this date (ISO 8601)"),
21
21
  end_date: z.string().optional().describe("Filter posts before this date (ISO 8601)"),
@@ -6,6 +6,41 @@ const fetchFreqEnum = z
6
6
  .enum(["1", "3", "6", "12", "24", "48", "72"])
7
7
  .describe("How often to scan for new posts (hours)");
8
8
 
9
+ // A label (intent category) is either a bare name string, or an object with an
10
+ // optional `default` flag that controls whether the label shows in the feed by
11
+ // default. At most 2 labels may be default-true; the API caps any extras to false.
12
+ // `key` is accepted as an alias for `name` so a label object returned by a GET
13
+ // (which uses `key`) can be passed straight back on update.
14
+ const labelSchema = z.union([
15
+ z.string(),
16
+ z.object({
17
+ name: z.string().optional().describe("Label name (use for new labels)"),
18
+ key: z
19
+ .string()
20
+ .optional()
21
+ .describe("Alias for `name`; the value GET returns, so a label from a GET can be sent back unchanged"),
22
+ description: z.string().optional().describe("What this label represents"),
23
+ default: z
24
+ .boolean()
25
+ .optional()
26
+ .describe(
27
+ "Show this label in the feed by default. At most 2 labels may be true; extras are capped to false."
28
+ ),
29
+ }),
30
+ ]);
31
+
32
+ type LabelParam =
33
+ | string
34
+ | { name?: string; key?: string; description?: string; default?: boolean };
35
+
36
+ // Normalize to the object array the API expects. A bare string becomes { name },
37
+ // so callers that pass plain label names keep working (and now actually persist).
38
+ function toApiLabels(
39
+ labels: LabelParam[]
40
+ ): Array<{ name?: string; key?: string; description?: string; default?: boolean }> {
41
+ return labels.map((l) => (typeof l === "string" ? { name: l } : l));
42
+ }
43
+
9
44
  export function registerWatchlistTools(
10
45
  server: McpServer,
11
46
  client: OutxClient
@@ -14,37 +49,72 @@ export function registerWatchlistTools(
14
49
 
15
50
  server.tool(
16
51
  "create_keyword_watchlist",
17
- "Create a watchlist that monitors LinkedIn posts matching keywords. Posts appear after the first scan cycle.",
52
+ "Create a watchlist that monitors LinkedIn posts matching keywords. Two modes: provide `keywords` to track exact terms, OR provide `prompt` to have OutX generate keywords and intent labels from a plain-English description. Pass one or the other, not both.",
18
53
  {
19
54
  keywords: z
20
55
  .array(z.string())
21
- .min(1)
22
- .describe("Keywords to track (e.g. ['AI startup funding', 'series A'])"),
56
+ .optional()
57
+ .describe(
58
+ "Direct mode. Keywords to track (e.g. ['AI startup funding', 'series A']). Mutually exclusive with `prompt`."
59
+ ),
23
60
  required_keywords: z
24
61
  .array(z.string())
25
62
  .optional()
26
- .describe("Posts MUST contain at least one of these words"),
63
+ .describe("Direct mode only. Applied to every keyword. Posts MUST contain at least one of these words."),
27
64
  exclude_keywords: z
28
65
  .array(z.string())
29
66
  .optional()
30
- .describe("Exclude posts containing any of these words"),
31
- name: z.string().optional().describe("Watchlist name"),
32
- description: z.string().optional(),
33
- labels: z.array(z.string()).optional().describe("Custom labels for categorization"),
67
+ .describe("Direct mode only. Applied to every keyword. Posts containing any of these are excluded."),
68
+ prompt: z
69
+ .string()
70
+ .optional()
71
+ .describe(
72
+ "AI mode. Plain-English description of what to track (e.g. 'People looking for remote software engineering jobs'). OutX generates keywords and intent labels in the background. Mutually exclusive with `keywords`."
73
+ ),
74
+ name: z.string().optional().describe("Watchlist name. Auto-generated if omitted."),
75
+ description: z.string().optional().describe("Direct mode only. Optional description for the watchlist."),
76
+ objective: z
77
+ .string()
78
+ .optional()
79
+ .describe(
80
+ "Direct mode only. One-sentence objective (who/what you want to find and why). Drives the relevance-scoring vector so direct-mode lists rank like prompt-built ones. Omit and the list ranks only by keyword match."
81
+ ),
82
+ labels: z
83
+ .array(labelSchema)
84
+ .optional()
85
+ .describe(
86
+ "Direct mode only. Intent labels for categorization. Each is a name string, or an object {name, description, default} where `default` controls feed visibility (max 2 default-true)."
87
+ ),
34
88
  fetchFreqInHours: fetchFreqEnum.optional(),
35
89
  },
36
90
  async (params) => {
37
- const body: Record<string, unknown> = {
38
- keywords: params.keywords.map((kw) => {
91
+ const hasKeywords = Array.isArray(params.keywords) && params.keywords.length > 0;
92
+ const hasPrompt = typeof params.prompt === "string" && params.prompt.trim().length > 0;
93
+
94
+ if (hasKeywords && hasPrompt) {
95
+ throw new Error("Provide either `keywords` or `prompt`, not both.");
96
+ }
97
+ if (!hasKeywords && !hasPrompt) {
98
+ throw new Error("Provide either `keywords` (array) or `prompt` (string).");
99
+ }
100
+
101
+ const body: Record<string, unknown> = {};
102
+
103
+ if (hasPrompt) {
104
+ body.prompt = params.prompt!.trim();
105
+ } else {
106
+ body.keywords = params.keywords!.map((kw) => {
39
107
  const obj: Record<string, unknown> = { keyword: kw };
40
108
  if (params.required_keywords) obj.required_keywords = params.required_keywords;
41
109
  if (params.exclude_keywords) obj.exclude_keywords = params.exclude_keywords;
42
110
  return obj;
43
- }),
44
- };
111
+ });
112
+ if (params.description) body.description = params.description;
113
+ if (params.objective) body.objective = params.objective;
114
+ if (params.labels) body.labels = toApiLabels(params.labels);
115
+ }
116
+
45
117
  if (params.name) body.name = params.name;
46
- if (params.description) body.description = params.description;
47
- if (params.labels) body.labels = params.labels;
48
118
  if (params.fetchFreqInHours)
49
119
  body.fetchFreqInHours = parseInt(params.fetchFreqInHours);
50
120
 
@@ -65,7 +135,7 @@ export function registerWatchlistTools(
65
135
 
66
136
  server.tool(
67
137
  "update_keyword_watchlist",
68
- "Update a keyword watchlist. Can change name, fetch frequency, Slack webhook, pause/resume tracking via `disable`, and replace the tracked keywords or labels.",
138
+ "Update a keyword watchlist. Patch any fields (name, fetchFreqInHours, disable, slack_webhook_url, keywords, labels), OR pass `prompt` to have OutX regenerate keywords and labels from a new description. Patch and prompt modes are mutually exclusive.",
69
139
  {
70
140
  id: z.string().describe("Watchlist ID"),
71
141
  name: z.string().optional(),
@@ -83,38 +153,66 @@ export function registerWatchlistTools(
83
153
  .array(z.string())
84
154
  .optional()
85
155
  .describe(
86
- "Replace tracked keywords. All existing keywords are removed and these are inserted."
156
+ "Patch mode. Replace tracked keywords; all existing keywords are removed and these are inserted. Mutually exclusive with `prompt`."
87
157
  ),
88
158
  required_keywords: z
89
159
  .array(z.string())
90
160
  .optional()
91
- .describe("Applied to every keyword above. Posts MUST contain at least one."),
161
+ .describe("Patch mode only. Applied to every keyword above. Posts MUST contain at least one."),
92
162
  exclude_keywords: z
93
163
  .array(z.string())
94
164
  .optional()
95
- .describe("Applied to every keyword above. Posts containing any are excluded."),
165
+ .describe("Patch mode only. Applied to every keyword above. Posts containing any are excluded."),
96
166
  labels: z
97
- .array(z.string())
167
+ .array(labelSchema)
98
168
  .optional()
99
- .describe("Replace label set used to categorize matched posts."),
169
+ .describe(
170
+ "Patch mode only. Replaces the label set. Each is a name string, or an object {name, description, default}. GET the watchlist first to see current labels and their `default` flags so you can preserve them."
171
+ ),
172
+ prompt: z
173
+ .string()
174
+ .optional()
175
+ .describe(
176
+ "Regenerate mode. New plain-English prompt. OutX wipes the existing keywords and labels and regenerates them in the background. Mutually exclusive with patch fields above (other than `id`)."
177
+ ),
100
178
  },
101
179
  async (params) => {
180
+ const hasPrompt = typeof params.prompt === "string" && params.prompt.trim().length > 0;
181
+ const hasPatch =
182
+ params.name !== undefined ||
183
+ params.fetchFreqInHours !== undefined ||
184
+ params.disable !== undefined ||
185
+ params.slack_webhook_url !== undefined ||
186
+ params.keywords !== undefined ||
187
+ params.labels !== undefined;
188
+
189
+ if (hasPrompt && hasPatch) {
190
+ throw new Error(
191
+ "Provide either patch fields (name, fetchFreqInHours, disable, slack_webhook_url, keywords, labels) or `prompt` for regeneration, not both."
192
+ );
193
+ }
194
+
102
195
  const body: Record<string, unknown> = { id: params.id };
103
- if (params.name) body.name = params.name;
104
- if (params.fetchFreqInHours)
105
- body.fetchFreqInHours = parseInt(params.fetchFreqInHours);
106
- if (params.disable !== undefined) body.disable = params.disable;
107
- if (params.slack_webhook_url !== undefined)
108
- body.slack_webhook_url = params.slack_webhook_url;
109
- if (params.keywords) {
110
- body.keywords = params.keywords.map((kw) => {
111
- const obj: Record<string, unknown> = { keyword: kw };
112
- if (params.required_keywords) obj.required_keywords = params.required_keywords;
113
- if (params.exclude_keywords) obj.exclude_keywords = params.exclude_keywords;
114
- return obj;
115
- });
196
+
197
+ if (hasPrompt) {
198
+ body.prompt = params.prompt!.trim();
199
+ } else {
200
+ if (params.name) body.name = params.name;
201
+ if (params.fetchFreqInHours)
202
+ body.fetchFreqInHours = parseInt(params.fetchFreqInHours);
203
+ if (params.disable !== undefined) body.disable = params.disable;
204
+ if (params.slack_webhook_url !== undefined)
205
+ body.slack_webhook_url = params.slack_webhook_url;
206
+ if (params.keywords) {
207
+ body.keywords = params.keywords.map((kw) => {
208
+ const obj: Record<string, unknown> = { keyword: kw };
209
+ if (params.required_keywords) obj.required_keywords = params.required_keywords;
210
+ if (params.exclude_keywords) obj.exclude_keywords = params.exclude_keywords;
211
+ return obj;
212
+ });
213
+ }
214
+ if (params.labels) body.labels = toApiLabels(params.labels);
116
215
  }
117
- if (params.labels) body.labels = params.labels;
118
216
 
119
217
  const result = await client.put("/api-keyword-watchlist", body);
120
218
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
@@ -135,49 +233,6 @@ export function registerWatchlistTools(
135
233
  }
136
234
  );
137
235
 
138
- server.tool(
139
- "create_keyword_watchlist_from_prompt",
140
- "Create a keyword watchlist from a natural-language prompt. OutX generates optimized keywords and intent labels automatically using AI. The watchlist is created immediately; keyword generation runs in the background. Hits the same endpoint as create_keyword_watchlist with a different body shape (prompt instead of keywords).",
141
- {
142
- prompt: z
143
- .string()
144
- .describe(
145
- "Plain English description of what to track (e.g. 'People looking for remote software engineering jobs'). Can include URLs for better keyword quality."
146
- ),
147
- name: z.string().optional().describe("Watchlist name (auto-generated if omitted)"),
148
- fetchFreqInHours: fetchFreqEnum.optional(),
149
- },
150
- async (params) => {
151
- const body: Record<string, unknown> = { prompt: params.prompt };
152
- if (params.name) body.name = params.name;
153
- if (params.fetchFreqInHours)
154
- body.fetchFreqInHours = parseInt(params.fetchFreqInHours);
155
-
156
- const result = await client.post("/api-keyword-watchlist", body);
157
- return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
158
- }
159
- );
160
-
161
- server.tool(
162
- "regenerate_keyword_watchlist_from_prompt",
163
- "Replace the prompt on an existing keyword watchlist. OutX wipes the current keywords and labels and regenerates them from the new prompt. Regeneration runs in the background. Hits the same endpoint as update_keyword_watchlist with a prompt body.",
164
- {
165
- id: z.string().describe("Watchlist ID to update"),
166
- prompt: z
167
- .string()
168
- .describe("New natural-language prompt. OutX will regenerate keywords and labels from this."),
169
- },
170
- async (params) => {
171
- const body: Record<string, unknown> = {
172
- id: params.id,
173
- prompt: params.prompt,
174
- };
175
-
176
- const result = await client.put("/api-keyword-watchlist", body);
177
- return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
178
- }
179
- );
180
-
181
236
  // ── People Watchlists ───────────────────────────────────
182
237
 
183
238
  server.tool(
@@ -343,4 +398,178 @@ export function registerWatchlistTools(
343
398
  return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
344
399
  }
345
400
  );
401
+
402
+ // ── Reddit Watchlists ───────────────────────────────────
403
+
404
+ server.tool(
405
+ "create_reddit_watchlist",
406
+ "Create a watchlist that monitors Reddit posts matching keywords. Two modes: provide `keywords` to track exact terms, OR provide `prompt` to have OutX generate keywords and intent labels from a plain-English description. Pass one or the other, not both.",
407
+ {
408
+ keywords: z
409
+ .array(z.string())
410
+ .optional()
411
+ .describe("Direct mode. Keywords to track on Reddit. Mutually exclusive with `prompt`."),
412
+ required_keywords: z
413
+ .array(z.string())
414
+ .optional()
415
+ .describe("Direct mode only. Applied to every keyword. Posts MUST contain at least one of these words."),
416
+ exclude_keywords: z
417
+ .array(z.string())
418
+ .optional()
419
+ .describe("Direct mode only. Applied to every keyword. Posts containing any of these are excluded."),
420
+ prompt: z
421
+ .string()
422
+ .optional()
423
+ .describe("AI mode. Plain-English description of what to track on Reddit. Mutually exclusive with `keywords`."),
424
+ name: z.string().optional().describe("Watchlist name. Auto-generated if omitted."),
425
+ description: z.string().optional().describe("Direct mode only. Optional description for the watchlist."),
426
+ objective: z
427
+ .string()
428
+ .optional()
429
+ .describe("Direct mode only. One-sentence objective; drives the relevance-scoring vector so direct-mode lists rank like prompt-built ones."),
430
+ labels: z
431
+ .array(labelSchema)
432
+ .optional()
433
+ .describe("Direct mode only. Intent labels. Each is a name string, or an object {name, description, default} (max 2 default-true)."),
434
+ fetchFreqInHours: fetchFreqEnum.optional().describe("Scan frequency in hours (Reddit default 24)."),
435
+ },
436
+ async (params) => {
437
+ const hasKeywords = Array.isArray(params.keywords) && params.keywords.length > 0;
438
+ const hasPrompt = typeof params.prompt === "string" && params.prompt.trim().length > 0;
439
+
440
+ if (hasKeywords && hasPrompt) {
441
+ throw new Error("Provide either `keywords` or `prompt`, not both.");
442
+ }
443
+ if (!hasKeywords && !hasPrompt) {
444
+ throw new Error("Provide either `keywords` (array) or `prompt` (string).");
445
+ }
446
+
447
+ const body: Record<string, unknown> = {};
448
+
449
+ if (hasPrompt) {
450
+ body.prompt = params.prompt!.trim();
451
+ } else {
452
+ body.keywords = params.keywords!.map((kw) => {
453
+ const obj: Record<string, unknown> = { keyword: kw };
454
+ if (params.required_keywords) obj.required_keywords = params.required_keywords;
455
+ if (params.exclude_keywords) obj.exclude_keywords = params.exclude_keywords;
456
+ return obj;
457
+ });
458
+ if (params.description) body.description = params.description;
459
+ if (params.objective) body.objective = params.objective;
460
+ if (params.labels) body.labels = toApiLabels(params.labels);
461
+ }
462
+
463
+ if (params.name) body.name = params.name;
464
+ if (params.fetchFreqInHours)
465
+ body.fetchFreqInHours = parseInt(params.fetchFreqInHours);
466
+
467
+ const result = await client.post("/api-reddit-watchlist", body);
468
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
469
+ }
470
+ );
471
+
472
+ server.tool(
473
+ "list_reddit_watchlists",
474
+ "List all reddit watchlists for your account.",
475
+ {},
476
+ async () => {
477
+ const result = await client.get("/api-reddit-watchlist");
478
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
479
+ }
480
+ );
481
+
482
+ server.tool(
483
+ "update_reddit_watchlist",
484
+ "Update a reddit watchlist. Patch any fields (name, fetchFreqInHours, disable, slack_webhook_url, keywords, labels), OR pass `prompt` to regenerate keywords and labels. Patch and prompt modes are mutually exclusive.",
485
+ {
486
+ id: z.string().describe("Watchlist ID"),
487
+ name: z.string().optional(),
488
+ fetchFreqInHours: fetchFreqEnum.optional(),
489
+ disable: z
490
+ .boolean()
491
+ .optional()
492
+ .describe("Set to true to pause tracking, false to resume"),
493
+ slack_webhook_url: z
494
+ .string()
495
+ .nullable()
496
+ .optional()
497
+ .describe("Slack incoming webhook URL for alerts. Pass null to clear."),
498
+ keywords: z
499
+ .array(z.string())
500
+ .optional()
501
+ .describe("Patch mode. Replace tracked keywords. Mutually exclusive with `prompt`."),
502
+ required_keywords: z
503
+ .array(z.string())
504
+ .optional()
505
+ .describe("Patch mode only. Applied to every keyword above."),
506
+ exclude_keywords: z
507
+ .array(z.string())
508
+ .optional()
509
+ .describe("Patch mode only. Applied to every keyword above."),
510
+ labels: z
511
+ .array(labelSchema)
512
+ .optional()
513
+ .describe("Patch mode only. Replaces the label set. GET the watchlist first to preserve current `default` flags."),
514
+ prompt: z
515
+ .string()
516
+ .optional()
517
+ .describe("Regenerate mode. New plain-English prompt. Mutually exclusive with the patch fields above."),
518
+ },
519
+ async (params) => {
520
+ const hasPrompt = typeof params.prompt === "string" && params.prompt.trim().length > 0;
521
+ const hasPatch =
522
+ params.name !== undefined ||
523
+ params.fetchFreqInHours !== undefined ||
524
+ params.disable !== undefined ||
525
+ params.slack_webhook_url !== undefined ||
526
+ params.keywords !== undefined ||
527
+ params.labels !== undefined;
528
+
529
+ if (hasPrompt && hasPatch) {
530
+ throw new Error(
531
+ "Provide either patch fields (name, fetchFreqInHours, disable, slack_webhook_url, keywords, labels) or `prompt` for regeneration, not both."
532
+ );
533
+ }
534
+
535
+ const body: Record<string, unknown> = { id: params.id };
536
+
537
+ if (hasPrompt) {
538
+ body.prompt = params.prompt!.trim();
539
+ } else {
540
+ if (params.name) body.name = params.name;
541
+ if (params.fetchFreqInHours)
542
+ body.fetchFreqInHours = parseInt(params.fetchFreqInHours);
543
+ if (params.disable !== undefined) body.disable = params.disable;
544
+ if (params.slack_webhook_url !== undefined)
545
+ body.slack_webhook_url = params.slack_webhook_url;
546
+ if (params.keywords) {
547
+ body.keywords = params.keywords.map((kw) => {
548
+ const obj: Record<string, unknown> = { keyword: kw };
549
+ if (params.required_keywords) obj.required_keywords = params.required_keywords;
550
+ if (params.exclude_keywords) obj.exclude_keywords = params.exclude_keywords;
551
+ return obj;
552
+ });
553
+ }
554
+ if (params.labels) body.labels = toApiLabels(params.labels);
555
+ }
556
+
557
+ const result = await client.put("/api-reddit-watchlist", body);
558
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
559
+ }
560
+ );
561
+
562
+ server.tool(
563
+ "delete_reddit_watchlist",
564
+ "Delete a reddit watchlist and all its tracked data.",
565
+ {
566
+ id: z.string().describe("Watchlist ID to delete"),
567
+ },
568
+ async (params) => {
569
+ const result = await client.delete("/api-reddit-watchlist", {
570
+ id: params.id,
571
+ });
572
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
573
+ }
574
+ );
346
575
  }