index.ts•19.9 kB
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
Tool,
CallToolRequestSchema,
ListToolsRequestSchema,
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import axios, { AxiosInstance } from 'axios';
import dotenv from 'dotenv';
dotenv.config();
// Tool definitions
const FIND_LOOKALIKE_COMPANIES_TOOL: Tool = {
name: 'ocean_find_lookalike_companies',
description: 'Find lookalike companies based on a seed company.',
inputSchema: {
type: 'object',
properties: {
company_name: {
type: 'string',
description: 'The name of the seed company',
},
company_domain: {
type: 'string',
description: 'The domain of the seed company (alternative to company_name)',
},
company_id: {
type: 'string',
description: 'The Ocean.io ID of the seed company (alternative to company_name and company_domain)',
},
limit: {
type: 'number',
description: 'Maximum number of lookalike companies to return (optional, default: 10)',
},
min_similarity: {
type: 'number',
description: 'Minimum similarity score (0-1) for lookalike companies (optional)',
}
},
required: [],
},
};
const SEARCH_COMPANIES_TOOL: Tool = {
name: 'ocean_search_companies',
description: 'Search for companies with advanced filtering options.',
inputSchema: {
type: 'object',
properties: {
size: {
type: 'number',
description: 'Number of results to return per page',
},
from: {
type: 'number',
description: 'Starting index for pagination',
},
searchAfter: {
type: 'string',
description: 'Token for pagination (alternative to from)',
},
companiesFilters: {
type: 'object',
description: 'Filters for company search',
properties: {
lookalikeDomains: {
type: 'array',
items: { type: 'string' },
description: 'List of domains to find similar companies to',
},
minScore: {
type: 'number',
description: 'Minimum similarity score (0-1) for lookalike companies',
},
includeDomains: {
type: 'array',
items: { type: 'string' },
description: 'List of domains to include in results',
},
excludeDomains: {
type: 'array',
items: { type: 'string' },
description: 'List of domains to exclude from results',
},
companySizes: {
type: 'array',
items: { type: 'string' },
description: 'List of company size ranges (e.g., "2-10", "51-200")',
},
ecommerce: {
type: 'boolean',
description: 'Filter for companies with e-commerce capabilities',
},
socialMedias: {
type: 'object',
properties: {
medias: {
type: 'array',
items: { type: 'string' },
description: 'List of social media platforms',
},
mode: {
type: 'string',
enum: ['anyOf', 'allOf'],
description: 'Match mode for social media platforms',
},
},
description: 'Filter for companies with specific social media presence',
},
yearFounded: {
type: 'object',
properties: {
from: { type: 'number' },
to: { type: 'number' },
},
description: 'Range of years when companies were founded',
},
revenues: {
type: 'array',
items: { type: 'string' },
description: 'List of revenue ranges (e.g., "0-1M", "1-10M")',
},
countries: {
type: 'array',
items: { type: 'string' },
description: 'List of country codes to filter by',
},
industries: {
type: 'object',
properties: {
industries: {
type: 'array',
items: { type: 'string' },
},
mode: {
type: 'string',
enum: ['anyOf', 'allOf'],
},
},
description: 'Filter for companies in specific industries',
},
technologies: {
type: 'object',
properties: {
technologies: {
type: 'array',
items: { type: 'string' },
},
mode: {
type: 'string',
enum: ['anyOf', 'allOf'],
},
},
description: 'Filter for companies using specific technologies',
},
},
},
peopleFilters: {
type: 'object',
description: 'Filters for people associated with companies',
properties: {
includeIds: {
type: 'array',
items: { type: 'string' },
description: 'List of person IDs to include',
},
excludeIds: {
type: 'array',
items: { type: 'string' },
description: 'List of person IDs to exclude',
},
seniorities: {
type: 'array',
items: { type: 'string' },
description: 'List of seniority levels',
},
jobTitles: {
type: 'array',
items: { type: 'string' },
description: 'List of job titles to include',
},
excludeJobTitles: {
type: 'array',
items: { type: 'string' },
description: 'List of job titles to exclude',
},
departments: {
type: 'array',
items: { type: 'string' },
description: 'List of departments',
},
countries: {
type: 'array',
items: { type: 'string' },
description: 'List of country codes',
},
states: {
type: 'array',
items: {
type: 'object',
properties: {
abbreviation: { type: 'string' },
country: { type: 'string' },
},
},
description: 'List of states/provinces',
},
},
},
},
required: [],
},
};
// Type definitions
interface FindLookalikeCompaniesParams {
company_name?: string;
company_domain?: string;
company_id?: string;
limit?: number;
min_similarity?: number;
}
interface SearchCompaniesParams {
size?: number;
from?: number;
searchAfter?: string;
companiesFilters?: {
lookalikeDomains?: string[];
minScore?: number;
includeDomains?: string[];
excludeDomains?: string[];
companySizes?: string[];
ecommerce?: boolean;
socialMedias?: {
medias: string[];
mode: 'anyOf' | 'allOf';
};
yearFounded?: {
from: number;
to: number;
};
revenues?: string[];
countries?: string[];
industries?: {
industries: string[];
mode: 'anyOf' | 'allOf';
};
technologies?: {
technologies: string[];
mode: 'anyOf' | 'allOf';
};
// Additional filter properties can be added as needed
};
peopleFilters?: {
includeIds?: string[];
excludeIds?: string[];
seniorities?: string[];
jobTitles?: string[];
excludeJobTitles?: string[];
departments?: string[];
countries?: string[];
states?: Array<{
abbreviation: string;
country: string;
}>;
// Additional filter properties can be added as needed
};
}
// Type guards
function isFindLookalikeCompaniesParams(args: unknown): args is FindLookalikeCompaniesParams {
if (
typeof args !== 'object' ||
args === null
) {
return false;
}
// At least one of company_name, company_domain, or company_id must be provided
if (
!('company_name' in args && typeof (args as { company_name: unknown }).company_name === 'string') &&
!('company_domain' in args && typeof (args as { company_domain: unknown }).company_domain === 'string') &&
!('company_id' in args && typeof (args as { company_id: unknown }).company_id === 'string')
) {
return false;
}
// Optional parameters
if (
'limit' in args &&
(args as { limit: unknown }).limit !== undefined &&
typeof (args as { limit: unknown }).limit !== 'number'
) {
return false;
}
if (
'min_similarity' in args &&
(args as { min_similarity: unknown }).min_similarity !== undefined &&
typeof (args as { min_similarity: unknown }).min_similarity !== 'number'
) {
return false;
}
return true;
}
function isSearchCompaniesParams(args: unknown): args is SearchCompaniesParams {
if (typeof args !== 'object' || args === null) {
return false;
}
// All parameters are optional, but we should validate their types if present
const obj = args as Record<string, unknown>;
// Validate number types
for (const prop of ['size', 'from']) {
if (prop in obj && obj[prop] !== undefined && typeof obj[prop] !== 'number') {
return false;
}
}
// Validate string types
if ('searchAfter' in obj && obj.searchAfter !== undefined && typeof obj.searchAfter !== 'string') {
return false;
}
// Validate companiesFilters and peopleFilters objects
if ('companiesFilters' in obj && obj.companiesFilters !== undefined) {
if (typeof obj.companiesFilters !== 'object' || obj.companiesFilters === null) {
return false;
}
}
if ('peopleFilters' in obj && obj.peopleFilters !== undefined) {
if (typeof obj.peopleFilters !== 'object' || obj.peopleFilters === null) {
return false;
}
}
return true;
}
// Server implementation
const server = new Server(
{
name: 'ocean-io-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
logging: {},
},
}
);
// Get API key from environment variables
const OCEAN_API_KEY = process.env.OCEAN_API_KEY;
const OCEAN_API_URL_V1 = 'https://api.ocean.io/v1';
const OCEAN_API_URL_V2 = 'https://api.ocean.io/v2';
// Check if API key is provided
if (!OCEAN_API_KEY) {
console.error('Error: OCEAN_API_KEY environment variable is required');
process.exit(1);
}
// Configuration for retries and monitoring
const CONFIG = {
retry: {
maxAttempts: Number(process.env.OCEAN_RETRY_MAX_ATTEMPTS) || 3,
initialDelay: Number(process.env.OCEAN_RETRY_INITIAL_DELAY) || 1000,
maxDelay: Number(process.env.OCEAN_RETRY_MAX_DELAY) || 10000,
backoffFactor: Number(process.env.OCEAN_RETRY_BACKOFF_FACTOR) || 2,
},
};
// Initialize Axios instances for API requests
const apiClientV1: AxiosInstance = axios.create({
baseURL: OCEAN_API_URL_V1,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OCEAN_API_KEY}`
}
});
const apiClientV2: AxiosInstance = axios.create({
baseURL: OCEAN_API_URL_V2,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OCEAN_API_KEY}`
}
});
let isStdioTransport = false;
function safeLog(
level:
| 'error'
| 'debug'
| 'info'
| 'notice'
| 'warning'
| 'critical'
| 'alert'
| 'emergency',
data: any
): void {
if (isStdioTransport) {
// For stdio transport, log to stderr to avoid protocol interference
console.error(
`[${level}] ${typeof data === 'object' ? JSON.stringify(data) : data}`
);
} else {
// For other transport types, use the normal logging mechanism
server.sendLoggingMessage({ level, data });
}
}
// Add utility function for delay
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Add retry logic with exponential backoff
async function withRetry<T>(
operation: () => Promise<T>,
context: string,
attempt = 1
): Promise<T> {
try {
return await operation();
} catch (error) {
const isRateLimit =
error instanceof Error &&
(error.message.includes('rate limit') || error.message.includes('429'));
if (isRateLimit && attempt < CONFIG.retry.maxAttempts) {
const delayMs = Math.min(
CONFIG.retry.initialDelay *
Math.pow(CONFIG.retry.backoffFactor, attempt - 1),
CONFIG.retry.maxDelay
);
safeLog(
'warning',
`Rate limit hit for ${context}. Attempt ${attempt}/${CONFIG.retry.maxAttempts}. Retrying in ${delayMs}ms`
);
await delay(delayMs);
return withRetry(operation, context, attempt + 1);
}
throw error;
}
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
FIND_LOOKALIKE_COMPANIES_TOOL,
SEARCH_COMPANIES_TOOL,
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const startTime = Date.now();
try {
const { name, arguments: args } = request.params;
// Log incoming request with timestamp
safeLog(
'info',
`[${new Date().toISOString()}] Received request for tool: ${name}`
);
if (!args) {
throw new Error('No arguments provided');
}
switch (name) {
case 'ocean_find_lookalike_companies': {
if (!isFindLookalikeCompaniesParams(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for ocean_find_lookalike_companies. You must provide at least one of: company_name, company_domain, or company_id.'
);
}
try {
// First, if we don't have a company_id, we need to find it
let companyId = args.company_id;
if (!companyId) {
// Search for the company by name or domain
const searchParams: any = {};
if (args.company_name) {
searchParams.name = args.company_name;
}
if (args.company_domain) {
searchParams.domain = args.company_domain;
}
const searchResponse = await withRetry(
async () => apiClientV1.get('/companies/search', { params: searchParams }),
'search company'
);
if (!searchResponse.data.companies || searchResponse.data.companies.length === 0) {
return {
content: [
{
type: 'text',
text: `No companies found matching the provided criteria.`,
},
],
isError: true,
};
}
// Use the first company found
companyId = searchResponse.data.companies[0].id;
}
// Now find lookalike companies
const lookalikeParams: any = {
company_id: companyId,
limit: args.limit || 10
};
if (args.min_similarity !== undefined) {
lookalikeParams.min_similarity = args.min_similarity;
}
const response = await withRetry(
async () => apiClientV1.get('/companies/lookalikes', { params: lookalikeParams }),
'find lookalike companies'
);
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
isError: false,
};
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? `API Error: ${error.response?.data?.message || error.message}`
: `Error: ${error instanceof Error ? error.message : String(error)}`;
return {
content: [{ type: 'text', text: errorMessage }],
isError: true,
};
}
}
case 'ocean_search_companies': {
if (!isSearchCompaniesParams(args)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid arguments for ocean_search_companies.'
);
}
try {
// Prepare the request payload
const payload: any = {};
// Add pagination parameters
if (args.size !== undefined) {
payload.size = args.size;
}
if (args.from !== undefined) {
payload.from = args.from;
}
if (args.searchAfter !== undefined) {
payload.searchAfter = args.searchAfter;
}
// Add filters
if (args.companiesFilters) {
payload.companiesFilters = args.companiesFilters;
}
if (args.peopleFilters) {
payload.peopleFilters = args.peopleFilters;
}
// Make the API request to the v2 endpoint
const response = await withRetry(
async () => apiClientV2.post('/search/companies', payload, {
params: { apiToken: OCEAN_API_KEY }
}),
'search companies'
);
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
isError: false,
};
} catch (error) {
const errorMessage = axios.isAxiosError(error)
? `API Error: ${error.response?.data?.message || error.message}`
: `Error: ${error instanceof Error ? error.message : String(error)}`;
return {
content: [{ type: 'text', text: errorMessage }],
isError: true,
};
}
}
default:
return {
content: [
{ type: 'text', text: `Unknown tool: ${name}` },
],
isError: true,
};
}
} catch (error) {
// Log detailed error information
safeLog('error', {
message: `Request failed: ${
error instanceof Error ? error.message : String(error)
}`,
tool: request.params.name,
arguments: request.params.arguments,
timestamp: new Date().toISOString(),
duration: Date.now() - startTime,
});
return {
content: [
{
type: 'text',
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
} finally {
// Log request completion with performance metrics
safeLog('info', `Request completed in ${Date.now() - startTime}ms`);
}
});
// Server startup
async function runServer() {
try {
console.error('Initializing Ocean.io MCP Server...');
const transport = new StdioServerTransport();
// Detect if we're using stdio transport
isStdioTransport = transport instanceof StdioServerTransport;
if (isStdioTransport) {
console.error(
'Running in stdio mode, logging will be directed to stderr'
);
}
await server.connect(transport);
// Now that we're connected, we can send logging messages
safeLog('info', 'Ocean.io MCP Server initialized successfully');
safeLog(
'info',
`Configuration: API URLs: V1: ${OCEAN_API_URL_V1}, V2: ${OCEAN_API_URL_V2}`
);
console.error('Ocean.io MCP Server running on stdio');
} catch (error) {
console.error('Fatal error running server:', error);
process.exit(1);
}
}
runServer().catch((error: any) => {
console.error('Fatal error running server:', error);
process.exit(1);
});