desearch-mcp-server 0.0.1 → 0.1.3

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.
@@ -0,0 +1,471 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
+ import DesearchImport from "desearch-js";
4
+ import { z } from "zod";
5
+ import { AI_SEARCH_TOOLS, WEB_LINK_TOOLS, toolIdSchema } from "./tool-sources.js";
6
+ export const SERVER_NAME = "Desearch";
7
+ export const SERVER_VERSION = "0.1.3";
8
+ // desearch-js 1.5 publishes a default class. Node16 resolution types that package
9
+ // as a module namespace, so the constructor is applied through a cast.
10
+ const Desearch = DesearchImport;
11
+ /**
12
+ * Every tool reads live Desearch, web, or X data and returns it. None of them
13
+ * write caller state. Hints only: clients must not treat them as a safety check.
14
+ * destructiveHint is false so a client that ignores the readOnlyHint gate does
15
+ * not treat a search as a destructive update (the MCP default for that hint is true).
16
+ */
17
+ const READ_ONLY_OPEN_WORLD = {
18
+ readOnlyHint: true,
19
+ destructiveHint: false,
20
+ openWorldHint: true,
21
+ };
22
+ const optionalPostCount = z
23
+ .number()
24
+ .int()
25
+ .min(1)
26
+ .max(100)
27
+ .optional()
28
+ .describe("Number of posts to retrieve (1-100).");
29
+ const optionalLinksCount = z
30
+ .number()
31
+ .int()
32
+ .min(10)
33
+ .max(200)
34
+ .optional()
35
+ .describe("Results to return. Min 10. Max 200.");
36
+ function pageContentFields() {
37
+ return {
38
+ url: z.string().describe("Public URL to read, example: 'https://desearch.ai'"),
39
+ format: z
40
+ .enum(["html", "text"])
41
+ .optional()
42
+ .describe("Content format to return: 'html' or 'text'."),
43
+ js: z.boolean().optional().describe("Render JavaScript before reading the page."),
44
+ wait: z
45
+ .number()
46
+ .optional()
47
+ .describe("Post-load wait in milliseconds when JavaScript rendering is enabled."),
48
+ };
49
+ }
50
+ const engagementThreshold = z
51
+ .union([z.number().int().min(0), z.string()])
52
+ .optional();
53
+ function definedFields(fields) {
54
+ const out = {};
55
+ for (const key of Object.keys(fields)) {
56
+ if (fields[key] !== undefined) {
57
+ out[key] = fields[key];
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ /**
63
+ * Payload note when a link search body has no link list.
64
+ * Does not name web vs AI search, and does not say the query had "results".
65
+ * A missing list (the cost-only API bug) and an empty list are the same note:
66
+ * this response body does not contain links.
67
+ */
68
+ export const NO_LINKS_MESSAGE = "no links in response";
69
+ /**
70
+ * Keys that have carried link lists. `/links/web` uses `search_results`.
71
+ * AI search has used `search`, `results`, and `data`.
72
+ */
73
+ const LINK_COLLECTION_KEYS = [
74
+ "search_results",
75
+ "results",
76
+ "links",
77
+ "search",
78
+ "data",
79
+ "youtube_search_results",
80
+ "hacker_news_search_results",
81
+ "reddit_search_results",
82
+ "arxiv_search_results",
83
+ "wikipedia_search_results",
84
+ "hacker_news_search",
85
+ "reddit_search",
86
+ "youtube_search",
87
+ "tweets",
88
+ "miner_tweets",
89
+ ];
90
+ function isPlainObject(value) {
91
+ return typeof value === "object" && value !== null && !Array.isArray(value);
92
+ }
93
+ function linkLists(value) {
94
+ const lists = [];
95
+ for (const key of LINK_COLLECTION_KEYS) {
96
+ const entry = value[key];
97
+ if (Array.isArray(entry)) {
98
+ lists.push(entry);
99
+ }
100
+ }
101
+ return lists;
102
+ }
103
+ /**
104
+ * Pass a link payload through when it contains at least one link.
105
+ * A cost-only body (billing fields, no link list) and an empty link list
106
+ * both get `message: "no links in response"` plus the original fields,
107
+ * including billing. The note does not claim the search returned results
108
+ * and does not name which search tool produced the body.
109
+ */
110
+ export function presentSearchBody(value) {
111
+ if (!isPlainObject(value)) {
112
+ return value;
113
+ }
114
+ const lists = linkLists(value);
115
+ if (lists.some((list) => list.length > 0)) {
116
+ return value;
117
+ }
118
+ return {
119
+ ...value,
120
+ message: NO_LINKS_MESSAGE,
121
+ };
122
+ }
123
+ function ok(value) {
124
+ return {
125
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
126
+ };
127
+ }
128
+ function fail(label, error) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `${label}: ${error instanceof Error ? error.message : String(error)}`,
134
+ },
135
+ ],
136
+ isError: true,
137
+ };
138
+ }
139
+ function registerTool(server, apiKey, name, title, description, inputSchema, handler) {
140
+ // SDK 1.30's tool generics blow the TypeScript instantiation limit on these
141
+ // schemas. Runtime registration is the same registerTool call.
142
+ const register = server.registerTool.bind(server);
143
+ const guarded = async (args) => {
144
+ if (!apiKey) {
145
+ return fail("Desearch", new Error("Desearch API key required. Send it in the Authorization: Bearer <key> header or the x-api-key header."));
146
+ }
147
+ return handler(args);
148
+ };
149
+ register(name, {
150
+ title,
151
+ description,
152
+ inputSchema,
153
+ annotations: { ...READ_ONLY_OPEN_WORLD, title },
154
+ }, guarded);
155
+ }
156
+ /**
157
+ * One MCP server bound to a single Desearch API key.
158
+ * Stdio uses the process env key. Remote HTTP builds a new server per request
159
+ * so each caller spends their own key.
160
+ * `client` is a test seam. Production callers omit it and the server builds
161
+ * a desearch-js client from `apiKey`.
162
+ */
163
+ export function createDesearchMcpServer(apiKey, client) {
164
+ const desearch = client ?? new Desearch(apiKey);
165
+ const server = new McpServer({
166
+ name: SERVER_NAME,
167
+ version: SERVER_VERSION,
168
+ });
169
+ registerTool(server, apiKey, "ai-search", "AI Search", "AI search and analysis on web using Desearch AI", {
170
+ prompt: z.string().describe("Question, example: 'What is the latest news on AI?'"),
171
+ tools: z
172
+ .array(toolIdSchema(AI_SEARCH_TOOLS))
173
+ .optional()
174
+ .default(["web", "twitter"])
175
+ .describe("Source ids sent to POST /desearch/ai/search. Use short ids such as 'web' and 'twitter'. Legacy labels such as 'Web Search' are accepted and rewritten to those ids. Example: ['web', 'twitter']."),
176
+ date_filter: z
177
+ .enum([
178
+ "PAST_24_HOURS",
179
+ "PAST_2_DAYS",
180
+ "PAST_WEEK",
181
+ "PAST_2_WEEKS",
182
+ "PAST_MONTH",
183
+ "PAST_2_MONTHS",
184
+ "PAST_YEAR",
185
+ "PAST_2_YEARS",
186
+ ])
187
+ .optional()
188
+ .describe("Deprecated relative window; prefer start_date/end_date. Example: 'PAST_WEEK'"),
189
+ start_date: z
190
+ .string()
191
+ .optional()
192
+ .describe("Start of the date range in UTC (YYYY-MM-DDTHH:MM:SSZ). Use with end_date."),
193
+ end_date: z
194
+ .string()
195
+ .optional()
196
+ .describe("End of the date range in UTC (YYYY-MM-DDTHH:MM:SSZ). Use with start_date."),
197
+ result_type: z
198
+ .enum(["ONLY_LINKS", "LINKS_WITH_FINAL_SUMMARY"])
199
+ .optional()
200
+ .describe("ONLY_LINKS returns links only; LINKS_WITH_FINAL_SUMMARY adds an AI summary. Link arrays are kept under whichever key the API uses, along with billing fields. ONLY_LINKS still depends on the API to include those links."),
201
+ include_domains: z
202
+ .array(z.string())
203
+ .optional()
204
+ .describe("Restrict Web Search results to these domains, example: ['bbc.com', 'reuters.com']"),
205
+ exclude_domains: z
206
+ .array(z.string())
207
+ .optional()
208
+ .describe("Drop Web Search results from these domains, example: ['pinterest.com']"),
209
+ model: z
210
+ .enum(["NOVA", "ORBIT"])
211
+ .default("NOVA")
212
+ .describe("Model to use for the search: NOVA (default) or ORBIT."),
213
+ }, async ({ prompt, tools, date_filter, start_date, end_date, result_type, include_domains, exclude_domains, model }) => {
214
+ try {
215
+ const payload = {
216
+ prompt,
217
+ tools,
218
+ date_filter,
219
+ start_date,
220
+ end_date,
221
+ result_type,
222
+ include_domains,
223
+ exclude_domains,
224
+ model,
225
+ streaming: false,
226
+ };
227
+ return ok(presentSearchBody(await desearch.aiSearch(payload)));
228
+ }
229
+ catch (error) {
230
+ return fail("AI Search error", error);
231
+ }
232
+ });
233
+ registerTool(server, apiKey, "x-search", "X Search", "Search X (Twitter) using Desearch AI. Optional filters narrow by user, date, language, verification, media, and engagement. Sort stays Top.", {
234
+ query: z
235
+ .string()
236
+ .describe("Twitter advanced search query, example: 'from:elonmusk since:2023-01-01 min_replies:10'"),
237
+ count: z
238
+ .number()
239
+ .optional()
240
+ .default(20)
241
+ .describe("Number of search results to return (default: 20), max is 100"),
242
+ user: z.string().optional().describe("User to search for, example: 'elonmusk'"),
243
+ start_date: z
244
+ .string()
245
+ .optional()
246
+ .describe("Start date in UTC (YYYY-MM-DD). Use with end_date."),
247
+ end_date: z
248
+ .string()
249
+ .optional()
250
+ .describe("End date in UTC (YYYY-MM-DD). Use with start_date."),
251
+ lang: z.string().optional().describe("Language code, example: 'en', 'es', 'fr'"),
252
+ verified: z.boolean().optional().describe("Filter for verified users."),
253
+ blue_verified: z.boolean().optional().describe("Filter for blue-checkmark verified users."),
254
+ is_quote: z.boolean().optional().describe("Include only posts that are quotes."),
255
+ is_video: z.boolean().optional().describe("Include only posts with video."),
256
+ is_image: z.boolean().optional().describe("Include only posts with images."),
257
+ min_retweets: engagementThreshold.describe("Minimum number of retweets."),
258
+ min_replies: engagementThreshold.describe("Minimum number of replies."),
259
+ min_likes: engagementThreshold.describe("Minimum number of likes."),
260
+ }, async ({ query, count, user, start_date, end_date, lang, verified, blue_verified, is_quote, is_video, is_image, min_retweets, min_replies, min_likes, }) => {
261
+ try {
262
+ return ok(await desearch.xSearch({
263
+ query,
264
+ sort: "Top",
265
+ count,
266
+ ...definedFields({
267
+ user,
268
+ start_date,
269
+ end_date,
270
+ lang,
271
+ verified,
272
+ blue_verified,
273
+ is_quote,
274
+ is_video,
275
+ is_image,
276
+ min_retweets,
277
+ min_replies,
278
+ min_likes,
279
+ }),
280
+ }));
281
+ }
282
+ catch (error) {
283
+ return fail("X Search error", error);
284
+ }
285
+ });
286
+ registerTool(server, apiKey, "web-search", "Web Search", "SERP-style web search using Desearch. Returns ranked titles, links, and snippets.", {
287
+ query: z.string().describe("Search query, example: 'latest news on AI'"),
288
+ start: z
289
+ .number()
290
+ .int()
291
+ .min(0)
292
+ .optional()
293
+ .describe("How many results to skip for pagination (0, 10, 20, ...). Omit for the first page."),
294
+ }, async ({ query, start }) => {
295
+ try {
296
+ return ok(await desearch.webSearch({ query, start }));
297
+ }
298
+ catch (error) {
299
+ return fail("Web Search error", error);
300
+ }
301
+ });
302
+ registerTool(server, apiKey, "web-links-search", "Web Links Search", "Search the web for links using Desearch. Only the web source is accepted.", {
303
+ prompt: z
304
+ .string()
305
+ .describe("Search query prompt, example: 'open source browser automation tools'"),
306
+ tools: z
307
+ .array(toolIdSchema(WEB_LINK_TOOLS))
308
+ .min(1)
309
+ .default(["web"])
310
+ .describe("Sources to search. Only 'web' is accepted; other ids are rejected before the API call. 'Web Search' is accepted and rewritten to 'web'. Defaults to ['web']."),
311
+ count: z
312
+ .number()
313
+ .int()
314
+ .min(10)
315
+ .max(200)
316
+ .optional()
317
+ .describe("Results to return per source. Min 10. Max 200."),
318
+ }, async ({ prompt, tools, count }) => {
319
+ try {
320
+ return ok(presentSearchBody(await desearch.aiWebLinksSearch({
321
+ prompt,
322
+ tools,
323
+ ...definedFields({ count }),
324
+ })));
325
+ }
326
+ catch (error) {
327
+ return fail("Web Links Search error", error);
328
+ }
329
+ });
330
+ registerTool(server, apiKey, "x-links-search", "X Links Search", "AI search for X (Twitter) post links using Desearch. Returns links from posts that match the prompt.", {
331
+ prompt: z.string().describe("Search query prompt, example: 'Bittensor subnet updates'"),
332
+ count: optionalLinksCount,
333
+ }, async ({ prompt, count }) => {
334
+ try {
335
+ return ok(await desearch.aiXLinksSearch({ prompt, count }));
336
+ }
337
+ catch (error) {
338
+ return fail("X Links Search error", error);
339
+ }
340
+ });
341
+ registerTool(server, apiKey, "x-posts-by-urls", "Get X Posts by URLs", "Fetch full X (Twitter) posts for a list of post URLs.", {
342
+ urls: z
343
+ .array(z.string())
344
+ .min(1)
345
+ .describe("Post URLs to fetch, example: ['https://x.com/user/status/123']"),
346
+ }, async ({ urls }) => {
347
+ try {
348
+ return ok(await desearch.xPostsByUrls({ urls }));
349
+ }
350
+ catch (error) {
351
+ return fail("X Posts By URLs error", error);
352
+ }
353
+ });
354
+ registerTool(server, apiKey, "x-post-by-id", "Get X Post by ID", "Fetch a single X (Twitter) post by its ID.", {
355
+ id: z.string().describe("The unique ID of the post, example: '1234567890'"),
356
+ }, async ({ id }) => {
357
+ try {
358
+ return ok(await desearch.xPostById({ id }));
359
+ }
360
+ catch (error) {
361
+ return fail("X Post By ID error", error);
362
+ }
363
+ });
364
+ registerTool(server, apiKey, "x-posts-by-user", "Search X Posts by User", "Search X (Twitter) posts by a specific user, with an optional keyword query.", {
365
+ user: z.string().describe("User to search for, example: 'elonmusk'"),
366
+ query: z.string().optional().describe("Advanced search query to filter this user's posts."),
367
+ count: optionalPostCount,
368
+ }, async ({ user, query, count }) => {
369
+ try {
370
+ return ok(await desearch.xPostsByUser({ user, query, count }));
371
+ }
372
+ catch (error) {
373
+ return fail("X Posts By User error", error);
374
+ }
375
+ });
376
+ registerTool(server, apiKey, "x-post-retweeters", "List X Post Retweeters", "List users who retweeted an X (Twitter) post. Pass cursor to page through more users.", {
377
+ id: z.string().describe("The ID of the post to get retweeters for."),
378
+ cursor: z.string().optional().describe("Cursor for pagination from a previous response."),
379
+ }, async ({ id, cursor }) => {
380
+ try {
381
+ return ok(await desearch.xPostRetweeters({ id, cursor }));
382
+ }
383
+ catch (error) {
384
+ return fail("X Post Retweeters error", error);
385
+ }
386
+ });
387
+ registerTool(server, apiKey, "x-user-posts", "Get X User Timeline", "Retrieve a user's X (Twitter) timeline posts by username. Pass cursor to page through more posts.", {
388
+ username: z.string().describe("Username to fetch posts for, example: 'elonmusk'"),
389
+ cursor: z.string().optional().describe("Cursor for pagination from a previous response."),
390
+ }, async ({ username, cursor }) => {
391
+ try {
392
+ return ok(await desearch.xUserPosts({ username, cursor }));
393
+ }
394
+ catch (error) {
395
+ return fail("X User Posts error", error);
396
+ }
397
+ });
398
+ registerTool(server, apiKey, "x-user-replies", "Get X User Replies", "Fetch posts and replies by an X (Twitter) user, with an optional keyword query.", {
399
+ user: z.string().describe("Username of the user to search for, example: 'elonmusk'"),
400
+ count: optionalPostCount,
401
+ query: z.string().optional().describe("Advanced search query to filter this user's posts and replies."),
402
+ }, async ({ user, count, query }) => {
403
+ try {
404
+ return ok(await desearch.xUserReplies({ user, count, query }));
405
+ }
406
+ catch (error) {
407
+ return fail("X User Replies error", error);
408
+ }
409
+ });
410
+ registerTool(server, apiKey, "x-post-replies", "Get X Post Replies", "Fetch replies to an X (Twitter) post, with an optional keyword query.", {
411
+ post_id: z.string().describe("The ID of the post to fetch replies for."),
412
+ count: optionalPostCount,
413
+ query: z.string().optional().describe("Advanced search query to filter replies."),
414
+ }, async ({ post_id, count, query }) => {
415
+ try {
416
+ return ok(await desearch.xPostReplies({ post_id, count, query }));
417
+ }
418
+ catch (error) {
419
+ return fail("X Post Replies error", error);
420
+ }
421
+ });
422
+ registerTool(server, apiKey, "extract", "Extract Page Content", "Extract a public URL and return its content as plain text or HTML using Desearch. Preferred over web-crawl for new integrations.", pageContentFields(), async ({ url, format, js, wait }) => {
423
+ try {
424
+ return ok(await desearch.extract({ url, format, js, wait }));
425
+ }
426
+ catch (error) {
427
+ return fail("Extract error", error);
428
+ }
429
+ });
430
+ registerTool(server, apiKey, "web-crawl", "Crawl Web Page (Legacy)", "Crawl a public URL and return its content as plain text or HTML on the legacy Desearch /web/crawl route. The SDK marks webCrawl deprecated in favor of extract; this tool stays for parity with that route. Prefer extract for new integrations.", pageContentFields(), async ({ url, format, js, wait }) => {
431
+ try {
432
+ return ok(await desearch.webCrawl({ url, format, js, wait }));
433
+ }
434
+ catch (error) {
435
+ return fail("Web Crawl error", error);
436
+ }
437
+ });
438
+ registerTool(server, apiKey, "x-trends", "Get X Trends", "Retrieve trending topics on X (Twitter) for a location by its WOEID using Desearch.", {
439
+ woeid: z
440
+ .number()
441
+ .int()
442
+ .describe("WOEID of the location, example: 23424977 for the United States."),
443
+ count: z
444
+ .number()
445
+ .int()
446
+ .min(30)
447
+ .max(100)
448
+ .optional()
449
+ .describe("Number of trends to return (30-100)."),
450
+ }, async ({ woeid, count }) => {
451
+ try {
452
+ return ok(await desearch.xTrends({ woeid, count }));
453
+ }
454
+ catch (error) {
455
+ return fail("X Trends error", error);
456
+ }
457
+ });
458
+ // Scanners ask for these lists during discovery. There is nothing to return,
459
+ // but an empty list is a successful response. Method-not-found looks like a
460
+ // broken server to some directory crawlers.
461
+ server.server.registerCapabilities({
462
+ prompts: {},
463
+ resources: {},
464
+ });
465
+ server.server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
466
+ server.server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
467
+ server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
468
+ resourceTemplates: [],
469
+ }));
470
+ return server;
471
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Short source ids shared by ai-search and web-links-search.
4
+ *
5
+ * Wire values are the short ids from live OpenAPI
6
+ * (https://api.desearch.ai/openapi.json, checked 2026-10-01) and from
7
+ * desearch-js `ToolEnum` / `WebToolEnum`. Display labels such as "Web Search"
8
+ * are the previous ai-search schema. They are accepted here and rewritten to
9
+ * the short id before the Desearch call.
10
+ *
11
+ * Live OpenAPI names a narrower set than desearch-js 1.5:
12
+ * - `ToolEnum` (`POST /desearch/ai/search`): `web`, `twitter`. Items are
13
+ * `ToolEnum | string`, and the previous MCP tool already offered the other
14
+ * desearch-js sources under display labels. Those stay, spelled as short ids.
15
+ * PR #13 did not see a 422 for this route.
16
+ * - `WebToolEnum` (`POST /desearch/ai/search/links/web`): `web` only. The other
17
+ * short ids 422 (`supported tools are Web Search`). `WEB_LINK_TOOLS` is
18
+ * `["web"]` so the MCP enum does not offer ids the route rejects.
19
+ * `"Web Search"` still rewrites to `web`.
20
+ */
21
+ export declare const AI_SEARCH_TOOLS: readonly ["web", "twitter", "arxiv", "wikipedia", "youtube", "hackernews", "reddit"];
22
+ export declare const WEB_LINK_TOOLS: readonly ["web"];
23
+ /** Previous ai-search labels. Not advertised in the JSON Schema enum. */
24
+ export declare const LEGACY_DISPLAY_TO_ID: Readonly<Record<string, string>>;
25
+ export declare function canonicalToolId(value: string): string;
26
+ type NonEmptyIds = readonly [string, ...string[]];
27
+ /**
28
+ * JSON Schema enum is the short ids. Zod still accepts a legacy display label
29
+ * and replaces it with the short id the API examples and SDKs send.
30
+ */
31
+ export declare function toolIdSchema<T extends NonEmptyIds>(ids: T): z.ZodEffects<z.ZodEnum<[string, ...string[]]>, string, unknown>;
32
+ export {};
33
+ //# sourceMappingURL=tool-sources.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-sources.d.ts","sourceRoot":"","sources":["../tool-sources.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,eAAe,sFAQlB,CAAC;AAEX,eAAO,MAAM,cAAc,kBAAmB,CAAC;AAE/C,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQjE,CAAC;AAEF,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAErD;AAED,KAAK,WAAW,GAAG,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,CAAC;AAElD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,WAAW,EAAE,GAAG,EAAE,CAAC,mEAKzD"}
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Short source ids shared by ai-search and web-links-search.
4
+ *
5
+ * Wire values are the short ids from live OpenAPI
6
+ * (https://api.desearch.ai/openapi.json, checked 2026-10-01) and from
7
+ * desearch-js `ToolEnum` / `WebToolEnum`. Display labels such as "Web Search"
8
+ * are the previous ai-search schema. They are accepted here and rewritten to
9
+ * the short id before the Desearch call.
10
+ *
11
+ * Live OpenAPI names a narrower set than desearch-js 1.5:
12
+ * - `ToolEnum` (`POST /desearch/ai/search`): `web`, `twitter`. Items are
13
+ * `ToolEnum | string`, and the previous MCP tool already offered the other
14
+ * desearch-js sources under display labels. Those stay, spelled as short ids.
15
+ * PR #13 did not see a 422 for this route.
16
+ * - `WebToolEnum` (`POST /desearch/ai/search/links/web`): `web` only. The other
17
+ * short ids 422 (`supported tools are Web Search`). `WEB_LINK_TOOLS` is
18
+ * `["web"]` so the MCP enum does not offer ids the route rejects.
19
+ * `"Web Search"` still rewrites to `web`.
20
+ */
21
+ export const AI_SEARCH_TOOLS = [
22
+ "web",
23
+ "twitter",
24
+ "arxiv",
25
+ "wikipedia",
26
+ "youtube",
27
+ "hackernews",
28
+ "reddit",
29
+ ];
30
+ export const WEB_LINK_TOOLS = ["web"];
31
+ /** Previous ai-search labels. Not advertised in the JSON Schema enum. */
32
+ export const LEGACY_DISPLAY_TO_ID = {
33
+ "Web Search": "web",
34
+ "Twitter Search": "twitter",
35
+ "ArXiv Search": "arxiv",
36
+ "Wikipedia Search": "wikipedia",
37
+ "Youtube Search": "youtube",
38
+ "Hacker News Search": "hackernews",
39
+ "Reddit Search": "reddit",
40
+ };
41
+ export function canonicalToolId(value) {
42
+ return LEGACY_DISPLAY_TO_ID[value] ?? value;
43
+ }
44
+ /**
45
+ * JSON Schema enum is the short ids. Zod still accepts a legacy display label
46
+ * and replaces it with the short id the API examples and SDKs send.
47
+ */
48
+ export function toolIdSchema(ids) {
49
+ return z.preprocess((value) => (typeof value === "string" ? canonicalToolId(value) : value), z.enum(ids));
50
+ }
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "desearch-mcp-server",
3
- "version": "0.0.1",
4
- "description": "A Model Context Protocol server with Desearch for real-time AI search, X search and web search.",
3
+ "version": "0.1.3",
4
+ "mcpName": "io.github.Desearch-ai/mcp-desearch",
5
+ "description": "AI search, X search and web search for AI agents, plus page extraction and X data tools. Bring your own Desearch API key.",
6
+ "license": "MIT",
7
+ "homepage": "https://www.desearch.ai/docs/guide/sdk/mcp",
5
8
  "type": "module",
6
9
  "author": "Desearch AI",
7
10
  "repository": {
@@ -12,7 +15,8 @@
12
15
  "desearch-mcp-server": "./build/index.js"
13
16
  },
14
17
  "files": [
15
- "build"
18
+ "build",
19
+ "CHANGELOG.md"
16
20
  ],
17
21
  "keywords": [
18
22
  "desearch",
@@ -25,13 +29,16 @@
25
29
  ],
26
30
  "scripts": {
27
31
  "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
32
+ "start:http": "node build/index.js --http",
33
+ "test": "npm run build && node --test test/*.test.mjs",
28
34
  "inspector": "npx @modelcontextprotocol/inspector build/index.js",
29
35
  "prepare": "npm run build",
30
36
  "prepublishOnly": "npm run build"
31
37
  },
32
38
  "dependencies": {
33
- "@modelcontextprotocol/sdk": "^1.11.3",
34
- "desearch-js": "^1.0.1",
39
+ "@modelcontextprotocol/sdk": "^1.30.1",
40
+ "desearch-js": "^1.5.0",
41
+ "undici": "^7.29.1",
35
42
  "zod": "^3.24.4"
36
43
  },
37
44
  "devDependencies": {
@@ -39,6 +46,9 @@
39
46
  "typescript": "^5.8.3"
40
47
  },
41
48
  "engines": {
42
- "node": ">=18.0.0"
49
+ "node": ">=20.18.1"
50
+ },
51
+ "overrides": {
52
+ "undici": "$undici"
43
53
  }
44
54
  }