Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,696 changes: 1,632 additions & 64 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,21 @@
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.4",
"@nestjs/common": "^10.4.7",
"@nestjs/core": "^10.4.7",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.20",
"@nestjs/typeorm": "^11.0.0",
"@rekog/mcp-nest": "^1.8.2",
"jsonwebtoken": "^9.0.2",
"linkedapi-node": "^1.2.2",
"zod": "^4.1.1"
"passport": "^0.7.0",
"reflect-metadata": "^0.1.14",
"tslib": "^2.7.0",
"typeorm": "^0.3.26",
"zod": "^4.1.1",
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
"@eslint/compat": "^1.2.8",
Expand Down
24 changes: 24 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { McpModule, McpTransportType } from '@rekog/mcp-nest';

import { LinkedApiPromptsProvider } from './linked-api-prompts.provider';
import { LinkedApiMCPServer } from './linked-api-server';
import { LinkedApiToolsProvider } from './linked-api-tools.provider';
import { systemPrompt } from './prompts';

export function createAppModule(transport: McpTransportType | McpTransportType[]) {
@Module({
imports: [
McpModule.forRoot({
name: 'linkedapi-mcp',
version: '1.0.0',
instructions: systemPrompt,
transport,
}),
],
providers: [LinkedApiMCPServer, LinkedApiToolsProvider, LinkedApiPromptsProvider],
})
class AppModule {}

return AppModule;
}
210 changes: 21 additions & 189 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,10 @@
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import http from 'node:http';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { McpTransportType } from '@rekog/mcp-nest';
import 'reflect-metadata';

import { LinkedApiMCPServer } from './linked-api-server';
import { availablePrompts, getPromptContent, systemPrompt } from './prompts';
import { JsonHTTPServerTransport } from './utils/json-http-transport';
import { logger } from './utils/logger';
import { LinkedApiProgressNotification } from './utils/types';

function deriveClientFromUserAgent(userAgent: string): string {
const ua = userAgent.toLowerCase();
if (ua.includes('cursor')) return 'cursor';
if (ua.includes('windsurf')) return 'windsurf';
if (ua.includes('vscode') || ua.includes('visual studio code')) return 'vscode';
if (ua.includes('chatgpt') || ua.includes('openai')) return 'chatgpt';
if (ua.includes('curl')) return 'curl';
if (ua.includes('postman')) return 'postman';
if (
ua.includes('mozilla') ||
ua.includes('chrome') ||
ua.includes('safari') ||
ua.includes('firefox')
)
return 'browser';
return userAgent;
}
import { createAppModule } from './app.module';

function getArgValue(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
Expand All @@ -45,168 +18,27 @@ function hasFlag(flag: string): boolean {
return process.argv.includes(flag);
}

async function main() {
const server = new Server(
{
name: 'linkedapi-mcp',
version: '1.0.0',
description: 'MCP Server for Linked API (https://linkedapi.io)',
},
{
capabilities: {
tools: {},
prompts: {},
},
instructions: systemPrompt,
},
);

const progressCallback = (_notification: LinkedApiProgressNotification) => {};
const linkedApiServer = new LinkedApiMCPServer(progressCallback);

server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = linkedApiServer.getTools();
return {
tools,
};
});

server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: availablePrompts,
};
});

server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name } = request.params;

try {
const content = name === 'performance_guidelines' ? systemPrompt : getPromptContent(name);

return {
description: `Linked API MCP: ${name.replace('_', ' ')}`,
messages: [
{
role: 'user',
content: {
type: 'text',
text: content,
},
},
],
};
} catch {
throw new Error(`Unknown prompt: ${name}`);
}
});

server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const requestInfoAny = (
extra as unknown as { requestInfo?: { method?: string; transport?: string } }
)?.requestInfo;
const method = requestInfoAny?.method ?? 'N/A';
const transportType = (requestInfoAny?.transport as 'http' | 'sse' | undefined) ?? 'N/A';
logger.info(
{
method,
transport: transportType,
},
'Tool request received',
);

try {
const localLinkedApiToken = process.env.LINKED_API_TOKEN;
const localIdentificationToken = process.env.IDENTIFICATION_TOKEN;
const headers = extra?.requestInfo?.headers ?? {};
const linkedApiToken = (headers['linked-api-token'] ?? localLinkedApiToken ?? '') as string;
const identificationToken = (headers['identification-token'] ??
localIdentificationToken ??
'') as string;
let mcpClient = (headers['client'] ?? '') as string;
if (!mcpClient) {
const userAgentHeader = headers['user-agent'];
if (typeof userAgentHeader === 'string' && userAgentHeader.trim().length > 0) {
mcpClient = deriveClientFromUserAgent(userAgentHeader);
}
}

const result = await linkedApiServer.executeWithTokens(request.params, {
linkedApiToken,
identificationToken,
mcpClient,
});
return result;
} catch (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.',
},
],
};
}
});

