Skip to main content
Glama

Personupplysning MCP Server

http-server.ts20 kB
#!/usr/bin/env node /** * Personupplysning MCP HTTP Server * * Express-based HTTP server with JSON-RPC 2.0 over HTTP POST * for Model Context Protocol (MCP) */ import express from 'express'; import cors from 'cors'; import { companyDataService } from './services/company-data-service.js'; import { logger, createLogger } from './utils/logger.js'; import { validateEnvironmentOrThrow } from './utils/validation.js'; import { toMCPError } from './utils/errors.js'; import { validateInput, SearchCompaniesInputSchema, GetCompanyDetailsInputSchema, GetCompanyDocumentsInputSchema, GetAnnualReportInputSchema, GetCacheStatsInputSchema, } from './utils/validators.js'; import 'dotenv/config'; // Server info const SERVER_NAME = 'personupplysning-mcp'; const SERVER_VERSION = '0.1.1'; const PORT = process.env.PORT || 3000; // Validate environment on startup validateEnvironmentOrThrow(); const app = express(); const serverLogger = createLogger(SERVER_NAME); // Middleware app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: false, })); app.use(express.json()); // Tools definition const TOOLS = [ { name: 'search_companies', description: 'Sök efter svenska företag i lokal databas (1.85M företag). Snabb sökning på företagsnamn eller organisationsnummer.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Sökterm: företagsnamn eller organisationsnummer (10 siffror)' }, limit: { type: 'number', description: 'Max antal resultat (default: 10)', default: 10 } }, required: ['query'] } }, { name: 'get_company_details', description: 'Hämta detaljerad företagsinformation från Bolagsverket API med cache-first strategi (30 dagars cache).', inputSchema: { type: 'object', properties: { organisationsidentitet: { type: 'string', description: 'Organisationsnummer (10 siffror)' } }, required: ['organisationsidentitet'] } }, { name: 'get_company_documents', description: 'Lista alla årsredovisningar och dokument för företag från Bolagsverket (7 dagars cache).', inputSchema: { type: 'object', properties: { organisationsidentitet: { type: 'string', description: 'Organisationsnummer (10 siffror)' } }, required: ['organisationsidentitet'] } }, { name: 'get_annual_report', description: 'Hämta och parsera årsredovisning för företag. Returnerar finansiell data extraherad från iXBRL-format.', inputSchema: { type: 'object', properties: { organisationsidentitet: { type: 'string', description: 'Organisationsnummer (10 siffror)' }, year: { type: 'number', description: 'År för årsredovisning (optional, senaste om ej angivet)' } }, required: ['organisationsidentitet'] } }, { name: 'get_cache_stats', description: 'Visa cache-statistik och API-användning för servern.', inputSchema: { type: 'object', properties: {} } } ]; // Resources definition const RESOURCES = [ { uri: 'company://search?q={query}&limit={limit}', name: 'Company Search Results', description: 'Search results from local database (1.85M companies)', mimeType: 'application/json', }, { uri: 'company://{organisationsidentitet}', name: 'Company Details', description: 'Detailed company information from Bolagsverket API with cache', mimeType: 'application/json', }, { uri: 'company://{organisationsidentitet}/documents', name: 'Company Documents List', description: 'All annual reports and documents for a company', mimeType: 'application/json', }, { uri: 'company://{organisationsidentitet}/report/{year}', name: 'Annual Report', description: 'Specific annual report with financial data (year optional)', mimeType: 'application/json', }, { uri: 'company://stats', name: 'Cache Statistics', description: 'Server cache statistics and API usage metrics', mimeType: 'application/json', }, ]; // Prompts definition const PROMPTS = [ { name: 'analyze_company_finances', description: 'Analyze the financial health of a Swedish company using available data', arguments: [ { name: 'organisationsidentitet', description: 'Company organization number (10 digits)', required: true, }, ], }, { name: 'compare_companies', description: 'Compare financial metrics between multiple Swedish companies', arguments: [ { name: 'company_ids', description: 'Comma-separated list of organization numbers', required: true, }, ], }, { name: 'industry_overview', description: 'Generate an overview of companies in a specific Swedish industry', arguments: [ { name: 'industry_query', description: 'Industry name or keyword (e.g., "tech", "retail")', required: true, }, { name: 'limit', description: 'Number of companies to analyze (default: 10)', required: false, }, ], }, ]; // GET /mcp - Server information endpoint app.get('/mcp', (req, res) => { res.json({ protocol: 'mcp', version: SERVER_VERSION, name: SERVER_NAME, description: 'Swedish company data via Bolagsverket API and Supabase cache (1.85M companies)', authentication: 'none', transport: 'http', capabilities: { tools: true, resources: true, prompts: true, }, connection: { method: 'POST', endpoint: '/mcp', content_type: 'application/json', format: 'MCP JSON-RPC 2.0', }, }); }); // Main MCP endpoint - handles JSON-RPC requests app.post('/mcp', async (req, res) => { try { const { jsonrpc, id, method, params } = req.body; // Validate JSON-RPC version if (jsonrpc !== '2.0') { return res.status(200).json({ jsonrpc: '2.0', id: id || null, error: { code: -32600, message: 'Invalid Request: jsonrpc must be "2.0"', }, }); } // Handle initialize method if (method === 'initialize') { return res.status(200).json({ jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {}, resources: {}, prompts: {}, }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION, }, }, }); } // Handle initialized notification (no response per JSON-RPC spec) if (method === 'notifications/initialized') { return res.status(204).end(); } // Handle tools/list if (method === 'tools/list') { return res.status(200).json({ jsonrpc: '2.0', id, result: { tools: TOOLS, }, }); } // Handle tools/call if (method === 'tools/call') { const { name, arguments: args } = params; try { let result; switch (name) { case 'search_companies': { const validated = validateInput(SearchCompaniesInputSchema, args); const searchResult = await companyDataService.searchCompanies( validated.query, validated.limit || 10 ); result = { content: [ { type: 'text', text: JSON.stringify(searchResult, null, 2), }, ], }; break; } case 'get_company_details': { const validated = validateInput(GetCompanyDetailsInputSchema, args); const details = await companyDataService.getCompanyDetails( validated.organisationsidentitet ); result = { content: [ { type: 'text', text: JSON.stringify(details, null, 2), }, ], }; break; } case 'get_company_documents': { const validated = validateInput(GetCompanyDocumentsInputSchema, args); const documents = await companyDataService.getDocumentList( validated.organisationsidentitet ); result = { content: [ { type: 'text', text: JSON.stringify(documents, null, 2), }, ], }; break; } case 'get_annual_report': { const validated = validateInput(GetAnnualReportInputSchema, args); const report = await companyDataService.getAnnualReport( validated.organisationsidentitet, validated.year ); result = { content: [ { type: 'text', text: JSON.stringify(report, null, 2), }, ], }; break; } case 'get_cache_stats': { validateInput(GetCacheStatsInputSchema, args); const stats = await companyDataService.getCacheStats(); result = { content: [ { type: 'text', text: JSON.stringify(stats, null, 2), }, ], }; break; } default: return res.status(200).json({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name}`, }, }); } return res.status(200).json({ jsonrpc: '2.0', id, result, }); } catch (error) { const mcpError = toMCPError(error); return res.status(200).json({ jsonrpc: '2.0', id, error: { code: mcpError.code, message: mcpError.message, }, }); } } // Handle resources/list if (method === 'resources/list') { return res.status(200).json({ jsonrpc: '2.0', id, result: { resources: RESOURCES, }, }); } // Handle resources/read if (method === 'resources/read') { const { uri } = params; try { const url = new URL(uri); const protocol = url.protocol.replace(':', ''); if (protocol !== 'company') { return res.status(200).json({ jsonrpc: '2.0', id, error: { code: -32602, message: `Unsupported protocol: ${protocol}`, }, }); } const path = url.pathname; const searchParams = url.searchParams; let content; // Handle different resource URIs if (path === '/search') { const query = searchParams.get('q'); const limit = parseInt(searchParams.get('limit') || '10', 10); if (!query) { throw new Error('Missing query parameter'); } content = await companyDataService.searchCompanies(query, limit); } else if (path === '/stats') { content = await companyDataService.getCacheStats(); } else if (path.match(/^\/\d{10}$/)) { const orgId = path.substring(1); content = await companyDataService.getCompanyDetails(orgId); } else if (path.match(/^\/\d{10}\/documents$/)) { const orgId = path.split('/')[1]; content = await companyDataService.getDocumentList(orgId); } else if (path.match(/^\/\d{10}\/report\/\d{4}$/)) { const parts = path.split('/'); const orgId = parts[1]; const year = parseInt(parts[3], 10); content = await companyDataService.getAnnualReport(orgId, year); } else if (path.match(/^\/\d{10}\/report$/)) { const orgId = path.split('/')[1]; content = await companyDataService.getAnnualReport(orgId); } else { return res.status(200).json({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid resource path: ${path}`, }, }); } return res.status(200).json({ jsonrpc: '2.0', id, result: { contents: [ { uri, mimeType: 'application/json', text: JSON.stringify(content, null, 2), }, ], }, }); } catch (error) { const mcpError = toMCPError(error); return res.status(200).json({ jsonrpc: '2.0', id, error: { code: mcpError.code, message: mcpError.message, }, }); } } // Handle prompts/list if (method === 'prompts/list') { return res.status(200).json({ jsonrpc: '2.0', id, result: { prompts: PROMPTS, }, }); } // Handle prompts/get if (method === 'prompts/get') { const { name, arguments: args } = params; const prompt = PROMPTS.find((p) => p.name === name); if (!prompt) { return res.status(200).json({ jsonrpc: '2.0', id, error: { code: -32602, message: `Prompt not found: ${name}`, }, }); } // Generate prompt messages based on the prompt name let messages; switch (name) { case 'analyze_company_finances': messages = [ { role: 'user', content: { type: 'text', text: `Analysera den finansiella hälsan för företaget med organisationsnummer ${args.organisationsidentitet}. Använd get_company_details och get_annual_report för att hämta data. Inkludera nyckeltal som omsättning, resultat, soliditet och kassaflöde.`, }, }, ]; break; case 'compare_companies': messages = [ { role: 'user', content: { type: 'text', text: `Jämför företagen med organisationsnummer ${args.company_ids}. Använd get_company_details och get_annual_report för varje företag. Jämför nyckeltal som omsättning, resultat, antal anställda och tillväxt.`, }, }, ]; break; case 'industry_overview': messages = [ { role: 'user', content: { type: 'text', text: `Skapa en branschöversikt för "${args.industry_query}". Använd search_companies för att hitta ${args.limit || 10} relevanta företag, sedan get_company_details för att analysera varje företag. Sammanfatta branschens tillstånd och identifiera ledande aktörer.`, }, }, ]; break; default: return res.status(200).json({ jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown prompt: ${name}`, }, }); } return res.status(200).json({ jsonrpc: '2.0', id, result: { messages, }, }); } // Method not found (HTTP 200 per JSON-RPC 2.0 spec) return res.status(200).json({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}`, }, }); } catch (error) { serverLogger.error({ error }, 'Error handling request'); // JSON-RPC errors use HTTP 200 (application-level error) return res.status(200).json({ jsonrpc: '2.0', id: req.body?.id || null, error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error', }, }); } }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Root endpoint - simple info page app.get('/', (req, res) => { res.send(` <!DOCTYPE html> <html lang="sv"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Personupplysning MCP Server</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; line-height: 1.6; color: #24292e; background: #f6f8fa; padding: 20px; } .container { max-width: 800px; margin: 0 auto; background: white; padding: 40px; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); } h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; margin-bottom: 16px; } h2 { font-size: 1.5em; margin-top: 24px; margin-bottom: 16px; } p { margin-bottom: 16px; } code { background: #f6f8fa; padding: 0.2em 0.4em; border-radius: 3px; font-family: 'Consolas', monospace; } .badge { display: inline-block; padding: 4px 8px; background: #0366d6; color: white; border-radius: 3px; font-size: 12px; margin-right: 8px; } .links { margin: 24px 0; padding: 12px; background: #f1f8ff; border: 1px solid #c8e1ff; border-radius: 6px; } .links a { color: #0366d6; text-decoration: none; margin-right: 16px; } .links a:hover { text-decoration: underline; } </style> </head> <body> <div class="container"> <h1>Personupplysning MCP Server</h1> <div class="links"> <span class="badge">v${SERVER_VERSION}</span> <a href="/mcp">API Endpoint</a> <a href="/health">Health Check</a> </div> <h2>Overview</h2> <p> Model Context Protocol (MCP) server providing access to Swedish company data via Bolagsverket API and Supabase cache with 1.85M companies. </p> <h2>Features</h2> <ul> <li>Search 1.85M Swedish companies by name or organization number</li> <li>Fetch detailed company information from Bolagsverket API</li> <li>Access annual reports and financial data in iXBRL format</li> <li>Intelligent cache-first strategy (30-day cache for details)</li> <li>Full MCP protocol support: Tools, Resources, and Prompts</li> </ul> <h2>Connection</h2> <p> <strong>Endpoint:</strong> <code>POST /mcp</code><br> <strong>Protocol:</strong> MCP JSON-RPC 2.0<br> <strong>Transport:</strong> HTTP POST </p> <h2>Tools</h2> <ul> <li><code>search_companies</code> - Search local database</li> <li><code>get_company_details</code> - Fetch from Bolagsverket API</li> <li><code>get_company_documents</code> - List annual reports</li> <li><code>get_annual_report</code> - Parse financial data</li> <li><code>get_cache_stats</code> - Cache statistics</li> </ul> </div> </body> </html> `); }); // Start server app.listen(PORT, () => { serverLogger.info(`${SERVER_NAME} v${SERVER_VERSION} running on port ${PORT}`); serverLogger.info(`Info endpoint: http://localhost:${PORT}/mcp`); serverLogger.info(`Health check: http://localhost:${PORT}/health`); });

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/isakskogstad/personupplysning-mcp'

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