openclaw-exa-search 1.0.4 → 2.0.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 CHANGED
@@ -54,6 +54,7 @@ npm install
54
54
  npm run typecheck
55
55
  npm test # unit tests
56
56
  npm run test:integration # integration tests (hits real Exa endpoint)
57
+ npm run build
57
58
  ```
58
59
 
59
60
  ## Notes
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Exa MCP Client — HTTP/SSE communication with Exa hosted MCP endpoint
3
+ * No API key required; uses mcp.exa.ai with all tools enabled
4
+ */
5
+ interface ExaSuccessResponse {
6
+ jsonrpc: "2.0";
7
+ id: number;
8
+ result: {
9
+ content?: Array<{
10
+ type: string;
11
+ text: string;
12
+ }>;
13
+ tools?: Array<{
14
+ name: string;
15
+ description: string;
16
+ inputSchema: unknown;
17
+ }>;
18
+ };
19
+ }
20
+ interface ExaErrorResponse {
21
+ jsonrpc: "2.0";
22
+ id: number;
23
+ error: {
24
+ code: number;
25
+ message: string;
26
+ };
27
+ }
28
+ type ExaResponse = ExaSuccessResponse | ExaErrorResponse;
29
+ /**
30
+ * Parse SSE response — extract the last complete JSON-RPC message.
31
+ * Per SSE spec, multiple `data:` lines within one event are joined with "\n",
32
+ * but Exa sends one JSON object per `data:` line. We take the last `data:` line
33
+ * that parses as valid JSON to handle both single and multi-event streams.
34
+ */
35
+ export declare function parseSSEResponse(text: string): string;
36
+ /**
37
+ * Call an Exa MCP tool
38
+ */
39
+ export declare function callExa(method: string, params?: Record<string, unknown>, fetchFn?: typeof fetch): Promise<ExaResponse>;
40
+ /**
41
+ * Extract text content from an Exa response
42
+ */
43
+ export declare function extractTextContent(response: ExaResponse): string;
44
+ export declare function webSearch(params: {
45
+ query: string;
46
+ numResults?: number;
47
+ type?: "auto" | "fast" | "deep";
48
+ livecrawl?: "fallback" | "preferred";
49
+ }): Promise<string>;
50
+ export declare function codeSearch(params: {
51
+ query: string;
52
+ tokensNum?: number;
53
+ }): Promise<string>;
54
+ export declare function companyResearch(params: {
55
+ companyName: string;
56
+ numResults?: number;
57
+ }): Promise<string>;
58
+ export declare function twitterSearch(params: {
59
+ query: string;
60
+ numResults?: number;
61
+ startPublishedDate?: string;
62
+ endPublishedDate?: string;
63
+ }): Promise<string>;
64
+ export declare function peopleSearch(params: {
65
+ query: string;
66
+ numResults?: number;
67
+ }): Promise<string>;
68
+ export declare function financialSearch(params: {
69
+ query: string;
70
+ numResults?: number;
71
+ startPublishedDate?: string;
72
+ endPublishedDate?: string;
73
+ }): Promise<string>;
74
+ export {};
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Exa MCP Client — HTTP/SSE communication with Exa hosted MCP endpoint
3
+ * No API key required; uses mcp.exa.ai with all tools enabled
4
+ */
5
+ const EXA_BASE_URL = "https://mcp.exa.ai/mcp";
6
+ const EXA_TOOLS = [
7
+ "web_search_exa",
8
+ "web_search_advanced_exa",
9
+ "get_code_context_exa",
10
+ "company_research_exa",
11
+ "people_search_exa",
12
+ ].join(",");
13
+ const EXA_URL = `${EXA_BASE_URL}?tools=${EXA_TOOLS}`;
14
+ const REQUEST_TIMEOUT_MS = 30_000;
15
+ let requestIdCounter = 0;
16
+ /**
17
+ * Parse SSE response — extract the last complete JSON-RPC message.
18
+ * Per SSE spec, multiple `data:` lines within one event are joined with "\n",
19
+ * but Exa sends one JSON object per `data:` line. We take the last `data:` line
20
+ * that parses as valid JSON to handle both single and multi-event streams.
21
+ */
22
+ export function parseSSEResponse(text) {
23
+ const lines = text.split("\n");
24
+ const dataLines = [];
25
+ for (const line of lines) {
26
+ if (line.startsWith("data: ")) {
27
+ dataLines.push(line.substring(6));
28
+ }
29
+ }
30
+ if (dataLines.length === 0) {
31
+ throw new Error("No data: field found in SSE response");
32
+ }
33
+ // Try each data line from last to first — return the first valid JSON
34
+ for (let i = dataLines.length - 1; i >= 0; i--) {
35
+ try {
36
+ JSON.parse(dataLines[i]);
37
+ return dataLines[i];
38
+ }
39
+ catch {
40
+ // not valid JSON, try next
41
+ }
42
+ }
43
+ // Fallback: join all and let caller handle parse error
44
+ return dataLines.join("");
45
+ }
46
+ /**
47
+ * Call an Exa MCP tool
48
+ */
49
+ export async function callExa(method, params, fetchFn = fetch) {
50
+ const requestId = ++requestIdCounter;
51
+ const request = {
52
+ jsonrpc: "2.0",
53
+ id: requestId,
54
+ method,
55
+ params,
56
+ };
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
59
+ try {
60
+ const response = await fetchFn(EXA_URL, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Accept: "application/json, text/event-stream",
65
+ },
66
+ body: JSON.stringify(request),
67
+ signal: controller.signal,
68
+ });
69
+ if (!response.ok) {
70
+ throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
71
+ }
72
+ const text = await response.text();
73
+ // Try JSON first, fallback to SSE parsing
74
+ try {
75
+ return JSON.parse(text);
76
+ }
77
+ catch {
78
+ const jsonData = parseSSEResponse(text);
79
+ return JSON.parse(jsonData);
80
+ }
81
+ }
82
+ catch (error) {
83
+ if (error instanceof DOMException && error.name === "AbortError") {
84
+ throw new Error(`Exa request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
85
+ }
86
+ throw error;
87
+ }
88
+ finally {
89
+ clearTimeout(timer);
90
+ }
91
+ }
92
+ /**
93
+ * Extract text content from an Exa response
94
+ */
95
+ export function extractTextContent(response) {
96
+ if ("error" in response) {
97
+ throw new Error(`Exa API error: ${response.error.code} - ${response.error.message}`);
98
+ }
99
+ const result = response.result;
100
+ if (!result?.content) {
101
+ throw new Error("No content in response");
102
+ }
103
+ return result.content
104
+ .filter((item) => item.type === "text")
105
+ .map((item) => item.text)
106
+ .join("\n\n");
107
+ }
108
+ // ─── Tool Functions ────────────────────────────────────────────────
109
+ export async function webSearch(params) {
110
+ const response = await callExa("tools/call", {
111
+ name: "web_search_exa",
112
+ arguments: params,
113
+ });
114
+ return extractTextContent(response);
115
+ }
116
+ export async function codeSearch(params) {
117
+ const response = await callExa("tools/call", {
118
+ name: "get_code_context_exa",
119
+ arguments: params,
120
+ });
121
+ return extractTextContent(response);
122
+ }
123
+ export async function companyResearch(params) {
124
+ const response = await callExa("tools/call", {
125
+ name: "company_research_exa",
126
+ arguments: params,
127
+ });
128
+ return extractTextContent(response);
129
+ }
130
+ export async function twitterSearch(params) {
131
+ const args = {
132
+ query: params.query,
133
+ category: "tweet",
134
+ numResults: params.numResults ?? 10,
135
+ };
136
+ if (params.startPublishedDate !== undefined)
137
+ args.startPublishedDate = params.startPublishedDate;
138
+ if (params.endPublishedDate !== undefined)
139
+ args.endPublishedDate = params.endPublishedDate;
140
+ const response = await callExa("tools/call", {
141
+ name: "web_search_advanced_exa",
142
+ arguments: args,
143
+ });
144
+ return extractTextContent(response);
145
+ }
146
+ export async function peopleSearch(params) {
147
+ const response = await callExa("tools/call", {
148
+ name: "people_search_exa",
149
+ arguments: params,
150
+ });
151
+ return extractTextContent(response);
152
+ }
153
+ export async function financialSearch(params) {
154
+ const args = {
155
+ query: params.query,
156
+ category: "financial report",
157
+ numResults: params.numResults ?? 10,
158
+ };
159
+ if (params.startPublishedDate !== undefined)
160
+ args.startPublishedDate = params.startPublishedDate;
161
+ if (params.endPublishedDate !== undefined)
162
+ args.endPublishedDate = params.endPublishedDate;
163
+ const response = await callExa("tools/call", {
164
+ name: "web_search_advanced_exa",
165
+ arguments: args,
166
+ });
167
+ return extractTextContent(response);
168
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OpenClaw Exa Search Plugin
3
+ *
4
+ * Registers 6 tools: web_search, code_search, company_research,
5
+ * twitter_search, people_search, financial_report_search
6
+ */
7
+ import type { OpenClawPluginApi, AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
8
+ declare const TOOLS: AnyAgentTool[];
9
+ declare const _default: {
10
+ id: string;
11
+ name: string;
12
+ description: string;
13
+ register(api: OpenClawPluginApi): void;
14
+ };
15
+ export default _default;
16
+ export { TOOLS };
package/dist/index.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * OpenClaw Exa Search Plugin
3
+ *
4
+ * Registers 6 tools: web_search, code_search, company_research,
5
+ * twitter_search, people_search, financial_report_search
6
+ */
7
+ import { Type } from "@sinclair/typebox";
8
+ import { webSearch, codeSearch, companyResearch, twitterSearch, peopleSearch, financialSearch, } from "./exa-mcp-client.js";
9
+ // ─── Helper ────────────────────────────────────────────────────────
10
+ function wrapExecute(fn) {
11
+ return async (_id, params) => {
12
+ try {
13
+ const result = await fn(params);
14
+ return { content: [{ type: "text", text: result }], details: {} };
15
+ }
16
+ catch (error) {
17
+ const message = error instanceof Error ? error.message : String(error);
18
+ return {
19
+ content: [{ type: "text", text: `Error: ${message}` }],
20
+ details: { isError: true },
21
+ };
22
+ }
23
+ };
24
+ }
25
+ // ─── Tool definitions ──────────────────────────────────────────────
26
+ const TOOLS = [
27
+ {
28
+ name: "exa_web_search",
29
+ label: "Exa Web Search",
30
+ description: "Search the web using Exa AI neural search. Find current information, news, facts, or answer questions about any topic. Returns clean, formatted content ready for LLM use.",
31
+ parameters: Type.Object({
32
+ query: Type.String({ description: "Web search query" }),
33
+ numResults: Type.Optional(Type.Number({ description: "Number of search results to return (default: 8)" })),
34
+ type: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("fast"), Type.Literal("deep")], {
35
+ description: "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive research",
36
+ })),
37
+ livecrawl: Type.Optional(Type.Union([Type.Literal("fallback"), Type.Literal("preferred")], {
38
+ description: "Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling",
39
+ })),
40
+ }),
41
+ execute: wrapExecute(webSearch),
42
+ },
43
+ {
44
+ name: "exa_code_search",
45
+ label: "Exa Code Search",
46
+ description: "Find code examples, documentation, and programming solutions from GitHub, Stack Overflow, and official docs. Useful for API usage, library examples, code snippets, and debugging help.",
47
+ parameters: Type.Object({
48
+ query: Type.String({
49
+ description: "Search query for code context - e.g., 'React useState hook examples', 'Python pandas dataframe filtering'",
50
+ }),
51
+ tokensNum: Type.Optional(Type.Number({
52
+ description: "Number of tokens to return (1000-50000). Lower for focused queries, higher for comprehensive docs (default: 5000)",
53
+ })),
54
+ }),
55
+ execute: wrapExecute(codeSearch),
56
+ },
57
+ {
58
+ name: "exa_company_research",
59
+ label: "Exa Company Research",
60
+ description: "Research any company to get business information, news, and insights. Returns information from trusted business sources about products, services, recent news, or industry position.",
61
+ parameters: Type.Object({
62
+ companyName: Type.String({
63
+ description: "Name of the company to research",
64
+ }),
65
+ numResults: Type.Optional(Type.Number({
66
+ description: "Number of search results to return (default: 5)",
67
+ })),
68
+ }),
69
+ execute: wrapExecute(companyResearch),
70
+ },
71
+ {
72
+ name: "exa_twitter_search",
73
+ label: "Exa Twitter Search",
74
+ description: "Search Twitter/X posts. Find tweets, discussions, and social commentary on any topic. Useful for tracking public sentiment, announcements, and trending discussions.",
75
+ parameters: Type.Object({
76
+ query: Type.String({
77
+ description: "Search query for Twitter/X posts",
78
+ }),
79
+ numResults: Type.Optional(Type.Number({
80
+ description: "Number of tweets to return (default: 10)",
81
+ })),
82
+ startPublishedDate: Type.Optional(Type.String({
83
+ description: "Filter tweets published after this date (ISO 8601 format, e.g., '2024-01-01T00:00:00.000Z')",
84
+ })),
85
+ endPublishedDate: Type.Optional(Type.String({
86
+ description: "Filter tweets published before this date (ISO 8601 format)",
87
+ })),
88
+ }),
89
+ execute: wrapExecute(twitterSearch),
90
+ },
91
+ {
92
+ name: "exa_people_search",
93
+ label: "Exa People Search",
94
+ description: "Find people and their professional profiles. Search for individuals by name, role, company, or expertise. Returns public LinkedIn and professional profile data.",
95
+ parameters: Type.Object({
96
+ query: Type.String({
97
+ description: "Search query for people - e.g., 'CTO at Anthropic', 'machine learning researchers Stanford'",
98
+ }),
99
+ numResults: Type.Optional(Type.Number({
100
+ description: "Number of results to return (default: 5)",
101
+ })),
102
+ }),
103
+ execute: wrapExecute(peopleSearch),
104
+ },
105
+ {
106
+ name: "exa_financial_report_search",
107
+ label: "Exa Financial Report Search",
108
+ description: "Search financial reports — 10-K, 10-Q, annual reports, quarterly earnings, and SEC filings for public companies.",
109
+ parameters: Type.Object({
110
+ query: Type.String({
111
+ description: "Search query for financial reports - e.g., 'Apple 2024 Q4 earnings', 'Tesla annual report 2024'",
112
+ }),
113
+ numResults: Type.Optional(Type.Number({
114
+ description: "Number of results to return (default: 10)",
115
+ })),
116
+ startPublishedDate: Type.Optional(Type.String({
117
+ description: "Filter reports published after this date (ISO 8601 format)",
118
+ })),
119
+ endPublishedDate: Type.Optional(Type.String({
120
+ description: "Filter reports published before this date (ISO 8601 format)",
121
+ })),
122
+ }),
123
+ execute: wrapExecute(financialSearch),
124
+ },
125
+ ];
126
+ // ─── Plugin entry ──────────────────────────────────────────────────
127
+ export default {
128
+ id: "openclaw-exa-search",
129
+ name: "Exa Search",
130
+ description: "Exa AI search integration for OpenClaw — web, code, company, Twitter/X, people, and financial report search. No API key required.",
131
+ register(api) {
132
+ for (const tool of TOOLS) {
133
+ api.registerTool(tool);
134
+ }
135
+ },
136
+ };
137
+ export { TOOLS };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Integration tests — hits real Exa MCP endpoint
3
+ * Run with: npm run test:integration
4
+ */
5
+ export {};
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Integration tests — hits real Exa MCP endpoint
3
+ * Run with: npm run test:integration
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import { webSearch, codeSearch, companyResearch, twitterSearch, peopleSearch, financialSearch, } from "./exa-mcp-client.js";
7
+ const TIMEOUT = 30_000;
8
+ describe("integration: Exa MCP endpoint", () => {
9
+ it("exa_web_search", async () => {
10
+ const result = await webSearch({ query: "OpenAI latest news", numResults: 3 });
11
+ expect(result).toBeTruthy();
12
+ expect(result.length).toBeGreaterThan(50);
13
+ console.log("[web_search] ✅", result.slice(0, 200), "...");
14
+ }, TIMEOUT);
15
+ it("exa_code_search", async () => {
16
+ const result = await codeSearch({ query: "React useState hook examples", tokensNum: 2000 });
17
+ expect(result).toBeTruthy();
18
+ expect(result.length).toBeGreaterThan(50);
19
+ console.log("[code_search] ✅", result.slice(0, 200), "...");
20
+ }, TIMEOUT);
21
+ it("exa_company_research", async () => {
22
+ const result = await companyResearch({ companyName: "Anthropic", numResults: 3 });
23
+ expect(result).toBeTruthy();
24
+ expect(result.length).toBeGreaterThan(50);
25
+ console.log("[company_research] ✅", result.slice(0, 200), "...");
26
+ }, TIMEOUT);
27
+ it("exa_twitter_search", async () => {
28
+ const result = await twitterSearch({ query: "AI agents", numResults: 5 });
29
+ expect(result).toBeTruthy();
30
+ expect(result.length).toBeGreaterThan(50);
31
+ console.log("[twitter_search] ✅", result.slice(0, 200), "...");
32
+ }, TIMEOUT);
33
+ it("exa_people_search", async () => {
34
+ const result = await peopleSearch({ query: "CTO at Anthropic", numResults: 3 });
35
+ expect(result).toBeTruthy();
36
+ expect(result.length).toBeGreaterThan(50);
37
+ console.log("[people_search] ✅", result.slice(0, 200), "...");
38
+ }, TIMEOUT);
39
+ it("exa_financial_search", async () => {
40
+ const result = await financialSearch({ query: "Apple 2024 annual report", numResults: 3 });
41
+ expect(result).toBeTruthy();
42
+ expect(result.length).toBeGreaterThan(50);
43
+ console.log("[financial_search] ✅", result.slice(0, 200), "...");
44
+ }, TIMEOUT);
45
+ });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-exa-search",
3
3
  "name": "Exa Search",
