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>
148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
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");
|
|
}
|