Skip to main content
Glama
index.js56 kB
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { validateEnv } from './config.js'; // Load and validate environment variables const env = validateEnv(); // Construct API base URL using domain from config const LOXO_API_BASE = `https://${env.LOXO_DOMAIN}/api`; // MCP Best Practice: Character limit for responses to prevent overwhelming context const CHARACTER_LIMIT = 25000; // Helper function to truncate responses with clear messaging function truncateResponse(content, limit = CHARACTER_LIMIT) { if (content.length <= limit) { return { text: content, wasTruncated: false }; } const truncated = content.substring(0, limit); const message = `\n\n[Response truncated at ${limit} characters. Original length: ${content.length} characters. Use filtering parameters to reduce result size.]`; return { text: truncated + message, wasTruncated: true }; } // Helper function to format responses based on format preference function formatResponse(data, format = 'json') { if (format === 'markdown') { // For now, return JSON wrapped in markdown code block // Future enhancement: convert to proper markdown tables/lists return '```json\n' + JSON.stringify(data, null, 2) + '\n```'; } return JSON.stringify(data, null, 2); } // Helper function to create actionable error messages function formatApiError(status, statusText, responseBody, endpoint) { switch (status) { case 401: return `Authentication failed: Invalid or expired API key.\n\nNext steps:\n1. Verify your LOXO_API_KEY in .env is correct\n2. Check if your API key has expired in Loxo settings\n3. Ensure you have API access enabled for your account`; case 403: return `Access forbidden: You don't have permission to access this resource.\n\nNext steps:\n1. Verify your API key has the required permissions\n2. Check if this endpoint requires specific user roles\n3. Contact your Loxo administrator if you need elevated access`; case 404: const idMatch = endpoint.match(/\/(\d+|[a-f0-9-]{36})(?:\/|$)/); const id = idMatch ? idMatch[1] : 'specified'; return `Resource not found: The ${id} ID does not exist.\n\nNext steps:\n1. Verify the ID is correct\n2. Check if the resource was deleted\n3. Use search tools to find the correct ID`; case 422: return `Invalid request: The provided data is invalid.\n\nDetails: ${responseBody}\n\nNext steps:\n1. Check required fields are provided\n2. Verify field formats (dates as ISO strings, IDs as strings/integers)\n3. Review tool parameter requirements`; case 429: return `Rate limit exceeded: Too many requests.\n\nNext steps:\n1. Wait a few moments before retrying\n2. Reduce request frequency\n3. Contact Loxo support to increase your rate limit`; case 500: case 502: case 503: return `Loxo API server error (${status}): The Loxo service is experiencing issues.\n\nNext steps:\n1. Wait a few minutes and retry\n2. Check Loxo service status\n3. If issue persists, contact Loxo support`; default: return `API request failed (${status}): ${statusText}\n\nNext steps:\n1. Review the error details above\n2. Verify your request parameters\n3. Check Loxo API documentation for this endpoint`; } } // Helper function for API calls async function makeRequest(endpoint, options = {}) { const url = `${LOXO_API_BASE}${endpoint}`; const headers = { 'accept': 'application/json', 'authorization': `Bearer ${env.LOXO_API_KEY}`, ...options.headers }; try { const response = await fetch(url, { ...options, headers }); const responseText = await response.text(); if (!response.ok) { console.error('API Response:', { status: response.status, statusText: response.statusText, body: responseText }); // Throw actionable error message const errorMessage = formatApiError(response.status, response.statusText, responseText, endpoint); throw new Error(errorMessage); } // Only try to parse as JSON if we have content return responseText ? JSON.parse(responseText) : null; } catch (error) { // If it's already our formatted error, re-throw it if (error instanceof Error && error.message.includes('Next steps:')) { throw error; } // Handle network errors and other issues if (error instanceof Error) { if (error.message.includes('fetch')) { throw new Error(`Network error: Unable to connect to Loxo API.\n\nNext steps:\n1. Check your internet connection\n2. Verify LOXO_DOMAIN is correct in .env (current: ${env.LOXO_DOMAIN})\n3. Check if Loxo API is accessible from your network`); } if (error.message.includes('JSON')) { throw new Error(`Invalid response: Loxo API returned malformed data.\n\nNext steps:\n1. Retry the request\n2. Check if the endpoint is correct\n3. Contact Loxo support if issue persists`); } } // Log internal details but don't expose them to user console.error('Unexpected error:', error); console.error('Request details:', { endpoint, method: options.method || 'GET', hasBody: !!options.body }); // Generic fallback error throw new Error(`Unexpected error occurred.\n\nNext steps:\n1. Retry the request\n2. Check your parameters are valid\n3. Review logs for technical details`); } } // Add before the server creation // Add after imports const PersonEventSchema = z.object({ person_id: z.string().optional(), job_id: z.string().optional(), company_id: z.string().optional(), activity_type_id: z.string(), notes: z.string().optional(), created_at: z.string().optional(), // For scheduled events, set future datetime }); const SearchSchema = z.object({ query: z.string().optional(), company: z.string().optional(), title: z.string().optional(), scroll_id: z.union([z.number(), z.string()]).optional(), // Accept both number and string per_page: z.number().optional().default(100) }); // Schema specifically for search-candidates tool arguments const SearchCandidatesSchema = z.object({ query: z.string().optional().describe("General Lucene search query. Use for specific field searches like past companies, skills, etc."), company: z.string().optional().describe("Current company name to search for."), title: z.string().optional().describe("Current job title to search for."), scroll_id: z.string().optional().describe("Pagination scroll ID from previous search results."), // API expects string per_page: z.number().int().optional().default(100).describe("Number of results per page (default 100, max typically 100 by Loxo)."), person_global_status_id: z.number().int().optional().describe("Filter by person global status ID."), person_type_id: z.number().int().optional().describe("Filter by person type ID."), list_id: z.number().int().optional().describe("Filter by person list ID."), include_related_agencies: z.boolean().optional().describe("Include results from related agencies.") }); // Schema for search-companies tool const SearchCompaniesSchema = z.object({ query: z.string().optional().describe("Search query (Lucene syntax)."), scroll_id: z.string().optional().describe("Cursor for pagination."), company_type_id: z.number().int().optional().describe("Filter by company type ID."), list_id: z.number().int().optional().describe("Filter by list ID."), company_global_status_id: z.number().int().optional().describe("Filter by company global status ID.") }); // Schema for get-company-details tool const GetCompanyDetailsSchema = z.object({ company_id: z.number().int().describe("The ID of the company to retrieve.") }); // Schema for list-users tool const ListUsersSchema = z.object({}); // No specific input parameters const EntityIdSchema = z.object({ id: z.string() // Represents person_id for these tools }); const PersonSubResourceIdSchema = z.object({ person_id: z.string(), resource_id: z.string() // Represents job_profile_id or education_profile_id }); // Create server instance const server = new Server({ name: "loxo-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "loxo_get_activity_types", description: "Get a list of all available activity types in Loxo (e.g., calls, meetings, interviews). Use this before scheduling or logging activities to find the correct activity_type_id. Example: Call this first to get activity type IDs, then use loxo_schedule_activity with the correct ID.", inputSchema: { type: "object", properties: { response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: [], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_todays_tasks", description: "Get scheduled items (tasks, calls, meetings) for a date range. Uses cursor-based pagination with scroll_id. Examples: (1) Get today's tasks: omit all parameters. (2) Get tasks for specific user: provide user_id. (3) Get tasks for date range: provide start_date and end_date in ISO format (YYYY-MM-DD). Combine parameters to filter by user and date range.", inputSchema: { type: "object", properties: { user_id: { type: "number", description: "Optional: Filter by user ID" }, start_date: { type: "string", description: "Optional: Start date for filtering (ISO format)" }, end_date: { type: "string", description: "Optional: End date for filtering (ISO format)" }, per_page: { type: "number", description: "Number of results per page" }, scroll_id: { type: "string", description: "Cursor for pagination" }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: [], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_schedule_activity", description: "Schedule a future activity (call, meeting, interview) with a candidate. Use loxo_get_activity_types first to get the correct activity_type_id. Example: Schedule a call tomorrow at 2pm - set created_at to future ISO datetime (2024-01-15T14:00:00Z), provide person_id, activity_type_id for 'call', and notes. Optionally link to a job_id or company_id.", inputSchema: { type: "object", properties: { person_id: { type: "string", description: "ID of the person (candidate) for this activity" }, job_id: { type: "string", description: "Optional: ID of the job related to this activity" }, company_id: { type: "string", description: "Optional: ID of the company related to this activity" }, activity_type_id: { type: "string", description: "ID of the activity type" }, created_at: { type: "string", description: "ISO datetime when the activity should occur (future date/time for scheduled activities)" }, notes: { type: "string", description: "Notes about the scheduled activity" } }, required: ["activity_type_id", "created_at"] }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, { name: "loxo_search_candidates", description: "Search for candidates using Lucene query syntax. Uses cursor-based pagination with scroll_id. Returns skillsets and tags in results for filtering without additional API calls.\n\nIMPORTANT - LOXO FIELD NAME MAPPING:\n- Query uses 'skills' (search index field): query='skills:\"Python\"'\n- Response returns 'skillsets' (API field): {skillsets: \"Python, JavaScript\"}\n- Query uses 'all_raw_tags', response returns 'all_raw_tags' (same)\n\nSIMPLE QUERY EXAMPLES:\n(1) Past employer: query='job_profiles.company_name:\"Google\"'\n(2) Skills: query='skills:\"Python\"'\n(3) Current role: company='Acme Corp' and title='Engineer'\n\nCOMPLEX MULTI-CRITERIA EXAMPLES:\n(4) Multiple titles with skills: query='(current_title:\"Director\" OR current_title:\"Senior Director\") AND skills:\"financial due diligence\"'\n(5) Multiple role types at specific level: query='(current_title:(\"Deal Advisory\" OR \"Transaction Services\" OR \"Transaction Advisory\")) AND current_title:\"Director\" AND skills:\"due diligence\"'\n(6) Past companies with skills: query='(job_profiles.company_name:(\"KPMG\" OR \"Deloitte\" OR \"PwC\" OR \"EY\")) AND skills:(\"M&A\" OR \"financial due diligence\")'\n(7) Combined current AND past: query='current_title:\"Director\" AND job_profiles.company_name:(\"Big 4\") AND skills:\"financial modeling\"'\n(8) Tags: query='all_raw_tags:\"key account\"'\n\nNULL/EMPTY FIELD SEARCHES (data quality checks):\n(9) Candidates WITHOUT skills: query='NOT _exists_:skills'\n(10) Candidates WITH skills: query='_exists_:skills'\n(11) Candidates WITHOUT tags: query='NOT _exists_:all_raw_tags'\n(12) Candidates missing location: query='NOT _exists_:location'\n(13) Candidates missing current company: query='NOT _exists_:current_company'\n\nTIPS: Use OR for multiple options, AND to combine criteria, parentheses for grouping, NOT _exists_:fieldname for null checks. ALWAYS use search index field names (skills not skillsets) in queries. Start with comprehensive queries to get all relevant candidates in fewer API calls.\n\nReturns: id, name, current_title, current_company, location, skillsets (from 'skills' field), all_raw_tags. Use scroll_id from pagination for next page.", inputSchema: { type: "object", properties: { query: { type: "string", description: "General Lucene search query. Use for specific field searches like past companies, skills, etc. (optional)" }, company: { type: "string", description: "Current company name to search for (optional)" }, title: { type: "string", description: "Current job title to search for (optional)" }, scroll_id: { type: "string", // OpenAPI spec says string for scroll_id description: "Pagination scroll ID from previous search results." }, per_page: { type: "number", description: "Number of results per page (default 100, max typically 100 by Loxo)." }, person_global_status_id: { type: "integer", description: "Filter by person global status ID." }, person_type_id: { type: "integer", description: "Filter by person type ID." }, list_id: { type: "integer", description: "Filter by person list ID." }, include_related_agencies: { type: "boolean", description: "Include results from related agencies." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } } }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_candidate", description: "Get complete candidate profile including bio, location, current role, skills, tags, compensation, and embedded lists of jobs/education/emails/phones. Use this for overview. For guaranteed complete contact info or work history, use dedicated tools: loxo_get_person_emails, loxo_get_person_phones, loxo_list_person_job_profiles, loxo_list_person_education_profiles. Example: After searching candidates, use their ID here to get full details.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Candidate ID" }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["id"] }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_person_emails", description: "Get all email addresses for a candidate with type information (work, personal, etc.). Use when you need guaranteed complete email list or when candidate profile doesn't include emails. Example: After finding a candidate, use their ID to get all email addresses for outreach.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the person." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_person_phones", description: "Get all phone numbers for a candidate with type information (mobile, work, home, etc.). Use when you need guaranteed complete phone list or when candidate profile doesn't include phones. Example: After finding a candidate, use their ID to get all phone numbers for calling.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the person." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_list_person_job_profiles", description: "Get complete work history for a candidate (all job profiles with company, title, dates, descriptions). Returns list of all positions. Use loxo_get_person_job_profile_detail for additional details of a specific position if needed. Example: After finding a candidate with Google experience, get their full work history to see all roles and tenure.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the person." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_person_job_profile_detail", description: "Get detailed information about a specific position in a candidate's work history. Use after loxo_list_person_job_profiles to get additional details for a particular job. Requires both person_id and resource_id (job profile ID from list response). Example: Candidate worked at 3 companies, get details of their Google role specifically.", inputSchema: { type: "object", properties: { person_id: { type: "string", description: "The ID of the person." }, resource_id: { type: "string", description: "The ID of the job profile." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["person_id", "resource_id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_list_person_education_profiles", description: "Get complete education history for a candidate (degrees, schools, graduation dates, descriptions). Returns list of all education entries. Use loxo_get_person_education_profile_detail for additional details if needed. Example: Check if candidate has required degree or attended target schools.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the person." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_person_education_profile_detail", description: "Get detailed information about a specific education entry in a candidate's profile. Use after loxo_list_person_education_profiles to get additional details. Requires both person_id and resource_id (education profile ID from list response). Example: Candidate has multiple degrees, get details of their Stanford MBA specifically.", inputSchema: { type: "object", properties: { person_id: { type: "string", description: "The ID of the person." }, resource_id: { type: "string", description: "The ID of the education profile." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["person_id", "resource_id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_search_jobs", description: "Search for jobs using Lucene query syntax. Uses page-based pagination (NOT scroll_id like candidates). Lucene examples: (1) Title: query='title:\"Senior Engineer\"' (2) Location: query='location:\"Remote\"' (3) Combined: query='title:\"Engineer\" AND location:\"San Francisco\"'. Use page parameter for pagination (starts at 1). Returns job listings with key details. Example: Find all remote senior positions to match with candidates.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query for jobs (Lucene syntax supported)" }, page: { type: "number", description: "Page number for pagination (starting at 1)" }, per_page: { type: "number", description: "Number of results per page" }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } } }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_job", description: "Get complete job details including description, requirements, compensation, status, hiring team, and related contacts. Use after searching jobs to get full posting details. Example: After finding relevant jobs via search, get full details to assess candidate fit or share with candidate.", inputSchema: { type: "object", properties: { id: { type: "string", description: "Job ID" }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["id"] }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_log_activity", description: "Log a completed activity (call, email, interview) that already happened. Uses current timestamp automatically. Use loxo_get_activity_types first to get correct activity_type_id. Example: Just finished phone screen with candidate - log it with activity_type_id for 'phone screen', person_id, and notes about the conversation. Optionally link to job_id or company_id.", inputSchema: { type: "object", properties: { person_id: { type: "string", description: "ID of the person (candidate) for this activity" }, job_id: { type: "string", description: "Optional: ID of the job related to this activity" }, company_id: { type: "string", description: "Optional: ID of the company related to this activity" }, activity_type_id: { type: "string", description: "ID of the activity type" }, notes: { type: "string", description: "Notes about the completed activity" } }, required: ["activity_type_id"] }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, { name: "loxo_search_companies", description: "Search for companies using Lucene query syntax. Uses cursor-based pagination with scroll_id. Lucene examples: (1) Name: query='name:\"Acme*\"' (wildcard search) (2) Combine with filters: query + company_type_id or company_global_status_id. Use scroll_id from response for next page. Example: Find all tech companies in your database to source candidates from target employers.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query (Lucene syntax)." }, scroll_id: { type: "string", description: "Cursor for pagination." }, company_type_id: { type: "integer", description: "Filter by company type ID." }, list_id: { type: "integer", description: "Filter by list ID." }, company_global_status_id: { type: "integer", description: "Filter by company global status ID." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: [], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_get_company_details", description: "Get complete company profile including description, contacts, relationships, and status. Use after searching companies to get full details. Requires company_id (integer). Example: After finding target companies via search, get full details to understand hiring contacts and company background for candidate sourcing.", inputSchema: { type: "object", properties: { company_id: { type: "integer", description: "The ID of the company to retrieve." }, response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: ["company_id"], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, }, { name: "loxo_list_users", description: "Get all users in your Loxo agency (recruiters, coordinators, etc.) with names and emails. Use this to find user_id values for filtering scheduled tasks or assigning ownership. Example: Get all recruiters to see who owns which candidates or to filter tasks by specific team member.", inputSchema: { type: "object", properties: { response_format: { type: "string", enum: ["json", "markdown"], description: "Response format: 'json' for structured data (default), 'markdown' for human-readable formatted text" } }, required: [], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, }, } ] }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args = {} } = request.params; try { switch (name) { case "loxo_get_activity_types": { const { response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/activity_types`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_todays_tasks": { const { user_id, start_date, end_date, per_page, scroll_id, response_format = 'json' } = args; let searchParams = new URLSearchParams(); if (user_id) searchParams.append('user_id', user_id.toString()); if (start_date) searchParams.append('start_date', start_date); if (end_date) searchParams.append('end_date', end_date); if (per_page) searchParams.append('per_page', per_page.toString()); if (scroll_id) searchParams.append('scroll_id', scroll_id); const apiResponse = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/schedule_items?${searchParams.toString()}`); // Structure response with pagination metadata const items = apiResponse?.schedule_items || apiResponse?.items || apiResponse || []; const toolResponse = { results: items, pagination: { scroll_id: apiResponse?.scroll_id || null, has_more: !!(apiResponse?.scroll_id), total_count: apiResponse?.total_count || 0, returned_count: Array.isArray(items) ? items.length : 0 } }; const formatted = formatResponse(toolResponse, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_schedule_activity": { const { person_id, job_id, company_id, activity_type_id, created_at, notes } = PersonEventSchema.parse(args); const formData = new URLSearchParams(); if (person_id) formData.append('person_event[person_id]', person_id); if (job_id) formData.append('person_event[job_id]', job_id); if (company_id) formData.append('person_event[company_id]', company_id); formData.append('person_event[activity_type_id]', activity_type_id); if (created_at) formData.append('person_event[created_at]', created_at); if (notes) formData.append('person_event[notes]', notes); const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/person_events`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData.toString() }); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } case "loxo_search_candidates": { const { query, company, title, scroll_id, per_page, person_global_status_id, person_type_id, list_id, include_related_agencies, response_format = 'json' } = args; let searchParams = new URLSearchParams(); if (per_page) searchParams.append('per_page', per_page.toString()); if (scroll_id) searchParams.append('scroll_id', scroll_id); // scroll_id is already string from schema if (person_global_status_id) searchParams.append('person_global_status_id', person_global_status_id.toString()); if (person_type_id) searchParams.append('person_type_id', person_type_id.toString()); if (list_id) searchParams.append('list_id', list_id.toString()); if (include_related_agencies !== undefined) searchParams.append('include_related_agencies', include_related_agencies.toString()); let constructedQueryParts = []; if (query) { // User-provided base query constructedQueryParts.push(`(${query})`); } if (company) { // Specific field for current company if Loxo supports it, otherwise part of general query // Assuming 'current_company_name_text' or similar. This is speculative. // If not known, this should be part of the general 'query' input by the user. // For now, let's keep it simple and assume it's part of the main query string or Loxo handles it. // A more robust solution would require knowing Loxo's exact Lucene schema. // Let's assume for now that if 'company' is provided, it's added to the general query. constructedQueryParts.push(`current_company_name_text:"${company}"`); // Example, might need adjustment } if (title) { // Similar for title constructedQueryParts.push(`current_title_text:"${title}"`); // Example } const finalQueryString = constructedQueryParts.length > 0 ? constructedQueryParts.join(' AND ') : (query ? query : '*:*'); searchParams.append('query', finalQueryString); console.error('Final Search query for API:', finalQueryString); try { const apiResponse = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people?${searchParams.toString()}`); const candidateResults = (apiResponse?.people || []).map((person) => ({ id: person.id, name: person.name, current_title: person.current_title, current_company: person.current_company, location: person.location, skillsets: person.skillsets, all_raw_tags: person.all_raw_tags, })); const toolResponse = { results: candidateResults, pagination: { scroll_id: apiResponse?.scroll_id || null, has_more: !!(apiResponse?.scroll_id), total_count: apiResponse?.total_count || 0, returned_count: candidateResults.length } }; // Format and truncate response const formatted = formatResponse(toolResponse, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } catch (err) { const error = err; console.error('Search error:', error); return { content: [{ type: "text", text: `Error searching candidates: ${error.message}` }], isError: true }; } } case "loxo_get_candidate": { const { id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${id}`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_search_jobs": { const { query, per_page, page = 1, response_format = 'json' } = args; // Build search params let searchParams = new URLSearchParams(); if (query) searchParams.append('query', query); if (per_page) searchParams.append('per_page', per_page.toString()); searchParams.append('page', page.toString()); const apiResponse = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/jobs?${searchParams.toString()}`); // Structure response with pagination metadata const jobs = apiResponse?.jobs || apiResponse || []; const totalCount = apiResponse?.total_count || 0; const returnedCount = Array.isArray(jobs) ? jobs.length : 0; const perPageValue = per_page || 20; const currentPage = page; const totalPages = totalCount > 0 ? Math.ceil(totalCount / perPageValue) : 1; const toolResponse = { results: jobs, pagination: { page: currentPage, per_page: perPageValue, total_pages: totalPages, total_count: totalCount, returned_count: returnedCount, has_more: currentPage < totalPages, next_page: currentPage < totalPages ? currentPage + 1 : null } }; const formatted = formatResponse(toolResponse, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_job": { const { id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/jobs/${id}`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_log_activity": { const { person_id, job_id, company_id, activity_type_id, notes } = PersonEventSchema.parse(args); const formData = new URLSearchParams(); if (person_id) formData.append('person_event[person_id]', person_id); if (job_id) formData.append('person_event[job_id]', job_id); if (company_id) formData.append('person_event[company_id]', company_id); formData.append('person_event[activity_type_id]', activity_type_id); if (notes) formData.append('person_event[notes]', notes); const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/person_events`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formData.toString() }); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } case "loxo_search_companies": { const { query, scroll_id, company_type_id, list_id, company_global_status_id, response_format = 'json' } = args; let searchParams = new URLSearchParams(); if (query) searchParams.append('query', query); if (scroll_id) searchParams.append('scroll_id', scroll_id); if (company_type_id) searchParams.append('company_type_id', company_type_id.toString()); if (list_id) searchParams.append('list_id', list_id.toString()); if (company_global_status_id) searchParams.append('company_global_status_id', company_global_status_id.toString()); const apiResponse = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/companies?${searchParams.toString()}`); const toolResponse = { results: apiResponse?.companies || [], pagination: { scroll_id: apiResponse?.scroll_id || null, has_more: !!(apiResponse?.scroll_id), total_count: apiResponse?.total_count || 0, returned_count: apiResponse?.companies?.length || 0 } }; const formatted = formatResponse(toolResponse, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_company_details": { const { company_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/companies/${company_id}`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_list_users": { const { response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/users`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_person_emails": { const { id: person_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${person_id}/emails`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_person_phones": { const { id: person_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${person_id}/phones`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_list_person_job_profiles": { const { id: person_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${person_id}/job_profiles`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_person_job_profile_detail": { const { person_id, resource_id: job_profile_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${person_id}/job_profiles/${job_profile_id}`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_list_person_education_profiles": { const { id: person_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${person_id}/education_profiles`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } case "loxo_get_person_education_profile_detail": { const { person_id, resource_id: education_profile_id, response_format = 'json' } = args; const response = await makeRequest(`/${env.LOXO_AGENCY_SLUG}/people/${person_id}/education_profiles/${education_profile_id}`); const formatted = formatResponse(response, response_format); const { text } = truncateResponse(formatted); return { content: [{ type: "text", text }] }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (err) { const error = err; console.error(`Error executing tool ${name}:`, error); // Handle Zod validation errors with actionable messages if (error.name === 'ZodError' || error.message.includes('Zod')) { return { content: [{ type: "text", text: `Parameter validation failed: ${error.message}\n\nNext steps:\n1. Check all required parameters are provided\n2. Verify parameter types match the schema\n3. Review tool documentation for parameter requirements` }], isError: true }; } // Pass through our formatted error messages or provide generic fallback return { content: [{ type: "text", text: error?.message || 'Unknown error occurred\n\nNext steps:\n1. Retry the request\n2. Check your parameters\n3. Review logs for details' }], isError: true }; } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Loxo MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error:", error); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tbensonwest/loxo-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server