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:
Danijel
2026-03-17 23:45:42 +01:00
commit fe432d4a09
30 changed files with 5780 additions and 0 deletions

182
src/http-server.ts Normal file
View 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}`);
}
});