diff --git a/packages/app/src/plugins/mcp-server.ts b/packages/app/src/plugins/mcp-server.ts new file mode 100644 index 00000000..6b58f791 --- /dev/null +++ b/packages/app/src/plugins/mcp-server.ts @@ -0,0 +1,183 @@ +/** + * MCP (Model Context Protocol) server plugin. + * Exposes AIUI capabilities as MCP tools that Claude can call via tool_use. + */ +import { mockFilms } from '@/mocks/films' +import { mockSongs } from '@/mocks/songs' +import { mockPodcasts } from '@/mocks/podcasts' + +export interface MCPTool { + name: string + description: string + input_schema: { + type: 'object' + properties: Record + required?: string[] + } +} + +export interface MCPToolCall { + id: string + name: string + input: Record +} + +export interface MCPToolResult { + type: 'tool_result' + tool_use_id: string + content: string +} + +/** Available MCP tools exposed by AIUI */ +export const mcpTools: MCPTool[] = [ + { + name: 'search_films', + description: 'Search the user\'s film library by title, director, or genre', + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query (title, director, or genre)' }, + }, + required: ['query'], + }, + }, + { + name: 'search_songs', + description: 'Search the user\'s song library by title, artist, or genre', + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query (title, artist, or genre)' }, + }, + required: ['query'], + }, + }, + { + name: 'search_podcasts', + description: 'Search the user\'s podcast library by title, host, or genre', + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query (title, host, or genre)' }, + }, + required: ['query'], + }, + }, + { + name: 'get_library_stats', + description: 'Get statistics about the user\'s content library', + input_schema: { + type: 'object', + properties: {}, + }, + }, +] + +/** Route a tool call to the appropriate handler and return the result */ +export function handleToolCall(call: MCPToolCall): MCPToolResult { + const handler = toolHandlers[call.name] + if (!handler) { + return { + type: 'tool_result', + tool_use_id: call.id, + content: JSON.stringify({ error: `Unknown tool: ${call.name}` }), + } + } + + const result = handler(call.input) + return { + type: 'tool_result', + tool_use_id: call.id, + content: typeof result === 'string' ? result : JSON.stringify(result), + } +} + +type ToolHandler = (input: Record) => unknown + +const toolHandlers: Record = { + search_films(input) { + const query = (input.query as string ?? '').toLowerCase() + const results = mockFilms.filter(f => + f.title.toLowerCase().includes(query) || + f.director.toLowerCase().includes(query) || + f.genres.some(g => g.toLowerCase().includes(query)), + ) + return { + count: results.length, + films: results.slice(0, 10).map(f => ({ + id: f.id, + title: f.title, + year: f.year, + director: f.director, + genres: f.genres, + rating: f.rating, + })), + } + }, + + search_songs(input) { + const query = (input.query as string ?? '').toLowerCase() + const results = mockSongs.filter(s => + s.title.toLowerCase().includes(query) || + s.artist.toLowerCase().includes(query) || + (s.genres ?? []).some(g => g.toLowerCase().includes(query)), + ) + return { + count: results.length, + songs: results.slice(0, 10).map(s => ({ + id: s.id, + title: s.title, + artist: s.artist, + album: s.album, + year: s.year, + genres: s.genres, + })), + } + }, + + search_podcasts(input) { + const query = (input.query as string ?? '').toLowerCase() + const results = mockPodcasts.filter(p => + p.title.toLowerCase().includes(query) || + (p.host ?? '').toLowerCase().includes(query) || + (p.genres ?? []).some(g => g.toLowerCase().includes(query)), + ) + return { + count: results.length, + podcasts: results.slice(0, 10).map(p => ({ + id: p.id, + title: p.title, + host: p.host, + year: p.year, + genres: p.genres, + })), + } + }, + + get_library_stats() { + return { + films: mockFilms.length, + songs: mockSongs.length, + podcasts: mockPodcasts.length, + genres: { + film: [...new Set(mockFilms.flatMap(f => f.genres))].sort(), + song: [...new Set(mockSongs.flatMap(s => s.genres ?? []))].sort(), + podcast: [...new Set(mockPodcasts.flatMap(p => p.genres ?? []))].sort(), + }, + } + }, +} + +/** Parse tool_use blocks from a Claude SSE response and check if tool calls are present */ +export function parseToolUseBlocks(content: unknown[]): MCPToolCall[] { + if (!Array.isArray(content)) return [] + return content + .filter((block): block is { type: 'tool_use'; id: string; name: string; input: Record } => + typeof block === 'object' && block !== null && (block as { type: string }).type === 'tool_use', + ) + .map(block => ({ + id: block.id, + name: block.name, + input: block.input, + })) +}