UNPKG

shared-supabase-mcp-minimal

Version:

XBRL Financial Data MCP Server for Supabase with Claude Desktop compatibility

418 lines (364 loc) 12.8 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import fetch from 'node-fetch'; // Configuration from environment variables // IMPORTANT: Use anon endpoint to bypass auth layer const XBRL_API_URL = process.env.XBRL_API_URL || 'https://wpwqxhyiglbtlaimrjrx.supabase.co/functions/v1/xbrl-gateway'; const XBRL_API_KEY = process.env.XBRL_API_KEY; // JWT TOKEN は不要になりました(Edge Functionで直接DBアクセス) // const XBRL_JWT_TOKEN = process.env.XBRL_JWT_TOKEN; // Validate required environment variables if (!XBRL_API_KEY) { console.error('ERROR: XBRL_API_KEY environment variable is required'); process.exit(1); } // Helper function to make API calls async function apiCall(endpoint, params = {}) { const url = new URL(`${XBRL_API_URL}${endpoint}`); // Add query parameters Object.keys(params).forEach(key => { if (params[key] !== undefined && params[key] !== null) { url.searchParams.append(key, params[key]); } }); // Debug logging for URL construction console.error(`[DEBUG] apiCall URL: ${url.toString()}`); // Build headers with API key const headers = { 'Content-Type': 'application/json', 'X-API-Key': XBRL_API_KEY }; // Debug: log headers being sent console.error(`[DEBUG] Request headers:`, JSON.stringify(headers)); const response = await fetch(url, { method: 'POST', headers: headers }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API call failed: ${response.status} ${response.statusText}. ${errorText}`); } return response.json(); } // Helper to get Markdown content from storage async function getMarkdownContent(storagePath) { if (!storagePath) { throw new Error('Storage path is required'); } // Call storage endpoint to get the actual Markdown content const response = await apiCall('/storage/read', { path: storagePath }); return response.content || response.data || ''; } class XBRLFinancialMCPServer { constructor() { this.server = new Server( { name: 'xbrl-financial-minimal', version: '8.3.0', }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); } setupToolHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'search_company', description: 'Search for a company by Japanese or English name. Returns metadata only (max 5 results).', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Company name to search (Japanese or English)' }, fiscal_year: { type: 'string', description: 'Optional: Filter by fiscal year (e.g., FY2024)', pattern: '^FY\\d{4}$' } }, required: ['query'] } }, { name: 'get_markdown_toc', description: 'Get table of contents (headings) from a financial document. Use this first to understand document structure.', inputSchema: { type: 'object', properties: { storage_path: { type: 'string', description: 'The storage path from search results' } }, required: ['storage_path'] } }, { name: 'get_markdown_chunk', description: 'Get a chunk of Markdown content (20KB per call). Use after reviewing TOC to read specific sections.', inputSchema: { type: 'object', properties: { storage_path: { type: 'string', description: 'The storage path from search results' }, offset: { type: 'number', description: 'Byte offset to start reading from (default: 0)', default: 0 }, length: { type: 'number', description: 'Number of characters to read (default: 20000, max: 30000)', default: 20000 } }, required: ['storage_path'] } }, { name: 'get_markdown', description: '[DEPRECATED] Use get_markdown_toc and get_markdown_chunk instead for better performance.', inputSchema: { type: 'object', properties: { storage_path: { type: 'string', description: 'The storage path from search results' } }, required: ['storage_path'] } } ] }; }); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch(request.params.name) { case 'search_company': return await this.searchCompany(request.params.arguments); case 'get_markdown_toc': return await this.getMarkdownTOC(request.params.arguments); case 'get_markdown_chunk': return await this.getMarkdownChunk(request.params.arguments); case 'get_markdown': return await this.getMarkdown(request.params.arguments); default: throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { console.error(`Error executing tool ${request.params.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error.message}` } ] }; } }); } async searchCompany(args = {}) { const { query, fiscal_year } = args; if (!query) { throw new Error('Query parameter is required'); } // Debug logging for Japanese search console.error(`[DEBUG] searchCompany called with query: "${query}"`); console.error(`[DEBUG] Query bytes:`, Buffer.from(query, 'utf8')); console.error(`[DEBUG] Query length: ${query.length}`); try { // Search in metadata table (limit to 5 for performance) const params = { search: query, limit: 5 }; if (fiscal_year) { params.fiscal_year = fiscal_year; } const response = await apiCall('/markdown-files', params); // Extract files from response let files = []; if (Array.isArray(response)) { files = response; } else if (response.results) { files = response.results; // New RPC format } else if (response.files) { files = response.files; } else if (response.data) { files = response.data; } // Format results to show only essential information const results = files.map(file => ({ company_id: file.company_id, company_name: file.company_name, english_name: file.english_name || file.company_name_en, fiscal_year: file.fiscal_year, file_type: file.file_type, storage_path: file.storage_path })); return { content: [ { type: 'text', text: JSON.stringify({ success: true, count: results.length, results: results, message: results.length > 0 ? `Found ${results.length} documents. Use get_markdown_toc first to see document structure, then get_markdown_chunk to read specific sections.` : 'No documents found for the search criteria.' }, null, 2) } ] }; } catch (error) { throw new Error(`Search failed: ${error.message}`); } } async getMarkdownTOC(args = {}) { const { storage_path } = args; if (!storage_path) { throw new Error('storage_path parameter is required'); } try { const url = `${XBRL_API_URL}/markdown-files/toc`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': XBRL_API_KEY }, body: JSON.stringify({ storage_path }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`TOC fetch failed: ${response.status} ${errorText}`); } const data = await response.json(); return { content: [ { type: 'text', text: JSON.stringify({ success: true, storage_path: data.storage_path, total_size: data.total_size, sections: data.sections, message: data.message }, null, 2) } ] }; } catch (error) { throw new Error(`Failed to get TOC: ${error.message}`); } } async getMarkdownChunk(args = {}) { const { storage_path, offset = 0, length = 20000 } = args; if (!storage_path) { throw new Error('storage_path parameter is required'); } try { const url = `${XBRL_API_URL}/markdown-files/chunk`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': XBRL_API_KEY }, body: JSON.stringify({ storage_path, offset, length }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Chunk fetch failed: ${response.status} ${errorText}`); } const data = await response.json(); return { content: [ { type: 'text', text: JSON.stringify({ success: true, storage_path: data.storage_path, chunk: data.chunk, file_info: data.file_info, message: data.chunk.has_more ? `Read ${data.chunk.length} chars. Use offset=${data.chunk.next_offset} to continue.` : 'End of file reached.' }, null, 2) } ] }; } catch (error) { throw new Error(`Failed to get chunk: ${error.message}`); } } async getMarkdown(args = {}) { const { storage_path } = args; if (!storage_path) { throw new Error('storage_path parameter is required'); } try { // Deprecated: direct storage access console.error('[WARN] get_markdown is deprecated. Use get_markdown_toc and get_markdown_chunk for better performance.'); // Supabase Storageから直接読み込み // storage_path形式: "markdown/7203/FY2024/toyota_fy2024_yuho.md" const storageUrl = 'https://wpwqxhyiglbtlaimrjrx.supabase.co/storage/v1/object/public/'; const fullUrl = `${storageUrl}${storage_path}`; const response = await fetch(fullUrl, { method: 'GET', headers: { 'Accept': 'text/plain, text/markdown' } }); if (!response.ok) { // 404の場合は、ファイルが見つからない if (response.status === 404) { throw new Error(`File not found: ${storage_path}`); } throw new Error(`Storage fetch failed: ${response.status} ${response.statusText}`); } const content = await response.text(); return { content: [ { type: 'text', text: content } ] }; } catch (error) { throw new Error(`Failed to get Markdown content: ${error.message}`); } } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error(`XBRL Financial MCP Server (Minimal) v8.3.0 running - Chunk Pagination版`); console.error(`Configured for: ${XBRL_API_URL}`); } } // Start the server const server = new XBRLFinancialMCPServer(); server.run().catch(console.error);