4
- "version": "1.0.4",
4
+ "version": "2.0.0",
5
5
  "description": "Exa AI search integration for OpenClaw — web, code, company, Twitter/X, people, and financial report search. No API key required.",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,32 +1,63 @@
1
1
  {
2
2
  "name": "openclaw-exa-search",
3
- "version": "1.0.4",
3
+ "version": "2.0.0",
4
+ "type": "module",
4
5
  "description": "Exa AI search integration for OpenClaw — web, code, company, Twitter/X, people, and financial report search. No API key required.",
5
6
  "engines": {
6
7
  "node": ">=18"
7
8
  },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
8
11
  "scripts": {
12
+ "build": "tsc",
9
13
  "test": "vitest run",
10
14
  "test:integration": "vitest run --config vitest.integration.config.ts",
11
15
  "test:watch": "vitest",
12
16
  "typecheck": "tsc --noEmit"
13
17
  },
14
- "keywords": ["openclaw", "openclaw-plugin", "exa", "search", "web-search", "code-search", "company-research", "twitter-search", "people-search", "financial-reports", "ai", "neural-search", "mcp"],
18
+ "keywords": [
19
+ "openclaw",
20
+ "openclaw-plugin",
21
+ "exa",
22
+ "search",
23
+ "web-search",
24
+ "code-search",
25
+ "company-research",
26
+ "twitter-search",
27
+ "people-search",
28
+ "financial-reports",
29
+ "ai",
30
+ "neural-search",
31
+ "mcp"
32
+ ],
15
33
  "author": "sawyer0x110",
16
34
  "license": "MIT",
17
35
  "openclaw": {
18
- "extensions": ["./src/index.ts"]
36
+ "extensions": [
37
+ "./dist/index.js"
38
+ ]
19
39
  },
20
40
  "repository": {
21
41
  "type": "git",
22
42
  "url": "https://github.com/sawyer0x110/openclaw-exa-search"
23
43
  },
44
+ "peerDependencies": {
45
+ "openclaw": ">=2025.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "openclaw": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "dependencies": {
53
+ "@sinclair/typebox": "^0.34.0"
54
+ },
24
55
  "devDependencies": {
25
56
  "typescript": "^5.7.0",
26
57
  "vitest": "^3.0.0"
27
58
  },
28
59
  "files": [
29
- "src",
60
+ "dist",
30
61
  "openclaw.plugin.json",
31
62
  "README.md"
32
63
  ]
@@ -1,132 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { parseSSEResponse, extractTextContent, callExa } from "./exa-mcp-client";
3
-
4
- describe("parseSSEResponse", () => {
5
- it("extracts data from SSE format", () => {
6
- const sse = 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n';
7
- expect(parseSSEResponse(sse)).toBe('{"jsonrpc":"2.0","id":1,"result":{}}');
8
- });
9
-
10
- it("throws when no data field", () => {
11
- expect(() => parseSSEResponse("event: message\n")).toThrow(
12
- "No data: field found"
13
- );
14
- });
15
-
16
- it("returns last valid JSON data line from multi-line SSE", () => {
17
- const sse =
18
- 'data: {"jsonrpc":"2.0","id":1,"result":{"content":[]}}\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"final"}]}}\n';
19
- const result = parseSSEResponse(sse);
20
- expect(JSON.parse(result).result.content[0].text).toBe("final");
21
- });
22
-
23
- it("falls back to concatenation when no line is valid JSON", () => {
24
- const sse = "data: not-json-a\ndata: not-json-b\n";
25
- expect(parseSSEResponse(sse)).toBe("not-json-anot-json-b");
26
- });
27
- });
28
-
29
- describe("extractTextContent", () => {
30
- it("extracts text from valid response", () => {
31
- const response = {
32
- jsonrpc: "2.0" as const,
33
- id: 1,
34
- result: {
35
- content: [
36
- { type: "text", text: "Hello" },
37
- { type: "text", text: "World" },
38
- ],
39
- },
40
- };
41
- expect(extractTextContent(response)).toBe("Hello\n\nWorld");
42
- });
43
-
44
- it("throws on API error", () => {
45
- const response = {
46
- jsonrpc: "2.0" as const,
47
- id: 1,
48
- error: { code: 400, message: "Bad request" },
49
- };
50
- expect(() => extractTextContent(response)).toThrow("Exa API error: 400");
51
- });
52
-
53
- it("throws on missing content", () => {
54
- const response = {
55
- jsonrpc: "2.0" as const,
56
- id: 1,
57
- result: {},
58
- };
59
- expect(() => extractTextContent(response)).toThrow("No content");
60
- });
61
-
62
- it("filters non-text content types", () => {
63
- const response = {
64
- jsonrpc: "2.0" as const,
65
- id: 1,
66
- result: {
67
- content: [
68
- { type: "image", text: "ignored" },
69
- { type: "text", text: "kept" },
70
- ],
71
- },
72
- };
73
- expect(extractTextContent(response)).toBe("kept");
74
- });
75
- });
76
-
77
- describe("callExa", () => {
78
- function mockFetch(body: unknown, status = 200): typeof fetch {
79
- return vi.fn().mockResolvedValue({
80
- ok: status >= 200 && status < 300,
81
- status,
82
- statusText: status === 200 ? "OK" : "Bad Request",
83
- text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)),
84
- });
85
- }
86
-
87
- it("sends correct JSON-RPC request and parses JSON response", async () => {
88
- const responseBody = {
89
- jsonrpc: "2.0",
90
- id: 1,
91
- result: { content: [{ type: "text", text: "ok" }] },
92
- };
93
- const fetchFn = mockFetch(responseBody);
94
-
95
- const result = await callExa("tools/call", { name: "web_search_exa", arguments: { query: "test" } }, fetchFn);
96
-
97
- expect(fetchFn).toHaveBeenCalledTimes(1);
98
- const [url, opts] = (fetchFn as any).mock.calls[0];
99
- expect(url).toContain("mcp.exa.ai");
100
- expect(opts.method).toBe("POST");
101
- expect(opts.headers["Content-Type"]).toBe("application/json");
102
-
103
- const body = JSON.parse(opts.body);
104
- expect(body.jsonrpc).toBe("2.0");
105
- expect(body.method).toBe("tools/call");
106
- expect(body.params.name).toBe("web_search_exa");
107
-
108
- expect((result as any).result.content[0].text).toBe("ok");
109
- });
110
-
111
- it("falls back to SSE parsing when response is not JSON", async () => {
112
- const sseBody = 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"sse"}]}}\n\n';
113
- const fetchFn = mockFetch(sseBody);
114
-
115
- const result = await callExa("tools/call", { name: "web_search_exa", arguments: {} }, fetchFn);
116
- expect((result as any).result.content[0].text).toBe("sse");
117
- });
118
-
119
- it("throws on HTTP error", async () => {
120
- const fetchFn = mockFetch("Bad Request", 400);
121
- await expect(callExa("tools/call", {}, fetchFn)).rejects.toThrow("HTTP error: 400");
122
- });
123
-
124
- it("throws descriptive timeout error on abort", async () => {
125
- const fetchFn = vi.fn().mockImplementation(() => {
126
- const err = new DOMException("The operation was aborted", "AbortError");
127
- return Promise.reject(err);
128
- });
129
-
130
- await expect(callExa("tools/call", {}, fetchFn)).rejects.toThrow("timed out");
131
- });
132
- });
@@ -1,242 +0,0 @@
1
- /**
2
- * Exa MCP Client — HTTP/SSE communication with Exa hosted MCP endpoint
3
- * No API key required; uses mcp.exa.ai with all tools enabled
4
- */
5
-
6
- const EXA_BASE_URL = "https://mcp.exa.ai/mcp";
7
-
8
- const EXA_TOOLS = [
9
- "web_search_exa",
10
- "web_search_advanced_exa",
11
- "get_code_context_exa",
12
- "company_research_exa",
13
- "people_search_exa",
14
- ].join(",");
15
-
16
- const EXA_URL = `${EXA_BASE_URL}?tools=${EXA_TOOLS}`;
17
-
18
- const REQUEST_TIMEOUT_MS = 30_000;
19
-
20
- let requestIdCounter = 0;
21
-
22
- interface ExaRequest {
23
- jsonrpc: "2.0";
24
- id: number;
25
- method: string;
26
- params?: Record<string, unknown>;
27
- }
28
-
29
- interface ExaSuccessResponse {
30
- jsonrpc: "2.0";
31
- id: number;
32
- result: {
33
- content?: Array<{ type: string; text: string }>;
34
- tools?: Array<{ name: string; description: string; inputSchema: unknown }>;
35
- };
36
- }
37
-
38
- interface ExaErrorResponse {
39
- jsonrpc: "2.0";
40
- id: number;
41
- error: {
42
- code: number;
43
- message: string;
44
- };
45
- }
46
-
47
- type ExaResponse = ExaSuccessResponse | ExaErrorResponse;
48
-
49
- /**
50
- * Parse SSE response — extract the last complete JSON-RPC message.
51
- * Per SSE spec, multiple `data:` lines within one event are joined with "\n",
52
- * but Exa sends one JSON object per `data:` line. We take the last `data:` line
53
- * that parses as valid JSON to handle both single and multi-event streams.
54
- */
55
- export function parseSSEResponse(text: string): string {
56
- const lines = text.split("\n");
57
- const dataLines: string[] = [];
58
- for (const line of lines) {
59
- if (line.startsWith("data: ")) {
60
- dataLines.push(line.substring(6));
61
- }
62
- }
63
- if (dataLines.length === 0) {
64
- throw new Error("No data: field found in SSE response");
65
- }
66
- // Try each data line from last to first — return the first valid JSON
67
- for (let i = dataLines.length - 1; i >= 0; i--) {
68
- try {
69
- JSON.parse(dataLines[i]);
70
- return dataLines[i];
71
- } catch {
72
- // not valid JSON, try next
73
- }
74
- }
75
- // Fallback: join all and let caller handle parse error
76
- return dataLines.join("");
77
- }
78
-
79
- /**
80
- * Call an Exa MCP tool
81
- */
82
- export async function callExa(
83
- method: string,
84
- params?: Record<string, unknown>,
85
- fetchFn: typeof fetch = fetch
86
- ): Promise<ExaResponse> {
87
- const requestId = ++requestIdCounter;
88
-
89
- const request: ExaRequest = {
90
- jsonrpc: "2.0",
91
- id: requestId,
92
- method,
93
- params,
94
- };
95
-
96
- const controller = new AbortController();
97
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
98
-
99
- try {
100
- const response = await fetchFn(EXA_URL, {
101
- method: "POST",
102
- headers: {
103
- "Content-Type": "application/json",
104
- Accept: "application/json, text/event-stream",
105
- },
106
- body: JSON.stringify(request),
107
- signal: controller.signal,
108
- });
109
-
110
- if (!response.ok) {
111
- throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
112
- }
113
-
114
- const text = await response.text();
115
-
116
- // Try JSON first, fallback to SSE parsing
117
- try {
118
- return JSON.parse(text) as ExaResponse;
119
- } catch {
120
- const jsonData = parseSSEResponse(text);
121
- return JSON.parse(jsonData) as ExaResponse;
122
- }
123
- } catch (error: unknown) {
124
- if (error instanceof DOMException && error.name === "AbortError") {
125
- throw new Error(`Exa request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
126
- }
127
- throw error;
128
- } finally {
129
- clearTimeout(timer);
130
- }
131
- }
132
-
133
- /**
134
- * Extract text content from an Exa response
135
- */
136
- export function extractTextContent(response: ExaResponse): string {
137
- if ("error" in response) {
138
- throw new Error(
139
- `Exa API error: ${response.error.code} - ${response.error.message}`
140
- );
141
- }
142
-
143
- const result = (response as ExaSuccessResponse).result;
144
- if (!result?.content) {
145
- throw new Error("No content in response");
146
- }
147
-
148
- return result.content
149
- .filter((item) => item.type === "text")
150
- .map((item) => item.text)
151
- .join("\n\n");
152
- }
153
-
154
- // ─── Tool Functions ────────────────────────────────────────────────
155
-
156
- export async function webSearch(params: {
157
- query: string;
158
- numResults?: number;
159
- type?: "auto" | "fast" | "deep";
160
- livecrawl?: "fallback" | "preferred";
161
- }): Promise<string> {
162
- const response = await callExa("tools/call", {
163
- name: "web_search_exa",
164
- arguments: params,
165
- });
166
- return extractTextContent(response);
167
- }
168
-
169
- export async function codeSearch(params: {
170
- query: string;
171
- tokensNum?: number;
172
- }): Promise<string> {
173
- const response = await callExa("tools/call", {
174
- name: "get_code_context_exa",
175
- arguments: params,
176
- });
177
- return extractTextContent(response);
178
- }
179
-
180
- export async function companyResearch(params: {
181
- companyName: string;
182
- numResults?: number;
183
- }): Promise<string> {
184
- const response = await callExa("tools/call", {
185
- name: "company_research_exa",
186
- arguments: params,
187
- });
188
- return extractTextContent(response);
189
- }
190
-
191
- export async function twitterSearch(params: {
192
- query: string;
193
- numResults?: number;
194
- startPublishedDate?: string;
195
- endPublishedDate?: string;
196
- }): Promise<string> {
197
- const args: Record<string, unknown> = {
198
- query: params.query,
199
- category: "tweet",
200
- numResults: params.numResults ?? 10,
201
- };
202
- if (params.startPublishedDate !== undefined) args.startPublishedDate = params.startPublishedDate;
203
- if (params.endPublishedDate !== undefined) args.endPublishedDate = params.endPublishedDate;
204
-
205
- const response = await callExa("tools/call", {
206
- name: "web_search_advanced_exa",
207
- arguments: args,
208
- });
209
- return extractTextContent(response);
210
- }
211
-
212
- export async function peopleSearch(params: {
213
- query: string;
214
- numResults?: number;
215
- }): Promise<string> {
216
- const response = await callExa("tools/call", {
217
- name: "people_search_exa",
218
- arguments: params,
219
- });
220
- return extractTextContent(response);
221
- }
222
-
223
- export async function financialSearch(params: {
224
- query: string;
225
- numResults?: number;
226
- startPublishedDate?: string;
227
- endPublishedDate?: string;
228
- }): Promise<string> {
229
- const args: Record<string, unknown> = {
230
- query: params.query,
231
- category: "financial report",
232
- numResults: params.numResults ?? 10,
233
- };
234
- if (params.startPublishedDate !== undefined) args.startPublishedDate = params.startPublishedDate;
235
- if (params.endPublishedDate !== undefined) args.endPublishedDate = params.endPublishedDate;
236
-
237
- const response = await callExa("tools/call", {
238
- name: "web_search_advanced_exa",
239
- arguments: args,
240
- });
241
- return extractTextContent(response);
242
- }
package/src/index.test.ts DELETED
@@ -1,69 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import pluginInit, { TOOLS } from "./index";
3
-
4
- describe("plugin registration", () => {
5
- it("registers all 6 tools", () => {
6
- const registered: string[] = [];
7
- const api = {
8
- registerTool: vi.fn((tool: any) => registered.push(tool.name)),
9
- };
10
-
11
- pluginInit(api);
12
-
13
- expect(registered).toEqual([
14
- "exa_web_search",
15
- "exa_code_search",
16
- "exa_company_research",
17
- "exa_twitter_search",
18
- "exa_people_search",
19
- "exa_financial_report_search",
20
- ]);
21
- expect(api.registerTool).toHaveBeenCalledTimes(6);
22
- });
23
- });
24
-
25
- describe("tool definitions", () => {
26
- it("all tools have required fields", () => {
27
- for (const tool of TOOLS) {
28
- expect(tool.name).toMatch(/^exa_/);
29
- expect(tool.description).toBeTruthy();
30
- expect(tool.parameters).toBeDefined();
31
- expect(typeof tool.execute).toBe("function");
32
-
33
- const required = (tool.parameters as any).required;
34
- expect(required.length).toBeGreaterThan(0);
35
- }
36
- });
37
-
38
- it("all tools have unique names", () => {
39
- const names = TOOLS.map((t) => t.name);
40
- expect(new Set(names).size).toBe(names.length);
41
- });
42
- });
43
-
44
- describe("wrapExecute", () => {
45
- it("returns content array on success", async () => {
46
- // Use real Exa endpoint — web_search should work
47
- const tool = TOOLS.find((t) => t.name === "exa_web_search")!;
48
- const result = await tool.execute("test-id", { query: "OpenAI", numResults: 1 });
49
-
50
- expect(result.isError).toBeUndefined();
51
- expect(result.content).toHaveLength(1);
52
- expect(result.content[0].type).toBe("text");
53
- expect(result.content[0].text.length).toBeGreaterThan(0);
54
- });
55
-
56
- it("wraps errors with isError flag", async () => {
57
- // Call with a tool that will fail — pass invalid params to trigger Exa error
58
- const tool = TOOLS.find((t) => t.name === "exa_people_search")!;
59
- // Empty query should cause an error from Exa
60
- const result = await tool.execute("test-id", { query: "" });
61
-
62
- // Either succeeds or fails gracefully — both paths are valid
63
- if (result.isError) {
64
- expect(result.content[0].text).toMatch(/^Error:/);
65
- } else {
66
- expect(result.content[0].type).toBe("text");
67
- }
68
- });
69
- });
package/src/index.ts DELETED
@@ -1,210 +0,0 @@
1
- /**
2
- * OpenClaw Exa Search Plugin
3
- *
4
- * Registers 6 tools: web_search, code_search, company_research,
5
- * twitter_search, people_search, financial_report_search
6
- */
7
-
8
- import {
9
- webSearch,
10
- codeSearch,
11
- companyResearch,
12
- twitterSearch,
13
- peopleSearch,
14
- financialSearch,
15
- } from "./exa-mcp-client";
16
-
17
- interface ToolDefinition {
18
- name: string;
19
- description: string;
20
- parameters: Record<string, unknown>;
21
- execute: (id: string, params: Record<string, unknown>) => Promise<{
22
- content: Array<{ type: string; text: string }>;
23
- isError?: boolean;
24
- }>;
25
- }
26
-
27
- interface PluginAPI {
28
- registerTool: (tool: ToolDefinition) => void;
29
- }
30
-
31
- function wrapExecute(
32
- fn: (params: any) => Promise<string>
33
- ): ToolDefinition["execute"] {
34
- return async (_id: string, params: Record<string, unknown>) => {
35
- try {
36
- const result = await fn(params);
37
- return { content: [{ type: "text", text: result }] };
38
- } catch (error: unknown) {
39
- const message = error instanceof Error ? error.message : String(error);
40
- return {
41
- content: [{ type: "text", text: `Error: ${message}` }],
42
- isError: true,
43
- };
44
- }
45
- };
46
- }
47
-
48
- const TOOLS: ToolDefinition[] = [
49
- {
50
- name: "exa_web_search",
51
- description:
52
- "Search the web using Exa AI neural search. Find current information, news, facts, or answer questions about any topic. Returns clean, formatted content ready for LLM use.",
53
- parameters: {
54
- type: "object",
55
- properties: {
56
- query: { type: "string", description: "Web search query" },
57
- numResults: {
58
- type: "number",
59
- description: "Number of search results to return (default: 8)",
60
- },
61
- type: {
62
- type: "string",
63
- enum: ["auto", "fast", "deep"],
64
- description:
65
- "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive research",
66
- },
67
- livecrawl: {
68
- type: "string",
69
- enum: ["fallback", "preferred"],
70
- description:
71
- "Live crawl mode - 'fallback': use live crawling as backup if cached unavailable, 'preferred': prioritize live crawling",
72
- },
73
- },
74
- required: ["query"],
75
- },
76
- execute: wrapExecute(webSearch),
77
- },
78
- {
79
- name: "exa_code_search",
80
- description:
81
- "Find code examples, documentation, and programming solutions from GitHub, Stack Overflow, and official docs. Useful for API usage, library examples, code snippets, and debugging help.",
82
- parameters: {
83
- type: "object",
84
- properties: {
85
- query: {
86
- type: "string",
87
- description:
88
- "Search query for code context - e.g., 'React useState hook examples', 'Python pandas dataframe filtering'",
89
- },
90
- tokensNum: {
91
- type: "number",
92
- description:
93
- "Number of tokens to return (1000-50000). Lower for focused queries, higher for comprehensive docs (default: 5000)",
94
- },
95
- },
96
- required: ["query"],
97
- },
98
- execute: wrapExecute(codeSearch),
99
- },
100
- {
101
- name: "exa_company_research",
102
- description:
103
- "Research any company to get business information, news, and insights. Returns information from trusted business sources about products, services, recent news, or industry position.",
104
- parameters: {
105
- type: "object",
106
- properties: {
107
- companyName: {
108
- type: "string",
109
- description: "Name of the company to research",
110
- },
111
- numResults: {
112
- type: "number",
113
- description: "Number of search results to return (default: 5)",
114
- },
115
- },
116
- required: ["companyName"],
117
- },
118
- execute: wrapExecute(companyResearch),
119
- },
120
- {
121
- name: "exa_twitter_search",
122
- description:
123
- "Search Twitter/X posts. Find tweets, discussions, and social commentary on any topic. Useful for tracking public sentiment, announcements, and trending discussions.",
124
- parameters: {
125
- type: "object",
126
- properties: {
127
- query: {
128
- type: "string",
129
- description: "Search query for Twitter/X posts",
130
- },
131
- numResults: {
132
- type: "number",
133
- description: "Number of tweets to return (default: 10)",
134
- },
135
- startPublishedDate: {
136
- type: "string",
137
- description:
138
- "Filter tweets published after this date (ISO 8601 format, e.g., '2024-01-01T00:00:00.000Z')",
139
- },
140
- endPublishedDate: {
141
- type: "string",
142
- description:
143
- "Filter tweets published before this date (ISO 8601 format)",
144
- },
145
- },
146
- required: ["query"],
147
- },
148
- execute: wrapExecute(twitterSearch),
149
- },
150
- {
151
- name: "exa_people_search",
152
- description:
153
- "Find people and their professional profiles. Search for individuals by name, role, company, or expertise. Returns public LinkedIn and professional profile data.",
154
- parameters: {
155
- type: "object",
156
- properties: {
157
- query: {
158
- type: "string",
159
- description:
160
- "Search query for people - e.g., 'CTO at Anthropic', 'machine learning researchers Stanford'",
161
- },
162
- numResults: {
163
- type: "number",
164
- description: "Number of results to return (default: 5)",
165
- },
166
- },
167
- required: ["query"],
168
- },
169
- execute: wrapExecute(peopleSearch),
170
- },
171
- {
172
- name: "exa_financial_report_search",
173
- description:
174
- "Search financial reports — 10-K, 10-Q, annual reports, quarterly earnings, and SEC filings for public companies.",
175
- parameters: {
176
- type: "object",
177
- properties: {
178
- query: {
179
- type: "string",
180
- description:
181
- "Search query for financial reports - e.g., 'Apple 2024 Q4 earnings', 'Tesla annual report 2024'",
182
- },
183
- numResults: {
184
- type: "number",
185
- description: "Number of results to return (default: 10)",
186
- },
187
- startPublishedDate: {
188
- type: "string",
189
- description:
190
- "Filter reports published after this date (ISO 8601 format)",
191
- },
192
- endPublishedDate: {
193
- type: "string",
194
- description:
195
- "Filter reports published before this date (ISO 8601 format)",
196
- },
197
- },
198
- required: ["query"],
199
- },
200
- execute: wrapExecute(financialSearch),
201
- },
202
- ];
203
-
204
- export default function (api: PluginAPI) {
205
- for (const tool of TOOLS) {
206
- api.registerTool(tool);
207
- }
208
- }
209
-
210
- export { TOOLS };
@@ -1,60 +0,0 @@
1
- /**
2
- * Integration tests — hits real Exa MCP endpoint
3
- * Run with: npm run test:integration
4
- */
5
-
6
- import { describe, it, expect } from "vitest";
7
- import {
8
- webSearch,
9
- codeSearch,
10
- companyResearch,
11
- twitterSearch,
12
- peopleSearch,
13
- financialSearch,
14
- } from "./exa-mcp-client";
15
-
16
- const TIMEOUT = 30_000;
17
-
18
- describe("integration: Exa MCP endpoint", () => {
19
- it("exa_web_search", async () => {
20
- const result = await webSearch({ query: "OpenAI latest news", numResults: 3 });
21
- expect(result).toBeTruthy();
22
- expect(result.length).toBeGreaterThan(50);
23
- console.log("[web_search] ✅", result.slice(0, 200), "...");
24
- }, TIMEOUT);
25
-
26
- it("exa_code_search", async () => {
27
- const result = await codeSearch({ query: "React useState hook examples", tokensNum: 2000 });
28
- expect(result).toBeTruthy();
29
- expect(result.length).toBeGreaterThan(50);
30
- console.log("[code_search] ✅", result.slice(0, 200), "...");
31
- }, TIMEOUT);
32
-
33
- it("exa_company_research", async () => {
34
- const result = await companyResearch({ companyName: "Anthropic", numResults: 3 });
35
- expect(result).toBeTruthy();
36
- expect(result.length).toBeGreaterThan(50);
37
- console.log("[company_research] ✅", result.slice(0, 200), "...");
38
- }, TIMEOUT);
39
-
40
- it("exa_twitter_search", async () => {
41
- const result = await twitterSearch({ query: "AI agents", numResults: 5 });
42
- expect(result).toBeTruthy();
43
- expect(result.length).toBeGreaterThan(50);
44
- console.log("[twitter_search] ✅", result.slice(0, 200), "...");
45
- }, TIMEOUT);
46
-
47
- it("exa_people_search", async () => {
48
- const result = await peopleSearch({ query: "CTO at Anthropic", numResults: 3 });
49
- expect(result).toBeTruthy();
50
- expect(result.length).toBeGreaterThan(50);
51
- console.log("[people_search] ✅", result.slice(0, 200), "...");
52
- }, TIMEOUT);
53
-
54
- it("exa_financial_search", async () => {
55
- const result = await financialSearch({ query: "Apple 2024 annual report", numResults: 3 });
56
- expect(result).toBeTruthy();
57
- expect(result.length).toBeGreaterThan(50);
58
- console.log("[financial_search] ✅", result.slice(0, 200), "...");
59
- }, TIMEOUT);
60
- });