@nightsquawktech/kimai-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @nightsquawktech/kimai-mcp-server
2
+
3
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/NightSquawk/kimai-mcp-server/badge)](https://scorecard.dev/viewer/?uri=github.com/NightSquawk/kimai-mcp-server)
4
+
5
+ Model Context Protocol server for Kimai time-tracking instances. Read tools cover the full Kimai API surface; timesheet write tools exist but are guarded behind explicit authorization.
6
+
7
+ ## API Notes
8
+
9
+ Kimai exposes a JSON API under `/api`. Current Kimai documentation uses bearer-token authentication:
10
+
11
+ ```http
12
+ Authorization: Bearer <api-token>
13
+ ```
14
+
15
+ The base URL should be the browser URL without a trailing slash, for example `https://demo.kimai.org`, not `https://demo.kimai.org/`. Kimai's current API docs are available at your own instance under `/api/doc`, and official docs note that the OpenAPI export is available from that UI.
16
+
17
+ Pagination uses `page` and `size` query parameters. Kimai defaults to 50 records per page and documents 500 as the maximum page size. Paginated responses may include `X-Page`, `X-Total-Count`, `X-Total-Pages`, and `X-Per-Page` headers.
18
+
19
+ Read tools are safe to call without changing Kimai. Write tools are intentionally guarded because Kimai timekeeping data is sensitive:
20
+
21
+ - Every write tool requires `authorization_confirmed: true`.
22
+ - Every write tool requires an `authorization_note` describing the user's explicit approval.
23
+ - Every write tool writes a JSON backup under the system temp directory before or immediately after mutation.
24
+ - Update/state-change tools save the previous Kimai record before calling the mutation endpoint.
25
+ - Create/duplicate tools save the request/source and created record so the created ID can be audited and reverted manually if needed.
26
+ - Delete tools are intentionally not exposed.
27
+
28
+ References:
29
+
30
+ - https://www.kimai.org/documentation/rest-api.html
31
+ - https://www.kimai.org/documentation/user-api.html
32
+ - https://www.kimai.org/documentation/api-pagination.html
33
+
34
+ ## Tools
35
+
36
+ - `kimai_get_server_info` reads `/api/ping`, `/api/version`, `/api/plugins`, and `/api/config/timesheet`.
37
+ - `kimai_get_current_user` reads `/api/users/me`.
38
+ - `kimai_list_users` and `kimai_get_user` read users.
39
+ - `kimai_list_customers` and `kimai_get_customer` read customers.
40
+ - `kimai_list_projects` and `kimai_get_project` read projects.
41
+ - `kimai_list_activities` and `kimai_get_activity` read activities.
42
+ - `kimai_list_tags` reads tags.
43
+ - `kimai_list_timesheets`, `kimai_get_timesheet`, `kimai_list_active_timesheets`, and `kimai_list_recent_timesheets` read time entries.
44
+ - `kimai_create_timesheet` creates a time entry after explicit authorization and stores a temp backup.
45
+ - `kimai_update_timesheet` updates a time entry after explicit authorization and stores the previous record in a temp backup.
46
+ - `kimai_stop_timesheet` stops an active time entry after explicit authorization and stores the previous record in a temp backup.
47
+ - `kimai_restart_timesheet` restarts a stopped time entry after explicit authorization and stores the previous record in a temp backup.
48
+ - `kimai_duplicate_timesheet` duplicates a time entry after explicit authorization and stores source/created records in a temp backup.
49
+ - `kimai_list_teams` and `kimai_get_team` read teams.
50
+ - `kimai_list_invoices` and `kimai_get_invoice` read invoice history.
51
+ - `kimai_list_expenses` and `kimai_get_expense` read expenses when that endpoint is available.
52
+ - `kimai_list_tasks` and `kimai_get_task` read tasks when that endpoint is available.
53
+
54
+ ## Setup
55
+
56
+ Configure an MCP client to run the server with `npx`:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "kimai": {
62
+ "command": "npx",
63
+ "args": ["-y", "@nightsquawktech/kimai-mcp-server"],
64
+ "env": {
65
+ "KIMAI_BASE_URL": "https://your-instance.example.com",
66
+ "KIMAI_API_TOKEN": "your-api-token"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ For Codex global TOML config:
74
+
75
+ ```toml
76
+ [mcp_servers.kimai]
77
+ command = "npx"
78
+ args = ["-y", "@nightsquawktech/kimai-mcp-server"]
79
+
80
+ [mcp_servers.kimai.env]
81
+ KIMAI_BASE_URL = "https://your-instance.example.com"
82
+ KIMAI_API_TOKEN = "your-api-token"
83
+ ```
84
+
85
+ ### Environment variables
86
+
87
+ | Variable | Required | Description |
88
+ |----------|----------|-------------|
89
+ | `KIMAI_BASE_URL` | Yes | Base URL of your Kimai instance without a trailing slash, e.g. `https://your-instance.example.com` |
90
+ | `KIMAI_API_TOKEN` | Yes | Kimai API token (bearer token) |
91
+ | `KIMAI_TIMEOUT_MS` | No | HTTP request timeout in milliseconds, default `30000` |
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ npm install
97
+ npm run dev
98
+ npm run build
99
+ ```
100
+
101
+ Create `.env` for local development:
102
+
103
+ ```env
104
+ KIMAI_BASE_URL=https://your-instance.example.com
105
+ KIMAI_API_TOKEN=your-api-token
106
+ KIMAI_TIMEOUT_MS=30000
107
+ ```
108
+
109
+ The server uses stdio for local MCP clients. Use stderr for logs so stdout remains reserved for MCP protocol messages.
110
+
111
+ ## License
112
+
113
+ Licensed under the [GNU AGPL v3.0](./LICENSE). Free for personal and open-source use.
114
+
115
+ Organizations that cannot comply with the AGPL can purchase a commercial license. See [COMMERCIAL.md](./COMMERCIAL.md) or contact hello@nightsquawk.tech.
@@ -0,0 +1,9 @@
1
+ export const SERVER_NAME = "kimai-mcp-server";
2
+ export const SERVER_VERSION = "0.1.0";
3
+ export const ENV = {
4
+ baseUrl: "KIMAI_BASE_URL",
5
+ apiToken: "KIMAI_API_TOKEN",
6
+ timeoutMs: "KIMAI_TIMEOUT_MS"
7
+ };
8
+ export const DEFAULT_TIMEOUT_MS = 30_000;
9
+ export const RESPONSE_CHARACTER_LIMIT = 25_000;
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SERVER_NAME, SERVER_VERSION } from "./constants.js";
5
+ import { KimaiClient } from "./services/kimai-client.js";
6
+ import { loadConfig } from "./services/config.js";
7
+ import { registerTools } from "./tools/index.js";
8
+ async function main() {
9
+ const config = loadConfig();
10
+ const client = new KimaiClient(config);
11
+ const server = new McpServer({
12
+ name: SERVER_NAME,
13
+ version: SERVER_VERSION
14
+ });
15
+ registerTools(server, client);
16
+ const transport = new StdioServerTransport();
17
+ await server.connect(transport);
18
+ console.error(`${SERVER_NAME} ${SERVER_VERSION} running on stdio`);
19
+ }
20
+ main().catch((error) => {
21
+ console.error(error instanceof Error ? error.message : String(error));
22
+ process.exit(1);
23
+ });
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ export const ResponseFormatSchema = z
3
+ .enum(["markdown", "json"])
4
+ .default("markdown")
5
+ .describe("Output format: markdown for human-readable summaries, json for structured output.");
6
+ export const PageSchema = z
7
+ .number()
8
+ .int()
9
+ .min(1)
10
+ .default(1)
11
+ .describe("Kimai API page number for paginated endpoints.");
12
+ export const SizeSchema = z
13
+ .number()
14
+ .int()
15
+ .min(1)
16
+ .max(500)
17
+ .default(50)
18
+ .describe("Kimai API page size. Kimai supports a maximum size of 500.");
19
+ export const IdSchema = z.union([z.number().int().positive(), z.string().min(1)]).describe("Kimai entity ID.");
20
+ export const DateTimeSchema = z
21
+ .string()
22
+ .min(1)
23
+ .optional()
24
+ .describe("Optional date/time filter. Kimai returns ISO 8601; write endpoints use HTML5 local date-time.");
25
+ export const EntityStateSchema = z
26
+ .enum(["all", "visible", "hidden"])
27
+ .optional()
28
+ .describe("Optional visibility filter for endpoints that support it.");
@@ -0,0 +1,65 @@
1
+ import { z } from "zod";
2
+ import { DateTimeSchema, IdSchema, ResponseFormatSchema } from "./common.js";
3
+ const AuthorizationSchema = {
4
+ authorization_confirmed: z
5
+ .literal(true)
6
+ .describe("Must be true only after the human user explicitly authorizes this sensitive Kimai edit."),
7
+ authorization_note: z
8
+ .string()
9
+ .min(8)
10
+ .describe("Short note capturing the user's explicit authorization and reason for the edit.")
11
+ };
12
+ const TimesheetEditableFields = {
13
+ begin: DateTimeSchema.describe("Timesheet begin timestamp in Kimai HTML5 datetime-local format, e.g. 2026-06-01T09:00:00."),
14
+ end: DateTimeSchema.describe("Optional timesheet end timestamp in Kimai HTML5 datetime-local format, e.g. 2026-06-01T10:30:00."),
15
+ project: IdSchema.optional().describe("Kimai project ID."),
16
+ activity: IdSchema.optional().describe("Kimai activity ID."),
17
+ description: z.string().optional().describe("Optional timesheet description."),
18
+ fixed_rate: z.number().optional().describe("Optional fixed rate override."),
19
+ hourly_rate: z.number().optional().describe("Optional hourly rate override."),
20
+ user: IdSchema.optional().describe("Optional user ID; requires permission to edit other users' time."),
21
+ tags: z.array(z.string().min(1)).optional().describe("Optional tags; sent to Kimai as a comma-separated list."),
22
+ exported: z.boolean().optional().describe("Optional exported flag. This can lock/unlock timesheet records."),
23
+ billable: z.boolean().optional().describe("Optional billable flag.")
24
+ };
25
+ export const CreateTimesheetSchema = z
26
+ .object({
27
+ ...AuthorizationSchema,
28
+ begin: z.string().min(1).describe("Timesheet begin timestamp in Kimai HTML5 datetime-local format, e.g. 2026-06-01T09:00:00."),
29
+ project: IdSchema.describe("Kimai project ID."),
30
+ activity: IdSchema.describe("Kimai activity ID."),
31
+ end: TimesheetEditableFields.end,
32
+ description: TimesheetEditableFields.description,
33
+ fixed_rate: TimesheetEditableFields.fixed_rate,
34
+ hourly_rate: TimesheetEditableFields.hourly_rate,
35
+ user: TimesheetEditableFields.user,
36
+ tags: TimesheetEditableFields.tags,
37
+ exported: TimesheetEditableFields.exported,
38
+ billable: TimesheetEditableFields.billable,
39
+ full: z.boolean().default(true).describe("Request fully serialized Kimai response where supported."),
40
+ response_format: ResponseFormatSchema
41
+ })
42
+ .strict();
43
+ export const UpdateTimesheetSchema = z
44
+ .object({
45
+ ...AuthorizationSchema,
46
+ id: IdSchema,
47
+ ...TimesheetEditableFields,
48
+ response_format: ResponseFormatSchema
49
+ })
50
+ .strict();
51
+ export const TimesheetStateChangeSchema = z
52
+ .object({
53
+ ...AuthorizationSchema,
54
+ id: IdSchema,
55
+ begin: DateTimeSchema.describe("Optional restart begin timestamp in Kimai HTML5 datetime-local format."),
56
+ response_format: ResponseFormatSchema
57
+ })
58
+ .strict();
59
+ export const DuplicateTimesheetSchema = z
60
+ .object({
61
+ ...AuthorizationSchema,
62
+ id: IdSchema,
63
+ response_format: ResponseFormatSchema
64
+ })
65
+ .strict();
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { DateTimeSchema, EntityStateSchema, IdSchema, PageSchema, ResponseFormatSchema, SizeSchema } from "./common.js";
3
+ export const CollectionSchema = z
4
+ .object({
5
+ page: PageSchema,
6
+ size: SizeSchema,
7
+ response_format: ResponseFormatSchema
8
+ })
9
+ .strict();
10
+ export const EntityIdSchema = z
11
+ .object({
12
+ id: IdSchema,
13
+ response_format: ResponseFormatSchema
14
+ })
15
+ .strict();
16
+ export const SearchableCollectionSchema = CollectionSchema.extend({
17
+ term: z.string().min(1).optional().describe("Optional search term for endpoints that support it."),
18
+ order_by: z.string().min(1).optional().describe("Optional Kimai orderBy field."),
19
+ order: z.enum(["ASC", "DESC"]).optional().describe("Optional sort direction.")
20
+ }).strict();
21
+ export const CustomerCollectionSchema = SearchableCollectionSchema.extend({
22
+ visible: EntityStateSchema
23
+ }).strict();
24
+ export const ProjectCollectionSchema = SearchableCollectionSchema.extend({
25
+ customer: IdSchema.optional().describe("Optional customer ID filter."),
26
+ visible: EntityStateSchema
27
+ }).strict();
28
+ export const ActivityCollectionSchema = SearchableCollectionSchema.extend({
29
+ project: IdSchema.optional().describe("Optional project ID filter."),
30
+ visible: EntityStateSchema
31
+ }).strict();
32
+ export const TimesheetCollectionSchema = CollectionSchema.extend({
33
+ user: IdSchema.optional().describe("Optional user ID filter."),
34
+ customer: IdSchema.optional().describe("Optional customer ID filter."),
35
+ project: IdSchema.optional().describe("Optional project ID filter."),
36
+ activity: IdSchema.optional().describe("Optional activity ID filter."),
37
+ begin: DateTimeSchema.describe("Optional begin date/time filter."),
38
+ end: DateTimeSchema.describe("Optional end date/time filter."),
39
+ exported: z.boolean().optional().describe("Optional exported-state filter."),
40
+ active: z.boolean().optional().describe("Optional active/running filter."),
41
+ tags: z.array(z.string().min(1)).optional().describe("Optional tag filters.")
42
+ }).strict();
43
+ export const TagCollectionSchema = CollectionSchema.extend({
44
+ name: z.string().min(1).optional().describe("Optional tag name search.")
45
+ }).strict();
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ import { ResponseFormatSchema } from "./common.js";
3
+ export const ServerInfoSchema = z
4
+ .object({
5
+ response_format: ResponseFormatSchema
6
+ })
7
+ .strict();
@@ -0,0 +1,16 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ export async function writeMutationBackup(record) {
5
+ const directory = join(tmpdir(), "kimai-mcp-backups");
6
+ await mkdir(directory, { recursive: true });
7
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
8
+ const safeOperation = record.operation.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase();
9
+ const filename = `${stamp}-${safeOperation}.json`;
10
+ const fullPath = join(directory, filename);
11
+ await writeFile(fullPath, JSON.stringify({
12
+ created_at: new Date().toISOString(),
13
+ ...record
14
+ }, null, 2), "utf8");
15
+ return fullPath;
16
+ }
@@ -0,0 +1,22 @@
1
+ import "dotenv/config";
2
+ import { DEFAULT_TIMEOUT_MS, ENV } from "../constants.js";
3
+ export function loadConfig() {
4
+ const baseUrl = process.env[ENV.baseUrl]?.trim();
5
+ const apiToken = process.env[ENV.apiToken]?.trim();
6
+ const timeoutRaw = process.env[ENV.timeoutMs]?.trim();
7
+ if (!baseUrl) {
8
+ throw new Error(`${ENV.baseUrl} is required, for example https://example.kimai.cloud`);
9
+ }
10
+ if (!apiToken) {
11
+ throw new Error(`${ENV.apiToken} is required. Generate an API token in your Kimai user profile.`);
12
+ }
13
+ const timeoutMs = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : DEFAULT_TIMEOUT_MS;
14
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
15
+ throw new Error(`${ENV.timeoutMs} must be an integer of at least 1000 milliseconds.`);
16
+ }
17
+ return {
18
+ baseUrl: baseUrl.replace(/\/+$/, ""),
19
+ apiToken,
20
+ timeoutMs
21
+ };
22
+ }
@@ -0,0 +1,38 @@
1
+ import axios from "axios";
2
+ export function formatApiError(error) {
3
+ if (axios.isAxiosError(error)) {
4
+ if (error.response) {
5
+ const status = error.response.status;
6
+ const detail = formatErrorData(error.response.data);
7
+ if (status === 401 || status === 403) {
8
+ return `Kimai denied the request (${status}). Check KIMAI_API_TOKEN and the user's API permissions.${detail}`;
9
+ }
10
+ if (status === 404) {
11
+ return `Kimai resource not found (404). Check the ID and endpoint path.${detail}`;
12
+ }
13
+ if (status === 429) {
14
+ return `Kimai rate limited the request (429). Retry later or reduce request volume.${detail}`;
15
+ }
16
+ return `Kimai API request failed (${status}).${detail}`;
17
+ }
18
+ if (error.code === "ECONNABORTED") {
19
+ return "Kimai API request timed out. Increase KIMAI_TIMEOUT_MS or narrow the query.";
20
+ }
21
+ if (error.code) {
22
+ return `Kimai API connection failed (${error.code}). Check KIMAI_BASE_URL and network access.`;
23
+ }
24
+ }
25
+ return `Unexpected Kimai error: ${error instanceof Error ? error.message : String(error)}`;
26
+ }
27
+ function formatErrorData(data) {
28
+ if (data === undefined || data === null)
29
+ return "";
30
+ if (typeof data === "string")
31
+ return ` Response: ${data}`;
32
+ try {
33
+ return ` Response: ${JSON.stringify(data)}`;
34
+ }
35
+ catch {
36
+ return "";
37
+ }
38
+ }
@@ -0,0 +1,66 @@
1
+ import axios from "axios";
2
+ export class KimaiClient {
3
+ http;
4
+ constructor(config) {
5
+ this.http = axios.create({
6
+ baseURL: config.baseUrl,
7
+ timeout: config.timeoutMs,
8
+ headers: {
9
+ Accept: "application/json",
10
+ "Content-Type": "application/json",
11
+ Authorization: `Bearer ${config.apiToken}`
12
+ }
13
+ });
14
+ }
15
+ async request(method, path, options = {}) {
16
+ const response = await this.http.request({
17
+ method,
18
+ url: normalizePath(path),
19
+ ...options
20
+ });
21
+ return {
22
+ data: response.data,
23
+ pagination: extractPagination(response)
24
+ };
25
+ }
26
+ get(path, params) {
27
+ return this.request("GET", path, { params: pruneUndefined(params) });
28
+ }
29
+ post(path, data, params) {
30
+ return this.request("POST", path, { data: pruneUndefined(data), params: pruneUndefined(params) });
31
+ }
32
+ patch(path, data, params) {
33
+ return this.request("PATCH", path, { data: pruneUndefined(data), params: pruneUndefined(params) });
34
+ }
35
+ }
36
+ function normalizePath(path) {
37
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
38
+ return cleanPath.includes("?") ? cleanPath : cleanPath.replace(/\/+$/, "");
39
+ }
40
+ function pruneUndefined(params) {
41
+ if (!params)
42
+ return undefined;
43
+ const entries = Object.entries(params).filter(([, value]) => {
44
+ if (value === undefined || value === null || value === "")
45
+ return false;
46
+ if (Array.isArray(value) && value.length === 0)
47
+ return false;
48
+ return true;
49
+ });
50
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
51
+ }
52
+ function extractPagination(response) {
53
+ const headers = response.headers;
54
+ return {
55
+ page: toNumber(headers["x-page"]),
56
+ total_count: toNumber(headers["x-total-count"]),
57
+ total_pages: toNumber(headers["x-total-pages"]),
58
+ per_page: toNumber(headers["x-per-page"])
59
+ };
60
+ }
61
+ function toNumber(value) {
62
+ if (typeof value !== "string")
63
+ return undefined;
64
+ const parsed = Number.parseInt(value, 10);
65
+ return Number.isFinite(parsed) ? parsed : undefined;
66
+ }
@@ -0,0 +1,45 @@
1
+ import { CollectionSchema, EntityIdSchema, SearchableCollectionSchema } from "../schemas/resources.js";
2
+ import { registerCollectionReadTool, registerEntityReadTool } from "./read-tools.js";
3
+ export function registerBusinessTools(server, client) {
4
+ registerCollectionReadTool(server, client, {
5
+ name: "kimai_list_teams",
6
+ title: "List Kimai Teams",
7
+ description: "List Kimai teams from /api/teams. This tool is read-only.",
8
+ inputSchema: CollectionSchema.shape,
9
+ path: () => "/api/teams",
10
+ preferredFields: ["id", "name"],
11
+ heading: "Kimai Teams"
12
+ });
13
+ registerEntityReadTool(server, client, {
14
+ name: "kimai_get_team",
15
+ title: "Get Kimai Team",
16
+ description: "Read one Kimai team from /api/teams/<id>. This tool is read-only.",
17
+ inputSchema: EntityIdSchema.shape,
18
+ path: (params) => `/api/teams/${encodeURIComponent(String(params.id))}`,
19
+ preferredFields: ["id", "name"],
20
+ heading: "Kimai Team"
21
+ });
22
+ registerCollectionReadTool(server, client, {
23
+ name: "kimai_list_invoices",
24
+ title: "List Kimai Invoices",
25
+ description: "List Kimai invoices from /api/invoices. This tool is read-only.",
26
+ inputSchema: SearchableCollectionSchema.shape,
27
+ path: () => "/api/invoices",
28
+ query: (params) => ({
29
+ term: params.term,
30
+ orderBy: params.order_by,
31
+ order: params.order
32
+ }),
33
+ preferredFields: ["id", "invoiceNumber", "customer", "user", "total", "createdAt", "status"],
34
+ heading: "Kimai Invoices"
35
+ });
36
+ registerEntityReadTool(server, client, {
37
+ name: "kimai_get_invoice",
38
+ title: "Get Kimai Invoice",
39
+ description: "Read one Kimai invoice from /api/invoices/<id>. This tool is read-only.",
40
+ inputSchema: EntityIdSchema.shape,
41
+ path: (params) => `/api/invoices/${encodeURIComponent(String(params.id))}`,
42
+ preferredFields: ["id", "invoiceNumber", "customer", "user", "total", "createdAt", "status"],
43
+ heading: "Kimai Invoice"
44
+ });
45
+ }
@@ -0,0 +1,81 @@
1
+ import { ActivityCollectionSchema, CustomerCollectionSchema, EntityIdSchema, ProjectCollectionSchema, TagCollectionSchema } from "../schemas/resources.js";
2
+ import { registerCollectionReadTool, registerEntityReadTool } from "./read-tools.js";
3
+ export function registerCatalogTools(server, client) {
4
+ registerCollectionReadTool(server, client, {
5
+ name: "kimai_list_customers",
6
+ title: "List Kimai Customers",
7
+ description: "List customers visible to the Kimai API token from /api/customers. This tool is read-only.",
8
+ inputSchema: CustomerCollectionSchema.shape,
9
+ path: () => "/api/customers",
10
+ query: (params) => commonQuery(params),
11
+ preferredFields: ["id", "name", "number", "company", "visible", "country"],
12
+ heading: "Kimai Customers"
13
+ });
14
+ registerEntityReadTool(server, client, {
15
+ name: "kimai_get_customer",
16
+ title: "Get Kimai Customer",
17
+ description: "Read one Kimai customer from /api/customers/<id>. This tool is read-only.",
18
+ inputSchema: EntityIdSchema.shape,
19
+ path: (params) => `/api/customers/${encodeURIComponent(String(params.id))}`,
20
+ preferredFields: ["id", "name", "number", "company", "visible", "country"],
21
+ heading: "Kimai Customer"
22
+ });
23
+ registerCollectionReadTool(server, client, {
24
+ name: "kimai_list_projects",
25
+ title: "List Kimai Projects",
26
+ description: "List projects visible to the Kimai API token from /api/projects, optionally filtered by customer. This tool is read-only.",
27
+ inputSchema: ProjectCollectionSchema.shape,
28
+ path: () => "/api/projects",
29
+ query: (params) => commonQuery(params),
30
+ preferredFields: ["id", "name", "customer", "visible", "orderNumber", "budget"],
31
+ heading: "Kimai Projects"
32
+ });
33
+ registerEntityReadTool(server, client, {
34
+ name: "kimai_get_project",
35
+ title: "Get Kimai Project",
36
+ description: "Read one Kimai project from /api/projects/<id>. This tool is read-only.",
37
+ inputSchema: EntityIdSchema.shape,
38
+ path: (params) => `/api/projects/${encodeURIComponent(String(params.id))}`,
39
+ preferredFields: ["id", "name", "customer", "visible", "orderNumber", "budget"],
40
+ heading: "Kimai Project"
41
+ });
42
+ registerCollectionReadTool(server, client, {
43
+ name: "kimai_list_activities",
44
+ title: "List Kimai Activities",
45
+ description: "List activities visible to the Kimai API token from /api/activities, optionally filtered by project. This tool is read-only.",
46
+ inputSchema: ActivityCollectionSchema.shape,
47
+ path: () => "/api/activities",
48
+ query: (params) => commonQuery(params),
49
+ preferredFields: ["id", "name", "project", "visible", "budget", "timeBudget"],
50
+ heading: "Kimai Activities"
51
+ });
52
+ registerEntityReadTool(server, client, {
53
+ name: "kimai_get_activity",
54
+ title: "Get Kimai Activity",
55
+ description: "Read one Kimai activity from /api/activities/<id>. This tool is read-only.",
56
+ inputSchema: EntityIdSchema.shape,
57
+ path: (params) => `/api/activities/${encodeURIComponent(String(params.id))}`,
58
+ preferredFields: ["id", "name", "project", "visible", "budget", "timeBudget"],
59
+ heading: "Kimai Activity"
60
+ });
61
+ registerCollectionReadTool(server, client, {
62
+ name: "kimai_list_tags",
63
+ title: "List Kimai Tags",
64
+ description: "List Kimai tags from /api/tags/find. This tool is read-only.",
65
+ inputSchema: TagCollectionSchema.shape,
66
+ path: () => "/api/tags/find",
67
+ query: (params) => ({ name: params.name }),
68
+ preferredFields: ["id", "name", "color"],
69
+ heading: "Kimai Tags"
70
+ });
71
+ }
72
+ function commonQuery(params) {
73
+ return {
74
+ term: params.term,
75
+ orderBy: params.order_by,
76
+ order: params.order,
77
+ visible: params.visible,
78
+ customer: params.customer,
79
+ project: params.project
80
+ };
81
+ }
@@ -0,0 +1,70 @@
1
+ import { RESPONSE_CHARACTER_LIMIT } from "../constants.js";
2
+ export function paginateResponse(response, page, size) {
3
+ const items = extractItems(response.data);
4
+ const total = response.pagination.total_count ?? items.length;
5
+ const totalPages = response.pagination.total_pages;
6
+ const currentPage = response.pagination.page ?? page;
7
+ const hasMore = totalPages !== undefined ? currentPage < totalPages : items.length === size;
8
+ return {
9
+ total,
10
+ count: items.length,
11
+ page: currentPage,
12
+ size: response.pagination.per_page ?? size,
13
+ items,
14
+ has_more: hasMore,
15
+ ...(hasMore ? { next_page: currentPage + 1 } : {}),
16
+ ...(totalPages !== undefined ? { total_pages: totalPages } : {})
17
+ };
18
+ }
19
+ export function makeToolResponse(data, text, isError = false) {
20
+ const responseText = text.length > RESPONSE_CHARACTER_LIMIT
21
+ ? `${text.slice(0, RESPONSE_CHARACTER_LIMIT)}\n\nResponse truncated at ${RESPONSE_CHARACTER_LIMIT} characters. Use filters or a smaller page size.`
22
+ : text;
23
+ return {
24
+ content: [{ type: "text", text: responseText }],
25
+ structuredContent: data,
26
+ ...(isError ? { isError: true } : {})
27
+ };
28
+ }
29
+ export function formatResponse(format, data, markdown) {
30
+ return format === "json" ? JSON.stringify(data, null, 2) : markdown;
31
+ }
32
+ export function summarizeRecord(record, preferredFields) {
33
+ if (!record || typeof record !== "object") {
34
+ return String(record);
35
+ }
36
+ const source = record;
37
+ const fields = preferredFields
38
+ .filter((field) => source[field] !== undefined && source[field] !== null && source[field] !== "")
39
+ .map((field) => `${field}: ${formatInline(source[field])}`);
40
+ return fields.length > 0 ? fields.join(", ") : JSON.stringify(record);
41
+ }
42
+ export function extractItems(raw) {
43
+ if (Array.isArray(raw)) {
44
+ return raw;
45
+ }
46
+ if (raw && typeof raw === "object") {
47
+ const source = raw;
48
+ for (const key of ["results", "items", "data", "rows"]) {
49
+ if (Array.isArray(source[key])) {
50
+ return source[key];
51
+ }
52
+ }
53
+ }
54
+ return raw === undefined || raw === null ? [] : [raw];
55
+ }
56
+ function formatInline(value) {
57
+ if (value && typeof value === "object") {
58
+ const source = value;
59
+ if (typeof source.name === "string")
60
+ return source.name;
61
+ if (typeof source.username === "string")
62
+ return source.username;
63
+ if (typeof source.alias === "string")
64
+ return source.alias;
65
+ if (typeof source.id === "number" || typeof source.id === "string")
66
+ return String(source.id);
67
+ return JSON.stringify(value);
68
+ }
69
+ return String(value);
70
+ }
@@ -0,0 +1,16 @@
1
+ import { registerBusinessTools } from "./business.js";
2
+ import { registerCatalogTools } from "./catalog.js";
3
+ import { registerPluginTools } from "./plugins.js";
4
+ import { registerServerTools } from "./server.js";
5
+ import { registerTimesheetMutationTools } from "./timesheet-mutations.js";
6
+ import { registerTimesheetTools } from "./timesheets.js";
7
+ import { registerUserTools } from "./users.js";
8
+ export function registerTools(server, client) {
9
+ registerServerTools(server, client);
10
+ registerUserTools(server, client);
11
+ registerCatalogTools(server, client);
12
+ registerTimesheetTools(server, client);
13
+ registerTimesheetMutationTools(server, client);
14
+ registerBusinessTools(server, client);
15
+ registerPluginTools(server, client);
16
+ }