shared-supabase-mcp-minimal
Version:
XBRL Financial Data MCP Server for Supabase with Claude Desktop compatibility
418 lines (364 loc) • 12.8 kB
JavaScript
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);