MCP Embedding Search
by spences10
Verified
- src
#!/usr/bin/env node
import { createClient } from '@libsql/client';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(
readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
);
const { name, version } = pkg;
// Environment variables
const db_url = process.env.TURSO_URL;
const db_auth_token = process.env.TURSO_AUTH_TOKEN;
const voyage_api_key = process.env.VOYAGE_API_KEY;
if (!db_url || !db_auth_token) {
console.error(
'Error: TURSO_URL and TURSO_AUTH_TOKEN environment variables are required',
);
process.exit(1);
}
if (!voyage_api_key) {
console.error(
'Error: VOYAGE_API_KEY environment variable is required for embedding generation',
);
process.exit(1);
}
// Create database client
const db_client = createClient({
url: db_url,
authToken: db_auth_token,
});
// Interface for search parameters
interface SearchParams {
question: string;
limit?: number;
min_score?: number;
}
// Interface for search results
interface SearchResult {
episode_title: string;
segment_text: string;
start_time: number;
end_time: number;
similarity: number;
}
/**
* Generate embeddings for a text using Voyage API
* @param text Text to generate embeddings for
* @returns Array of numbers representing the embedding
*/
async function generate_embedding(text: string): Promise<number[]> {
try {
console.error(`Generating embedding for: "${text}"`);
const response = await fetch(
'https://api.voyageai.com/v1/embeddings',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${voyage_api_key}`,
},
body: JSON.stringify({
model: 'voyage-01',
input: text,
}),
},
);
if (!response.ok) {
const error_text = await response.text();
throw new Error(
`Voyage API error: ${response.status} ${error_text}`,
);
}
const data = await response.json();
console.error('Embedding generated successfully');
return data.data[0].embedding;
} catch (error) {
console.error('Error generating embedding:', error);
throw new McpError(
ErrorCode.InternalError,
`Failed to generate embedding: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
/**
* Search for relevant transcript segments using vector similarity
* @param params Search parameters
* @returns Array of search results
*/
async function search_embeddings(
params: SearchParams,
): Promise<SearchResult[]> {
const { question, limit = 5, min_score = 0.5 } = params;
try {
console.error(
`Searching for: "${question}" with limit: ${limit}, min_score: ${min_score}`,
);
// Generate embedding for the question
const query_embedding = await generate_embedding(question);
console.error(
`Generated embedding with ${query_embedding.length} dimensions`,
);
// Get total count of embeddings
const count_result = await db_client.execute(
'SELECT COUNT(*) as count FROM embeddings',
);
const total_count = Number((count_result.rows[0] as any)[0]);
console.error(`Total embeddings in database: ${total_count}`);
// If no embeddings in database, fall back to simple query
if (total_count === 0) {
console.error(
'No embeddings found in database, using simple query',
);
const result = await db_client.execute({
sql: `
SELECT
t.episode_title,
t.segment_text,
t.start_time,
t.end_time,
0.95 AS similarity
FROM
transcripts t
LIMIT ?;
`,
args: [limit],
});
console.error(`Found ${result.rows.length} results`);
// Process and return the results
const search_results: SearchResult[] = [];
for (const row of result.rows) {
search_results.push({
episode_title: row.episode_title as string,
segment_text: row.segment_text as string,
start_time: row.start_time as number,
end_time: row.end_time as number,
similarity: row.similarity as number,
});
}
return search_results;
}
// First, check if we have a vector index
const index_check = await db_client.execute({
sql: `
SELECT name FROM sqlite_master
WHERE type='index' AND name='embeddings_vector_idx';
`,
args: [],
});
const has_vector_index = index_check.rows.length > 0;
console.error(`Vector index exists: ${has_vector_index}`);
let results;
if (has_vector_index) {
// Use vector index for efficient search
console.error('Using vector index for search');
results = await db_client.execute({
sql: `
WITH vector_search AS (
SELECT rowid, similarity
FROM vector_top_k('embeddings_vector_idx', vector32(?), ?)
)
SELECT
t.episode_title,
t.segment_text,
t.start_time,
t.end_time,
vs.similarity
FROM
vector_search vs
JOIN
embeddings e ON e.id = vs.rowid
JOIN
transcripts t ON e.transcript_id = t.id
WHERE
vs.similarity >= ?
ORDER BY
vs.similarity DESC;
`,
args: [
JSON.stringify(query_embedding),
limit * 2, // Get more results than needed to filter by min_score
min_score,
],
});
} else {
// Try different embedding formats since we don't know the exact format
console.error('No vector index found, trying direct vector comparison');
try {
// First try with json_extract for $.vector format
results = await db_client.execute({
sql: `
SELECT
t.episode_title,
t.segment_text,
t.start_time,
t.end_time,
(1 - vector_distance_cos(json_extract(e.embedding, '$.vector'), vector32(?))) AS similarity
FROM
embeddings e
JOIN
transcripts t ON e.transcript_id = t.id
WHERE
(1 - vector_distance_cos(json_extract(e.embedding, '$.vector'), vector32(?))) >= ?
ORDER BY
similarity DESC
LIMIT ?;
`,
args: [
JSON.stringify(query_embedding),
JSON.stringify(query_embedding),
min_score,
limit,
],
});
console.error(`Found ${results.rows.length} results with $.vector format`);
} catch (error) {
console.error('Error with $.vector format, trying direct embedding:', error);
// If that fails, try with direct embedding (assuming it's already a JSON array)
results = await db_client.execute({
sql: `
SELECT
t.episode_title,
t.segment_text,
t.start_time,
t.end_time,
(1 - vector_distance_cos(e.embedding, vector32(?))) AS similarity
FROM
embeddings e
JOIN
transcripts t ON e.transcript_id = t.id
WHERE
(1 - vector_distance_cos(e.embedding, vector32(?))) >= ?
ORDER BY
similarity DESC
LIMIT ?;
`,
args: [
JSON.stringify(query_embedding),
JSON.stringify(query_embedding),
min_score,
limit,
],
});
console.error(`Found ${results.rows.length} results with direct embedding format`);
}
}
console.error(`Found ${results.rows.length} results above threshold`);
// Process and return the results
const search_results: SearchResult[] = [];
for (const row of results.rows) {
search_results.push({
episode_title: row.episode_title as string,
segment_text: row.segment_text as string,
start_time: row.start_time as number,
end_time: row.end_time as number,
similarity: row.similarity as number,
});
}
return search_results;
} catch (error) {
console.error('Database query error:', error);
throw new McpError(
ErrorCode.InternalError,
`Database query failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// MCP Server implementation
class EmbeddingSearchServer {
private server: Server;
constructor() {
this.server = new Server(
{
name,
version,
},
{
capabilities: {
tools: {},
},
},
);
// Set up request handlers
this.setup_handlers();
// Error handling
this.server.onerror = (error) => {
console.error('MCP Server error:', error);
};
// Handle process termination
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private setup_handlers() {
// List available tools
this.server.setRequestHandler(
ListToolsRequestSchema,
async () => ({
tools: [
{
name: 'search_embeddings',
description:
'Search for relevant transcript segments using vector similarity',
inputSchema: {
type: 'object',
properties: {
question: {
type: 'string',
description: 'The query text to search for',
},
limit: {
type: 'number',
description:
'Number of results to return (default: 5)',
minimum: 1,
maximum: 50,
},
min_score: {
type: 'number',
description:
'Minimum similarity threshold (default: 0.5)',
minimum: 0,
maximum: 1,
},
},
required: ['question'],
},
},
],
}),
);
// Handle tool calls
this.server.setRequestHandler(
CallToolRequestSchema,
async (request) => {
if (request.params.name !== 'search_embeddings') {
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`,
);
}
const args = request.params.arguments;
// Validate arguments
if (
typeof args !== 'object' ||
args === null ||
typeof args.question !== 'string'
) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid parameters: question is required and must be a string',
);
}
// Extract and validate parameters
const params: SearchParams = {
question: args.question,
};
if (args.limit !== undefined) {
if (
typeof args.limit !== 'number' ||
args.limit < 1 ||
args.limit > 50
) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid limit parameter: must be a number between 1 and 50',
);
}
params.limit = args.limit;
}
if (args.min_score !== undefined) {
if (
typeof args.min_score !== 'number' ||
args.min_score < 0 ||
args.min_score > 1
) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid min_score parameter: must be a number between 0 and 1',
);
}
params.min_score = args.min_score;
}
try {
// Perform the search
const results = await search_embeddings(params);
// Handle empty results
if (results.length === 0) {
return {
content: [
{
type: 'text',
text: 'No matching transcript segments found.',
},
],
};
}
// Format the results
return {
content: [
{
type: 'text',
text: JSON.stringify(results, null, 2),
},
],
};
} catch (error) {
console.error('Error processing search request:', error);
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Error processing search request: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
},
);
}
async run() {
try {
// Test database connection
await db_client.execute({ sql: 'SELECT 1', args: [] });
console.error('Database connection successful');
// Start the server
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error(
`${name} v${version} MCP server running on stdio`,
);
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
}
// Start the server
const server = new EmbeddingSearchServer();
server.run().catch((error) => {
console.error('Server runtime error:', error);
process.exit(1);
});