feat: add SolidTime MCP server for time tracking integration
Implements a Model Context Protocol server that exposes SolidTime's time tracking API as 22+ tools for use with Claude, Cursor, and other MCP-compatible clients. Supports stdio and HTTP transport modes, Docker deployment, and self-hosted SolidTime instances. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
182
src/http-server.ts
Normal file
182
src/http-server.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { createServer } from "./server.js";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
const DEFAULT_SOLIDTIME_API_URL = process.env.SOLIDTIME_API_URL;
|
||||
|
||||
interface Session {
|
||||
transport: StreamableHTTPServerTransport;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
// Clean up stale sessions every 10 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
const maxAge = 30 * 60 * 1000; // 30 minutes
|
||||
for (const [id, session] of sessions) {
|
||||
if (now - session.createdAt > maxAge) {
|
||||
session.transport.close().catch(() => {});
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000
|
||||
);
|
||||
|
||||
function extractConfig(req: http.IncomingMessage) {
|
||||
const apiToken = req.headers["x-solidtime-api-token"] as string | undefined;
|
||||
const organizationId = req.headers["x-solidtime-organization-id"] as string | undefined;
|
||||
const apiUrl =
|
||||
(req.headers["x-solidtime-api-url"] as string | undefined) || DEFAULT_SOLIDTIME_API_URL;
|
||||
const timezone = req.headers["x-solidtime-timezone"] as string | undefined;
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error(
|
||||
"Missing required header: x-solidtime-api-token. " +
|
||||
"Configure it in your MCP client settings."
|
||||
);
|
||||
}
|
||||
|
||||
return { apiToken, organizationId, apiUrl, timezone };
|
||||
}
|
||||
|
||||
function setCorsHeaders(res: http.ServerResponse) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, mcp-session-id, x-solidtime-api-token, x-solidtime-organization-id, x-solidtime-api-url, x-solidtime-timezone"
|
||||
);
|
||||
res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
|
||||
}
|
||||
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
setCorsHeaders(res);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", sessions: sessions.size }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url !== "/mcp") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Not found. Use /mcp for MCP protocol or /health for health check." }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
|
||||
try {
|
||||
// GET = SSE stream listener, DELETE = close session
|
||||
if (req.method === "GET" || req.method === "DELETE") {
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid or missing session" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
await session.transport.close();
|
||||
sessions.delete(sessionId);
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
await session.transport.handleRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const body = await new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
req.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||
req.on("end", () => resolve(data));
|
||||
req.on("error", reject);
|
||||
});
|
||||
const parsedBody = JSON.parse(body);
|
||||
|
||||
if (!sessionId) {
|
||||
// Must be an initialization request
|
||||
const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
|
||||
const isInit = messages.some(isInitializeRequest);
|
||||
|
||||
if (!isInit) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Missing mcp-session-id header for non-initialization request",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract credentials from headers and create a per-session MCP server
|
||||
const config = extractConfig(req);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
});
|
||||
const mcpServer = await createServer(config);
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const sid = transport.sessionId!;
|
||||
sessions.set(sid, { transport, createdAt: Date.now() });
|
||||
|
||||
transport.onclose = () => {
|
||||
sessions.delete(sid);
|
||||
};
|
||||
|
||||
await transport.handleRequest(req, res, parsedBody);
|
||||
} else {
|
||||
// Existing session
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Session not found. It may have expired." }));
|
||||
return;
|
||||
}
|
||||
// Refresh session timestamp
|
||||
session.createdAt = Date.now();
|
||||
await session.transport.handleRequest(req, res, parsedBody);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(405, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Method not allowed" }));
|
||||
} catch (err) {
|
||||
console.error("Request error:", err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: err instanceof Error ? err.message : "Internal server error",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`SolidTime MCP HTTP server listening on port ${PORT}`);
|
||||
console.log(` MCP endpoint: http://0.0.0.0:${PORT}/mcp`);
|
||||
console.log(` Health check: http://0.0.0.0:${PORT}/health`);
|
||||
if (DEFAULT_SOLIDTIME_API_URL) {
|
||||
console.log(` Default API URL: ${DEFAULT_SOLIDTIME_API_URL}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user