Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
npm-debug.log
.DS_Store
.git
.gitignore
dist
**/.vscode
**/.idea
**/*.log
coverage
.env
53 changes: 53 additions & 0 deletions .github/workflows/merge-main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: PR Merged -> main

on:
push:
branches: ['main']

jobs:
linting:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/[email protected]

- name: Set up Node.js
uses: actions/[email protected]
with:
node-version: 20

- name: Install dependencies
run: npm ci

- name: Run ESLint
run: npm run lint

build-push:
needs: [linting]
runs-on: ubuntu-latest
if: always() && needs.linting.result == 'success'

steps:
- name: Checkout code
uses: actions/[email protected]

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2

- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ secrets.DOCKER_IMAGE_NAME }}:prod
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ secrets.DOCKER_IMAGE_NAME }}:prod
cache-to: type=inline
27 changes: 27 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src
COPY README.md ./README.md
RUN npm run build

FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /app/dist ./dist

ENV HOST=0.0.0.0 \
PORT=3000

EXPOSE 3000

CMD ["node", "dist/index.js", "--http"]


2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linkedapi-mcp",
"version": "0.2.0",
"version": "0.3.0",
"description": "MCP server for Linked API",
"main": "dist/index.js",
"bin": {
Expand Down
89 changes: 74 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,27 @@ import {
ListPromptsRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
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 { LinkedApiProgressNotification } from './utils/types';

async function main() {
const linkedApiToken = process.env.LINKED_API_TOKEN;
const identificationToken = process.env.IDENTIFICATION_TOKEN;
function getArgValue(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
if (index === -1) return undefined;
const value = process.argv[index + 1];
if (!value || value.startsWith('--')) return undefined;
return value;
}

function hasFlag(flag: string): boolean {
return process.argv.includes(flag);
}

async function main() {
const server = new Server(
{
name: 'linkedapi-mcp',
Expand All @@ -33,14 +44,7 @@ async function main() {
);

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

const linkedApiServer = new LinkedApiMCPServer(
{
linkedApiToken: linkedApiToken!,
identificationToken: identificationToken!,
},
progressCallback,
);
const linkedApiServer = new LinkedApiMCPServer(progressCallback);

server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = linkedApiServer.getTools();
Expand Down Expand Up @@ -78,15 +82,26 @@ async function main() {
}
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
debugLog('Tool request received', {
toolName: request.params.name,
arguments: request.params.arguments,
progressToken: request.params._meta?.progressToken,
});

try {
const result = await linkedApiServer.callTool(request.params);
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;

const result = await linkedApiServer.executeWithTokens(request.params, {
linkedApiToken,
identificationToken,
});
return result;
} catch (error) {
debugLog('Tool execution failed', {
Expand All @@ -96,8 +111,52 @@ async function main() {
throw error;
}
});
const transport = new StdioServerTransport();
await server.connect(transport);

if (hasFlag('--http') || hasFlag('--transport=http')) {
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');
if (!req.headers['linked-api-token'] && linkedApiTokenQP) {
req.headers['linked-api-token'] = linkedApiTokenQP;
}
if (!req.headers['identification-token'] && identificationTokenQP) {
req.headers['identification-token'] = identificationTokenQP;
}
await transport.handleRequest(req, res);
} catch (error) {
debugLog('HTTP request handling failed', {
error: error instanceof Error ? error.message : String(error),
});
res.statusCode = 500;
res.end('Internal Server Error');
}
});

httpServer.listen(port, host, () => {
debugLog('HTTP transport listening', {
host,
port,
});
});
} else {
const transport = new StdioServerTransport();
await server.connect(transport);
debugLog('stdio transport connected');
}
}

main().catch((error) => {
Expand Down
52 changes: 17 additions & 35 deletions src/linked-api-server.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import {
LinkedApi,
LinkedApiError,
LinkedApiWorkflowTimeoutError,
TLinkedApiConfig,
} from 'linkedapi-node';
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 { handleLinkedApiError } from './utils/handle-linked-api-error';
import {
CallToolResult,
ExtendedCallToolRequest,
LinkedApiProgressNotification,
} from './utils/types';

export class LinkedApiMCPServer {
private linkedapi: LinkedApi;
private tools: LinkedApiTools;
private progressCallback: (notification: LinkedApiProgressNotification) => void;

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

public getTools(): Tool[] {
return this.tools.tools.map((tool) => tool.getTool());
}

public async executeWithTokens(
request: ExtendedCallToolRequest['params'],
config: TLinkedApiConfig,
progressCallback: (notification: LinkedApiProgressNotification) => void,
) {
this.linkedapi = new LinkedApi(
): Promise<CallToolResult> {
const linkedApi = new LinkedApi(
buildLinkedApiHttpClient(
{
linkedApiToken: config.linkedApiToken!,
Expand All @@ -33,26 +35,14 @@ export class LinkedApiMCPServer {
'mcp',
),
);
this.progressCallback = progressCallback;

this.tools = new LinkedApiTools(this.linkedapi, this.progressCallback);
}

public getTools(): Tool[] {
return [...this.tools.tools.map((t) => t.getTool())];
}

public async callTool(request: ExtendedCallToolRequest['params']): Promise<CallToolResult> {
const { name, arguments: args, _meta } = request;
const progressToken = _meta?.progressToken;

try {
const tool = this.tools.toolByName(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
const tool = this.tools.toolByName(name)!;
const params = tool.validate(args);
const { data, errors } = await tool.execute(params, progressToken);
const { data, errors } = await tool.execute(linkedApi, params, progressToken);
if (errors.length > 0 && !data) {
return {
content: [
Expand All @@ -73,15 +63,7 @@ export class LinkedApiMCPServer {
};
} catch (error) {
if (error instanceof LinkedApiError) {
let body: unknown = error;
if (error instanceof LinkedApiWorkflowTimeoutError) {
const { message, workflowId, operationName } = error;
body = {
message,
workflowId,
operationName,
};
}
const body = handleLinkedApiError(error);
return {
content: [
{
Expand Down
59 changes: 27 additions & 32 deletions src/linked-api-tools.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import LinkedApi from 'linkedapi-node';

import { CheckConnectionStatusTool } from './tools/check-connection-status.js';
import { CommentOnPostTool } from './tools/comment-on-post.js';
import { ExecuteCustomWorkflowTool } from './tools/execute-custom-workflow.js';
Expand Down Expand Up @@ -32,40 +30,37 @@ import { LinkedApiProgressNotification } from './utils/types.js';
export class LinkedApiTools {
public readonly tools: ReadonlyArray<LinkedApiTool<unknown, unknown>>;

constructor(
linkedapi: LinkedApi,
progressCallback: (progress: LinkedApiProgressNotification) => void,
) {
constructor(progressCallback: (progress: LinkedApiProgressNotification) => void) {
this.tools = [
// Standard tools
new SendMessageTool(linkedapi, progressCallback),
new GetConversationTool(linkedapi, progressCallback),
new CheckConnectionStatusTool(linkedapi, progressCallback),
new RetrieveConnectionsTool(linkedapi, progressCallback),
new SendConnectionRequestTool(linkedapi, progressCallback),
new WithdrawConnectionRequestTool(linkedapi, progressCallback),
new RetrievePendingRequestsTool(linkedapi, progressCallback),
new RemoveConnectionTool(linkedapi, progressCallback),
new SearchCompaniesTool(linkedapi, progressCallback),
new SearchPeopleTool(linkedapi, progressCallback),
new FetchCompanyTool(linkedapi, progressCallback),
new FetchPersonTool(linkedapi, progressCallback),
new FetchPostTool(linkedapi, progressCallback),
new ReactToPostTool(linkedapi, progressCallback),
new CommentOnPostTool(linkedapi, progressCallback),
new RetrieveSSITool(linkedapi, progressCallback),
new RetrievePerformanceTool(linkedapi, progressCallback),
new SendMessageTool(progressCallback),
new GetConversationTool(progressCallback),
new CheckConnectionStatusTool(progressCallback),
new RetrieveConnectionsTool(progressCallback),
new SendConnectionRequestTool(progressCallback),
new WithdrawConnectionRequestTool(progressCallback),
new RetrievePendingRequestsTool(progressCallback),
new RemoveConnectionTool(progressCallback),
new SearchCompaniesTool(progressCallback),
new SearchPeopleTool(progressCallback),
new FetchCompanyTool(progressCallback),
new FetchPersonTool(progressCallback),
new FetchPostTool(progressCallback),
new ReactToPostTool(progressCallback),
new CommentOnPostTool(progressCallback),
new RetrieveSSITool(progressCallback),
new RetrievePerformanceTool(progressCallback),
// Sales Navigator tools
new NvSendMessageTool(linkedapi, progressCallback),
new NvGetConversationTool(linkedapi, progressCallback),
new NvSearchCompaniesTool(linkedapi, progressCallback),
new NvSearchPeopleTool(linkedapi, progressCallback),
new NvFetchCompanyTool(linkedapi, progressCallback),
new NvFetchPersonTool(linkedapi, progressCallback),
new NvSendMessageTool(progressCallback),
new NvGetConversationTool(progressCallback),
new NvSearchCompaniesTool(progressCallback),
new NvSearchPeopleTool(progressCallback),
new NvFetchCompanyTool(progressCallback),
new NvFetchPersonTool(progressCallback),
// Other tools
new ExecuteCustomWorkflowTool(linkedapi, progressCallback),
new GetWorkflowResultTool(linkedapi, progressCallback),
new GetApiUsageTool(linkedapi, progressCallback),
new ExecuteCustomWorkflowTool(progressCallback),
new GetWorkflowResultTool(progressCallback),
new GetApiUsageTool(progressCallback),
];
}

Expand Down
Loading