Skip to main content
Glama
jsonrpc.ts16.6 kB
// ============================================================================= // kivv - MCP Server: JSON-RPC Protocol Handler // ============================================================================= // Implements MCP JSON-RPC protocol for standard MCP clients (mcp-remote, etc.) // Supports: initialize, tools/list, tools/call methods // ============================================================================= import { Context } from 'hono'; import { Env, User, JsonRpcRequest, JsonRpcSuccessResponse, JsonRpcErrorResponse, JsonRpcErrorCodes, McpInitializeResult, McpToolsListResult, McpToolDefinition, McpToolsCallParams, McpToolsCallResult, ListLibraryRequest, } from '../../shared/types'; import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, } from '../../shared/constants'; // ============================================================================= // MCP Protocol Constants // ============================================================================= const MCP_PROTOCOL_VERSION = '2024-11-05'; const SERVER_NAME = 'kivv-mcp'; const SERVER_VERSION = '1.0.0'; // ============================================================================= // Tool Definitions // ============================================================================= const TOOL_DEFINITIONS: McpToolDefinition[] = [ { name: 'list_library', description: 'Query user\'s paper library with optional filters and pagination. Returns papers from your arXiv research library.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: `Maximum number of papers to return (default: ${DEFAULT_PAGE_LIMIT}, max: ${MAX_PAGE_LIMIT})`, }, offset: { type: 'number', description: 'Number of papers to skip for pagination (default: 0)', }, explored: { type: 'boolean', description: 'Filter by exploration status. true = only explored, false = only unexplored, omit = all papers', }, bookmarked: { type: 'boolean', description: 'Filter by bookmark status. true = only bookmarked, false = only unbookmarked, omit = all papers', }, }, required: [], }, }, { name: 'search_papers', description: 'Search papers in your library by keywords. Searches in title and abstract fields.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search keywords to find in paper titles and abstracts', }, limit: { type: 'number', description: `Maximum number of papers to return (default: ${DEFAULT_PAGE_LIMIT}, max: ${MAX_PAGE_LIMIT})`, }, offset: { type: 'number', description: 'Number of papers to skip for pagination (default: 0)', }, explored: { type: 'boolean', description: 'Filter by exploration status', }, bookmarked: { type: 'boolean', description: 'Filter by bookmark status', }, }, required: ['query'], }, }, { name: 'mark_explored', description: 'Update a paper\'s status. Mark as explored/unexplored, bookmark/unbookmark, or add notes.', inputSchema: { type: 'object', properties: { paper_id: { type: 'number', description: 'The ID of the paper to update', }, explored: { type: 'boolean', description: 'Mark paper as explored (true) or unexplored (false)', }, bookmarked: { type: 'boolean', description: 'Mark paper as bookmarked (true) or unbookmarked (false)', }, notes: { type: 'string', description: 'Add or update notes for this paper. Set to null to clear notes.', }, }, required: ['paper_id'], }, }, ]; // ============================================================================= // JSON-RPC Response Helpers // ============================================================================= function createSuccessResponse( result: unknown, id: string | number | null ): JsonRpcSuccessResponse { return { jsonrpc: '2.0', result, id, }; } function createErrorResponse( code: number, message: string, id: string | number | null, data?: unknown ): JsonRpcErrorResponse { return { jsonrpc: '2.0', error: { code, message, data }, id, }; } // ============================================================================= // MCP Method Handlers // ============================================================================= function handleInitialize(): McpInitializeResult { return { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: {}, }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION, }, }; } function handleToolsList(): McpToolsListResult { return { tools: TOOL_DEFINITIONS, }; } async function handleToolsCall( params: McpToolsCallParams, user: User, env: Env ): Promise<McpToolsCallResult> { const { name, arguments: args = {} } = params; switch (name) { case 'list_library': return await executeListLibrary(args as ListLibraryRequest, user, env); case 'search_papers': return await executeSearchPapers(args as { query: string; limit?: number; offset?: number; explored?: boolean; bookmarked?: boolean }, user, env); case 'mark_explored': return await executeMarkExplored(args as { paper_id: number; explored?: boolean; bookmarked?: boolean; notes?: string | null }, user, env); default: return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true, }; } } // ============================================================================= // Tool Execution (Reuses existing logic) // ============================================================================= async function executeListLibrary( args: ListLibraryRequest, user: User, env: Env ): Promise<McpToolsCallResult> { const limit = Math.min(args.limit ?? DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); const offset = args.offset ?? 0; // Build WHERE clause with user isolation const filters: string[] = ['ups.user_id = ?']; const bindings: (number | boolean)[] = [user.id]; if (args.explored !== undefined && args.explored !== null) { filters.push('ups.explored = ?'); bindings.push(args.explored ? 1 : 0); } if (args.bookmarked !== undefined && args.bookmarked !== null) { filters.push('ups.bookmarked = ?'); bindings.push(args.bookmarked ? 1 : 0); } const whereClause = filters.join(' AND '); const query = ` SELECT p.id, p.arxiv_id, p.title, p.authors, p.abstract, p.categories, p.published_date, p.pdf_url, p.summary, ups.explored, ups.bookmarked, ups.notes FROM papers p INNER JOIN user_paper_status ups ON p.id = ups.paper_id WHERE ${whereClause} ORDER BY p.published_date DESC LIMIT ? OFFSET ? `; try { const queryBindings = [...bindings, limit, offset]; const result = await env.DB.prepare(query).bind(...queryBindings).all(); const countQuery = ` SELECT COUNT(*) as total FROM papers p INNER JOIN user_paper_status ups ON p.id = ups.paper_id WHERE ${whereClause} `; const countResult = await env.DB.prepare(countQuery).bind(...bindings).first<{ total: number }>(); const total = countResult?.total ?? 0; const papers = result.results.map((paper: any) => ({ ...paper, explored: Boolean(paper.explored), bookmarked: Boolean(paper.bookmarked), })); const responseText = JSON.stringify({ papers, total, limit, offset, }, null, 2); return { content: [{ type: 'text', text: responseText }], }; } catch (error) { console.error('[jsonrpc:list_library] Database error:', error); return { content: [{ type: 'text', text: 'Failed to query library' }], isError: true, }; } } async function executeSearchPapers( args: { query: string; limit?: number; offset?: number; explored?: boolean; bookmarked?: boolean }, user: User, env: Env ): Promise<McpToolsCallResult> { if (!args.query || typeof args.query !== 'string' || args.query.trim() === '') { return { content: [{ type: 'text', text: 'Query parameter is required and must be a non-empty string' }], isError: true, }; } const limit = Math.min(args.limit ?? DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); const offset = args.offset ?? 0; const trimmedQuery = args.query.trim(); // Build WHERE clause const filters: string[] = ['ups.user_id = ?']; const bindings: (number | string)[] = [user.id]; // Add search condition const escapedQuery = trimmedQuery.toLowerCase().replace(/%/g, ' ').replace(/_/g, ' '); const searchPattern = `%${escapedQuery}%`; filters.push('(LOWER(p.title) LIKE ? OR LOWER(p.abstract) LIKE ?)'); bindings.push(searchPattern, searchPattern); if (args.explored !== undefined && args.explored !== null) { filters.push('ups.explored = ?'); bindings.push(args.explored ? 1 : 0); } if (args.bookmarked !== undefined && args.bookmarked !== null) { filters.push('ups.bookmarked = ?'); bindings.push(args.bookmarked ? 1 : 0); } const whereClause = filters.join(' AND '); const query = ` SELECT p.id, p.arxiv_id, p.title, p.authors, p.abstract, p.categories, p.published_date, p.pdf_url, p.summary, ups.explored, ups.bookmarked, ups.notes FROM papers p INNER JOIN user_paper_status ups ON p.id = ups.paper_id WHERE ${whereClause} ORDER BY p.published_date DESC LIMIT ? OFFSET ? `; try { const queryBindings = [...bindings, limit, offset]; const result = await env.DB.prepare(query).bind(...queryBindings).all(); const countQuery = ` SELECT COUNT(*) as total FROM papers p INNER JOIN user_paper_status ups ON p.id = ups.paper_id WHERE ${whereClause} `; const countResult = await env.DB.prepare(countQuery).bind(...bindings).first<{ total: number }>(); const total = countResult?.total ?? 0; const papers = result.results.map((paper: any) => ({ ...paper, explored: Boolean(paper.explored), bookmarked: Boolean(paper.bookmarked), })); const responseText = JSON.stringify({ papers, total, limit, offset, query: trimmedQuery, }, null, 2); return { content: [{ type: 'text', text: responseText }], }; } catch (error) { console.error('[jsonrpc:search_papers] Database error:', error); return { content: [{ type: 'text', text: 'Failed to search papers' }], isError: true, }; } } async function executeMarkExplored( args: { paper_id: number; explored?: boolean; bookmarked?: boolean; notes?: string | null }, user: User, env: Env ): Promise<McpToolsCallResult> { if (args.paper_id === undefined || args.paper_id === null) { return { content: [{ type: 'text', text: 'paper_id is required' }], isError: true, }; } if (typeof args.paper_id !== 'number' || !Number.isInteger(args.paper_id) || args.paper_id < 1) { return { content: [{ type: 'text', text: 'paper_id must be a positive integer' }], isError: true, }; } try { // Verify paper exists const paper = await env.DB .prepare('SELECT id FROM papers WHERE id = ?') .bind(args.paper_id) .first(); if (!paper) { return { content: [{ type: 'text', text: `Paper with id ${args.paper_id} not found` }], isError: true, }; } const now = new Date().toISOString(); // Check if user_paper_status record exists const existingStatus = await env.DB .prepare('SELECT * FROM user_paper_status WHERE user_id = ? AND paper_id = ?') .bind(user.id, args.paper_id) .first(); if (existingStatus) { // UPDATE existing record const updates: string[] = ['read_at = ?']; const bindings: (string | number | null)[] = [now]; if (args.explored !== undefined) { updates.push('explored = ?'); bindings.push(args.explored ? 1 : 0); } if (args.bookmarked !== undefined) { updates.push('bookmarked = ?'); bindings.push(args.bookmarked ? 1 : 0); } if (args.notes !== undefined) { updates.push('notes = ?'); bindings.push(args.notes === null ? null : args.notes); } bindings.push(user.id, args.paper_id); await env.DB .prepare(`UPDATE user_paper_status SET ${updates.join(', ')} WHERE user_id = ? AND paper_id = ?`) .bind(...bindings) .run(); } else { // INSERT new record await env.DB .prepare(` INSERT INTO user_paper_status (user_id, paper_id, explored, bookmarked, notes, read_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `) .bind( user.id, args.paper_id, args.explored !== undefined ? (args.explored ? 1 : 0) : 0, args.bookmarked !== undefined ? (args.bookmarked ? 1 : 0) : 0, args.notes !== undefined ? args.notes : null, now, now ) .run(); } // Fetch updated status const updatedStatus = await env.DB .prepare('SELECT * FROM user_paper_status WHERE user_id = ? AND paper_id = ?') .bind(user.id, args.paper_id) .first<{ explored: number; bookmarked: number; notes: string | null; read_at: string | null; }>(); const responseText = JSON.stringify({ success: true, paper_id: args.paper_id, status: { explored: Boolean(updatedStatus?.explored), bookmarked: Boolean(updatedStatus?.bookmarked), notes: updatedStatus?.notes ?? null, read_at: updatedStatus?.read_at ?? null, }, }, null, 2); return { content: [{ type: 'text', text: responseText }], }; } catch (error) { console.error('[jsonrpc:mark_explored] Database error:', error); return { content: [{ type: 'text', text: 'Failed to update paper status' }], isError: true, }; } } // ============================================================================= // Main JSON-RPC Handler // ============================================================================= export async function handleJsonRpc(c: Context) { const user = c.get('user') as User; const env = c.env as Env; // Parse JSON-RPC request let request: JsonRpcRequest; try { request = await c.req.json<JsonRpcRequest>(); } catch (error) { return c.json( createErrorResponse(JsonRpcErrorCodes.PARSE_ERROR, 'Parse error', null), 200 ); } // Validate JSON-RPC request structure if (request.jsonrpc !== '2.0' || typeof request.method !== 'string') { return c.json( createErrorResponse(JsonRpcErrorCodes.INVALID_REQUEST, 'Invalid Request', request.id ?? null), 200 ); } const { method, params, id } = request; // Handle MCP methods try { switch (method) { case 'initialize': return c.json(createSuccessResponse(handleInitialize(), id), 200); case 'tools/list': return c.json(createSuccessResponse(handleToolsList(), id), 200); case 'tools/call': { if (!params || typeof params.name !== 'string') { return c.json( createErrorResponse(JsonRpcErrorCodes.INVALID_PARAMS, 'Invalid params: name is required', id), 200 ); } const result = await handleToolsCall(params as McpToolsCallParams, user, env); return c.json(createSuccessResponse(result, id), 200); } case 'notifications/initialized': // Client notification that initialization is complete - no response needed return c.json(createSuccessResponse({}, id), 200); default: return c.json( createErrorResponse(JsonRpcErrorCodes.METHOD_NOT_FOUND, `Method not found: ${method}`, id), 200 ); } } catch (error) { console.error('[jsonrpc] Error handling method:', method, error); return c.json( createErrorResponse(JsonRpcErrorCodes.INTERNAL_ERROR, 'Internal error', id), 200 ); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jeffaf/kivv'

If you have feedback or need assistance with the MCP directory API, please join our Discord server