Skip to content

Commit fc09f6e

Browse files
authored
Merge pull request #171 from tavily-ai/feat/eng-121-keyless-mode
feat: add keyless support
2 parents b664206 + b9785d1 commit fc09f6e

3 files changed

Lines changed: 72 additions & 68 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tavily-mcp",
3-
"version": "0.2.19",
3+
"version": "0.2.20",
44
"mcpName": "io.github.tavily-ai/tavily-mcp",
55
"description": "MCP server for advanced web search using Tavily",
66
"repository": {

src/index.ts

Lines changed: 69 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { hideBin } from 'yargs/helpers';
1313
dotenv.config();
1414

1515
const API_KEY = process.env.TAVILY_API_KEY;
16+
const IS_KEYLESS = !API_KEY;
1617
const HUMAN_ID = process.env.TAVILY_HUMAN_ID;
1718
const SESSION_ID = randomUUID();
1819

@@ -84,7 +85,7 @@ class TavilyClient {
8485
this.server = new Server(
8586
{
8687
name: "tavily-mcp",
87-
version: "0.2.19",
88+
version: "0.2.20",
8889
},
8990
{
9091
capabilities: {
@@ -97,13 +98,18 @@ class TavilyClient {
9798
headers: {
9899
'accept': 'application/json',
99100
'content-type': 'application/json',
100-
'Authorization': `Bearer ${API_KEY}`,
101-
'X-Client-Source': 'MCP',
101+
...(IS_KEYLESS
102+
? { 'X-Tavily-Access-Mode': 'keyless', 'X-Client-Source': 'tavily-mcp-keyless' }
103+
: { 'Authorization': `Bearer ${API_KEY}`, 'X-Client-Source': 'MCP' }),
102104
'X-Session-Id': SESSION_ID,
103105
...(HUMAN_ID ? { 'X-Human-Id': HUMAN_ID } : {}),
104106
}
105107
});
106108

109+
if (IS_KEYLESS) {
110+
console.error('[tavily-mcp] no TAVILY_API_KEY set; running in keyless mode. Search and extract are available; other tools will return a message explaining that an API key is required.');
111+
}
112+
107113
this.setupHandlers();
108114
this.setupErrorHandling();
109115
}
@@ -437,14 +443,6 @@ class TavilyClient {
437443
});
438444

439445
this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
440-
// Check for API key at request time and return proper JSON-RPC error
441-
if (!API_KEY) {
442-
throw new McpError(
443-
ErrorCode.InvalidRequest,
444-
"TAVILY_API_KEY environment variable is required. Please set it before using this MCP server."
445-
);
446-
}
447-
448446
try {
449447
let response: TavilyResponse;
450448
const args = request.params.arguments ?? {};
@@ -553,6 +551,14 @@ class TavilyClient {
553551
};
554552
} catch (error: any) {
555553
if (axios.isAxiosError(error)) {
554+
if (isKeylessEnvelope(error.response?.data)) {
555+
return {
556+
content: [{
557+
type: "text",
558+
text: formatKeylessEnvelope(error.response!.data)
559+
}]
560+
};
561+
}
556562
const toolName = request.params.name?.replace('tavily_', '') || '';
557563
const docsUrl = this.docsURLs[toolName] || '';
558564
const responseData = error.response?.data;
@@ -582,9 +588,8 @@ class TavilyClient {
582588
}
583589

584590
async search(params: any): Promise<TavilyResponse> {
585-
try {
586591
const endpoint = this.baseURLs.search;
587-
592+
588593
const defaults = this.getDefaultParameters();
589594

590595
// Prepare the request payload
@@ -604,7 +609,7 @@ class TavilyClient {
604609
start_date: params.start_date,
605610
end_date: params.end_date,
606611
exact_match: params.exact_match,
607-
api_key: API_KEY,
612+
...(IS_KEYLESS ? {} : { api_key: API_KEY }),
608613
};
609614

610615
// Apply default parameters
@@ -634,65 +639,30 @@ class TavilyClient {
634639

635640
const response = await this.axiosInstance.post(endpoint, cleanedParams);
636641
return response.data;
637-
} catch (error: any) {
638-
if (error.response?.status === 401) {
639-
throw new Error(`Invalid API key. Documentation: ${this.docsURLs.search}`);
640-
} else if (error.response?.status === 429) {
641-
throw new Error(`Usage limit exceeded. Documentation: ${this.docsURLs.search}`);
642-
}
643-
throw error;
644-
}
645642
}
646643

647644
async extract(params: any): Promise<TavilyResponse> {
648-
try {
649-
const response = await this.axiosInstance.post(this.baseURLs.extract, {
650-
...params,
651-
api_key: API_KEY
652-
});
653-
return response.data;
654-
} catch (error: any) {
655-
if (error.response?.status === 401) {
656-
throw new Error(`Invalid API key. Documentation: ${this.docsURLs.extract}`);
657-
} else if (error.response?.status === 429) {
658-
throw new Error(`Usage limit exceeded. Documentation: ${this.docsURLs.extract}`);
659-
}
660-
throw error;
661-
}
645+
const response = await this.axiosInstance.post(this.baseURLs.extract, {
646+
...params,
647+
...(IS_KEYLESS ? {} : { api_key: API_KEY })
648+
});
649+
return response.data;
662650
}
663651

664652
async crawl(params: any): Promise<TavilyCrawlResponse> {
665-
try {
666-
const response = await this.axiosInstance.post(this.baseURLs.crawl, {
667-
...params,
668-
api_key: API_KEY
669-
});
670-
return response.data;
671-
} catch (error: any) {
672-
if (error.response?.status === 401) {
673-
throw new Error(`Invalid API key. Documentation: ${this.docsURLs.crawl}`);
674-
} else if (error.response?.status === 429) {
675-
throw new Error(`Usage limit exceeded. Documentation: ${this.docsURLs.crawl}`);
676-
}
677-
throw error;
678-
}
653+
const response = await this.axiosInstance.post(this.baseURLs.crawl, {
654+
...params,
655+
...(IS_KEYLESS ? {} : { api_key: API_KEY })
656+
});
657+
return response.data;
679658
}
680659

681660
async map(params: any): Promise<TavilyMapResponse> {
682-
try {
683-
const response = await this.axiosInstance.post(this.baseURLs.map, {
684-
...params,
685-
api_key: API_KEY
686-
});
687-
return response.data;
688-
} catch (error: any) {
689-
if (error.response?.status === 401) {
690-
throw new Error(`Invalid API key. Documentation: ${this.docsURLs.map}`);
691-
} else if (error.response?.status === 429) {
692-
throw new Error(`Usage limit exceeded. Documentation: ${this.docsURLs.map}`);
693-
}
694-
throw error;
695-
}
661+
const response = await this.axiosInstance.post(this.baseURLs.map, {
662+
...params,
663+
...(IS_KEYLESS ? {} : { api_key: API_KEY })
664+
});
665+
return response.data;
696666
}
697667

698668
async research(params: any): Promise<TavilyResearchResponse> {
@@ -706,7 +676,7 @@ class TavilyClient {
706676
const response = await this.axiosInstance.post(this.baseURLs.research, {
707677
input: params.input,
708678
model: params.model || 'auto',
709-
api_key: API_KEY
679+
...(IS_KEYLESS ? {} : { api_key: API_KEY })
710680
});
711681

712682
const requestId = response.data.request_id;
@@ -766,6 +736,40 @@ class TavilyClient {
766736
}
767737
}
768738

739+
function isKeylessEnvelope(data: any): boolean {
740+
// Recognises the Tavily API's recoverable-error envelope shape.
741+
// Used for keyless rate-limit caps and endpoints that require an API key.
742+
return !!(data && typeof data === 'object'
743+
&& data.error && typeof data.error === 'object'
744+
&& typeof data.error.code === 'string');
745+
}
746+
747+
function formatKeylessEnvelope(data: any): string {
748+
// Render the Tavily API's recoverable-error envelope as plain text:
749+
// the natural-language message, followed by retry-after (when present).
750+
const err = data.error;
751+
const lines: string[] = [String(err.message ?? '')];
752+
if (err.retry_after_seconds != null) {
753+
lines.push(`Retry after: ${err.retry_after_seconds}s`);
754+
}
755+
if (Array.isArray(err.next_actions) && err.next_actions.length > 0) {
756+
lines.push('', 'Continuation options:');
757+
for (const a of err.next_actions) {
758+
if (a?.type === 'agentic_payment') {
759+
lines.push(`- Agentic payment (${a.scheme ?? 'x402'}): ${a.details ?? ''}`);
760+
} else if (a?.type === 'signup') {
761+
lines.push(`- Sign up for a Tavily API key: ${a.url ?? ''}`);
762+
} else if (a?.type === 'bonus_credits' && a.eligible) {
763+
lines.push(`- Earn ${a.credits_on_completion ?? ''} bonus credits by POSTing answers to ${a.endpoint ?? ''}`);
764+
if (Array.isArray(a.questions)) {
765+
a.questions.forEach((q: string, i: number) => lines.push(` ${i + 1}. ${q}`));
766+
}
767+
}
768+
}
769+
}
770+
return lines.filter(Boolean).join('\n');
771+
}
772+
769773
function formatResults(response: TavilyResponse): string {
770774
// Format API response into human-readable text
771775
const output: string[] = [];

0 commit comments

Comments
 (0)