if (hasFlag('--http') || hasFlag('--transport=http')) {
async function bootstrap() {
const logger = new Logger('Bootstrap');
const useHttp = hasFlag('--http') || hasFlag('--transport=http');
const transports = useHttp
? [McpTransportType.SSE, McpTransportType.STREAMABLE_HTTP]
: [McpTransportType.STDIO];
const AppModule = createAppModule(transports);
if (useHttp) {
const app = await NestFactory.create(AppModule);
const port = Number(process.env.PORT ?? getArgValue('--port') ?? 3000);
const host = process.env.HOST ?? getArgValue('--host') ?? '0.0.0.0';
const transport = new JsonHTTPServerTransport();

await server.connect(transport);

const httpServer = http.createServer(async (req, res) => {
try {
if (!req.url) {
res.statusCode = 400;
res.end('Bad Request');
return;
}
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
// 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) {
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, () => {
logger.info({ host }, `HTTP transport listening on port ${port}`);
});
await app.listen(port, host);
logger.log(`HTTP transports listening on port ${port}`);
} else {
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info('stdio transport connected');
await NestFactory.createApplicationContext(AppModule);
logger.log('STDIO transport initialized');
}
}

main().catch((error) => {
logger.error(error, 'Fatal error');
bootstrap().catch((error) => {
const logger = new Logger('Bootstrap');
logger.error(error);
process.exit(1);
});
41 changes: 41 additions & 0 deletions src/linked-api-prompts.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { MCP_PROMPT_METADATA_KEY } from '@rekog/mcp-nest';
import 'reflect-metadata';
import { z } from 'zod';

import { availablePrompts, getPromptContent } from './prompts';

@Injectable()
export class LinkedApiPromptsProvider implements OnModuleInit {
constructor() {}

onModuleInit(): void {
for (const prompt of availablePrompts) {
const methodName = `prompt_${prompt.name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
Object.defineProperty(this, methodName, {
value: async () => {
const content = getPromptContent(prompt.name);
return {
description: `Linked API MCP: ${prompt.name.replace('_', ' ')}`,
messages: [
{
role: 'user',
content: { type: 'text' as const,
text: content },
},
],
};
},
});
const methodRef = (this as Record<string, unknown>)[methodName] as (
...args: unknown[]
) => unknown;
const metadata = {
name: prompt.name,
description: prompt.description,
parameters: z.object({}),
};
Reflect.defineMetadata(MCP_PROMPT_METADATA_KEY, metadata, methodRef);
}
}
}
26 changes: 12 additions & 14 deletions src/linked-api-server.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { Injectable, Logger } from '@nestjs/common';
import { LinkedApi, LinkedApiError, TLinkedApiConfig } from 'linkedapi-node';
import { buildLinkedApiHttpClient } from 'linkedapi-node/dist/core';

import { LinkedApiTools } from './linked-api-tools';
import { defineRequestTimeoutInSeconds } from './utils/define-request-timeout';
import { handleLinkedApiError } from './utils/handle-linked-api-error';
import { logger } from './utils/logger';
import {
CallToolResult,
ExtendedCallToolRequest,
LinkedApiProgressNotification,
} from './utils/types';
import { CallToolResult, ExtendedCallToolRequest } from './utils/types';

@Injectable()
export class LinkedApiMCPServer {
private tools: LinkedApiTools;
public readonly tools: LinkedApiTools;
private readonly logger = new Logger(LinkedApiMCPServer.name);

constructor(progressCallback: (notification: LinkedApiProgressNotification) => void) {
this.tools = new LinkedApiTools(progressCallback);
constructor() {
this.tools = new LinkedApiTools(() => {});
}

public getTools(): Tool[] {
Expand All @@ -28,7 +26,7 @@ export class LinkedApiMCPServer {
{ linkedApiToken, identificationToken, mcpClient }: TLinkedApiConfig & { mcpClient: string },
): Promise<CallToolResult> {
const workflowTimeout = defineRequestTimeoutInSeconds(mcpClient) * 1000;
logger.info(
this.logger.log(
{
toolName: request.name,
arguments: request.arguments,
Expand Down Expand Up @@ -63,7 +61,7 @@ export class LinkedApiMCPServer {
const endTime = Date.now();
const duration = `${((endTime - startTime) / 1000).toFixed(2)} seconds`;
if (errors.length > 0 && !data) {
logger.error(
this.logger.error(
{
toolName,
duration,
Expand All @@ -80,7 +78,7 @@ export class LinkedApiMCPServer {
],
};
}
logger.info(
this.logger.log(
{
toolName,
duration,
Expand Down Expand Up @@ -110,7 +108,7 @@ export class LinkedApiMCPServer {
const duration = this.calculateDuration(startTime);
if (error instanceof LinkedApiError) {
const body = handleLinkedApiError(error);
logger.error(
this.logger.error(
{
toolName,
duration,
Expand All @@ -128,7 +126,7 @@ export class LinkedApiMCPServer {
};
}
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(
this.logger.error(
{
toolName,
duration,
Expand Down
Loading