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

View File

@@ -0,0 +1,147 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ApiClient } from "../api-client.js";
import { API_PATHS } from "../constants.js";
import { formatCurrency } from "../formatting.js";
import type { ProjectMember, PaginatedResponse } from "../types.js";
export function registerProjectMemberTools(server: McpServer, api: ApiClient, orgId: string) {
server.registerTool(
"solidtime_list_project_members",
{
title: "List Project Members",
description: "List all members assigned to a specific project.",
inputSchema: {
project_id: z.string().uuid().describe("Project UUID"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
const result = await api.get<PaginatedResponse<ProjectMember>>(
API_PATHS.projectMembers(orgId, params.project_id)
);
const members = result.data ?? [];
if (members.length === 0) {
return { content: [{ type: "text", text: "No project members found." }] };
}
const lines = members.map((m) => formatProjectMember(m));
lines.unshift(`Found ${members.length} project members:\n`);
return { content: [{ type: "text", text: lines.join("\n\n") }] };
}
);
server.registerTool(
"solidtime_add_project_member",
{
title: "Add Project Member",
description: "Add a member to a project with an optional custom billable rate.",
inputSchema: {
project_id: z.string().uuid().describe("Project UUID"),
member_id: z.string().uuid().describe("Member UUID to add"),
billable_rate: z
.number()
.int()
.min(0)
.optional()
.describe("Custom billable rate in cents for this member on this project"),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
async (params) => {
const body: Record<string, unknown> = {
member_id: params.member_id,
};
if (params.billable_rate !== undefined) body.billable_rate = params.billable_rate;
const result = await api.post<{ data: ProjectMember }>(
API_PATHS.projectMembers(orgId, params.project_id),
body
);
return {
content: [
{ type: "text", text: `Project member added.\n\n${formatProjectMember(result.data)}` },
],
};
}
);
server.registerTool(
"solidtime_update_project_member",
{
title: "Update Project Member",
description: "Update a project member's billable rate.",
inputSchema: {
id: z.string().uuid().describe("Project member UUID to update"),
billable_rate: z
.number()
.int()
.min(0)
.describe("New billable rate in cents for this project member"),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
const result = await api.put<{ data: ProjectMember }>(
API_PATHS.projectMember(orgId, params.id),
{ billable_rate: params.billable_rate }
);
return {
content: [
{
type: "text",
text: `Project member updated.\n\n${formatProjectMember(result.data)}`,
},
],
};
}
);
server.registerTool(
"solidtime_remove_project_member",
{
title: "Remove Project Member",
description: "Remove a member from a project.",
inputSchema: {
id: z.string().uuid().describe("Project member UUID to remove"),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: true,
},
},
async (params) => {
await api.delete(API_PATHS.projectMember(orgId, params.id));
return { content: [{ type: "text", text: `Project member ${params.id} removed.` }] };
}
);
}
function formatProjectMember(pm: ProjectMember): string {
const parts = [
`ID: ${pm.id}`,
`Member ID: ${pm.member_id}`,
`Project ID: ${pm.project_id}`,
];
if (pm.billable_rate !== null) {
parts.push(`Billable Rate: ${formatCurrency(pm.billable_rate)}/h`);
}
return parts.join("\n");
}