From c0ef69048712ee0081392521f766a5aa7a028600 Mon Sep 17 00:00:00 2001 From: Kiril Ivonchik Date: Fri, 12 Sep 2025 12:11:15 +0200 Subject: [PATCH 1/2] chore: better logging, client detection for proper timeout handling --- .vscode/launch.json | 19 +++++++ package-lock.json | 4 +- package.json | 4 +- src/index.ts | 52 +++++++++++------- src/linked-api-server.ts | 80 ++++++++++++++++++++++----- src/tools/get-api-usage-stats.ts | 13 +++-- src/tools/get-conversation.ts | 19 ++++--- src/tools/get-workflow-result.ts | 22 +++++--- src/tools/nv-get-conversation.ts | 19 ++++--- src/utils/debug-log.ts | 8 --- src/utils/define-request-timeout.ts | 16 ++++++ src/utils/execute-with-progress.ts | 2 +- src/utils/linked-api-tool.ts | 34 ++++++++---- src/utils/logger.ts | 84 +++++++++++++++++++++++++++++ 14 files changed, 288 insertions(+), 88 deletions(-) create mode 100644 .vscode/launch.json delete mode 100644 src/utils/debug-log.ts create mode 100644 src/utils/define-request-timeout.ts create mode 100644 src/utils/logger.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fce6b07 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + "version": "1.0.0", + "configurations": [ + { + "type": "node-terminal", + "name": "Linked API MCP - HTTP", + "request": "launch", + "command": "npm run dev:http", + "envFile": "${workspaceFolder}/.env" + }, + { + "type": "node-terminal", + "name": "Linked API MCP - STDIO", + "request": "launch", + "command": "npm run dev:stdio", + "envFile": "${workspaceFolder}/.env" + }, + ] +} diff --git a/package-lock.json b/package-lock.json index c07d41f..aa5625d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkedapi-mcp", - "version": "0.1.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkedapi-mcp", - "version": "0.1.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", diff --git a/package.json b/package.json index 3595c6d..2c06511 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkedapi-mcp", - "version": "0.3.1", + "version": "0.3.2", "description": "MCP server for Linked API", "main": "dist/index.js", "bin": { @@ -10,6 +10,8 @@ "scripts": { "build": "tsc", "dev": "tsx src/index.ts", + "dev:http": "tsx src/index.ts --http", + "dev:stdio": "tsx src/index.ts", "start": "node dist/index.js", "watch": "tsx watch src/index.ts", "clean": "rm -rf dist", diff --git a/src/index.ts b/src/index.ts index 37953c4..a16fb9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,8 +11,8 @@ import http from 'node:http'; import { LinkedApiMCPServer } from './linked-api-server'; import { availablePrompts, getPromptContent, systemPrompt } from './prompts'; -import { debugLog } from './utils/debug-log'; import { JsonHTTPServerTransport } from './utils/json-http-transport'; +import { logger } from './utils/logger'; import { LinkedApiProgressNotification } from './utils/types'; function getArgValue(flag: string): string | undefined { @@ -83,11 +83,7 @@ async function main() { }); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - debugLog('Tool request received', { - toolName: request.params.name, - arguments: request.params.arguments, - progressToken: request.params._meta?.progressToken, - }); + logger.info('Tool request received'); try { const localLinkedApiToken = process.env.LINKED_API_TOKEN; @@ -97,18 +93,30 @@ async function main() { const identificationToken = (headers['identification-token'] ?? localIdentificationToken ?? '') as string; + const mcpClient = (headers['client'] ?? '') as string; const result = await linkedApiServer.executeWithTokens(request.params, { linkedApiToken, identificationToken, + mcpClient, }); return result; } catch (error) { - debugLog('Tool execution failed', { - toolName: request.params.name, - error: error instanceof Error ? error.message : String(error), - }); - throw error; + logger.error( + { + toolName: request.params.name, + error: error instanceof Error ? error.message : String(error), + }, + 'Critical tool execution error', + ); + return { + content: [ + { + type: 'text', + text: 'Unknown error. Please try again.', + }, + ], + }; } }); @@ -130,36 +138,40 @@ async function main() { // Set query parameters to headers if they are not set const linkedApiTokenQP = url.searchParams.get('linked-api-token'); const identificationTokenQP = url.searchParams.get('identification-token'); + const mcpClient = url.searchParams.get('client'); if (!req.headers['linked-api-token'] && linkedApiTokenQP) { req.headers['linked-api-token'] = linkedApiTokenQP; } if (!req.headers['identification-token'] && identificationTokenQP) { req.headers['identification-token'] = identificationTokenQP; } + if (!req.headers['client'] && mcpClient) { + req.headers['client'] = mcpClient; + } await transport.handleRequest(req, res); } catch (error) { - debugLog('HTTP request handling failed', { - error: error instanceof Error ? error.message : String(error), - }); + logger.error( + { + error: error instanceof Error ? error.message : String(error), + }, + 'HTTP request handling failed', + ); res.statusCode = 500; res.end('Internal Server Error'); } }); httpServer.listen(port, host, () => { - debugLog('HTTP transport listening', { - host, - port, - }); + logger.info({ host }, `HTTP transport listening on port ${port}`); }); } else { const transport = new StdioServerTransport(); await server.connect(transport); - debugLog('stdio transport connected'); + logger.info('stdio transport connected'); } } main().catch((error) => { - debugLog('Fatal error', error); + logger.error(error, 'Fatal error'); process.exit(1); }); diff --git a/src/linked-api-server.ts b/src/linked-api-server.ts index feac4f2..ff4ed9b 100644 --- a/src/linked-api-server.ts +++ b/src/linked-api-server.ts @@ -3,8 +3,9 @@ import { LinkedApi, LinkedApiError, TLinkedApiConfig } from 'linkedapi-node'; import { buildLinkedApiHttpClient } from 'linkedapi-node/dist/core'; import { LinkedApiTools } from './linked-api-tools'; -import { debugLog } from './utils/debug-log'; +import { defineRequestTimeoutInSeconds } from './utils/define-request-timeout'; import { handleLinkedApiError } from './utils/handle-linked-api-error'; +import { logger } from './utils/logger'; import { CallToolResult, ExtendedCallToolRequest, @@ -24,26 +25,52 @@ export class LinkedApiMCPServer { public async executeWithTokens( request: ExtendedCallToolRequest['params'], - config: TLinkedApiConfig, + { linkedApiToken, identificationToken, mcpClient }: TLinkedApiConfig & { mcpClient: string }, ): Promise { - const linkedApi = new LinkedApi( + const workflowTimeout = defineRequestTimeoutInSeconds(mcpClient) * 1000; + logger.info( + { + toolName: request.name, + arguments: request.arguments, + mcpClient, + workflowTimeout, + }, + 'Tool execution started', + ); + const linkedapi = new LinkedApi( buildLinkedApiHttpClient( { - linkedApiToken: config.linkedApiToken!, - identificationToken: config.identificationToken!, + linkedApiToken: linkedApiToken, + identificationToken: identificationToken, }, 'mcp', ), ); - const { name, arguments: args, _meta } = request; + const { name: toolName, arguments: args, _meta } = request; const progressToken = _meta?.progressToken; + const startTime = Date.now(); try { - const tool = this.tools.toolByName(name)!; + const tool = this.tools.toolByName(toolName)!; const params = tool.validate(args); - const { data, errors } = await tool.execute(linkedApi, params, progressToken); + const { data, errors } = await tool.execute({ + linkedapi, + args: params, + workflowTimeout, + progressToken, + }); + const endTime = Date.now(); + const duration = `${((endTime - startTime) / 1000).toFixed(2)} seconds`; if (errors.length > 0 && !data) { + logger.error( + { + toolName, + duration, + errors, + }, + 'Tool execution failed', + ); return { content: [ { @@ -53,6 +80,14 @@ export class LinkedApiMCPServer { ], }; } + logger.info( + { + toolName, + duration, + data, + }, + 'Tool execution successful', + ); if (data) { return { content: [ @@ -72,8 +107,17 @@ export class LinkedApiMCPServer { ], }; } catch (error) { + const duration = this.calculateDuration(startTime); if (error instanceof LinkedApiError) { const body = handleLinkedApiError(error); + logger.error( + { + toolName, + duration, + body, + }, + 'Tool execution failed with Linked API error', + ); return { content: [ { @@ -84,19 +128,29 @@ export class LinkedApiMCPServer { }; } const errorMessage = error instanceof Error ? error.message : String(error); - debugLog(`Tool ${name} execution failed`, { - error: errorMessage, - stack: error instanceof Error ? error.stack : undefined, - }); + logger.error( + { + toolName, + duration, + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }, + 'Tool execution failed with unknown error', + ); return { content: [ { type: 'text' as const, - text: `Error executing ${name}: ${errorMessage}`, + text: `Error executing ${toolName}: ${errorMessage}`, }, ], }; } } + + private calculateDuration(startTime: number): string { + const endTime = Date.now(); + return `${((endTime - startTime) / 1000).toFixed(2)} seconds`; + } } diff --git a/src/tools/get-api-usage-stats.ts b/src/tools/get-api-usage-stats.ts index 0e5f4c4..d158fb3 100644 --- a/src/tools/get-api-usage-stats.ts +++ b/src/tools/get-api-usage-stats.ts @@ -11,10 +11,15 @@ export class GetApiUsageTool extends LinkedApiTool> { + public override async execute({ + linkedapi, + args, + }: { + linkedapi: LinkedApi; + args: TApiUsageParams; + workflowTimeout: number; + progressToken?: string | number; + }): Promise> { return await linkedapi.getApiUsage(args); } diff --git a/src/tools/get-conversation.ts b/src/tools/get-conversation.ts index 4464272..8b3d5a2 100644 --- a/src/tools/get-conversation.ts +++ b/src/tools/get-conversation.ts @@ -14,16 +14,15 @@ export class GetConversationTool extends LinkedApiTool< since: z.string().optional(), }); - public override async execute( - linkedapi: LinkedApi, - { - personUrl, - since, - }: { - personUrl: string; - since?: string; - }, - ): Promise> { + public override async execute({ + linkedapi, + args: { personUrl, since }, + }: { + linkedapi: LinkedApi; + args: { personUrl: string; since?: string }; + workflowTimeout: number; + progressToken?: string | number; + }): Promise> { const conversations = await this.getConversation(linkedapi, personUrl, since); if (conversations.errors.length === 0) { return conversations; diff --git a/src/tools/get-workflow-result.ts b/src/tools/get-workflow-result.ts index 198eba4..95383ca 100644 --- a/src/tools/get-workflow-result.ts +++ b/src/tools/get-workflow-result.ts @@ -17,16 +17,22 @@ export class GetWorkflowResultTool extends LinkedApiTool> { + public override async execute({ + linkedapi, + args: { workflowId, operationName }, + workflowTimeout, + progressToken, + }: { + linkedapi: LinkedApi; + args: IGetWorkflowResultParams; + workflowTimeout: number; + progressToken?: string | number; + }): Promise> { const operation = linkedapi.operations.find( - (operation) => operation.operationName === args.operationName, + (operation) => operation.operationName === operationName, )!; - return await executeWithProgress(this.progressCallback, operation, { - workflowId: args.workflowId, + return await executeWithProgress(this.progressCallback, operation, workflowTimeout, { + workflowId, progressToken, }); } diff --git a/src/tools/nv-get-conversation.ts b/src/tools/nv-get-conversation.ts index e2b90fc..3fb2d24 100644 --- a/src/tools/nv-get-conversation.ts +++ b/src/tools/nv-get-conversation.ts @@ -14,16 +14,15 @@ export class NvGetConversationTool extends LinkedApiTool< since: z.string().optional(), }); - public override async execute( - linkedapi: LinkedApi, - { - personUrl, - since, - }: { - personUrl: string; - since?: string; - }, - ): Promise> { + public override async execute({ + linkedapi, + args: { personUrl, since }, + }: { + linkedapi: LinkedApi; + args: { personUrl: string; since?: string }; + workflowTimeout: number; + progressToken?: string | number; + }): Promise> { const conversations = await this.getConversation(linkedapi, personUrl, since); if (conversations.errors.length === 0) { return conversations; diff --git a/src/utils/debug-log.ts b/src/utils/debug-log.ts deleted file mode 100644 index 7c45276..0000000 --- a/src/utils/debug-log.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function debugLog(message: string, data?: unknown) { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}]: ${message}`; - console.error(logMessage); - if (data) { - console.error(JSON.stringify(data, null, 2)); - } -} diff --git a/src/utils/define-request-timeout.ts b/src/utils/define-request-timeout.ts new file mode 100644 index 0000000..6272006 --- /dev/null +++ b/src/utils/define-request-timeout.ts @@ -0,0 +1,16 @@ +export function defineRequestTimeoutInSeconds(mcpClient: string): number { + const claudeTimeout = 180; + const bigTimeout = 600; + const defaultTimeout = Math.min(claudeTimeout, bigTimeout); + + switch (mcpClient) { + case 'claude': + return claudeTimeout; + case 'cursor': + case 'vscode': + case 'windsurf': + return bigTimeout; + default: + return defaultTimeout; + } +} diff --git a/src/utils/execute-with-progress.ts b/src/utils/execute-with-progress.ts index dba9752..528b090 100644 --- a/src/utils/execute-with-progress.ts +++ b/src/utils/execute-with-progress.ts @@ -5,13 +5,13 @@ import { LinkedApiProgressNotification } from './types'; export async function executeWithProgress( progressCallback: (progress: LinkedApiProgressNotification) => void, operation: Operation, + workflowTimeout: number, { params, workflowId, progressToken, }: { params?: TParams; workflowId?: string; progressToken?: string | number } = {}, ): Promise> { - const workflowTimeout = parseInt(process.env.HEALTH_CHECK_PERIOD || '180', 10) * 1000; let progress = 0; progressCallback({ diff --git a/src/utils/linked-api-tool.ts b/src/utils/linked-api-tool.ts index 2aeb308..3e52623 100644 --- a/src/utils/linked-api-tool.ts +++ b/src/utils/linked-api-tool.ts @@ -20,25 +20,37 @@ export abstract class LinkedApiTool { return this.schema.parse(args) as TParams; } - public abstract execute( - linkedapi: LinkedApi, - args: TParams, - progressToken?: string | number, - ): Promise>; + public abstract execute({ + linkedapi, + args, + workflowTimeout, + progressToken, + }: { + linkedapi: LinkedApi; + args: TParams; + workflowTimeout: number; + progressToken?: string | number; + }): Promise>; } export abstract class OperationTool extends LinkedApiTool { public abstract readonly operationName: TOperationName; - public override execute( - linkedapi: LinkedApi, - args: TParams, - progressToken?: string | number, - ): Promise> { + public override execute({ + linkedapi, + args, + workflowTimeout, + progressToken, + }: { + linkedapi: LinkedApi; + args: TParams; + workflowTimeout: number; + progressToken?: string | number; + }): Promise> { const operation = linkedapi.operations.find( (operation) => operation.operationName === this.operationName, )! as Operation; - return executeWithProgress(this.progressCallback, operation, { + return executeWithProgress(this.progressCallback, operation, workflowTimeout, { params: args, progressToken, }); diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..fd338be --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,84 @@ +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const levelPriority: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +function getMinLevel(): LogLevel { + const env = (process.env.LOG_LEVEL || '').toLowerCase(); + if (env === 'debug' || env === 'info' || env === 'warn' || env === 'error') return env; + return 'info'; +} + +const minLevel = getMinLevel(); + +function shouldLog(level: LogLevel): boolean { + return levelPriority[level] >= levelPriority[minLevel]; +} + +function toErrorObject(value: unknown) { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + return undefined; +} + +function writeLine(line: string) { + process.stderr.write(line + '\n'); +} + +function baseLog(level: LogLevel, arg1?: unknown, arg2?: unknown) { + if (!shouldLog(level)) return; + + let message: string | undefined; + let payload: unknown | undefined; + + if (typeof arg1 === 'string' && typeof arg2 === 'undefined') { + message = arg1; + } else if (typeof arg2 === 'string') { + message = arg2; + if (arg1 && typeof arg1 === 'object') { + const errObj = toErrorObject(arg1); + payload = errObj ?? arg1; + } else if (arg1 instanceof Error) { + payload = toErrorObject(arg1); + } + } else if (arg1 instanceof Error) { + payload = toErrorObject(arg1); + } else if (arg1 && typeof arg1 === 'object') { + payload = arg1 as Record; + } else if (typeof arg1 !== 'undefined') { + message = String(arg1); + } + + if (typeof message === 'string') { + writeLine(message); + } + + if (typeof payload !== 'undefined') { + try { + // Pretty-print JSON payload without internal fields like time/level + const pretty = JSON.stringify(payload, null, 2); + writeLine(pretty); + } catch { + // Fallback to toString if payload is not serializable + writeLine(String(payload)); + } + } +} + +export const logger = { + debug: (arg1?: unknown, arg2?: unknown) => baseLog('debug', arg1, arg2), + info: (arg1?: unknown, arg2?: unknown) => baseLog('info', arg1, arg2), + warn: (arg1?: unknown, arg2?: unknown) => baseLog('warn', arg1, arg2), + error: (arg1?: unknown, arg2?: unknown) => baseLog('error', arg1, arg2), +}; + +export type Logger = typeof logger; From 9cbc34362125e98a3d4d48d591ab11e757a5bbc7 Mon Sep 17 00:00:00 2001 From: Kiril Ivonchik Date: Fri, 12 Sep 2025 13:34:23 +0200 Subject: [PATCH 2/2] SSE transport --- src/tools/get-api-usage-stats.ts | 4 +- src/tools/retrieve-connections.ts | 2 +- src/utils/json-http-transport.ts | 90 +++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/tools/get-api-usage-stats.ts b/src/tools/get-api-usage-stats.ts index d158fb3..a3fc638 100644 --- a/src/tools/get-api-usage-stats.ts +++ b/src/tools/get-api-usage-stats.ts @@ -26,7 +26,7 @@ export class GetApiUsageTool extends LinkedApiTool; }; +type SseContext = { + res: ServerResponse; + keepalive: NodeJS.Timeout; +}; + export class JsonHTTPServerTransport implements Transport { public onclose?: () => void; public onerror?: (error: Error) => void; @@ -28,6 +33,7 @@ export class JsonHTTPServerTransport implements Transport { private started = false; private requestIdToConn = new Map(); private connections = new Map(); + private sse?: SseContext; async start(): Promise { if (this.started) throw new Error('Transport already started'); @@ -35,6 +41,17 @@ export class JsonHTTPServerTransport implements Transport { } async close(): Promise { + if (this.sse) { + try { + clearInterval(this.sse.keepalive); + if (!this.sse.res.writableEnded) { + this.sse.res.end(); + } + } catch { + // ignore + } + this.sse = undefined; + } this.connections.forEach((ctx) => { try { if (!ctx.res.writableEnded) { @@ -50,6 +67,13 @@ export class JsonHTTPServerTransport implements Transport { } async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { + // If SSE is connected, stream all server -> client messages via SSE + if (this.sse && !this.sse.res.writableEnded) { + const line = `data: ${JSON.stringify(message)}\n\n`; + this.sse.res.write(line); + return; + } + let relatedId = options?.relatedRequestId; if (isJSONRPCResponse(message) || isJSONRPCError(message)) { relatedId = message.id; @@ -82,13 +106,55 @@ export class JsonHTTPServerTransport implements Transport { ctx.orderedIds.forEach((id) => this.requestIdToConn.delete(id)); } - // Handle only POST requests; no SSE/GET support + // Handle HTTP requests: supports POST for JSON and GET for SSE async handleRequest( req: IncomingMessage & { auth?: unknown }, res: ServerResponse, parsedBody?: unknown, ): Promise { try { + // SSE endpoint: accept GET with text/event-stream + const acceptHeader = (req.headers['accept'] || '').toString(); + if (req.method === 'GET' && acceptHeader.includes('text/event-stream')) { + // Close previous SSE if any + if (this.sse) { + try { + clearInterval(this.sse.keepalive); + if (!this.sse.res.writableEnded) this.sse.res.end(); + } catch { + // ignore + } + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + // Send an initial comment to establish the stream + res.write(': connected\n\n'); + + const keepalive = setInterval(() => { + if (res.writableEnded) return; + res.write('event: ping\ndata: {}\n\n'); + }, 25000); + + this.sse = { + res, + keepalive, + }; + + res.on('close', () => { + try { + clearInterval(keepalive); + } finally { + this.sse = undefined; + } + }); + return; + } + if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }).end( JSON.stringify({ @@ -103,15 +169,18 @@ export class JsonHTTPServerTransport implements Transport { return; } + // For POST, allow generic Accepts; when SSE is connected, we don't require JSON accept const accept = req.headers['accept']; - if (!(accept && accept.includes('application/json'))) { + const acceptsJson = !!(accept && accept.includes('application/json')); + const sseActive = !!this.sse && !this.sse.res.writableEnded; + if (!acceptsJson && !sseActive) { res.writeHead(406); res.end( JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, - message: 'Not Acceptable: Client must accept application/json', + message: 'Not Acceptable: Client must accept application/json or have SSE open', }, id: null, }), @@ -165,6 +234,21 @@ export class JsonHTTPServerTransport implements Transport { } const orderedIds: RequestId[] = messages.filter(isJSONRPCRequest).map((m) => m.id); + const sseConnected = !!this.sse && !this.sse.res.writableEnded; + if (sseConnected) { + // With SSE, we emit responses on the SSE stream; reply 202 to POST immediately + res.writeHead(202).end(); + for (const msg of messages) { + this.onmessage?.(msg, { + requestInfo: { + headers: req.headers, + }, + authInfo: req.auth, + }); + } + return; + } + const connId = `${Date.now()}-${Math.random()}`; this.connections.set(connId, { res,