index.ts•7.04 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
CallToolResult,
ErrorCode,
ListToolsRequestSchema,
ListToolsResult,
McpError,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { KagiClient, SearchResponse } from './kagi-client.js';
const kagiClient = new KagiClient();
const server = new Server({
name: 'kagi-mcp',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
const SEARCH_TOOL: Tool = {
name: 'kagi_search_fetch',
description: 'Fetch web results based on one or more queries using the Kagi Search API. Use for general search and when the user explicitly tells you to \'fetch\' results/information. Results are from all queries given. They are numbered continuously, so that a user may be able to refer to a result by a specific number.',
inputSchema: {
type: 'object',
properties: {
queries: {
type: 'array',
items: {
type: 'string',
},
description: 'One or more concise, keyword-focused search queries. Include essential context within each query for standalone use.',
},
},
required: ['queries'],
},
};
const SUMMARIZER_TOOL: Tool = {
name: 'kagi_summarizer',
description: 'Summarize content from a URL using the Kagi Summarizer API. The Summarizer can summarize any document type (text webpage, video, audio, etc.)',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'A URL to a document to summarize.',
},
summary_type: {
type: 'string',
enum: ['summary', 'takeaway'],
default: 'summary',
description: 'Type of summary to produce. Options are \'summary\' for paragraph prose and \'takeaway\' for a bulleted list of key points.',
},
target_language: {
type: 'string',
description: 'Desired output language using language codes (e.g., \'EN\' for English). If not specified, the document\'s original language influences the output.',
},
},
required: ['url'],
},
};
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => {
return {
tools: [SEARCH_TOOL, SUMMARIZER_TOOL],
};
});
function formatSearchResults(queries: string[], responses: SearchResponse[]): string {
const resultTemplate = (result: {
result_number: number;
title: string;
url: string;
published: string;
snippet: string;
}) => `${result.result_number}: ${result.title}
${result.url}
Published Date: ${result.published}
${result.snippet}`;
const queryResponseTemplate = (query: string, formattedResults: string) => `-----
Results for search query "${query}":
-----
${formattedResults}`;
const perQueryResponseStrs: string[] = [];
let startIndex = 1;
for (let i = 0; i < queries.length; i++) {
const query = queries[i];
const response = responses[i];
// t == 0 is search result, t == 1 is related searches
const results = response.data.filter(result => result.t === 0);
const formattedResultsList = results.map((result, idx) =>
resultTemplate({
result_number: startIndex + idx,
title: result.title,
url: result.url,
published: result.published || 'Not Available',
snippet: result.snippet,
})
);
startIndex += results.length;
const formattedResultsStr = formattedResultsList.join('\n\n');
const queryResponseStr = queryResponseTemplate(query, formattedResultsStr);
perQueryResponseStrs.push(queryResponseStr);
}
return perQueryResponseStrs.join('\n\n');
}
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
try {
const { name, arguments: args } = request.params;
if (name === 'kagi_search_fetch') {
const queries = args?.queries as string[] | undefined;
if (!queries || queries.length === 0) {
throw new McpError(
ErrorCode.InvalidParams,
'Search called with no queries.'
);
}
try {
// Execute searches in parallel
const searchPromises = queries.map(query => kagiClient.search(query));
const results = await Promise.all(searchPromises);
const formattedResults = formatSearchResults(queries, results);
return {
content: [
{
type: 'text',
text: formattedResults,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
if (name === 'kagi_summarizer') {
const url = args?.url as string | undefined;
if (!url) {
throw new McpError(
ErrorCode.InvalidParams,
'Summarizer called with no URL.'
);
}
const summaryType = args?.summary_type as 'summary' | 'takeaway' | undefined;
const targetLanguage = args?.target_language as string | undefined;
const engine = process.env.KAGI_SUMMARIZER_ENGINE || 'cecil';
const validEngines = ['cecil', 'agnes', 'daphne', 'muriel'];
if (!validEngines.includes(engine)) {
throw new McpError(
ErrorCode.InvalidParams,
`Summarizer configured incorrectly, invalid summarization engine set: ${engine}. Must be one of the following: ${validEngines.join(', ')}`
);
}
try {
const summary = await kagiClient.summarize({
url,
engine: engine as 'cecil' | 'agnes' | 'daphne' | 'muriel',
summary_type: summaryType,
target_language: targetLanguage,
});
return {
content: [
{
type: 'text',
text: summary.data.output,
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
);
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Handle graceful shutdown
process.on('SIGINT', async () => {
await server.close();
process.exit(0);
});
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});