@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.
@@ -0,0 +1,40 @@
1
+ import { CollectionSchema, EntityIdSchema } from "../schemas/resources.js";
2
+ import { registerCollectionReadTool, registerEntityReadTool } from "./read-tools.js";
3
+ export function registerPluginTools(server, client) {
4
+ registerCollectionReadTool(server, client, {
5
+ name: "kimai_list_expenses",
6
+ title: "List Kimai Expenses",
7
+ description: "List expenses from /api/expenses when the Kimai expenses feature/plugin is available. This tool is read-only.",
8
+ inputSchema: CollectionSchema.shape,
9
+ path: () => "/api/expenses",
10
+ preferredFields: ["id", "user", "project", "activity", "date", "amount", "description"],
11
+ heading: "Kimai Expenses"
12
+ });
13
+ registerEntityReadTool(server, client, {
14
+ name: "kimai_get_expense",
15
+ title: "Get Kimai Expense",
16
+ description: "Read one expense from /api/expenses/<id> when the Kimai expenses feature/plugin is available. This tool is read-only.",
17
+ inputSchema: EntityIdSchema.shape,
18
+ path: (params) => `/api/expenses/${encodeURIComponent(String(params.id))}`,
19
+ preferredFields: ["id", "user", "project", "activity", "date", "amount", "description"],
20
+ heading: "Kimai Expense"
21
+ });
22
+ registerCollectionReadTool(server, client, {
23
+ name: "kimai_list_tasks",
24
+ title: "List Kimai Tasks",
25
+ description: "List tasks from /api/tasks when the Kimai tasks feature/plugin is available. This tool is read-only.",
26
+ inputSchema: CollectionSchema.shape,
27
+ path: () => "/api/tasks",
28
+ preferredFields: ["id", "title", "user", "project", "activity", "status", "priority"],
29
+ heading: "Kimai Tasks"
30
+ });
31
+ registerEntityReadTool(server, client, {
32
+ name: "kimai_get_task",
33
+ title: "Get Kimai Task",
34
+ description: "Read one task from /api/tasks/<id> when the Kimai tasks feature/plugin is available. This tool is read-only.",
35
+ inputSchema: EntityIdSchema.shape,
36
+ path: (params) => `/api/tasks/${encodeURIComponent(String(params.id))}`,
37
+ preferredFields: ["id", "title", "user", "project", "activity", "status", "priority"],
38
+ heading: "Kimai Task"
39
+ });
40
+ }
@@ -0,0 +1,66 @@
1
+ import { formatApiError } from "../services/errors.js";
2
+ import { formatResponse, makeToolResponse, paginateResponse, summarizeRecord } from "./format.js";
3
+ export function registerCollectionReadTool(server, client, options) {
4
+ server.registerTool(options.name, {
5
+ title: options.title,
6
+ description: options.description,
7
+ inputSchema: options.inputSchema,
8
+ annotations: {
9
+ readOnlyHint: true,
10
+ destructiveHint: false,
11
+ idempotentHint: true,
12
+ openWorldHint: true
13
+ }
14
+ }, async (params) => {
15
+ try {
16
+ const paging = params;
17
+ const response = await client.get(options.path(params), {
18
+ page: paging.page,
19
+ size: paging.size,
20
+ ...options.query?.(params)
21
+ });
22
+ const data = paginateResponse(response, paging.page, paging.size);
23
+ const markdown = [
24
+ `# ${options.heading}`,
25
+ "",
26
+ `Showing ${data.count} of ${data.total} records on page ${data.page}.`,
27
+ "",
28
+ ...data.items.map((item) => `- ${summarizeRecord(item, options.preferredFields)}`)
29
+ ].join("\n");
30
+ return makeToolResponse(data, formatResponse(paging.response_format, data, markdown));
31
+ }
32
+ catch (error) {
33
+ const data = { error: formatApiError(error) };
34
+ return makeToolResponse(data, data.error, true);
35
+ }
36
+ });
37
+ }
38
+ export function registerEntityReadTool(server, client, options) {
39
+ server.registerTool(options.name, {
40
+ title: options.title,
41
+ description: options.description,
42
+ inputSchema: options.inputSchema,
43
+ annotations: {
44
+ readOnlyHint: true,
45
+ destructiveHint: false,
46
+ idempotentHint: true,
47
+ openWorldHint: true
48
+ }
49
+ }, async (params) => {
50
+ try {
51
+ const responseFormat = params.response_format;
52
+ const response = await client.get(options.path(params));
53
+ const data = { item: response.data };
54
+ const markdown = [
55
+ `# ${options.heading}`,
56
+ "",
57
+ summarizeRecord(response.data, options.preferredFields)
58
+ ].join("\n");
59
+ return makeToolResponse(data, formatResponse(responseFormat, data, markdown));
60
+ }
61
+ catch (error) {
62
+ const data = { error: formatApiError(error) };
63
+ return makeToolResponse(data, data.error, true);
64
+ }
65
+ });
66
+ }
@@ -0,0 +1,37 @@
1
+ import { formatApiError } from "../services/errors.js";
2
+ import { ServerInfoSchema } from "../schemas/server.js";
3
+ import { formatResponse, makeToolResponse } from "./format.js";
4
+ export function registerServerTools(server, client) {
5
+ server.registerTool("kimai_get_server_info", {
6
+ title: "Get Kimai Server Info",
7
+ description: "Read Kimai API status, version, plugin, and timesheet configuration endpoints. This tool is read-only.",
8
+ inputSchema: ServerInfoSchema.shape,
9
+ annotations: {
10
+ readOnlyHint: true,
11
+ destructiveHint: false,
12
+ idempotentHint: true,
13
+ openWorldHint: true
14
+ }
15
+ }, async ({ response_format }) => {
16
+ const checks = await Promise.allSettled([
17
+ client.get("/api/ping"),
18
+ client.get("/api/version"),
19
+ client.get("/api/plugins"),
20
+ client.get("/api/config/timesheet")
21
+ ]);
22
+ const [ping, version, plugins, timesheetConfig] = checks.map((result) => result.status === "fulfilled" ? result.value.data : { error: formatApiError(result.reason) });
23
+ const data = { ping, version, plugins, timesheet_config: timesheetConfig };
24
+ const markdown = [
25
+ "# Kimai Server Info",
26
+ "",
27
+ `- Ping: ${formatInline(ping)}`,
28
+ `- Version: ${formatInline(version)}`,
29
+ `- Plugins: ${formatInline(plugins)}`,
30
+ `- Timesheet config: ${formatInline(timesheetConfig)}`
31
+ ].join("\n");
32
+ return makeToolResponse(data, formatResponse(response_format, data, markdown));
33
+ });
34
+ }
35
+ function formatInline(value) {
36
+ return typeof value === "string" ? value : JSON.stringify(value);
37
+ }
@@ -0,0 +1,190 @@
1
+ import { writeMutationBackup } from "../services/backups.js";
2
+ import { formatApiError } from "../services/errors.js";
3
+ import { CreateTimesheetSchema, DuplicateTimesheetSchema, TimesheetStateChangeSchema, UpdateTimesheetSchema } from "../schemas/mutations.js";
4
+ import { formatResponse, makeToolResponse, summarizeRecord } from "./format.js";
5
+ export function registerTimesheetMutationTools(server, client) {
6
+ server.registerTool("kimai_create_timesheet", {
7
+ title: "Create Kimai Timesheet",
8
+ description: "Create a Kimai timesheet record. Sensitive edit: call only after explicit human authorization. The request and created record are saved to a temp backup file.",
9
+ inputSchema: CreateTimesheetSchema.shape,
10
+ annotations: {
11
+ readOnlyHint: false,
12
+ destructiveHint: false,
13
+ idempotentHint: false,
14
+ openWorldHint: true
15
+ }
16
+ }, async (params) => {
17
+ try {
18
+ const payload = buildTimesheetPayload(params, ["authorization_confirmed", "authorization_note", "full", "response_format"]);
19
+ const response = await client.post("/api/timesheets", payload, params.full ? { full: "true" } : undefined);
20
+ const backupFile = await writeMutationBackup({
21
+ operation: "kimai_create_timesheet",
22
+ endpoint: "POST /api/timesheets",
23
+ authorization_note: params.authorization_note,
24
+ before: null,
25
+ request: payload,
26
+ after: response.data
27
+ });
28
+ const data = { backup_file: backupFile, timesheet: response.data };
29
+ const markdown = [
30
+ "# Kimai Timesheet Created",
31
+ "",
32
+ `Backup file: ${backupFile}`,
33
+ "",
34
+ summarizeRecord(response.data, ["id", "user", "project", "activity", "begin", "end", "duration", "description"])
35
+ ].join("\n");
36
+ return makeToolResponse(data, formatResponse(params.response_format, data, markdown));
37
+ }
38
+ catch (error) {
39
+ const data = { error: formatApiError(error) };
40
+ return makeToolResponse(data, data.error, true);
41
+ }
42
+ });
43
+ server.registerTool("kimai_update_timesheet", {
44
+ title: "Update Kimai Timesheet",
45
+ description: "Update a Kimai timesheet record. Sensitive edit: call only after explicit human authorization. The existing record is saved to a temp backup file before the edit.",
46
+ inputSchema: UpdateTimesheetSchema.shape,
47
+ annotations: {
48
+ readOnlyHint: false,
49
+ destructiveHint: false,
50
+ idempotentHint: false,
51
+ openWorldHint: true
52
+ }
53
+ }, async (params) => {
54
+ try {
55
+ const payload = buildTimesheetPayload(params, ["authorization_confirmed", "authorization_note", "id", "response_format"]);
56
+ if (Object.keys(payload).length === 0) {
57
+ return makeToolResponse({ error: "No editable fields were provided." }, "No editable fields were provided.", true);
58
+ }
59
+ const endpoint = `/api/timesheets/${encodeURIComponent(String(params.id))}`;
60
+ const before = await client.get(endpoint);
61
+ const response = await client.patch(endpoint, payload);
62
+ const backupFile = await writeMutationBackup({
63
+ operation: "kimai_update_timesheet",
64
+ endpoint: `PATCH ${endpoint}`,
65
+ authorization_note: params.authorization_note,
66
+ before: before.data,
67
+ request: payload,
68
+ after: response.data
69
+ });
70
+ const data = { backup_file: backupFile, before: before.data, timesheet: response.data };
71
+ const markdown = [
72
+ "# Kimai Timesheet Updated",
73
+ "",
74
+ `Backup file: ${backupFile}`,
75
+ "",
76
+ summarizeRecord(response.data, ["id", "user", "project", "activity", "begin", "end", "duration", "description"])
77
+ ].join("\n");
78
+ return makeToolResponse(data, formatResponse(params.response_format, data, markdown));
79
+ }
80
+ catch (error) {
81
+ const data = { error: formatApiError(error) };
82
+ return makeToolResponse(data, data.error, true);
83
+ }
84
+ });
85
+ server.registerTool("kimai_stop_timesheet", {
86
+ title: "Stop Kimai Timesheet",
87
+ description: "Stop an active Kimai timesheet. Sensitive edit: call only after explicit human authorization. The existing record is saved to a temp backup file before stopping.",
88
+ inputSchema: TimesheetStateChangeSchema.shape,
89
+ annotations: {
90
+ readOnlyHint: false,
91
+ destructiveHint: false,
92
+ idempotentHint: false,
93
+ openWorldHint: true
94
+ }
95
+ }, async (params) => runStateChange(client, params, "kimai_stop_timesheet", "stop"));
96
+ server.registerTool("kimai_restart_timesheet", {
97
+ title: "Restart Kimai Timesheet",
98
+ description: "Restart a stopped Kimai timesheet. Sensitive edit: call only after explicit human authorization. The existing record is saved to a temp backup file before restarting.",
99
+ inputSchema: TimesheetStateChangeSchema.shape,
100
+ annotations: {
101
+ readOnlyHint: false,
102
+ destructiveHint: false,
103
+ idempotentHint: false,
104
+ openWorldHint: true
105
+ }
106
+ }, async (params) => runStateChange(client, params, "kimai_restart_timesheet", "restart"));
107
+ server.registerTool("kimai_duplicate_timesheet", {
108
+ title: "Duplicate Kimai Timesheet",
109
+ description: "Duplicate an existing Kimai timesheet. Sensitive edit: call only after explicit human authorization. The source and created record are saved to a temp backup file.",
110
+ inputSchema: DuplicateTimesheetSchema.shape,
111
+ annotations: {
112
+ readOnlyHint: false,
113
+ destructiveHint: false,
114
+ idempotentHint: false,
115
+ openWorldHint: true
116
+ }
117
+ }, async (params) => {
118
+ try {
119
+ const sourceEndpoint = `/api/timesheets/${encodeURIComponent(String(params.id))}`;
120
+ const before = await client.get(sourceEndpoint);
121
+ const response = await client.patch(`${sourceEndpoint}/duplicate`);
122
+ const backupFile = await writeMutationBackup({
123
+ operation: "kimai_duplicate_timesheet",
124
+ endpoint: `PATCH ${sourceEndpoint}/duplicate`,
125
+ authorization_note: params.authorization_note,
126
+ before: before.data,
127
+ after: response.data
128
+ });
129
+ const data = { backup_file: backupFile, source: before.data, timesheet: response.data };
130
+ const markdown = [
131
+ "# Kimai Timesheet Duplicated",
132
+ "",
133
+ `Backup file: ${backupFile}`,
134
+ "",
135
+ summarizeRecord(response.data, ["id", "user", "project", "activity", "begin", "end", "duration", "description"])
136
+ ].join("\n");
137
+ return makeToolResponse(data, formatResponse(params.response_format, data, markdown));
138
+ }
139
+ catch (error) {
140
+ const data = { error: formatApiError(error) };
141
+ return makeToolResponse(data, data.error, true);
142
+ }
143
+ });
144
+ }
145
+ async function runStateChange(client, params, operation, action) {
146
+ try {
147
+ const sourceEndpoint = `/api/timesheets/${encodeURIComponent(String(params.id))}`;
148
+ const before = await client.get(sourceEndpoint);
149
+ const request = action === "restart" && params.begin ? { begin: params.begin } : undefined;
150
+ const response = await client.patch(`${sourceEndpoint}/${action}`, request);
151
+ const backupFile = await writeMutationBackup({
152
+ operation,
153
+ endpoint: `PATCH ${sourceEndpoint}/${action}`,
154
+ authorization_note: params.authorization_note,
155
+ before: before.data,
156
+ request,
157
+ after: response.data
158
+ });
159
+ const data = { backup_file: backupFile, before: before.data, timesheet: response.data };
160
+ const markdown = [
161
+ `# Kimai Timesheet ${action === "stop" ? "Stopped" : "Restarted"}`,
162
+ "",
163
+ `Backup file: ${backupFile}`,
164
+ "",
165
+ summarizeRecord(response.data, ["id", "user", "project", "activity", "begin", "end", "duration", "description"])
166
+ ].join("\n");
167
+ return makeToolResponse(data, formatResponse(params.response_format, data, markdown));
168
+ }
169
+ catch (error) {
170
+ const data = { error: formatApiError(error) };
171
+ return makeToolResponse(data, data.error, true);
172
+ }
173
+ }
174
+ function buildTimesheetPayload(params, excludedKeys) {
175
+ const excluded = new Set(excludedKeys);
176
+ const payload = {};
177
+ for (const [key, value] of Object.entries(params)) {
178
+ if (excluded.has(key) || value === undefined || value === null || value === "")
179
+ continue;
180
+ payload[toKimaiFieldName(key)] = Array.isArray(value) ? value.join(",") : value;
181
+ }
182
+ return payload;
183
+ }
184
+ function toKimaiFieldName(key) {
185
+ if (key === "fixed_rate")
186
+ return "fixedRate";
187
+ if (key === "hourly_rate")
188
+ return "hourlyRate";
189
+ return key;
190
+ }
@@ -0,0 +1,51 @@
1
+ import { CollectionSchema, EntityIdSchema, TimesheetCollectionSchema } from "../schemas/resources.js";
2
+ import { registerCollectionReadTool, registerEntityReadTool } from "./read-tools.js";
3
+ export function registerTimesheetTools(server, client) {
4
+ registerCollectionReadTool(server, client, {
5
+ name: "kimai_list_timesheets",
6
+ title: "List Kimai Timesheets",
7
+ description: "List Kimai timesheet entries from /api/timesheets with optional user, customer, project, activity, date, tag, exported, and active filters. This tool is read-only.",
8
+ inputSchema: TimesheetCollectionSchema.shape,
9
+ path: () => "/api/timesheets",
10
+ query: (params) => ({
11
+ user: params.user,
12
+ customer: params.customer,
13
+ project: params.project,
14
+ activity: params.activity,
15
+ begin: params.begin,
16
+ end: params.end,
17
+ exported: params.exported,
18
+ active: params.active,
19
+ tags: Array.isArray(params.tags) ? params.tags.join(",") : undefined
20
+ }),
21
+ preferredFields: ["id", "user", "project", "activity", "begin", "end", "duration", "rate", "description"],
22
+ heading: "Kimai Timesheets"
23
+ });
24
+ registerEntityReadTool(server, client, {
25
+ name: "kimai_get_timesheet",
26
+ title: "Get Kimai Timesheet",
27
+ description: "Read one Kimai timesheet entry from /api/timesheets/<id>. This tool is read-only.",
28
+ inputSchema: EntityIdSchema.shape,
29
+ path: (params) => `/api/timesheets/${encodeURIComponent(String(params.id))}`,
30
+ preferredFields: ["id", "user", "project", "activity", "begin", "end", "duration", "rate", "description"],
31
+ heading: "Kimai Timesheet"
32
+ });
33
+ registerCollectionReadTool(server, client, {
34
+ name: "kimai_list_active_timesheets",
35
+ title: "List Active Kimai Timesheets",
36
+ description: "List currently running Kimai timesheet entries from /api/timesheets/active. This tool is read-only.",
37
+ inputSchema: CollectionSchema.shape,
38
+ path: () => "/api/timesheets/active",
39
+ preferredFields: ["id", "user", "project", "activity", "begin", "duration", "description"],
40
+ heading: "Active Kimai Timesheets"
41
+ });
42
+ registerCollectionReadTool(server, client, {
43
+ name: "kimai_list_recent_timesheets",
44
+ title: "List Recent Kimai Timesheets",
45
+ description: "List recent Kimai timesheet entries from /api/timesheets/recent. This tool is read-only.",
46
+ inputSchema: CollectionSchema.shape,
47
+ path: () => "/api/timesheets/recent",
48
+ preferredFields: ["id", "user", "project", "activity", "begin", "end", "duration", "description"],
49
+ heading: "Recent Kimai Timesheets"
50
+ });
51
+ }
@@ -0,0 +1,47 @@
1
+ import { formatApiError } from "../services/errors.js";
2
+ import { CollectionSchema, EntityIdSchema } from "../schemas/resources.js";
3
+ import { ServerInfoSchema } from "../schemas/server.js";
4
+ import { formatResponse, makeToolResponse, summarizeRecord } from "./format.js";
5
+ import { registerCollectionReadTool, registerEntityReadTool } from "./read-tools.js";
6
+ export function registerUserTools(server, client) {
7
+ server.registerTool("kimai_get_current_user", {
8
+ title: "Get Current Kimai User",
9
+ description: "Read the current Kimai user from /api/users/me. This tool is read-only.",
10
+ inputSchema: ServerInfoSchema.shape,
11
+ annotations: {
12
+ readOnlyHint: true,
13
+ destructiveHint: false,
14
+ idempotentHint: true,
15
+ openWorldHint: true
16
+ }
17
+ }, async ({ response_format }) => {
18
+ try {
19
+ const response = await client.get("/api/users/me");
20
+ const data = { user: response.data };
21
+ const markdown = ["# Current Kimai User", "", summarizeRecord(response.data, ["id", "username", "alias", "email", "enabled"])].join("\n");
22
+ return makeToolResponse(data, formatResponse(response_format, data, markdown));
23
+ }
24
+ catch (error) {
25
+ const data = { error: formatApiError(error) };
26
+ return makeToolResponse(data, data.error, true);
27
+ }
28
+ });
29
+ registerCollectionReadTool(server, client, {
30
+ name: "kimai_list_users",
31
+ title: "List Kimai Users",
32
+ description: "List Kimai users visible to the API token from /api/users. This tool is read-only.",
33
+ inputSchema: CollectionSchema.shape,
34
+ path: () => "/api/users",
35
+ preferredFields: ["id", "username", "alias", "email", "enabled"],
36
+ heading: "Kimai Users"
37
+ });
38
+ registerEntityReadTool(server, client, {
39
+ name: "kimai_get_user",
40
+ title: "Get Kimai User",
41
+ description: "Read one Kimai user from /api/users/<id>. This tool is read-only.",
42
+ inputSchema: EntityIdSchema.shape,
43
+ path: (params) => `/api/users/${encodeURIComponent(String(params.id))}`,
44
+ preferredFields: ["id", "username", "alias", "email", "enabled"],
45
+ heading: "Kimai User"
46
+ });
47
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@nightsquawktech/kimai-mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for read-only access to Kimai time-tracking instances.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": "./dist/index.js",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE",
12
+ "COMMERCIAL.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "start": "node dist/index.js",
17
+ "dev": "tsx src/index.ts",
18
+ "clean": "rimraf dist"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.26.0",
25
+ "axios": "^1.13.2",
26
+ "dotenv": "^16.4.7",
27
+ "zod": "^3.25.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^24.0.0",
31
+ "rimraf": "^6.0.1",
32
+ "tsx": "^4.20.0",
33
+ "typescript": "^5.9.0"
34
+ },
35
+ "license": "AGPL-3.0-only",
36
+ "author": "Kevin Reyes (NightSquawk Tech) <hello@nightsquawk.tech>",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/NightSquawk/kimai-mcp-server.git"
40
+ },
41
+ "homepage": "https://github.com/NightSquawk/kimai-mcp-server#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/NightSquawk/kimai-mcp-server/issues"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }