Skip to main content
Glama

JoeMCP

index.ts.backup24.3 kB
#!/usr/bin/env node /** * JoeAPI MCP Server * * Exposes JoeAPI construction management REST API as MCP tools. * Provides direct access to clients, contacts, proposals, estimates, * action items, projects, and financial data. */ 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'; // Configuration: API URL from environment or production default const API_BASE_URL = process.env.JOEAPI_BASE_URL || 'https://joeapi.fly.dev'; // 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 const server = new Server( { name: 'joe-api-server', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // ========================================== // 1. CLIENTS & CONTACTS TOOLS // ========================================== server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'list_clients', description: 'Retrieve a paginated list of clients', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number (default: 1)', }, limit: { type: 'number', description: 'Items per page (default: 5, max: 100)', }, }, }, }, { name: 'create_client', description: 'Create a new client record', inputSchema: { type: 'object', properties: { Name: { type: 'string', description: 'Client name', }, EmailAddress: { type: 'string', description: 'Client email address', }, CompanyName: { type: 'string', description: 'Company name', }, Phone: { type: 'string', description: 'Phone number', }, }, required: ['Name', 'EmailAddress', 'CompanyName', 'Phone'], }, }, { name: 'list_contacts', description: 'Retrieve a list of contacts', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Items per page (default: 5)', }, }, }, }, { name: 'create_contact', description: 'Create a new contact', inputSchema: { type: 'object', properties: { Name: { type: 'string', description: 'Contact name', }, Email: { type: 'string', description: 'Email address', }, Phone: { type: 'string', description: 'Phone number', }, City: { type: 'string', description: 'City (optional)', }, State: { type: 'string', description: 'State (optional)', }, }, required: ['Name', 'Email', 'Phone'], }, }, // ========================================== // 2. PROPOSALS & ESTIMATES TOOLS // ========================================== { name: 'list_proposals', description: 'List all proposals', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Items per page (default: 5)', }, }, }, }, { name: 'get_proposal_details', description: 'Get specific proposal details including lines', inputSchema: { type: 'object', properties: { proposalId: { type: 'string', description: 'UUID of the proposal', }, includeLines: { type: 'boolean', description: 'If true, fetches proposal lines in a separate request (default: false)', }, }, required: ['proposalId'], }, }, { name: 'list_estimates', description: 'List all estimates', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Items per page (default: 5)', }, }, }, }, // ========================================== // 3. ACTION ITEMS TOOLS // ========================================== { name: 'list_action_items', description: 'List action items for a specific project', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'UUID of the project', }, limit: { type: 'number', description: 'Items per page (default: 5)', }, }, required: ['projectId'], }, }, { name: '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: { type: 'object', properties: { Title: { type: 'string', description: 'Action item title', }, Description: { type: 'string', description: 'Action item description', }, ProjectId: { type: 'string', description: 'UUID of the project', }, ActionTypeId: { type: 'number', description: '1=CostChange, 2=ScheduleChange, 3=Generic', }, DueDate: { type: 'string', description: 'ISO Date YYYY-MM-DD', }, Status: { type: 'number', description: 'Status code (default: 1)', }, Source: { type: 'number', description: 'Source code (default: 1)', }, InitialComment: { type: 'string', description: 'Initial comment (optional)', }, CostChange: { type: 'object', description: 'Cost change details (required if ActionTypeId=1)', properties: { Amount: { type: 'number', description: 'Cost change amount', }, EstimateCategoryId: { type: 'string', description: 'UUID of estimate category', }, RequiresClientApproval: { type: 'boolean', description: 'Whether client approval is required', }, }, }, ScheduleChange: { type: 'object', description: 'Schedule change details (required if ActionTypeId=2)', properties: { NoOfDays: { type: 'number', description: 'Number of days to adjust schedule', }, ConstructionTaskId: { type: 'string', description: 'UUID of construction task', }, RequiresClientApproval: { type: 'boolean', description: 'Whether client approval is required', }, }, }, }, required: ['Title', 'Description', 'ProjectId', 'ActionTypeId', 'DueDate'], }, }, { name: 'add_action_item_comment', description: 'Add a comment to an action item', inputSchema: { type: 'object', properties: { actionItemId: { type: 'string', description: 'Action item ID', }, comment: { type: 'string', description: 'Comment text', }, }, required: ['actionItemId', 'comment'], }, }, { name: 'assign_action_item_supervisor', description: 'Assign a supervisor to an action item', inputSchema: { type: 'object', properties: { actionItemId: { type: 'string', description: 'Action item ID', }, supervisorId: { type: 'number', description: 'Supervisor user ID', }, }, required: ['actionItemId', 'supervisorId'], }, }, // ========================================== // 4. PROJECTS TOOLS // ========================================== { name: 'list_projects', description: 'List all projects with pagination', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number (default: 1)', }, limit: { type: 'number', description: 'Items per page (default: 5, max: 100)', }, }, }, }, { name: 'get_project_details', description: 'Get full details of a project', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'UUID of the project', }, }, required: ['projectId'], }, }, { name: 'list_project_schedules', description: 'List project schedules', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Items per page (default: 5)', }, }, }, }, { name: 'search', description: 'Search for a project and get comprehensive data including transactions, action items, estimates, and schedule revisions', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query to find the project (searches in project name, description, status)', }, projectId: { type: 'string', description: 'Optional: Direct project ID if already known (skips search step)', }, }, required: ['query'], }, }, // ========================================== // 5. FINANCIAL TOOLS // ========================================== { name: 'get_financial_summary', description: 'Get transaction summary grouped by timeframe', inputSchema: { type: 'object', properties: { groupBy: { type: 'string', description: 'Group by: month, year, or week (default: month)', enum: ['month', 'year', 'week'], }, startDate: { type: 'string', description: 'Start date in YYYY-MM-DD format', }, endDate: { type: 'string', description: 'End date in YYYY-MM-DD format', }, }, required: ['startDate', 'endDate'], }, }, { name: 'get_project_finances', description: 'Get financial overview for a specific project (Job Balances and Cost Variance)', inputSchema: { type: 'object', properties: { projectId: { type: 'string', description: 'UUID of the project', }, }, required: ['projectId'], }, }, // ========================================== // 6. ASYNC AGENT TOOL // ========================================== { name: 'async', 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: { type: 'object', properties: { prompt: { type: 'string', description: 'The task or question to send to the async-agent', }, callId: { type: 'string', description: 'Optional call ID for tracking (if null, will be auto-generated)', }, }, required: ['prompt'], }, }, ], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; // Type guard: ensure args is defined if (!args) { return { content: [ { type: 'text', text: 'No arguments provided', }, ], isError: true, }; } try { switch (name) { // ===== CLIENTS ===== case 'list_clients': return makeRequest('GET', '/clients', null, { page: args.page || 1, limit: args.limit || 5, }); case 'create_client': return makeRequest('POST', '/clients', args); // ===== CONTACTS ===== case 'list_contacts': return makeRequest('GET', '/contacts', null, { limit: args.limit || 5, }); case 'create_contact': return makeRequest('POST', '/contacts', args); // ===== PROPOSALS ===== case 'list_proposals': return makeRequest('GET', '/proposals', null, { limit: args.limit || 5, }); case 'get_proposal_details': { const proposal = await makeRequest('GET', `/proposals/${args.proposalId}`); if (args.includeLines && !proposal.isError) { const lines = await makeRequest('GET', '/proposallines', null, { proposalId: args.proposalId, }); return { content: [ { type: 'text', text: `PROPOSAL:\n${proposal.content[0].text}\n\nLINES:\n${lines.content[0].text}`, }, ], }; } return proposal; } // ===== ESTIMATES ===== case 'list_estimates': return makeRequest('GET', '/estimates', null, { limit: args.limit || 5, }); // ===== ACTION ITEMS ===== case 'list_action_items': return makeRequest('GET', '/action-items', null, { projectId: args.projectId, limit: args.limit || 5, }); case 'create_action_item': return makeRequest('POST', '/action-items', args); case 'add_action_item_comment': return makeRequest('POST', `/action-items/${args.actionItemId}/comments`, { Comment: args.comment, }); case 'assign_action_item_supervisor': return makeRequest('POST', `/action-items/${args.actionItemId}/supervisors`, { SupervisorId: args.supervisorId, }); // ===== PROJECTS ===== case 'list_projects': return makeRequest('GET', '/project-details', null, { page: args.page || 1, limit: args.limit || 5, }); case 'get_project_details': return makeRequest('GET', `/project-details/${args.projectId}`); case 'list_project_schedules': return makeRequest('GET', '/project-schedules', null, { limit: args.limit || 5, }); case 'search': { const searchArgs = args as { query: string; projectId?: string }; let projectId = searchArgs.projectId; // Step 1: If no projectId provided, use the new /search endpoint if (!projectId) { const searchResponse = await makeRequest('GET', '/search', null, { q: searchArgs.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: "${searchArgs.query}"`, }, ], isError: true, }; } // Use first match projectId = 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 }), makeRequest('GET', '/action-items', null, { projectId }), makeRequest('GET', '/estimates', null, { projectId }), makeRequest('GET', '/schedule-revisions', null, { projectId }), makeRequest('GET', '/estimates/revision-history', null, { projectId }), ]); // 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: ${projectId})\n\n${output}`, }, ], }; } // ===== FINANCIAL ===== case 'get_financial_summary': return makeRequest('GET', '/transactions/summary', null, { groupBy: args.groupBy || 'month', startDate: args.startDate, endDate: args.endDate, }); case 'get_project_finances': { const balances = await makeRequest('GET', '/job-balances', null, { projectId: args.projectId, }); const variance = await makeRequest('GET', '/cost-variance', null, { projectId: args.projectId, }); return { content: [ { type: 'text', text: `JOB BALANCES:\n${balances.content[0].text}\n\nCOST VARIANCE:\n${variance.content[0].text}`, }, ], }; } // ===== ASYNC AGENT ===== case 'async': { const asyncArgs = args as { prompt: string; callId?: string }; 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: asyncArgs.prompt, searchWorkflow: true, }; // Include callId if provided if (asyncArgs.callId) { payload.callId = asyncArgs.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, }; } } default: return { content: [ { type: 'text', text: `Unknown tool: ${name}`, }, ], isError: true, }; } } catch (error: any) { return { content: [ { type: 'text', text: `Error executing ${name}: ${error.message || String(error)}`, }, ], isError: true, }; } }); // Start the server async function main() { 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