Skip to main content
Glama

JoeMCP

index-migrated.ts19.5 kB
#!/usr/bin/env node /** * JoeAPI MCP Server - Migrated to Smithery HTTP Transport * * Exposes JoeAPI construction management REST API as MCP tools. * Provides direct access to clients, contacts, proposals, estimates, * action items, projects, and financial data. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; // Export config schema for Smithery export const configSchema = z.object({ apiBaseUrl: z.string().url().default('https://joeapi.fly.dev').describe('Base URL for JoeAPI backend'), requestTimeout: z.number().optional().default(30000).describe('Request timeout in milliseconds'), apiKey: z.string().optional().describe('API key for authentication (if required)'), }); // Export default createServer function for Smithery export default function createServer({ config }: { config: z.infer<typeof configSchema> }) { const API_BASE_URL = config.apiBaseUrl; // Helper to handle API requests async function makeRequest( method: string, endpoint: string, data: any = null, params: Record<string, any> = {} ) { try { // Ensure endpoint starts with / const safeEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; // Build URL with query params const url = new URL(`${API_BASE_URL}/api/v1${safeEndpoint}`); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.append(key, String(value)); } }); } const options: RequestInit = { method, headers: { 'Content-Type': 'application/json', }, }; if (data && (method === 'POST' || method === 'PUT')) { options.body = JSON.stringify(data); } const response = await fetch(url.toString(), options); const responseData = await response.json(); if (!response.ok) { return { content: [ { type: 'text', text: `API Error ${response.status}: ${JSON.stringify(responseData, null, 2)}`, }, ], isError: true, }; } return { content: [ { type: 'text', text: JSON.stringify(responseData, null, 2), }, ], }; } catch (error: any) { const errorMsg = error.message || String(error); return { content: [ { type: 'text', text: `Network Error: ${errorMsg}`, }, ], isError: true, }; } } // Create MCP Server using new McpServer API const server = new McpServer({ name: 'joe-api-server', version: '1.0.0', }); // ========================================== // 1. CLIENTS & CONTACTS TOOLS // ========================================== server.registerTool( 'list_clients', { title: 'List Clients', description: 'Retrieve a paginated list of clients', inputSchema: { page: z.number().optional().describe('Page number (default: 1)'), limit: z.number().optional().describe('Items per page (default: 5, max: 100)'), }, }, async ({ page, limit }) => { return makeRequest('GET', '/clients', null, { page: page || 1, limit: limit || 5, }); } ); server.registerTool( 'create_client', { title: 'Create Client', description: 'Create a new client record', inputSchema: { Name: z.string().describe('Client name'), EmailAddress: z.string().describe('Client email address'), CompanyName: z.string().describe('Company name'), Phone: z.string().describe('Phone number'), }, }, async (args) => { return makeRequest('POST', '/clients', args); } ); server.registerTool( 'list_contacts', { title: 'List Contacts', description: 'Retrieve a list of contacts', inputSchema: { limit: z.number().optional().describe('Items per page (default: 5)'), }, }, async ({ limit }) => { return makeRequest('GET', '/contacts', null, { limit: limit || 5, }); } ); server.registerTool( 'create_contact', { title: 'Create Contact', description: 'Create a new contact', inputSchema: { Name: z.string().describe('Contact name'), Email: z.string().describe('Email address'), Phone: z.string().describe('Phone number'), City: z.string().optional().describe('City (optional)'), State: z.string().optional().describe('State (optional)'), }, }, async (args) => { return makeRequest('POST', '/contacts', args); } ); // ========================================== // 2. PROPOSALS & ESTIMATES TOOLS // ========================================== server.registerTool( 'list_proposals', { title: 'List Proposals', description: 'List all proposals', inputSchema: { limit: z.number().optional().describe('Items per page (default: 5)'), }, }, async ({ limit }) => { return makeRequest('GET', '/proposals', null, { limit: limit || 5, }); } ); server.registerTool( 'get_proposal_details', { title: 'Get Proposal Details', description: 'Get specific proposal details including lines', inputSchema: { proposalId: z.string().describe('UUID of the proposal'), includeLines: z.boolean().optional().describe('If true, fetches proposal lines in a separate request (default: false)'), }, }, async ({ proposalId, includeLines }) => { const proposal = await makeRequest('GET', `/proposals/${proposalId}`); if (includeLines && !proposal.isError) { const lines = await makeRequest('GET', '/proposallines', null, { proposalId: proposalId, }); return { content: [ { type: 'text', text: `PROPOSAL:\n${proposal.content[0].text}\n\nLINES:\n${lines.content[0].text}`, }, ], }; } return proposal; } ); server.registerTool( 'list_estimates', { title: 'List Estimates', description: 'List all estimates', inputSchema: { limit: z.number().optional().describe('Items per page (default: 5)'), }, }, async ({ limit }) => { return makeRequest('GET', '/estimates', null, { limit: limit || 5, }); } ); // ========================================== // 3. ACTION ITEMS TOOLS // ========================================== server.registerTool( 'list_action_items', { title: 'List Action Items', description: 'List action items for a specific project', inputSchema: { projectId: z.string().describe('UUID of the project'), limit: z.number().optional().describe('Items per page (default: 5)'), }, }, async ({ projectId, limit }) => { return makeRequest('GET', '/action-items', null, { projectId: projectId, limit: limit || 5, }); } ); server.registerTool( 'create_action_item', { title: 'Create Action Item', description: 'Create a new Action Item. Can be Generic (ActionTypeId=3), Cost Change (ActionTypeId=1), or Schedule Change (ActionTypeId=2). For Cost/Schedule changes, include the corresponding nested object.', inputSchema: { Title: z.string().describe('Action item title'), Description: z.string().describe('Action item description'), ProjectId: z.string().describe('UUID of the project'), ActionTypeId: z.number().describe('1=CostChange, 2=ScheduleChange, 3=Generic'), DueDate: z.string().describe('ISO Date YYYY-MM-DD'), Status: z.number().optional().describe('Status code (default: 1)'), Source: z.number().optional().describe('Source code (default: 1)'), InitialComment: z.string().optional().describe('Initial comment (optional)'), CostChange: z.object({ Amount: z.number().describe('Cost change amount'), EstimateCategoryId: z.string().describe('UUID of estimate category'), RequiresClientApproval: z.boolean().describe('Whether client approval is required'), }).optional().describe('Cost change details (required if ActionTypeId=1)'), ScheduleChange: z.object({ NoOfDays: z.number().describe('Number of days to adjust schedule'), ConstructionTaskId: z.string().describe('UUID of construction task'), RequiresClientApproval: z.boolean().describe('Whether client approval is required'), }).optional().describe('Schedule change details (required if ActionTypeId=2)'), }, }, async (args) => { return makeRequest('POST', '/action-items', args); } ); server.registerTool( 'add_action_item_comment', { title: 'Add Action Item Comment', description: 'Add a comment to an action item', inputSchema: { actionItemId: z.string().describe('Action item ID'), comment: z.string().describe('Comment text'), }, }, async ({ actionItemId, comment }) => { return makeRequest('POST', `/action-items/${actionItemId}/comments`, { Comment: comment, }); } ); server.registerTool( 'assign_action_item_supervisor', { title: 'Assign Action Item Supervisor', description: 'Assign a supervisor to an action item', inputSchema: { actionItemId: z.string().describe('Action item ID'), supervisorId: z.number().describe('Supervisor user ID'), }, }, async ({ actionItemId, supervisorId }) => { return makeRequest('POST', `/action-items/${actionItemId}/supervisors`, { SupervisorId: supervisorId, }); } ); // ========================================== // 4. PROJECTS TOOLS // ========================================== server.registerTool( 'list_projects', { title: 'List Projects', description: 'List all projects with pagination', inputSchema: { page: z.number().optional().describe('Page number (default: 1)'), limit: z.number().optional().describe('Items per page (default: 5, max: 100)'), }, }, async ({ page, limit }) => { return makeRequest('GET', '/project-details', null, { page: page || 1, limit: limit || 5, }); } ); server.registerTool( 'get_project_details', { title: 'Get Project Details', description: 'Get full details of a project', inputSchema: { projectId: z.string().describe('UUID of the project'), }, }, async ({ projectId }) => { return makeRequest('GET', `/project-details/${projectId}`); } ); server.registerTool( 'list_project_schedules', { title: 'List Project Schedules', description: 'List project schedules', inputSchema: { limit: z.number().optional().describe('Items per page (default: 5)'), }, }, async ({ limit }) => { return makeRequest('GET', '/project-schedules', null, { limit: limit || 5, }); } ); server.registerTool( 'search', { title: 'Search Projects', description: 'Search for a project and get comprehensive data including transactions, action items, estimates, and schedule revisions', inputSchema: { query: z.string().describe('Search query to find the project (searches in project name, description, status)'), projectId: z.string().optional().describe('Optional: Direct project ID if already known (skips search step)'), }, }, async ({ query, projectId }) => { let resolvedProjectId = projectId; // Step 1: If no projectId provided, use the new /search endpoint if (!resolvedProjectId) { const searchResponse = await makeRequest('GET', '/search', null, { q: query, }); if (searchResponse.isError) { return searchResponse; } // Parse the response to get first matching project try { const searchData = JSON.parse(searchResponse.content[0].text); const projects = searchData.data || []; if (projects.length === 0) { return { content: [ { type: 'text', text: `No project found matching query: "${query}"`, }, ], isError: true, }; } // Use first match resolvedProjectId = projects[0].projectId; } catch (error: any) { return { content: [ { type: 'text', text: `Error parsing search results: ${error.message}`, }, ], isError: true, }; } } // Step 2: Fetch all project data in parallel const [ transactions, actionItems, estimates, scheduleRevisions, estimateRevisions, ] = await Promise.all([ makeRequest('GET', '/transactions', null, { projectId: resolvedProjectId }), makeRequest('GET', '/action-items', null, { projectId: resolvedProjectId }), makeRequest('GET', '/estimates', null, { projectId: resolvedProjectId }), makeRequest('GET', '/schedule-revisions', null, { projectId: resolvedProjectId }), makeRequest('GET', '/estimates/revision-history', null, { projectId: resolvedProjectId }), ]); // Step 3: Aggregate results const sections = [ { title: 'TRANSACTIONS', data: transactions }, { title: 'ACTION ITEMS', data: actionItems }, { title: 'ESTIMATES', data: estimates }, { title: 'SCHEDULE REVISIONS', data: scheduleRevisions }, { title: 'ESTIMATE REVISIONS', data: estimateRevisions }, ]; const output = sections .map(({ title, data }) => { if (data.isError) { return `${title}:\nError: ${data.content[0].text}`; } return `${title}:\n${data.content[0].text}`; }) .join('\n\n---\n\n'); return { content: [ { type: 'text', text: `PROJECT SEARCH RESULTS (Project ID: ${resolvedProjectId})\n\n${output}`, }, ], }; } ); // ========================================== // 5. FINANCIAL TOOLS // ========================================== server.registerTool( 'get_financial_summary', { title: 'Get Financial Summary', description: 'Get transaction summary grouped by timeframe', inputSchema: { groupBy: z.enum(['month', 'year', 'week']).optional().describe('Group by: month, year, or week (default: month)'), startDate: z.string().describe('Start date in YYYY-MM-DD format'), endDate: z.string().describe('End date in YYYY-MM-DD format'), }, }, async ({ groupBy, startDate, endDate }) => { return makeRequest('GET', '/transactions/summary', null, { groupBy: groupBy || 'month', startDate: startDate, endDate: endDate, }); } ); server.registerTool( 'get_project_finances', { title: 'Get Project Finances', description: 'Get financial overview for a specific project (Job Balances and Cost Variance)', inputSchema: { projectId: z.string().describe('UUID of the project'), }, }, async ({ projectId }) => { const balances = await makeRequest('GET', '/job-balances', null, { projectId: projectId, }); const variance = await makeRequest('GET', '/cost-variance', null, { projectId: projectId, }); return { content: [ { type: 'text', text: `JOB BALANCES:\n${balances.content[0].text}\n\nCOST VARIANCE:\n${variance.content[0].text}`, }, ], }; } ); // ========================================== // 6. ASYNC AGENT TOOL // ========================================== server.registerTool( 'async', { title: 'Async Agent', description: 'Delegate complex multi-step workflows to the async-agent system. Use this for tasks that require multiple coordinated steps, data gathering from multiple sources, or complex orchestration.', inputSchema: { prompt: z.string().describe('The task or question to send to the async-agent'), callId: z.string().optional().describe('Optional call ID for tracking (if null, will be auto-generated)'), }, }, async ({ prompt, callId }) => { const ASYNC_AGENT_BASE_URL = 'https://joeapi-async-agent.fly.dev'; const TIMEOUT_MS = 120000; // 2 minutes try { // Prepare payload for async-agent const payload: any = { prompt: prompt, searchWorkflow: true, }; // Include callId if provided if (callId) { payload.callId = callId; } // Create AbortController for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); // Call async-agent webhook const response = await fetch(`${ASYNC_AGENT_BASE_URL}/webhooks/prompt`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); return { content: [ { type: 'text', text: `Async-agent error (${response.status}): ${errorText}`, }, ], isError: true, }; } const data = await response.json(); // Return formatted response return { content: [ { type: 'text', text: JSON.stringify(data, null, 2), }, ], }; } catch (error: any) { if (error.name === 'AbortError') { return { content: [ { type: 'text', text: `Async-agent timeout: Request exceeded ${TIMEOUT_MS / 1000} seconds`, }, ], isError: true, }; } return { content: [ { type: 'text', text: `Async-agent error: ${error.message || String(error)}`, }, ], isError: true, }; } } ); return server.server; } // Main function for STDIO compatibility async function main() { const apiBaseUrl = process.env.JOEAPI_BASE_URL || 'https://joeapi.fly.dev'; const apiKey = process.env.JOEAPI_API_KEY; const server = createServer({ config: { apiBaseUrl, requestTimeout: 30000, apiKey }, }); const transport = new StdioServerTransport(); await server.connect(transport); console.error('JoeAPI MCP Server running on stdio'); } main().catch((error) => { console.error('Fatal error in main():', 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/lumberjack-so/joeMCP'

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