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:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.history
|
||||||
|
.mcp.json
|
||||||
|
*.md
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# STDIO MODE (local, via `npm run dev` or `npm start`)
|
||||||
|
# =============================================================================
|
||||||
|
# These are read from the server's environment when running in stdio mode.
|
||||||
|
SOLIDTIME_API_TOKEN=your-api-token-here
|
||||||
|
SOLIDTIME_ORGANIZATION_ID=your-organization-uuid-here
|
||||||
|
# Optional — defaults to https://app.solidtime.io
|
||||||
|
# SOLIDTIME_API_URL=https://your-instance.example.com
|
||||||
|
# Optional — IANA timezone name for displaying and accepting local times (e.g. Europe/Berlin, America/New_York)
|
||||||
|
# Without this, all times are shown and expected in UTC.
|
||||||
|
# SOLIDTIME_TIMEZONE=Europe/Berlin
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HTTP MODE (cloud, via `npm run start:http` or Docker)
|
||||||
|
# =============================================================================
|
||||||
|
# In HTTP mode, API token and org ID come from MCP client headers,
|
||||||
|
# NOT from environment variables. Only server-level config goes here.
|
||||||
|
#
|
||||||
|
# PORT=3000
|
||||||
|
# SOLIDTIME_API_URL=https://app.solidtime.io
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
|
test-all-tools.sh
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN npx tsc
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/http-server.js"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Manuel Ernst
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
180
README.md
Normal file
180
README.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# solidtime-mcp-server
|
||||||
|
|
||||||
|
[](https://github.com/SwamiRama/solidtime-mcp-server/actions/workflows/ci.yml)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.npmjs.com/package/solidtime-mcp-server)
|
||||||
|
|
||||||
|
MCP server for [SolidTime](https://www.solidtime.io/) — the open-source time tracking app. Start/stop timers, manage time entries, projects, clients, tags, and tasks directly from Claude, Cursor, or any MCP-compatible client.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **22 tools** covering time entries, projects, clients, tags, tasks, and user info
|
||||||
|
- **Start/stop timers** with automatic active-timer detection
|
||||||
|
- **Aggregated reports** grouped by day, week, project, client, and more
|
||||||
|
- **Auto member_id resolution** — no manual configuration needed
|
||||||
|
- **Actionable error messages** — every error tells you what to do next
|
||||||
|
- **Zero external dependencies** beyond the MCP SDK (uses native `fetch`)
|
||||||
|
- Works with self-hosted SolidTime instances and the hosted version
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using npx (no install)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx solidtime-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install globally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g solidtime-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `SOLIDTIME_API_TOKEN` | Yes | — | Your SolidTime API token |
|
||||||
|
| `SOLIDTIME_ORGANIZATION_ID` | Yes | — | Your organization UUID |
|
||||||
|
| `SOLIDTIME_API_URL` | No | `https://app.solidtime.io` | Base URL for self-hosted instances |
|
||||||
|
|
||||||
|
Get your API token from **SolidTime > Settings > API**.
|
||||||
|
|
||||||
|
## Claude Desktop Configuration
|
||||||
|
|
||||||
|
Add to your `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"solidtime": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "solidtime-mcp-server"],
|
||||||
|
"env": {
|
||||||
|
"SOLIDTIME_API_TOKEN": "your-token-here",
|
||||||
|
"SOLIDTIME_ORGANIZATION_ID": "your-org-uuid-here",
|
||||||
|
"SOLIDTIME_API_URL": "https://your-instance.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Claude Code Configuration
|
||||||
|
|
||||||
|
Add to your `.mcp.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"solidtime": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "solidtime-mcp-server"],
|
||||||
|
"env": {
|
||||||
|
"SOLIDTIME_API_TOKEN": "your-token-here",
|
||||||
|
"SOLIDTIME_ORGANIZATION_ID": "your-org-uuid-here",
|
||||||
|
"SOLIDTIME_API_URL": "https://your-instance.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
### Time Entries (8 tools)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `solidtime_start_timer` | Start a running timer (checks for existing active timer first) |
|
||||||
|
| `solidtime_stop_timer` | Stop the active timer |
|
||||||
|
| `solidtime_get_active_timer` | Get the currently running timer |
|
||||||
|
| `solidtime_list_time_entries` | List entries with filters (date range, project, client, tags, billable) |
|
||||||
|
| `solidtime_create_time_entry` | Create a completed entry with start and end times |
|
||||||
|
| `solidtime_update_time_entry` | Update any field on an existing entry |
|
||||||
|
| `solidtime_delete_time_entry` | Permanently delete an entry |
|
||||||
|
| `solidtime_get_time_entry_report` | Aggregated report by day/week/month/project/client/etc. |
|
||||||
|
|
||||||
|
### Projects (4 tools)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `solidtime_list_projects` | List all projects (filter by archived status) |
|
||||||
|
| `solidtime_create_project` | Create a project with name, color, billable rate |
|
||||||
|
| `solidtime_update_project` | Update project fields |
|
||||||
|
| `solidtime_delete_project` | Permanently delete a project |
|
||||||
|
|
||||||
|
### Clients (3 tools)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `solidtime_list_clients` | List all clients (filter by archived status) |
|
||||||
|
| `solidtime_create_client` | Create a client |
|
||||||
|
| `solidtime_update_client` | Update a client's name |
|
||||||
|
|
||||||
|
### Tags (3 tools)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `solidtime_list_tags` | List all tags |
|
||||||
|
| `solidtime_create_tag` | Create a tag |
|
||||||
|
| `solidtime_update_tag` | Update a tag's name |
|
||||||
|
|
||||||
|
### Tasks (3 tools)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `solidtime_list_tasks` | List tasks (filter by project, done status) |
|
||||||
|
| `solidtime_create_task` | Create a task within a project |
|
||||||
|
| `solidtime_update_task` | Update task name, done status, or estimated time |
|
||||||
|
|
||||||
|
### Users (1 tool)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `solidtime_get_current_user` | Get your user profile and resolved member ID |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
**Start tracking time:**
|
||||||
|
> "Start a timer for the website redesign project"
|
||||||
|
|
||||||
|
**Log completed work:**
|
||||||
|
> "Create a time entry for today 9:00-11:30 on the API project, tagged as development"
|
||||||
|
|
||||||
|
**Get a weekly report:**
|
||||||
|
> "Show me a report of this week's hours grouped by project"
|
||||||
|
|
||||||
|
**Check what's running:**
|
||||||
|
> "Is there a timer running?"
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Authentication failed"
|
||||||
|
Your `SOLIDTIME_API_TOKEN` is invalid or expired. Generate a new one in SolidTime under Settings > API.
|
||||||
|
|
||||||
|
### "Permission denied"
|
||||||
|
Your token doesn't have access to the specified organization. Verify `SOLIDTIME_ORGANIZATION_ID`.
|
||||||
|
|
||||||
|
### "Cannot reach SolidTime"
|
||||||
|
Check that `SOLIDTIME_API_URL` is correct and the instance is accessible. For self-hosted: ensure the URL includes the protocol (e.g., `https://solidtime.example.com`).
|
||||||
|
|
||||||
|
### "Could not find member for user"
|
||||||
|
The authenticated user is not a member of the specified organization. Check `SOLIDTIME_ORGANIZATION_ID`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/SwamiRama/solidtime-mcp-server.git
|
||||||
|
cd solidtime-mcp-server
|
||||||
|
npm install
|
||||||
|
npm run dev # Run with tsx (dev mode)
|
||||||
|
npm run build # Compile TypeScript
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run typecheck # Type checking
|
||||||
|
npm run inspector # Test with MCP Inspector
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
solidtime-mcp:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
# Optional: default SolidTime API URL for all sessions.
|
||||||
|
# Clients can override this via the x-solidtime-api-url header.
|
||||||
|
- SOLIDTIME_API_URL=${SOLIDTIME_API_URL:-https://app.solidtime.io}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
18
eslint.config.js
Normal file
18
eslint.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import eslint from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
ignores: ["dist/"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
3117
package-lock.json
generated
Normal file
3117
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "solidtime-mcp-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP server for SolidTime time tracking — start/stop timers, manage entries, projects, clients, tags, and tasks",
|
||||||
|
"author": "Manuel",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"solidtime-mcp-server": "dist/index.js"
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"dev:http": "tsx src/http-server.ts",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"start:http": "node dist/http-server.js",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"format": "prettier --write src/",
|
||||||
|
"format:check": "prettier --check src/",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepare": "npm run build",
|
||||||
|
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"typescript-eslint": "^8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"mcp",
|
||||||
|
"model-context-protocol",
|
||||||
|
"solidtime",
|
||||||
|
"time-tracking",
|
||||||
|
"ai",
|
||||||
|
"claude"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SwamiRama/solidtime-mcp-server"
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/api-client.ts
Normal file
138
src/api-client.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { DEFAULT_API_URL } from "./constants.js";
|
||||||
|
|
||||||
|
export class SolidTimeApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: unknown
|
||||||
|
) {
|
||||||
|
super(getErrorMessage(status, body));
|
||||||
|
this.name = "SolidTimeApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(status: number, body: unknown): string {
|
||||||
|
const detail = typeof body === "object" && body !== null ? JSON.stringify(body) : String(body);
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 401:
|
||||||
|
return (
|
||||||
|
"Authentication failed. Possible causes:\n" +
|
||||||
|
" 1. Your SOLIDTIME_API_TOKEN may be expired or revoked — generate a new one in Settings > API.\n" +
|
||||||
|
" 2. Self-hosted: Passport keys may be missing — run 'php artisan passport:keys' on the server.\n" +
|
||||||
|
" 3. Self-hosted: Verify SOLIDTIME_API_URL points to the correct instance."
|
||||||
|
);
|
||||||
|
case 403:
|
||||||
|
return "Permission denied. Your token may lack access to this organization.";
|
||||||
|
case 404:
|
||||||
|
return "Resource not found. Use the list tools (e.g. solidtime_list_projects) to find valid IDs.";
|
||||||
|
case 422:
|
||||||
|
return `Validation error: ${formatValidationErrors(body)}`;
|
||||||
|
case 429:
|
||||||
|
return "Rate limited. Wait a moment and try again.";
|
||||||
|
default:
|
||||||
|
if (status >= 500) return `SolidTime server error (${status}). Try again later.`;
|
||||||
|
return `API error ${status}: ${detail}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValidationErrors(body: unknown): string {
|
||||||
|
if (typeof body !== "object" || body === null) return String(body);
|
||||||
|
const obj = body as Record<string, unknown>;
|
||||||
|
if (obj.errors && typeof obj.errors === "object") {
|
||||||
|
return Object.entries(obj.errors as Record<string, string[]>)
|
||||||
|
.map(([field, msgs]) => `${field}: ${msgs.join(", ")}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
if (obj.message) return String(obj.message);
|
||||||
|
return JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private token: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string | undefined, token: string) {
|
||||||
|
this.baseUrl = (baseUrl || DEFAULT_API_URL).replace(/\/$/, "");
|
||||||
|
this.token = token.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const url = path.startsWith("/api/v1")
|
||||||
|
? `${this.baseUrl}${path}`
|
||||||
|
: `${this.baseUrl}/api/v1${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot reach SolidTime at ${this.baseUrl}. Check the SOLIDTIME_API_URL setting. (${err})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseBody: unknown;
|
||||||
|
try {
|
||||||
|
responseBody = await response.json();
|
||||||
|
} catch {
|
||||||
|
responseBody = await response.text().catch(() => "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new SolidTimeApiError(response.status, responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(path: string): Promise<T> {
|
||||||
|
return this.request<T>("GET", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrNull<T>(path: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await this.request<T>("GET", path);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SolidTimeApiError && err.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>("POST", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>("PUT", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
patch<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>("PATCH", path, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(path: string): Promise<T> {
|
||||||
|
return this.request<T>("DELETE", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWithBody<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return this.request<T>("DELETE", path, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/constants.ts
Normal file
58
src/constants.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export const DEFAULT_API_URL = "https://app.solidtime.io";
|
||||||
|
|
||||||
|
export const API_PATHS = {
|
||||||
|
me: "/users/me",
|
||||||
|
myMemberships: "/users/me/memberships",
|
||||||
|
myTimeEntries: "/users/me/time-entries",
|
||||||
|
activeTimer: "/users/me/time-entries/active",
|
||||||
|
organization: (orgId: string) => `/api/v1/organizations/${orgId}`,
|
||||||
|
members: (orgId: string) => `/api/v1/organizations/${orgId}/members`,
|
||||||
|
member: (orgId: string, memberId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/members/${memberId}`,
|
||||||
|
memberInvitePlaceholder: (orgId: string, memberId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/members/${memberId}/invite-placeholder`,
|
||||||
|
memberMakePlaceholder: (orgId: string, memberId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/members/${memberId}/make-placeholder`,
|
||||||
|
timeEntries: (orgId: string) => `/api/v1/organizations/${orgId}/time-entries`,
|
||||||
|
timeEntry: (orgId: string, id: string) => `/api/v1/organizations/${orgId}/time-entries/${id}`,
|
||||||
|
timeEntryReport: (orgId: string) => `/api/v1/organizations/${orgId}/time-entries/aggregate`,
|
||||||
|
projects: (orgId: string) => `/api/v1/organizations/${orgId}/projects`,
|
||||||
|
project: (orgId: string, id: string) => `/api/v1/organizations/${orgId}/projects/${id}`,
|
||||||
|
projectMembers: (orgId: string, projectId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/projects/${projectId}/project-members`,
|
||||||
|
projectMember: (orgId: string, projectMemberId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/project-members/${projectMemberId}`,
|
||||||
|
clients: (orgId: string) => `/api/v1/organizations/${orgId}/clients`,
|
||||||
|
client: (orgId: string, id: string) => `/api/v1/organizations/${orgId}/clients/${id}`,
|
||||||
|
tags: (orgId: string) => `/api/v1/organizations/${orgId}/tags`,
|
||||||
|
tag: (orgId: string, id: string) => `/api/v1/organizations/${orgId}/tags/${id}`,
|
||||||
|
tasks: (orgId: string) => `/api/v1/organizations/${orgId}/tasks`,
|
||||||
|
task: (orgId: string, id: string) => `/api/v1/organizations/${orgId}/tasks/${id}`,
|
||||||
|
invitations: (orgId: string) => `/api/v1/organizations/${orgId}/invitations`,
|
||||||
|
invitation: (orgId: string, invitationId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/invitations/${invitationId}`,
|
||||||
|
invitationResend: (orgId: string, invitationId: string) =>
|
||||||
|
`/api/v1/organizations/${orgId}/invitations/${invitationId}/resend`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const GROUP_BY_VALUES = [
|
||||||
|
"day",
|
||||||
|
"week",
|
||||||
|
"month",
|
||||||
|
"year",
|
||||||
|
"user",
|
||||||
|
"project",
|
||||||
|
"task",
|
||||||
|
"client",
|
||||||
|
"billable",
|
||||||
|
"description",
|
||||||
|
"tag",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type GroupBy = (typeof GROUP_BY_VALUES)[number];
|
||||||
|
|
||||||
|
export const PAGINATION = {
|
||||||
|
defaultLimit: 50,
|
||||||
|
maxLimit: 500,
|
||||||
|
defaultPage: 1,
|
||||||
|
} as const;
|
||||||
78
src/formatting.ts
Normal file
78
src/formatting.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
||||||
|
if (h > 0) return `${h}h`;
|
||||||
|
if (m > 0) return `${m}m`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(cents: number): string {
|
||||||
|
const amount = cents / 100;
|
||||||
|
return amount.toLocaleString("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(utcString: string, timezone?: string): string {
|
||||||
|
if (!timezone) {
|
||||||
|
return utcString.replace("T", " ").replace("Z", " UTC");
|
||||||
|
}
|
||||||
|
const date = new Date(utcString);
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
timeZoneName: "short",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type !== "literal") p[part.type] = part.value;
|
||||||
|
}
|
||||||
|
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second} ${p.timeZoneName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a time string to UTC. If the string already has a timezone suffix (Z or ±HH:MM)
|
||||||
|
// it is normalised and returned as-is. If it has no suffix it is treated as local time in
|
||||||
|
// the given timezone and converted to UTC.
|
||||||
|
export function toUTC(timeStr: string, timezone?: string): string {
|
||||||
|
if (!timeStr) return timeStr;
|
||||||
|
if (timeStr.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(timeStr)) {
|
||||||
|
return new Date(timeStr).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
if (!timezone) return timeStr + "Z";
|
||||||
|
|
||||||
|
// Temporarily treat the naive string as UTC to get a Date object, then figure out
|
||||||
|
// what the local clock shows at that UTC moment and compute the real offset.
|
||||||
|
const naiveUTC = new Date(timeStr + "Z");
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(naiveUTC);
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.type !== "literal") p[part.type] = part.value;
|
||||||
|
}
|
||||||
|
const localAtNaive = new Date(
|
||||||
|
`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`
|
||||||
|
);
|
||||||
|
const offsetMs = localAtNaive.getTime() - naiveUTC.getTime();
|
||||||
|
return new Date(naiveUTC.getTime() - offsetMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowUTC(): string {
|
||||||
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
27
src/index.ts
Normal file
27
src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { createServer } from "./server.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const apiToken = process.env.SOLIDTIME_API_TOKEN;
|
||||||
|
const organizationId = process.env.SOLIDTIME_ORGANIZATION_ID;
|
||||||
|
const apiUrl = process.env.SOLIDTIME_API_URL;
|
||||||
|
const timezone = process.env.SOLIDTIME_TIMEZONE;
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
console.error("Error: SOLIDTIME_API_TOKEN environment variable is required.");
|
||||||
|
console.error("Get your API token from your SolidTime instance under Settings > API.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await createServer({ apiToken, organizationId, apiUrl, timezone });
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
20
src/schemas.ts
Normal file
20
src/schemas.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const coerceBoolean = z.preprocess((val) => {
|
||||||
|
if (typeof val === "string") {
|
||||||
|
if (val === "true") return true;
|
||||||
|
if (val === "false") return false;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}, z.boolean());
|
||||||
|
|
||||||
|
export const coerceUuidArray = z.preprocess((val) => {
|
||||||
|
if (typeof val === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(val);
|
||||||
|
} catch {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}, z.array(z.string().uuid()));
|
||||||
107
src/server.ts
Normal file
107
src/server.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { ApiClient, SolidTimeApiError } from "./api-client.js";
|
||||||
|
import { API_PATHS, DEFAULT_API_URL } from "./constants.js";
|
||||||
|
import { registerUserTools } from "./tools/users.js";
|
||||||
|
import { registerTimeEntryTools } from "./tools/time-entries.js";
|
||||||
|
import { registerProjectTools } from "./tools/projects.js";
|
||||||
|
import { registerClientTools } from "./tools/clients.js";
|
||||||
|
import { registerTagTools } from "./tools/tags.js";
|
||||||
|
import { registerTaskTools } from "./tools/tasks.js";
|
||||||
|
import { registerMemberTools } from "./tools/members.js";
|
||||||
|
import { registerOrganizationTools } from "./tools/organizations.js";
|
||||||
|
import { registerProjectMemberTools } from "./tools/project-members.js";
|
||||||
|
import { registerInvitationTools } from "./tools/invitations.js";
|
||||||
|
import type { User, UserMembership } from "./types.js";
|
||||||
|
|
||||||
|
interface ServerConfig {
|
||||||
|
apiToken: string;
|
||||||
|
organizationId?: string;
|
||||||
|
apiUrl?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServer(config: ServerConfig) {
|
||||||
|
const apiUrl = config.apiUrl || DEFAULT_API_URL;
|
||||||
|
const api = new ApiClient(apiUrl, config.apiToken);
|
||||||
|
|
||||||
|
console.error(`SolidTime MCP: Connecting to ${apiUrl}...`);
|
||||||
|
|
||||||
|
// Fetch user and memberships in parallel
|
||||||
|
let userResponse: { data: User };
|
||||||
|
try {
|
||||||
|
userResponse = await api.get<{ data: User }>(API_PATHS.me);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SolidTimeApiError && err.status === 401) {
|
||||||
|
throw new Error(
|
||||||
|
`Authentication failed against ${apiUrl}.\n` +
|
||||||
|
` Token: ${config.apiToken.substring(0, 10)}...(${config.apiToken.length} chars)\n` +
|
||||||
|
` Possible fixes:\n` +
|
||||||
|
` 1. Generate a new API token in Settings > API.\n` +
|
||||||
|
` 2. Self-hosted: run 'php artisan passport:keys' on the server.\n` +
|
||||||
|
` 3. Verify SOLIDTIME_API_URL is correct (currently: ${apiUrl}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const user = userResponse.data;
|
||||||
|
|
||||||
|
const membershipsResponse = await api.get<{ data: UserMembership[] }>(API_PATHS.myMemberships);
|
||||||
|
const memberships = membershipsResponse.data ?? [];
|
||||||
|
|
||||||
|
if (memberships.length === 0) {
|
||||||
|
throw new Error(`User ${user.email} has no organization memberships.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let membership: UserMembership;
|
||||||
|
if (config.organizationId) {
|
||||||
|
const found = memberships.find((m) => m.organization.id === config.organizationId);
|
||||||
|
if (!found) {
|
||||||
|
const available = memberships
|
||||||
|
.map((m) => ` ${m.organization.id} (${m.organization.name})`)
|
||||||
|
.join("\n");
|
||||||
|
throw new Error(
|
||||||
|
`Organization ${config.organizationId} not found. Your memberships:\n${available}\n` +
|
||||||
|
`Update your organization ID setting.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
membership = found;
|
||||||
|
} else if (memberships.length === 1) {
|
||||||
|
membership = memberships[0];
|
||||||
|
console.error(`SolidTime MCP: Auto-selected organization "${membership.organization.name}" (${membership.organization.id})`);
|
||||||
|
} else {
|
||||||
|
const available = memberships
|
||||||
|
.map((m) => ` ${m.organization.id} (${m.organization.name})`)
|
||||||
|
.join("\n");
|
||||||
|
throw new Error(
|
||||||
|
`Multiple organizations found. Set organization ID to one of:\n${available}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = membership.organization.id;
|
||||||
|
const memberId = membership.id;
|
||||||
|
const getMemberId = () => memberId;
|
||||||
|
const timezone = config.timezone;
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`SolidTime MCP: Authenticated as ${user.name} (${user.email}), org "${membership.organization.name}", member ${memberId}` +
|
||||||
|
(timezone ? `, timezone ${timezone}` : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "solidtime",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
registerUserTools(server, api, getMemberId);
|
||||||
|
registerTimeEntryTools(server, api, orgId, getMemberId, timezone);
|
||||||
|
registerProjectTools(server, api, orgId);
|
||||||
|
registerClientTools(server, api, orgId);
|
||||||
|
registerTagTools(server, api, orgId);
|
||||||
|
registerTaskTools(server, api, orgId);
|
||||||
|
registerMemberTools(server, api, orgId);
|
||||||
|
registerOrganizationTools(server, api, orgId);
|
||||||
|
registerProjectMemberTools(server, api, orgId);
|
||||||
|
registerInvitationTools(server, api, orgId);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
124
src/tools/clients.ts
Normal file
124
src/tools/clients.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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 type { Client, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerClientTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_clients",
|
||||||
|
{
|
||||||
|
title: "List Clients",
|
||||||
|
description: "List all clients in the organization. Optionally filter by archived status.",
|
||||||
|
inputSchema: {
|
||||||
|
archived: z.enum(["true", "false"]).optional().describe("Filter by archived status"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.archived) query.set("archived", params.archived);
|
||||||
|
const qs = query.toString();
|
||||||
|
const path = qs ? `${API_PATHS.clients(orgId)}?${qs}` : API_PATHS.clients(orgId);
|
||||||
|
|
||||||
|
const result = await api.get<PaginatedResponse<Client>>(path);
|
||||||
|
const clients = result.data ?? [];
|
||||||
|
if (clients.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No clients found." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = clients.map(
|
||||||
|
(c) => `ID: ${c.id}\nName: ${c.name}\nArchived: ${c.is_archived ? "Yes" : "No"}`
|
||||||
|
);
|
||||||
|
lines.unshift(`Found ${clients.length} clients:\n`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_create_client",
|
||||||
|
{
|
||||||
|
title: "Create Client",
|
||||||
|
description: "Create a new client.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).describe("Client name"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const result = await api.post<{ data: Client }>(API_PATHS.clients(orgId), {
|
||||||
|
name: params.name,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Client created.\n\nID: ${result.data.id}\nName: ${result.data.name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_client",
|
||||||
|
{
|
||||||
|
title: "Update Client",
|
||||||
|
description: "Update a client's name.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Client UUID to update"),
|
||||||
|
name: z.string().min(1).describe("New client name"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const result = await api.put<{ data: Client }>(API_PATHS.client(orgId, params.id), {
|
||||||
|
name: params.name,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Client updated.\n\nID: ${result.data.id}\nName: ${result.data.name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_client",
|
||||||
|
{
|
||||||
|
title: "Delete Client",
|
||||||
|
description: "Permanently delete a client. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Client UUID to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.client(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Client ${params.id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/tools/invitations.ts
Normal file
112
src/tools/invitations.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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 type { Invitation, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerInvitationTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_invitations",
|
||||||
|
{
|
||||||
|
title: "List Invitations",
|
||||||
|
description: "List all pending invitations for the organization.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await api.get<PaginatedResponse<Invitation>>(
|
||||||
|
API_PATHS.invitations(orgId)
|
||||||
|
);
|
||||||
|
const invitations = result.data ?? [];
|
||||||
|
if (invitations.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No pending invitations." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = invitations.map((inv) => formatInvitation(inv));
|
||||||
|
lines.unshift(`Found ${invitations.length} invitations:\n`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_create_invitation",
|
||||||
|
{
|
||||||
|
title: "Create Invitation",
|
||||||
|
description: "Invite a new user to the organization by email.",
|
||||||
|
inputSchema: {
|
||||||
|
email: z.string().email().describe("Email address to invite"),
|
||||||
|
role: z.string().describe("Role to assign (e.g. admin, manager, employee)"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const result = await api.post<{ data: Invitation }>(API_PATHS.invitations(orgId), {
|
||||||
|
email: params.email,
|
||||||
|
role: params.role,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Invitation sent.\n\n${formatInvitation(result.data)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_resend_invitation",
|
||||||
|
{
|
||||||
|
title: "Resend Invitation",
|
||||||
|
description: "Resend an existing invitation email.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Invitation UUID to resend"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.post(API_PATHS.invitationResend(orgId, params.id), {});
|
||||||
|
return { content: [{ type: "text", text: `Invitation ${params.id} resent.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_invitation",
|
||||||
|
{
|
||||||
|
title: "Delete Invitation",
|
||||||
|
description: "Delete a pending invitation.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Invitation UUID to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.invitation(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Invitation ${params.id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInvitation(inv: Invitation): string {
|
||||||
|
return [`ID: ${inv.id}`, `Email: ${inv.email}`, `Role: ${inv.role}`].join("\n");
|
||||||
|
}
|
||||||
150
src/tools/members.ts
Normal file
150
src/tools/members.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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 { Member, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerMemberTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_members",
|
||||||
|
{
|
||||||
|
title: "List Members",
|
||||||
|
description:
|
||||||
|
"List all members of the organization, including their roles and billable rates.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await api.get<PaginatedResponse<Member>>(API_PATHS.members(orgId));
|
||||||
|
const members = result.data ?? [];
|
||||||
|
if (members.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No members found." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = members.map((m) => formatMember(m));
|
||||||
|
lines.unshift(`Found ${members.length} members:\n`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_member",
|
||||||
|
{
|
||||||
|
title: "Update Member",
|
||||||
|
description: "Update a member's role or billable rate.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Member UUID to update"),
|
||||||
|
role: z.string().optional().describe("New role (e.g. admin, manager, employee, placeholder)"),
|
||||||
|
billable_rate: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("New billable rate in cents (e.g. 15000 = EUR 150.00)"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (params.role !== undefined) body.role = params.role;
|
||||||
|
if (params.billable_rate !== undefined) body.billable_rate = params.billable_rate;
|
||||||
|
|
||||||
|
const result = await api.put<{ data: Member }>(
|
||||||
|
API_PATHS.member(orgId, params.id),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Member updated.\n\n${formatMember(result.data)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_remove_member",
|
||||||
|
{
|
||||||
|
title: "Remove Member",
|
||||||
|
description: "Remove a member from the organization. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Member UUID to remove"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.member(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Member ${params.id} removed.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_invite_placeholder_member",
|
||||||
|
{
|
||||||
|
title: "Invite Placeholder Member",
|
||||||
|
description:
|
||||||
|
"Send an invitation to a placeholder member so they can create an account and take over their time entries.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Placeholder member UUID to invite"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.post(API_PATHS.memberInvitePlaceholder(orgId, params.id), {});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Invitation sent to placeholder member ${params.id}.` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_make_placeholder_member",
|
||||||
|
{
|
||||||
|
title: "Make Placeholder Member",
|
||||||
|
description:
|
||||||
|
"Convert a real member into a placeholder member. The user will lose access but their time entries are preserved.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Member UUID to convert to placeholder"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.post(API_PATHS.memberMakePlaceholder(orgId, params.id), {});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Member ${params.id} converted to placeholder.` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMember(m: Member): string {
|
||||||
|
const parts = [
|
||||||
|
`ID: ${m.id}`,
|
||||||
|
`User ID: ${m.user_id}`,
|
||||||
|
`Role: ${m.role}`,
|
||||||
|
];
|
||||||
|
if (m.billable_rate !== null) parts.push(`Billable Rate: ${formatCurrency(m.billable_rate)}/h`);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
80
src/tools/organizations.ts
Normal file
80
src/tools/organizations.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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 { Organization } from "../types.js";
|
||||||
|
|
||||||
|
export function registerOrganizationTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_get_organization",
|
||||||
|
{
|
||||||
|
title: "Get Organization",
|
||||||
|
description: "Get the current organization's details including name, currency, and billable rate.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await api.get<{ data: Organization }>(API_PATHS.organization(orgId));
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: formatOrganization(result.data) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_organization",
|
||||||
|
{
|
||||||
|
title: "Update Organization",
|
||||||
|
description: "Update the organization's name, currency, or billable rate.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).optional().describe("New organization name"),
|
||||||
|
currency: z.string().min(3).max(3).optional().describe("New currency code (e.g. EUR, USD)"),
|
||||||
|
billable_rate: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("New default billable rate in cents (e.g. 15000 = EUR 150.00)"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (params.name !== undefined) body.name = params.name;
|
||||||
|
if (params.currency !== undefined) body.currency = params.currency;
|
||||||
|
if (params.billable_rate !== undefined) body.billable_rate = params.billable_rate;
|
||||||
|
|
||||||
|
const result = await api.put<{ data: Organization }>(
|
||||||
|
API_PATHS.organization(orgId),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Organization updated.\n\n${formatOrganization(result.data)}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrganization(org: Organization): string {
|
||||||
|
const parts = [
|
||||||
|
`ID: ${org.id}`,
|
||||||
|
`Name: ${org.name}`,
|
||||||
|
`Currency: ${org.currency}`,
|
||||||
|
];
|
||||||
|
if (org.billable_rate !== null) {
|
||||||
|
parts.push(`Default Billable Rate: ${formatCurrency(org.billable_rate)}/h`);
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
147
src/tools/project-members.ts
Normal file
147
src/tools/project-members.ts
Normal 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");
|
||||||
|
}
|
||||||
178
src/tools/projects.ts
Normal file
178
src/tools/projects.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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 { coerceBoolean } from "../schemas.js";
|
||||||
|
import { formatCurrency, formatDuration } from "../formatting.js";
|
||||||
|
import type { Project, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerProjectTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_projects",
|
||||||
|
{
|
||||||
|
title: "List Projects",
|
||||||
|
description: "List all projects in the organization. Optionally filter by archived status.",
|
||||||
|
inputSchema: {
|
||||||
|
archived: z.enum(["true", "false"]).optional().describe("Filter by archived status"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.archived) query.set("archived", params.archived);
|
||||||
|
const qs = query.toString();
|
||||||
|
const path = qs ? `${API_PATHS.projects(orgId)}?${qs}` : API_PATHS.projects(orgId);
|
||||||
|
|
||||||
|
const result = await api.get<PaginatedResponse<Project>>(path);
|
||||||
|
const projects = result.data ?? [];
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No projects found." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = projects.map((p) => formatProject(p));
|
||||||
|
lines.unshift(`Found ${projects.length} projects:\n`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_create_project",
|
||||||
|
{
|
||||||
|
title: "Create Project",
|
||||||
|
description: "Create a new project.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).describe("Project name"),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9a-f]{6}$/)
|
||||||
|
.describe("Hex color, lowercase (e.g. #4caf50)"),
|
||||||
|
is_billable: coerceBoolean.describe("Whether time tracked to this project is billable"),
|
||||||
|
client_id: z.string().uuid().describe("Client UUID to assign (required)"),
|
||||||
|
billable_rate: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("Billable rate in cents (e.g. 15000 = EUR 150.00)"),
|
||||||
|
estimated_time: z.number().int().min(0).optional().describe("Estimated time in seconds"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: params.name,
|
||||||
|
color: params.color,
|
||||||
|
is_billable: params.is_billable,
|
||||||
|
client_id: params.client_id,
|
||||||
|
};
|
||||||
|
if (params.billable_rate !== undefined) body.billable_rate = params.billable_rate;
|
||||||
|
if (params.estimated_time !== undefined) body.estimated_time = params.estimated_time;
|
||||||
|
|
||||||
|
const result = await api.post<{ data: Project }>(API_PATHS.projects(orgId), body);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Project created.\n\n${formatProject(result.data)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_project",
|
||||||
|
{
|
||||||
|
title: "Update Project",
|
||||||
|
description: "Update an existing project. Only provided fields will be changed.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Project UUID to update"),
|
||||||
|
name: z.string().min(1).optional().describe("New project name"),
|
||||||
|
color: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#[0-9a-f]{6}$/)
|
||||||
|
.optional()
|
||||||
|
.describe("New hex color, lowercase (e.g. #4caf50)"),
|
||||||
|
is_billable: coerceBoolean.optional().describe("New billable status"),
|
||||||
|
is_archived: coerceBoolean.optional().describe("Archive or unarchive"),
|
||||||
|
client_id: z.string().uuid().optional().describe("New client UUID"),
|
||||||
|
billable_rate: z.number().int().min(0).optional().describe("New billable rate in cents"),
|
||||||
|
estimated_time: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("New estimated time in seconds"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
// SolidTime API requires all fields on PUT, so fetch current state first
|
||||||
|
const current = await api.get<{ data: Project }>(API_PATHS.project(orgId, params.id));
|
||||||
|
const project = current.data;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: params.name ?? project.name,
|
||||||
|
color: params.color ?? project.color,
|
||||||
|
is_billable: params.is_billable ?? project.is_billable,
|
||||||
|
client_id: params.client_id ?? project.client_id,
|
||||||
|
};
|
||||||
|
if (params.is_archived !== undefined) body.is_archived = params.is_archived;
|
||||||
|
if (params.billable_rate !== undefined) body.billable_rate = params.billable_rate;
|
||||||
|
else if (project.billable_rate !== null) body.billable_rate = project.billable_rate;
|
||||||
|
if (params.estimated_time !== undefined) body.estimated_time = params.estimated_time;
|
||||||
|
else if (project.estimated_time !== null) body.estimated_time = project.estimated_time;
|
||||||
|
|
||||||
|
const result = await api.put<{ data: Project }>(API_PATHS.project(orgId, params.id), body);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Project updated.\n\n${formatProject(result.data)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_project",
|
||||||
|
{
|
||||||
|
title: "Delete Project",
|
||||||
|
description: "Permanently delete a project. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Project UUID to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.project(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Project ${params.id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProject(p: Project): string {
|
||||||
|
const parts = [
|
||||||
|
`ID: ${p.id}`,
|
||||||
|
`Name: ${p.name}`,
|
||||||
|
`Color: ${p.color}`,
|
||||||
|
`Billable: ${p.is_billable ? "Yes" : "No"}`,
|
||||||
|
`Archived: ${p.is_archived ? "Yes" : "No"}`,
|
||||||
|
];
|
||||||
|
if (p.billable_rate !== null) parts.push(`Rate: ${formatCurrency(p.billable_rate)}/h`);
|
||||||
|
if (p.client_id) parts.push(`Client ID: ${p.client_id}`);
|
||||||
|
if (p.estimated_time !== null) parts.push(`Estimated: ${formatDuration(p.estimated_time)}`);
|
||||||
|
if (p.spent_time > 0) parts.push(`Spent: ${formatDuration(p.spent_time)}`);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
112
src/tools/tags.ts
Normal file
112
src/tools/tags.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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 type { Tag, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerTagTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_tags",
|
||||||
|
{
|
||||||
|
title: "List Tags",
|
||||||
|
description: "List all tags in the organization.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await api.get<PaginatedResponse<Tag>>(API_PATHS.tags(orgId));
|
||||||
|
const tags = result.data ?? [];
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No tags found." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = tags.map((t) => `ID: ${t.id}\nName: ${t.name}`);
|
||||||
|
lines.unshift(`Found ${tags.length} tags:\n`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_create_tag",
|
||||||
|
{
|
||||||
|
title: "Create Tag",
|
||||||
|
description: "Create a new tag.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).describe("Tag name"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const result = await api.post<{ data: Tag }>(API_PATHS.tags(orgId), { name: params.name });
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Tag created.\n\nID: ${result.data.id}\nName: ${result.data.name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_tag",
|
||||||
|
{
|
||||||
|
title: "Update Tag",
|
||||||
|
description: "Update a tag's name.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Tag UUID to update"),
|
||||||
|
name: z.string().min(1).describe("New tag name"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const result = await api.put<{ data: Tag }>(API_PATHS.tag(orgId, params.id), {
|
||||||
|
name: params.name,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Tag updated.\n\nID: ${result.data.id}\nName: ${result.data.name}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_tag",
|
||||||
|
{
|
||||||
|
title: "Delete Tag",
|
||||||
|
description: "Permanently delete a tag. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Tag UUID to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.tag(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Tag ${params.id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/tools/tasks.ts
Normal file
144
src/tools/tasks.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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 { coerceBoolean } from "../schemas.js";
|
||||||
|
import { formatDuration } from "../formatting.js";
|
||||||
|
import type { Task, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerTaskTools(server: McpServer, api: ApiClient, orgId: string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_tasks",
|
||||||
|
{
|
||||||
|
title: "List Tasks",
|
||||||
|
description: "List tasks. Optionally filter by project or done status.",
|
||||||
|
inputSchema: {
|
||||||
|
project_id: z.string().uuid().optional().describe("Filter by project UUID"),
|
||||||
|
done: z.enum(["true", "false"]).optional().describe("Filter by done status"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.project_id) query.set("project_id", params.project_id);
|
||||||
|
if (params.done) query.set("done", params.done);
|
||||||
|
const qs = query.toString();
|
||||||
|
const path = qs ? `${API_PATHS.tasks(orgId)}?${qs}` : API_PATHS.tasks(orgId);
|
||||||
|
|
||||||
|
const result = await api.get<PaginatedResponse<Task>>(path);
|
||||||
|
const tasks = result.data ?? [];
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No tasks found." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = tasks.map((t) => formatTask(t));
|
||||||
|
lines.unshift(`Found ${tasks.length} tasks:\n`);
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_create_task",
|
||||||
|
{
|
||||||
|
title: "Create Task",
|
||||||
|
description: "Create a new task within a project.",
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).describe("Task name"),
|
||||||
|
project_id: z.string().uuid().describe("Project UUID this task belongs to"),
|
||||||
|
estimated_time: z.number().int().min(0).optional().describe("Estimated time in seconds"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: params.name,
|
||||||
|
project_id: params.project_id,
|
||||||
|
};
|
||||||
|
if (params.estimated_time !== undefined) body.estimated_time = params.estimated_time;
|
||||||
|
|
||||||
|
const result = await api.post<{ data: Task }>(API_PATHS.tasks(orgId), body);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Task created.\n\n${formatTask(result.data)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_task",
|
||||||
|
{
|
||||||
|
title: "Update Task",
|
||||||
|
description: "Update a task's name, done status, or estimated time.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Task UUID to update"),
|
||||||
|
name: z.string().min(1).optional().describe("New task name"),
|
||||||
|
is_done: coerceBoolean.optional().describe("Mark task as done or not done"),
|
||||||
|
estimated_time: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.optional()
|
||||||
|
.describe("New estimated time in seconds"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (params.name !== undefined) body.name = params.name;
|
||||||
|
if (params.is_done !== undefined) body.is_done = params.is_done;
|
||||||
|
if (params.estimated_time !== undefined) body.estimated_time = params.estimated_time;
|
||||||
|
|
||||||
|
const result = await api.put<{ data: Task }>(API_PATHS.task(orgId, params.id), body);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Task updated.\n\n${formatTask(result.data)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_task",
|
||||||
|
{
|
||||||
|
title: "Delete Task",
|
||||||
|
description: "Permanently delete a task. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Task UUID to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.task(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Task ${params.id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTask(t: Task): string {
|
||||||
|
const parts = [
|
||||||
|
`ID: ${t.id}`,
|
||||||
|
`Name: ${t.name}`,
|
||||||
|
`Done: ${t.is_done ? "Yes" : "No"}`,
|
||||||
|
`Project ID: ${t.project_id}`,
|
||||||
|
];
|
||||||
|
if (t.estimated_time !== null) parts.push(`Estimated: ${formatDuration(t.estimated_time)}`);
|
||||||
|
if (t.spent_time > 0) parts.push(`Spent: ${formatDuration(t.spent_time)}`);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
478
src/tools/time-entries.ts
Normal file
478
src/tools/time-entries.ts
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiClient } from "../api-client.js";
|
||||||
|
import { API_PATHS, GROUP_BY_VALUES, PAGINATION } from "../constants.js";
|
||||||
|
import { coerceBoolean, coerceUuidArray } from "../schemas.js";
|
||||||
|
import { formatDuration, formatCurrency, formatDateTime, toUTC, nowUTC } from "../formatting.js";
|
||||||
|
import type { TimeEntry, TimeEntryReport, PaginatedResponse } from "../types.js";
|
||||||
|
|
||||||
|
export function registerTimeEntryTools(
|
||||||
|
server: McpServer,
|
||||||
|
api: ApiClient,
|
||||||
|
orgId: string,
|
||||||
|
getMemberId: () => string,
|
||||||
|
timezone?: string
|
||||||
|
) {
|
||||||
|
const tz = timezone;
|
||||||
|
const tzNote = tz
|
||||||
|
? ` Times are displayed and accepted in ${tz}. Provide times without a timezone suffix (e.g. 2026-03-05T11:45:00) to use local time, or append Z for UTC.`
|
||||||
|
: " Times are in UTC.";
|
||||||
|
async function getActiveTimer(): Promise<TimeEntry | null> {
|
||||||
|
const response = await api.getOrNull<{ data: TimeEntry }>(API_PATHS.activeTimer);
|
||||||
|
return response?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get Active Timer ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_get_active_timer",
|
||||||
|
{
|
||||||
|
title: "Get Active Timer",
|
||||||
|
description: "Get the currently running timer, or null if no timer is active.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const entry = await getActiveTimer();
|
||||||
|
if (!entry) {
|
||||||
|
return { content: [{ type: "text", text: "No active timer." }] };
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: formatTimeEntry(entry, tz) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Start Timer ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_start_timer",
|
||||||
|
{
|
||||||
|
title: "Start Timer",
|
||||||
|
description:
|
||||||
|
"Start a new running timer. Checks for an existing active timer first. Use solidtime_stop_timer to stop it later.",
|
||||||
|
inputSchema: {
|
||||||
|
project_id: z.string().uuid().optional().describe("Project UUID"),
|
||||||
|
task_id: z.string().uuid().optional().describe("Task UUID"),
|
||||||
|
description: z.string().max(5000).optional().describe("What you're working on"),
|
||||||
|
billable: coerceBoolean.optional().describe("Whether this time is billable"),
|
||||||
|
tag_ids: coerceUuidArray.optional().describe("Array of tag UUIDs to attach"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const existing = await getActiveTimer();
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `A timer is already running (started ${formatDateTime(existing.start, tz)}). Stop it first with solidtime_stop_timer.\n\n${formatTimeEntry(existing, tz)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
member_id: getMemberId(),
|
||||||
|
start: nowUTC(),
|
||||||
|
billable: params.billable ?? false,
|
||||||
|
};
|
||||||
|
if (params.project_id) body.project_id = params.project_id;
|
||||||
|
if (params.task_id) body.task_id = params.task_id;
|
||||||
|
if (params.description) body.description = params.description;
|
||||||
|
if (params.tag_ids) body.tags = params.tag_ids;
|
||||||
|
|
||||||
|
const result = await api.post<{ data: TimeEntry }>(API_PATHS.timeEntries(orgId), body);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Timer started.\n\n${formatTimeEntry(result.data, tz)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Stop Timer ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_stop_timer",
|
||||||
|
{
|
||||||
|
title: "Stop Timer",
|
||||||
|
description: "Stop the currently running timer by setting end time to now.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const existing = await getActiveTimer();
|
||||||
|
if (!existing) {
|
||||||
|
return { content: [{ type: "text", text: "No active timer to stop." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.put<{ data: TimeEntry }>(API_PATHS.timeEntry(orgId, existing.id), {
|
||||||
|
end: nowUTC(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Timer stopped.\n\n${formatTimeEntry(result.data, tz)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- List Time Entries ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_list_time_entries",
|
||||||
|
{
|
||||||
|
title: "List Time Entries",
|
||||||
|
description:
|
||||||
|
`List time entries with optional filters. Returns newest first. Use limit/offset for pagination.${tzNote}`,
|
||||||
|
inputSchema: {
|
||||||
|
start: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Filter: only entries starting after this datetime (e.g. 2026-03-01T00:00:00 for local or 2026-03-01T00:00:00Z for UTC)"
|
||||||
|
),
|
||||||
|
end: z.string().optional().describe("Filter: only entries ending before this datetime"),
|
||||||
|
active: z.enum(["true", "false"]).optional().describe("Filter by active/inactive"),
|
||||||
|
billable: z.enum(["true", "false"]).optional().describe("Filter by billable status"),
|
||||||
|
project_ids: coerceUuidArray.optional().describe("Filter by project UUIDs"),
|
||||||
|
client_ids: coerceUuidArray.optional().describe("Filter by client UUIDs"),
|
||||||
|
task_ids: coerceUuidArray.optional().describe("Filter by task UUIDs"),
|
||||||
|
tag_ids: coerceUuidArray.optional().describe("Filter by tag UUIDs"),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(PAGINATION.maxLimit)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
`Number of entries to return (1-${PAGINATION.maxLimit}, default ${PAGINATION.defaultLimit})`
|
||||||
|
),
|
||||||
|
offset: z.number().int().min(0).optional().describe("Offset for pagination (default 0)"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.start) query.set("start", toUTC(params.start, tz));
|
||||||
|
if (params.end) query.set("end", toUTC(params.end, tz));
|
||||||
|
if (params.active) query.set("active", params.active);
|
||||||
|
if (params.billable) query.set("billable", params.billable);
|
||||||
|
if (params.project_ids)
|
||||||
|
params.project_ids.forEach((id) => query.append("filter[project_ids][]", id));
|
||||||
|
if (params.client_ids)
|
||||||
|
params.client_ids.forEach((id) => query.append("filter[client_ids][]", id));
|
||||||
|
if (params.task_ids) params.task_ids.forEach((id) => query.append("filter[task_ids][]", id));
|
||||||
|
if (params.tag_ids) params.tag_ids.forEach((id) => query.append("filter[tag_ids][]", id));
|
||||||
|
|
||||||
|
const limit = params.limit ?? PAGINATION.defaultLimit;
|
||||||
|
const offset = params.offset ?? 0;
|
||||||
|
query.set("limit", String(limit));
|
||||||
|
query.set("offset", String(offset));
|
||||||
|
|
||||||
|
const path = `${API_PATHS.timeEntries(orgId)}?${query.toString()}`;
|
||||||
|
const result = await api.get<PaginatedResponse<TimeEntry>>(path);
|
||||||
|
|
||||||
|
const entries = result.data ?? [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No time entries found." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = entries.length === limit;
|
||||||
|
const lines = entries.map((e, i) => `${i + 1 + offset}. ${formatTimeEntry(e, tz)}`);
|
||||||
|
if (hasMore) {
|
||||||
|
lines.push(
|
||||||
|
`\n--- Showing ${entries.length} entries. Use offset=${offset + limit} to see more. ---`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.unshift(`Found ${entries.length} time entries:\n`);
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Create Time Entry ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_create_time_entry",
|
||||||
|
{
|
||||||
|
title: "Create Time Entry",
|
||||||
|
description:
|
||||||
|
`Create a completed time entry with start and end times. For running timers, use solidtime_start_timer instead.${tzNote}`,
|
||||||
|
inputSchema: {
|
||||||
|
start: z.string().describe("Start time (e.g. 2026-03-03T09:00:00 for local or 2026-03-03T09:00:00Z for UTC)"),
|
||||||
|
end: z.string().describe("End time (e.g. 2026-03-03T10:30:00 for local or 2026-03-03T10:30:00Z for UTC)"),
|
||||||
|
project_id: z.string().uuid().optional().describe("Project UUID"),
|
||||||
|
task_id: z.string().uuid().optional().describe("Task UUID"),
|
||||||
|
description: z.string().max(5000).optional().describe("What was done"),
|
||||||
|
billable: coerceBoolean
|
||||||
|
.optional()
|
||||||
|
.describe("Whether this time is billable (default false)"),
|
||||||
|
tag_ids: coerceUuidArray.optional().describe("Array of tag UUIDs to attach"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: false,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
member_id: getMemberId(),
|
||||||
|
start: toUTC(params.start, tz),
|
||||||
|
end: toUTC(params.end, tz),
|
||||||
|
billable: params.billable ?? false,
|
||||||
|
};
|
||||||
|
if (params.project_id) body.project_id = params.project_id;
|
||||||
|
if (params.task_id) body.task_id = params.task_id;
|
||||||
|
if (params.description) body.description = params.description;
|
||||||
|
if (params.tag_ids) body.tags = params.tag_ids;
|
||||||
|
|
||||||
|
const result = await api.post<{ data: TimeEntry }>(API_PATHS.timeEntries(orgId), body);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Time entry created.\n\n${formatTimeEntry(result.data, tz)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Update Time Entry ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_time_entry",
|
||||||
|
{
|
||||||
|
title: "Update Time Entry",
|
||||||
|
description: `Update an existing time entry. Only provided fields will be changed.${tzNote}`,
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Time entry UUID to update"),
|
||||||
|
start: z.string().optional().describe("New start time (no suffix = local, Z suffix = UTC)"),
|
||||||
|
end: z.string().optional().describe("New end time (no suffix = local, Z suffix = UTC)"),
|
||||||
|
project_id: z.string().uuid().optional().describe("New project UUID"),
|
||||||
|
task_id: z.string().uuid().optional().describe("New task UUID"),
|
||||||
|
description: z.string().max(5000).optional().describe("New description"),
|
||||||
|
billable: coerceBoolean.optional().describe("New billable status"),
|
||||||
|
tag_ids: z
|
||||||
|
.array(z.string().uuid())
|
||||||
|
.optional()
|
||||||
|
.describe("New tag UUIDs (replaces existing tags)"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (params.start !== undefined) body.start = toUTC(params.start, tz);
|
||||||
|
if (params.end !== undefined) body.end = toUTC(params.end, tz);
|
||||||
|
if (params.project_id !== undefined) body.project_id = params.project_id;
|
||||||
|
if (params.task_id !== undefined) body.task_id = params.task_id;
|
||||||
|
if (params.description !== undefined) body.description = params.description;
|
||||||
|
if (params.billable !== undefined) body.billable = params.billable;
|
||||||
|
if (params.tag_ids !== undefined) body.tags = params.tag_ids;
|
||||||
|
|
||||||
|
const result = await api.put<{ data: TimeEntry }>(
|
||||||
|
API_PATHS.timeEntry(orgId, params.id),
|
||||||
|
body
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Time entry updated.\n\n${formatTimeEntry(result.data, tz)}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Delete Time Entry ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_time_entry",
|
||||||
|
{
|
||||||
|
title: "Delete Time Entry",
|
||||||
|
description: "Permanently delete a time entry. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe("Time entry UUID to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.delete(API_PATHS.timeEntry(orgId, params.id));
|
||||||
|
return { content: [{ type: "text", text: `Time entry ${params.id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Time Entry Report ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_get_time_entry_report",
|
||||||
|
{
|
||||||
|
title: "Time Entry Report",
|
||||||
|
description:
|
||||||
|
"Get aggregated time entry data grouped by a dimension (day, week, month, year, project, client, task, user, billable, description, tag). Optionally add a sub-grouping.",
|
||||||
|
inputSchema: {
|
||||||
|
group_by: z.enum(GROUP_BY_VALUES).describe("Primary grouping dimension"),
|
||||||
|
sub_group: z.enum(GROUP_BY_VALUES).optional().describe("Secondary grouping dimension"),
|
||||||
|
start: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Filter: only entries starting after this UTC datetime"),
|
||||||
|
end: z.string().optional().describe("Filter: only entries ending before this UTC datetime"),
|
||||||
|
project_ids: coerceUuidArray.optional().describe("Filter by project UUIDs"),
|
||||||
|
client_ids: coerceUuidArray.optional().describe("Filter by client UUIDs"),
|
||||||
|
billable: z.enum(["true", "false"]).optional().describe("Filter by billable status"),
|
||||||
|
tag_ids: coerceUuidArray.optional().describe("Filter by tag UUIDs"),
|
||||||
|
member_ids: coerceUuidArray.optional().describe("Filter by member UUIDs"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("group", params.group_by);
|
||||||
|
if (params.sub_group) query.set("sub_group", params.sub_group);
|
||||||
|
if (params.start) query.set("start", toUTC(params.start, tz));
|
||||||
|
if (params.end) query.set("end", toUTC(params.end, tz));
|
||||||
|
if (params.billable) query.set("billable", params.billable);
|
||||||
|
if (params.project_ids)
|
||||||
|
params.project_ids.forEach((id) => query.append("filter[project_ids][]", id));
|
||||||
|
if (params.client_ids)
|
||||||
|
params.client_ids.forEach((id) => query.append("filter[client_ids][]", id));
|
||||||
|
if (params.tag_ids) params.tag_ids.forEach((id) => query.append("filter[tag_ids][]", id));
|
||||||
|
if (params.member_ids)
|
||||||
|
params.member_ids.forEach((id) => query.append("filter[member_ids][]", id));
|
||||||
|
|
||||||
|
const path = `${API_PATHS.timeEntryReport(orgId)}?${query.toString()}`;
|
||||||
|
const response = await api.get<{ data: TimeEntryReport }>(path);
|
||||||
|
const result = response.data;
|
||||||
|
|
||||||
|
if (!result.grouped_data || result.grouped_data.length === 0) {
|
||||||
|
return { content: [{ type: "text", text: "No data for this report." }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [`Report grouped by: ${result.grouped_type}\n`];
|
||||||
|
for (const group of result.grouped_data) {
|
||||||
|
const duration = formatDuration(group.seconds);
|
||||||
|
const cost = group.cost > 0 ? ` (${formatCurrency(group.cost)})` : "";
|
||||||
|
lines.push(`${group.key}: ${duration}${cost}`);
|
||||||
|
|
||||||
|
if (group.grouped_data) {
|
||||||
|
for (const sub of group.grouped_data) {
|
||||||
|
const subDuration = formatDuration(sub.seconds);
|
||||||
|
const subCost = sub.cost > 0 ? ` (${formatCurrency(sub.cost)})` : "";
|
||||||
|
lines.push(` ${sub.key}: ${subDuration}${subCost}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Update Multiple Time Entries ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_update_multiple_time_entries",
|
||||||
|
{
|
||||||
|
title: "Update Multiple Time Entries",
|
||||||
|
description:
|
||||||
|
"Update multiple time entries at once. All provided fields will be applied to all specified entries.",
|
||||||
|
inputSchema: {
|
||||||
|
ids: z.array(z.string().uuid()).min(1).describe("Array of time entry UUIDs to update"),
|
||||||
|
project_id: z.string().uuid().optional().describe("New project UUID for all entries"),
|
||||||
|
task_id: z.string().uuid().optional().describe("New task UUID for all entries"),
|
||||||
|
description: z.string().max(5000).optional().describe("New description for all entries"),
|
||||||
|
billable: coerceBoolean.optional().describe("New billable status for all entries"),
|
||||||
|
tag_ids: coerceUuidArray.optional().describe("New tag UUIDs for all entries"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
ids: params.ids,
|
||||||
|
changes: {},
|
||||||
|
};
|
||||||
|
const changes: Record<string, unknown> = {};
|
||||||
|
if (params.project_id !== undefined) changes.project_id = params.project_id;
|
||||||
|
if (params.task_id !== undefined) changes.task_id = params.task_id;
|
||||||
|
if (params.description !== undefined) changes.description = params.description;
|
||||||
|
if (params.billable !== undefined) changes.billable = params.billable;
|
||||||
|
if (params.tag_ids !== undefined) changes.tags = params.tag_ids;
|
||||||
|
body.changes = changes;
|
||||||
|
|
||||||
|
await api.patch(API_PATHS.timeEntries(orgId), body);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Updated ${params.ids.length} time entries.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Delete Multiple Time Entries ---
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_delete_multiple_time_entries",
|
||||||
|
{
|
||||||
|
title: "Delete Multiple Time Entries",
|
||||||
|
description:
|
||||||
|
"Permanently delete multiple time entries at once. This cannot be undone.",
|
||||||
|
inputSchema: {
|
||||||
|
ids: z
|
||||||
|
.array(z.string().uuid())
|
||||||
|
.min(1)
|
||||||
|
.describe("Array of time entry UUIDs to delete"),
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (params) => {
|
||||||
|
await api.deleteWithBody(API_PATHS.timeEntries(orgId), { ids: params.ids });
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: `Deleted ${params.ids.length} time entries.` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeEntry(entry: TimeEntry, timezone?: string): string {
|
||||||
|
const parts: string[] = [`ID: ${entry.id}`];
|
||||||
|
if (entry.description) parts.push(`Description: ${entry.description}`);
|
||||||
|
parts.push(`Start: ${formatDateTime(entry.start, timezone)}`);
|
||||||
|
if (entry.end) {
|
||||||
|
parts.push(`End: ${formatDateTime(entry.end, timezone)}`);
|
||||||
|
} else {
|
||||||
|
parts.push("Status: RUNNING");
|
||||||
|
}
|
||||||
|
if (entry.duration !== null && entry.duration !== undefined) {
|
||||||
|
parts.push(`Duration: ${formatDuration(entry.duration)}`);
|
||||||
|
}
|
||||||
|
parts.push(`Billable: ${entry.billable ? "Yes" : "No"}`);
|
||||||
|
if (entry.project_id) parts.push(`Project ID: ${entry.project_id}`);
|
||||||
|
if (entry.task_id) parts.push(`Task ID: ${entry.task_id}`);
|
||||||
|
if (entry.tags && entry.tags.length > 0) parts.push(`Tags: ${entry.tags.join(", ")}`);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
35
src/tools/users.ts
Normal file
35
src/tools/users.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { ApiClient } from "../api-client.js";
|
||||||
|
import { API_PATHS } from "../constants.js";
|
||||||
|
import type { User } from "../types.js";
|
||||||
|
|
||||||
|
export function registerUserTools(server: McpServer, api: ApiClient, getMemberId: () => string) {
|
||||||
|
server.registerTool(
|
||||||
|
"solidtime_get_current_user",
|
||||||
|
{
|
||||||
|
title: "Get Current User",
|
||||||
|
description:
|
||||||
|
"Get the current user profile including name, email, timezone, and the resolved member_id for this organization.",
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: true,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const response = await api.get<{ data: User }>(API_PATHS.me);
|
||||||
|
const user = response.data;
|
||||||
|
const memberId = getMemberId();
|
||||||
|
const text = [
|
||||||
|
`Name: ${user.name}`,
|
||||||
|
`Email: ${user.email}`,
|
||||||
|
`Timezone: ${user.timezone}`,
|
||||||
|
`Week starts: ${user.week_start}`,
|
||||||
|
`Member ID: ${memberId}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return { content: [{ type: "text", text }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/types.ts
Normal file
117
src/types.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
timezone: string;
|
||||||
|
week_start: string;
|
||||||
|
is_placeholder: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Member {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
billable_rate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntry {
|
||||||
|
id: string;
|
||||||
|
start: string;
|
||||||
|
end: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
description: string | null;
|
||||||
|
billable: boolean;
|
||||||
|
tags: string[];
|
||||||
|
project_id: string | null;
|
||||||
|
task_id: string | null;
|
||||||
|
member_id: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryReport {
|
||||||
|
grouped_type: string;
|
||||||
|
grouped_data:
|
||||||
|
| null
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
seconds: number;
|
||||||
|
cost: number;
|
||||||
|
grouped_type: string | null;
|
||||||
|
grouped_data: null | { key: string; seconds: number; cost: number }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
is_billable: boolean;
|
||||||
|
is_archived: boolean;
|
||||||
|
billable_rate: number | null;
|
||||||
|
estimated_time: number | null;
|
||||||
|
spent_time: number;
|
||||||
|
client_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_archived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_done: boolean;
|
||||||
|
project_id: string;
|
||||||
|
estimated_time: number | null;
|
||||||
|
spent_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
current_page?: number;
|
||||||
|
last_page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMembership {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
currency: string;
|
||||||
|
billable_rate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMember {
|
||||||
|
id: string;
|
||||||
|
member_id: string;
|
||||||
|
project_id: string;
|
||||||
|
billable_rate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invitation {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
errors?: Record<string, string[]>;
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user