#!/usr/bin/env node
import express, { Request, Response } from "express";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { MCPServer } from "./server.js";
import { readFileSync, appendFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Default port - check environment variable first, then command-line args
let PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 5008;
// Parse command-line arguments for --port=XXXX (overrides environment variable)
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg.startsWith("--port=")) {
const value = parseInt(arg.split("=")[1], 10);
if (!isNaN(value)) {
PORT = value;
} else {
console.error("Invalid value for --port");
process.exit(1);
}
}
}
const server = new MCPServer(
new Server(
{
name: "mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
logging: {},
},
}
)
);
const app = express();
// Enable CORS for MCP Inspector
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Accept, mcp-session-id');
res.header('Access-Control-Expose-Headers', 'mcp-session-id');
// Handle preflight requests
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
app.use(express.json());
// In production (Docker), PUBLIC_PATH env var points to the public folder
// In development, it's relative to the build directory
const publicPath = process.env.PUBLIC_PATH || join(__dirname, "..", "public");
const router = express.Router();
import type { HomePageConfig, Port, HTMLContent, ToolWithSchema, SchemaProperty, ExampleParams } from './types/html-generation.js';
/**
* Dynamic home page configuration
* Contains static metadata about the MCP server
*/
const homePageConfig: HomePageConfig = {
TITLE: "Twenty CRM MCP Server",
SERVER_NAME: "Twenty CRM MCP Server",
TECH_STACK: "Model Context Protocol (MCP) Server built with TypeScript",
DESCRIPTION:
"This is a Model Context Protocol (MCP) server that provides Twenty CRM data capabilities for AI agents and LLM applications. " +
"Built with TypeScript and the MCP SDK, it exposes Twenty CRM entities (Person, Company, Opportunity) through both GraphQL and REST APIs. " +
"Features include complete CRUD operations for core entities, support for custom objects, batch operations, and full MCP protocol compliance. " +
"The server enables AI agents to seamlessly integrate CRM functionality into their workflows using the standard MCP interface.",
};
/**
* Generate HTML for MCP Endpoints section
*
* Creates server-side rendered HTML for the endpoints section including:
* - HTTP endpoints (MCP protocol, health check)
* - MCP Inspector testing instructions
* - Claude Desktop configuration
*
* @param baseUrl - The base URL of the server (e.g., http://localhost:5008)
* @param tools - Array of tool definitions for generating dynamic examples
* @returns HTML string with all endpoint information and examples
*/
function generateEndpointsHTML(baseUrl: string, tools: ReadonlyArray<ToolWithSchema>): HTMLContent {
return `
<section class="personal-info" style="margin-top: 24px;">
<h2 class="section-title">MCP Server Endpoints</h2>
<p class="section-description">
Connect to this MCP server using the following endpoints:
</p>
<div class="form-group">
<h3 style="margin-bottom: 12px; font-size: 16px; color: #2e2e38;">
HTTP Endpoints
</h3>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, '${baseUrl}/mcp')">Copy</button>
<pre><code># MCP Protocol Endpoint
${baseUrl}/mcp</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, '${baseUrl}/health')">Copy</button>
<pre><code># Health Check Endpoint
${baseUrl}/health</code></pre>
</div>
</div>
<div class="form-group" style="margin-top: 24px;">
<h3 style="margin-bottom: 12px; font-size: 16px; color: #2e2e38;">
MCP Inspector Testing
</h3>
<p style="margin-bottom: 12px; font-size: 14px; color: #747480;">
Use the MCP Inspector to test and debug the server's tools:
</p>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npx @modelcontextprotocol/inspector --transport http --server-url ${baseUrl}/mcp')">Copy</button>
<pre><code># Launch MCP Inspector (manual)
npx @modelcontextprotocol/inspector \\
--transport http \\
--server-url ${baseUrl}/mcp</code></pre>
</div>
</div>
</section>
`;
}
/**
* Generate dynamic curl command for testing MCP tool calls
*
* Creates a curl command that calls the first available tool with sample parameters
*
* @param tools - Array of tool definitions from the MCP server registry
* @param baseUrl - The base URL of the server (e.g., http://localhost:5008)
* @returns Curl command string or empty string if no tools available
*/
function generateToolCallCurl(tools: ReadonlyArray<ToolWithSchema>, baseUrl: string): string {
if (tools.length === 0) {
return '';
}
const tool = tools[0];
const toolName = tool.name;
const properties = tool.inputSchema.properties;
// Generate sample arguments based on tool schema
const sampleArgs: ExampleParams = {};
Object.keys(properties).forEach(key => {
const prop: SchemaProperty = properties[key];
if (prop.enum && prop.enum.length > 0) {
sampleArgs[key] = prop.enum[0];
} else if (prop.type === 'array') {
if (prop.description?.includes('FIPS')) {
sampleArgs[key] = [48, 36]; // Texas and New York
} else {
sampleArgs[key] = [];
}
} else if (prop.type === 'string') {
sampleArgs[key] = 'example';
} else if (prop.type === 'number' || prop.type === 'integer') {
sampleArgs[key] = 0;
} else if (prop.type === 'boolean') {
sampleArgs[key] = true;
}
});
const toolCallPayload = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: toolName,
arguments: sampleArgs
}
};
// Create both compact and pretty versions
const payloadJsonCompact = JSON.stringify(toolCallPayload);
const payloadJsonPretty = JSON.stringify(toolCallPayload, null, 2);
return `curl -X POST ${baseUrl}/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "mcp-session-id: YOUR_SESSION_ID" -d '${payloadJsonCompact}'`;
}
/**
* Generate HTML for Quick Start section
*
* Creates server-side rendered HTML for the quick start guide including:
* - Local development commands (install, build, start)
* - Docker deployment commands (build, run, stop)
* - Testing commands
*
* @param baseUrl - The base URL of the server (e.g., http://localhost:5008)
* @returns HTML string with all quick start commands and instructions
*/
function generateQuickStartHTML(baseUrl: string): HTMLContent {
// Extract port from baseUrl for display purposes
const portMatch = baseUrl.match(/:(\d+)/);
const port = portMatch ? portMatch[1] : '5008';
return `
<section class="personal-info" style="margin-top: 24px;">
<h2 class="section-title">Quick Start</h2>
<p class="section-description">
Get started with this Model Context Protocol (MCP) server locally or with Docker.
This server exposes census data tools that can be used by AI agents and LLM applications.
</p>
<div class="form-group">
<h3 style="margin-bottom: 12px; font-size: 16px; color: #2e2e38;">
Local Development
</h3>
<p style="margin-bottom: 12px; font-size: 14px; color: #747480;">
Install dependencies and start the MCP server on your local machine:
</p>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm install')">Copy</button>
<pre><code># Install project dependencies
npm install</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm run build')">Copy</button>
<pre><code># Build the TypeScript project
npm run build</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm run mcp')">Copy</button>
<pre><code># Start MCP server with Inspector (port ${port})
npm run mcp</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm start')">Copy</button>
<pre><code># Or start MCP server only (port ${port})
npm start</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'open ${baseUrl}/')">Copy</button>
<pre><code># Open the MCP server home page in browser
open ${baseUrl}/</code></pre>
</div>
</div>
<div class="form-group" style="margin-top: 24px;">
<h3 style="margin-bottom: 12px; font-size: 16px; color: #2e2e38;">
Docker Deployment
</h3>
<p style="margin-bottom: 12px; font-size: 14px; color: #747480;">
Run the MCP server in a Docker container. Internal port 8080 is mapped to external port ${port}:
</p>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm run docker-build')">Copy</button>
<pre><code># Build Docker image with MCP server
npm run docker-build</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm run docker-run')">Copy</button>
<pre><code># Run container (maps external ${port} to internal 8080)
npm run docker-run</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm run docker-stop')">Copy</button>
<pre><code># Stop and remove the running MCP server container
npm run docker-stop</code></pre>
</div>
<div style="margin-top: 12px; padding: 12px; background-color: #f5f5f5; border-radius: 8px; border-left: 4px solid #2e2e38;">
<p style="margin: 0; font-size: 13px; color: #2e2e38;">
<strong>Port Mapping:</strong> Docker maps <code>-p ${port}:8080</code> (external:internal)
</p>
</div>
</div>
<div class="form-group" style="margin-top: 24px;">
<h3 style="margin-bottom: 12px; font-size: 16px; color: #2e2e38;">
Testing
</h3>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm test')">Copy</button>
<pre><code># Run tests
npm test</code></pre>
</div>
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, 'npm run test:coverage')">Copy</button>
<pre><code># Run tests with coverage
npm run test:coverage</code></pre>
</div>
</div>
</section>
`;
}
/**
* Generate HTML for tools section dynamically from registered tools
*
* Creates server-side rendered HTML for all registered MCP tools by:
* - Reading tool definitions from the live tool registry
* - Generating parameter documentation from JSON schemas
* - Creating example requests based on parameter types
* - Adding FIPS code reference for census tools
*
* This function is completely data-driven - adding new tools to the registry
* automatically adds them to the UI with appropriate examples.
*
* @param tools - Array of tool definitions from the MCP server registry
* @returns HTML string with all tools, their parameters, and examples
*/
function generateToolsHTML(tools: ReadonlyArray<ToolWithSchema>): HTMLContent {
if (tools.length === 0) {
return `
<section class="personal-info">
<h2 class="section-title">Available MCP Tools (0)</h2>
<p style="color: #747480;">No tools are currently registered.</p>
</section>
`;
}
let html = `
<section class="personal-info">
<h2 class="section-title">Available MCP Tools (${tools.length})</h2>
<p class="section-description" style="margin-bottom: 16px;">
This MCP server exposes the following tools through the Model Context Protocol.
These tools can be accessed by any MCP-compatible client such as Claude Desktop or through the MCP Inspector for testing.
</p>
<div class="form-group">
`;
tools.forEach((tool, index) => {
const toolName = tool.name || 'Unknown Tool';
const toolDescription = tool.description || 'No description available';
const inputSchema = tool.inputSchema || {};
const properties = inputSchema.properties || {};
const required = inputSchema.required || [];
html += `
<div style="${index > 0 ? 'margin-top: 24px;' : ''}">
<h3 style="margin-bottom: 12px; font-size: 16px; color: #2e2e38;">
${toolName}
</h3>
<p style="margin-bottom: 12px; font-size: 14px; color: #747480;">
${toolDescription}
</p>
`;
// Generate example based on schema
const exampleParams: ExampleParams = {};
Object.keys(properties).forEach(key => {
const prop: SchemaProperty = properties[key];
if (prop.enum && prop.enum.length > 0) {
exampleParams[key] = prop.enum[0];
} else if (prop.type === 'array') {
// For arrays, check if it's FIPS codes - use Texas and New York
if (prop.description?.includes('FIPS')) {
exampleParams[key] = [48, 36]; // Texas and New York
} else {
exampleParams[key] = [];
}
} else if (prop.type === 'string') {
exampleParams[key] = 'example';
} else if (prop.type === 'number') {
exampleParams[key] = 0;
} else if (prop.type === 'boolean') {
exampleParams[key] = true;
}
});
// Add example command box (single example)
if (Object.keys(exampleParams).length > 0) {
const exampleJSON = JSON.stringify(exampleParams, null, 2);
const escapedJSON = exampleJSON.replace(/"/g, '"');
html += `
<div class="command-box">
<button class="copy-button" onclick="copyCommand(this, '${escapedJSON}')">Copy</button>
<pre><code># Example: Get Texas and New York population data
${exampleJSON}</code></pre>
</div>
`;
}
// Add schema information
if (Object.keys(properties).length > 0) {
html += `
<div style="margin-top: 16px; padding: 12px; background-color: #f5f5f5; border-radius: 8px; border-left: 4px solid #2e2e38;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #2e2e38;"><strong>Parameters:</strong></p>
`;
Object.entries(properties).forEach(([key, prop]: [string, SchemaProperty]) => {
const isRequired = required.includes(key);
const propType = prop.type || 'unknown';
const propDesc = prop.description || '';
html += `
<p style="margin: 4px 0; font-size: 13px; color: #2e2e38;">
β’ <code style="background: #e0e0e0; padding: 2px 6px; border-radius: 3px;">${key}</code>
<span style="color: #747480;">(${propType}${isRequired ? ', required' : ', optional'})</span>
${propDesc ? `<br/> <span style="color: #747480; font-size: 12px;">${propDesc}</span>` : ''}
</p>
`;
// Add FIPS code reference if applicable
if (key === 'states' && prop.description?.includes('FIPS')) {
html += `
<p style="margin: 8px 0 0 0; font-size: 12px; color: #747480;">
<strong>Common FIPS Codes:</strong> CA(6), TX(48), FL(12), NY(36), PA(42), IL(17), OH(39), GA(13), NC(37), MI(26), All States(0)
</p>
`;
}
});
html += '</div>';
}
html += '</div>';
});
html += '</div></section>';
return html;
}
// Landing page with dynamic content
router.get("/", (req: Request, res: Response) => {
try {
const templatePath = join(publicPath, "index.html");
let html = readFileSync(templatePath, "utf-8");
// Get the host from request headers (includes port if non-standard)
const host = req.get('host') || `localhost:${PORT}`;
const protocol = req.protocol; // 'http' or 'https'
const baseUrl = `${protocol}://${host}`;
// Get tools from the MCP server and generate all sections dynamically
const tools = server.getToolDefinitions();
const toolsHTML = generateToolsHTML(tools);
const endpointsHTML = generateEndpointsHTML(baseUrl, tools);
const quickStartHTML = generateQuickStartHTML(baseUrl);
// Combine all content sections
const contentHTML = toolsHTML + endpointsHTML + quickStartHTML;
// Create dynamic config
const dynamicConfig = {
...homePageConfig,
CONTENT_HTML: contentHTML,
};
// Debug: Log the config values
console.log('Dynamic Config:', {
TITLE: dynamicConfig.TITLE,
SERVER_NAME: dynamicConfig.SERVER_NAME,
});
// Replace placeholders with actual values
Object.entries(dynamicConfig).forEach(([key, value]) => {
const placeholder = `{{${key}}}`;
html = html.replace(new RegExp(placeholder, "g"), value);
});
res.send(html);
} catch (error) {
console.error("Error serving home page:", error);
res.status(500).send("Error loading home page");
}
});
// Health check endpoint
router.get("/health", (req: Request, res: Response) => {
const healthCheck = {
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
service: "mcp-server",
version: "1.0.0",
checks: {
server: server.isHealthy() ? "ok" : "degraded"
}
};
const httpStatus = healthCheck.checks.server === "ok" ? 200 : 503;
res.status(httpStatus).json(healthCheck);
});
// single endpoint for the client to send messages to
const MCP_ENDPOINT = "/mcp";
router.post(MCP_ENDPOINT, async (req: Request, res: Response) => {
await server.handlePostRequest(req, res);
});
router.get(MCP_ENDPOINT, async (req: Request, res: Response) => {
await server.handleGetRequest(req, res);
});
router.get("/health", (req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
app.use("/", router);
// Serve static assets (CSS, SVG, etc.) but don't serve index.html automatically
// Our router handles "/" to generate dynamic content
app.use(express.static(publicPath, { index: false }));
app.listen(PORT, () => {
console.log(`\nπ MCP Streamable HTTP Server listening on port ${PORT}`);
console.log(`\nπ Home Page: http://localhost:${PORT}/`);
console.log(`π MCP Endpoint: http://localhost:${PORT}/mcp`);
console.log(`β€οΈ Health Check: http://localhost:${PORT}/health`);
// Log gateway status
const gatewayStatus = server.getGatewayStatus();
if (gatewayStatus) {
console.log(`π Agent Gateway: ${gatewayStatus.gateway}`);
console.log(` Status: ${gatewayStatus.isRegistered ? 'β
Registered' : 'β³ Registering...'}`);
console.log(` Agent: ${gatewayStatus.agent}\n`);
} else {
console.log(`π Agent Gateway: Not configured\n`);
}
});
process.on("SIGINT", async () => {
console.log("Shutting down server...");
await server.cleanup();
process.exit(0);
});