@lexapi/mcp
Advanced tools
| #!/usr/bin/env node | ||
| export {}; |
| #!/usr/bin/env node | ||
| import { createHttpServer } from './http-server.js'; | ||
| const portEnv = process.env.PORT ?? '3000'; | ||
| const port = Number.parseInt(portEnv, 10); | ||
| if (!Number.isFinite(port) || port < 1 || port > 65535) { | ||
| console.error(`[lexapi-mcp] invalid PORT: ${portEnv}`); | ||
| process.exit(1); | ||
| } | ||
| const server = createHttpServer(); | ||
| server.listen(port, () => { | ||
| console.error(`[lexapi-mcp] HTTP transport listening on :${port} (POST /v1, GET /health)`); | ||
| }); | ||
| for (const signal of ['SIGINT', 'SIGTERM']) { | ||
| process.on(signal, () => { | ||
| console.error(`[lexapi-mcp] ${signal} received, shutting down`); | ||
| server.close(() => process.exit(0)); | ||
| setTimeout(() => process.exit(1), 10_000).unref(); | ||
| }); | ||
| } |
| import { type Server as HttpServer } from 'node:http'; | ||
| export declare function createHttpServer(): HttpServer; |
| import { createServer } from 'node:http'; | ||
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | ||
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; | ||
| import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { LexAPIClient, LexAPIError } from './client.js'; | ||
| import { tools, toolsByName } from './tools.js'; | ||
| import { PACKAGE_VERSION } from './version.js'; | ||
| const PACKAGE_NAME = 'lexapi-mcp'; | ||
| const MCP_PATH = '/v1'; | ||
| function extractBearer(req) { | ||
| const header = req.headers['authorization']; | ||
| if (!header || typeof header !== 'string') | ||
| return null; | ||
| const match = header.match(/^Bearer\s+(.+)$/i); | ||
| return match ? match[1].trim() : null; | ||
| } | ||
| async function readJsonBody(req) { | ||
| const chunks = []; | ||
| for await (const chunk of req) { | ||
| chunks.push(chunk); | ||
| // 1 MB request-body cap — MCP JSON-RPC messages are tiny; anything larger is abuse. | ||
| if (chunks.reduce((n, c) => n + c.length, 0) > 1_000_000) { | ||
| throw new Error('request body too large'); | ||
| } | ||
| } | ||
| const text = Buffer.concat(chunks).toString('utf8'); | ||
| if (!text) | ||
| return undefined; | ||
| return JSON.parse(text); | ||
| } | ||
| function buildMcpServer(apiKey) { | ||
| const baseUrl = process.env.LEXAPI_BASE_URL; | ||
| const client = new LexAPIClient({ | ||
| apiKey, | ||
| ...(baseUrl ? { baseUrl } : {}), | ||
| }); | ||
| const server = new Server({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, { capabilities: { tools: {} } }); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: tools.map(({ name, description, inputSchema, annotations }) => ({ | ||
| name, | ||
| description, | ||
| inputSchema, | ||
| annotations, | ||
| })), | ||
| })); | ||
| server.setRequestHandler(CallToolRequestSchema, async (request) => { | ||
| const { name, arguments: args = {} } = request.params; | ||
| const tool = toolsByName[name]; | ||
| if (!tool) { | ||
| return { | ||
| isError: true, | ||
| content: [{ type: 'text', text: `Unknown tool: ${name}` }], | ||
| }; | ||
| } | ||
| try { | ||
| const result = await tool.handler(args, client); | ||
| return { | ||
| content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], | ||
| }; | ||
| } | ||
| catch (err) { | ||
| if (err instanceof LexAPIError) { | ||
| return { | ||
| isError: true, | ||
| content: [ | ||
| { | ||
| type: 'text', | ||
| text: `LexAPI error (${err.status} ${err.slug}): ${err.message}`, | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| return { | ||
| isError: true, | ||
| content: [{ type: 'text', text: `Tool execution failed: ${message}` }], | ||
| }; | ||
| } | ||
| }); | ||
| return server; | ||
| } | ||
| function writeJson(res, status, body) { | ||
| res.writeHead(status, { | ||
| 'Content-Type': 'application/json', | ||
| 'Cache-Control': 'no-store, private', | ||
| }); | ||
| res.end(JSON.stringify(body)); | ||
| } | ||
| async function handleMcp(req, res) { | ||
| const apiKey = extractBearer(req); | ||
| if (!apiKey) { | ||
| writeJson(res, 401, { | ||
| error: { | ||
| code: 'unauthorized', | ||
| message: 'Missing or invalid Authorization header. Send: Authorization: Bearer <your LexAPI key>. Get a key at https://lex-api.com/dashboard.', | ||
| }, | ||
| }); | ||
| return; | ||
| } | ||
| let body; | ||
| try { | ||
| body = await readJsonBody(req); | ||
| } | ||
| catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| writeJson(res, 400, { error: { code: 'bad_request', message } }); | ||
| return; | ||
| } | ||
| // Defense-in-depth headers — Cloudflare + intermediate proxies should not | ||
| // buffer streamed responses, and per-user MCP responses must never be cached. | ||
| res.setHeader('X-Accel-Buffering', 'no'); | ||
| res.setHeader('Cache-Control', 'no-store, private'); | ||
| const mcpServer = buildMcpServer(apiKey); | ||
| const transport = new StreamableHTTPServerTransport({ | ||
| sessionIdGenerator: undefined, | ||
| }); | ||
| res.on('close', () => { | ||
| transport.close().catch(() => { }); | ||
| mcpServer.close().catch(() => { }); | ||
| }); | ||
| await mcpServer.connect(transport); | ||
| await transport.handleRequest(req, res, body); | ||
| } | ||
| export function createHttpServer() { | ||
| return createServer(async (req, res) => { | ||
| try { | ||
| // CORS preflight — MCP clients called from browsers need this. | ||
| if (req.method === 'OPTIONS') { | ||
| res.writeHead(204, { | ||
| 'Access-Control-Allow-Origin': '*', | ||
| 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', | ||
| 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version', | ||
| 'Access-Control-Max-Age': '86400', | ||
| }); | ||
| res.end(); | ||
| return; | ||
| } | ||
| res.setHeader('Access-Control-Allow-Origin', '*'); | ||
| res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id'); | ||
| if (req.method === 'GET' && req.url === '/health') { | ||
| writeJson(res, 200, { | ||
| status: 'ok', | ||
| name: PACKAGE_NAME, | ||
| version: PACKAGE_VERSION, | ||
| tools: tools.length, | ||
| }); | ||
| return; | ||
| } | ||
| if (req.url === MCP_PATH || req.url === MCP_PATH + '/') { | ||
| await handleMcp(req, res); | ||
| return; | ||
| } | ||
| writeJson(res, 404, { error: { code: 'not_found', message: `No route for ${req.method} ${req.url}` } }); | ||
| } | ||
| catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| if (!res.headersSent) { | ||
| writeJson(res, 500, { error: { code: 'internal_error', message } }); | ||
| } | ||
| else { | ||
| res.end(); | ||
| } | ||
| } | ||
| }); | ||
| } |
| export declare const PACKAGE_VERSION: string; |
| import { readFileSync } from 'node:fs'; | ||
| import { dirname, join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| // Read version from package.json at load time so it can't drift from the | ||
| // published version. Works in all deploy shapes: npm tarball, source dev, | ||
| // Docker (dist/ sits next to package.json in every case). | ||
| const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); | ||
| const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); | ||
| export const PACKAGE_VERSION = pkg.version; |
+1
-1
@@ -7,4 +7,4 @@ #!/usr/bin/env node | ||
| import { tools, toolsByName } from './tools.js'; | ||
| import { PACKAGE_VERSION } from './version.js'; | ||
| const PACKAGE_NAME = 'lexapi-mcp'; | ||
| const PACKAGE_VERSION = '0.1.3'; | ||
| const apiKey = process.env.LEXAPI_API_KEY; | ||
@@ -11,0 +11,0 @@ if (!apiKey) { |
+4
-2
| { | ||
| "name": "@lexapi/mcp", | ||
| "version": "0.1.3", | ||
| "version": "0.2.0", | ||
| "mcpName": "io.github.Lex-API/lexapi-mcp", | ||
@@ -20,2 +20,3 @@ "description": "Model Context Protocol server for LexAPI — query EUR-Lex and EU case law from Claude, Cursor, and other MCP-enabled clients.", | ||
| "start": "node dist/index.js", | ||
| "start:http": "node dist/http-entry.js", | ||
| "clean": "rm -rf dist", | ||
@@ -55,4 +56,5 @@ "prepublishOnly": "npm run clean && npm run build" | ||
| "hono": "^4.12.27", | ||
| "qs": "^6.15.3" | ||
| "qs": "^6.15.3", | ||
| "body-parser": "^2.3.0" | ||
| } | ||
| } |
+3
-3
| # LexAPI MCP | ||
| [Model Context Protocol](https://modelcontextprotocol.io) server for [LexAPI](https://lex-api.com) — query EUR-Lex, EU case law, and the citation graph from Claude, Cursor, and other MCP-enabled clients. | ||
| [Model Context Protocol](https://modelcontextprotocol.io) server for [LexAPI](https://lex-api.com/?ref=npm-mcp) — query EUR-Lex, EU case law, and the citation graph from Claude, Cursor, and other MCP-enabled clients. | ||
@@ -9,3 +9,3 @@ Install once, get an API key, and ask your AI assistant: *"summarize Article 17 of the GDPR"* or *"which regulations amend Directive 95/46/EC?"* — the model calls LexAPI directly. | ||
| You'll need a LexAPI API key. Create one for free at [lex-api.com/dashboard](https://lex-api.com/dashboard) (50 calls/day on the FREE tier). | ||
| You'll need a LexAPI API key. Create one for free at [lex-api.com/dashboard](https://lex-api.com/dashboard?ref=npm-mcp) (500 credits/month on the FREE tier, no card required). | ||
@@ -61,3 +61,3 @@ ### Claude Desktop | ||
| |---|---|---| | ||
| | `LEXAPI_API_KEY` | *(required)* | Your API key. Get one at [lex-api.com/dashboard](https://lex-api.com/dashboard). | | ||
| | `LEXAPI_API_KEY` | *(required)* | Your API key. Get one at [lex-api.com/dashboard](https://lex-api.com/dashboard?ref=npm-mcp). | | ||
| | `LEXAPI_BASE_URL` | `https://lex-api.com/api/v1` | Override for self-hosted or staging. | | ||
@@ -64,0 +64,0 @@ |
Network access
Supply chain riskThis module accesses the network.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
37919
26.03%17
54.55%763
35.04%6
100%3
200%