mcp-costlocker 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,206 @@
1
+ import { SortDirections, TimeEntryGroupByOptions, TimeEntryGroupSortingOptions, TimeEntrySortingOptions, } from "../integrations/costlocker/generated/client.js";
2
+ import { toNextDate, validateDate } from "../integrations/costlocker/client.js";
3
+ import { jsonText, toolError, truncateItems, TRUNCATION_LIMIT } from "./shared.js";
4
+ import { z } from "zod";
5
+ const dateSchema = z
6
+ .string()
7
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
8
+ async function fetchTimeEntriesMerged(sdk, filterBase) {
9
+ const filterProject = {
10
+ ...filterBase,
11
+ nonproject: false,
12
+ };
13
+ const filterNon = {
14
+ ...filterBase,
15
+ nonproject: true,
16
+ };
17
+ const sort = {
18
+ sortBy: [TimeEntrySortingOptions.Date],
19
+ direction: SortDirections.Asc,
20
+ };
21
+ const pageSize = 100;
22
+ const fetchAll = async (filter) => {
23
+ const out = [];
24
+ let page = 1;
25
+ let total = 0;
26
+ while (true) {
27
+ const res = await sdk.McpTimeEntries({
28
+ filter,
29
+ pagination: { page, pageSize },
30
+ sorting: sort,
31
+ });
32
+ const items = res.timeEntries.items ?? [];
33
+ total = res.timeEntries.totalCount ?? items.length + out.length;
34
+ out.push(...items);
35
+ if (items.length < pageSize || out.length >= total)
36
+ break;
37
+ page += 1;
38
+ if (out.length >= TRUNCATION_LIMIT * 2)
39
+ break;
40
+ }
41
+ return out;
42
+ };
43
+ const [a, b] = await Promise.all([fetchAll(filterProject), fetchAll(filterNon)]);
44
+ const byUuid = new Map();
45
+ for (const e of [...a, ...b]) {
46
+ byUuid.set(String(e.uuid), e);
47
+ }
48
+ return Array.from(byUuid.values());
49
+ }
50
+ export function registerTimesheetTools(server, sdk) {
51
+ server.registerTool("costlocker_log_time", {
52
+ title: "Log time",
53
+ description: "[WRITE] Create a time entry on a budget activity. Duration is in hours. Confirm with the user first.",
54
+ annotations: {
55
+ destructiveHint: true,
56
+ idempotentHint: false,
57
+ openWorldHint: true,
58
+ },
59
+ inputSchema: {
60
+ budget_id: z.number().int().positive(),
61
+ activity_id: z.number().int().positive(),
62
+ person_id: z.number().int().positive().optional(),
63
+ hours: z.number().positive().max(24),
64
+ date: dateSchema.optional().describe("Defaults to today (UTC date)"),
65
+ description: z.string().optional(),
66
+ },
67
+ }, async (args) => {
68
+ try {
69
+ const budget_id = args.budget_id;
70
+ const activity_id = args.activity_id;
71
+ const hours = args.hours;
72
+ const dateStr = args.date ?? new Date().toISOString().slice(0, 10);
73
+ validateDate(dateStr);
74
+ const me = args.person_id
75
+ ? { id: args.person_id }
76
+ : (await sdk.McpGetMe({})).currentPerson;
77
+ const startAt = `${dateStr}T12:00:00.000Z`;
78
+ const data = await sdk.McpCreateTimeEntry({
79
+ input: [
80
+ {
81
+ assignmentKey: {
82
+ personId: me.id,
83
+ taskKey: {
84
+ budgetId: budget_id,
85
+ activityId: activity_id,
86
+ },
87
+ },
88
+ duration: hours,
89
+ description: args.description,
90
+ startAt,
91
+ },
92
+ ],
93
+ });
94
+ return jsonText(`Logged ${hours}h on budget ${budget_id}, activity ${activity_id}`, { entries: data.createTimeEntry });
95
+ }
96
+ catch (error) {
97
+ return toolError("logging time", error);
98
+ }
99
+ });
100
+ server.registerTool("costlocker_get_timesheet", {
101
+ description: "List time entries in a date range (project + non-project). Optional filters by person and budget.",
102
+ inputSchema: {
103
+ date_from: dateSchema.optional(),
104
+ date_to: dateSchema.optional().describe("Inclusive end date"),
105
+ person_id: z.number().int().positive().optional(),
106
+ budget_id: z.number().int().positive().optional(),
107
+ },
108
+ }, async (args) => {
109
+ try {
110
+ const today = new Date().toISOString().slice(0, 10);
111
+ const dateFrom = validateDate(args.date_from ?? today);
112
+ const dateTo = validateDate(args.date_to ?? dateFrom);
113
+ const endExclusive = toNextDate(dateTo);
114
+ const filterBase = {
115
+ dateRange: { start: dateFrom, end: endExclusive },
116
+ };
117
+ if (args.person_id) {
118
+ filterBase.personIds = { includeIds: [args.person_id] };
119
+ }
120
+ if (args.budget_id) {
121
+ filterBase.budget = [args.budget_id];
122
+ }
123
+ const merged = await fetchTimeEntriesMerged(sdk, filterBase);
124
+ const { items, truncated, total } = truncateItems(merged);
125
+ return jsonText(`Found ${total} entries${truncated ? ` (showing first ${items.length})` : ""}`, { entries: items });
126
+ }
127
+ catch (error) {
128
+ return toolError("getting timesheet", error);
129
+ }
130
+ });
131
+ server.registerTool("costlocker_get_monthly_timesheet", {
132
+ description: "Aggregated hours grouped by project and person for a date range.",
133
+ inputSchema: {
134
+ date_from: dateSchema,
135
+ date_to: dateSchema.describe("Inclusive end date"),
136
+ person_id: z.number().int().positive().optional(),
137
+ budget_id: z.number().int().positive().optional(),
138
+ },
139
+ }, async (args) => {
140
+ try {
141
+ const dateFrom = validateDate(args.date_from);
142
+ const dateTo = validateDate(args.date_to);
143
+ const endExclusive = toNextDate(dateTo);
144
+ const filter = {
145
+ dateRange: { start: dateFrom, end: endExclusive },
146
+ nonproject: false,
147
+ };
148
+ if (args.person_id)
149
+ filter.personIds = { includeIds: [args.person_id] };
150
+ if (args.budget_id)
151
+ filter.budget = [args.budget_id];
152
+ const data = await sdk.McpTimeEntriesGroup({
153
+ filter,
154
+ groupBy: {
155
+ groups: [
156
+ TimeEntryGroupByOptions.Projects,
157
+ TimeEntryGroupByOptions.Persons,
158
+ ],
159
+ },
160
+ sorting: {
161
+ sortBy: [TimeEntryGroupSortingOptions.Hours],
162
+ direction: SortDirections.Desc,
163
+ },
164
+ });
165
+ const groups = data.timeEntriesGroup ?? [];
166
+ const { items, truncated, total } = truncateItems(groups);
167
+ return jsonText(`Aggregated ${total} group row(s)${truncated ? ` (showing first ${items.length})` : ""}`, { data: items });
168
+ }
169
+ catch (error) {
170
+ return toolError("getting monthly timesheet", error);
171
+ }
172
+ });
173
+ server.registerTool("costlocker_get_running_entry", {
174
+ description: "Get the currently running time entry, if any.",
175
+ inputSchema: {},
176
+ }, async () => {
177
+ try {
178
+ const data = await sdk.McpRunningTracking({});
179
+ return jsonText("Running time entry", { entry: data.runningTracking });
180
+ }
181
+ catch (error) {
182
+ return toolError("getting running entry", error);
183
+ }
184
+ });
185
+ server.registerTool("costlocker_get_assignments", {
186
+ description: "List assignments the user can log time to. Uses current person unless person_id is set.",
187
+ inputSchema: {
188
+ person_id: z.number().int().positive().optional(),
189
+ },
190
+ }, async (args) => {
191
+ try {
192
+ const personId = args.person_id ?? (await sdk.McpGetMe({})).currentPerson.id;
193
+ const startsBefore = new Date(Date.now() + 1000 * 86400 * 365 * 10).toISOString();
194
+ const data = await sdk.McpAssignments({
195
+ personId,
196
+ startsBefore,
197
+ });
198
+ const raw = data.assignments ?? [];
199
+ const { items, truncated, total } = truncateItems(raw);
200
+ return jsonText(`${total} assignment(s)${truncated ? ` (showing first ${items.length})` : ""}`, { assignments: items });
201
+ }
202
+ catch (error) {
203
+ return toolError("getting assignments", error);
204
+ }
205
+ });
206
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "mcp-costlocker",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for Costlocker (GraphQL time entries).",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-costlocker": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx",
18
+ "format": "prettier --write .",
19
+ "typecheck": "tsc --noEmit",
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "prepublishOnly": "npm run build",
22
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --config=jest.config.mjs",
23
+ "docs:list": "tsx scripts/docs-list.ts",
24
+ "graphql:codegen": "graphql-codegen --config codegen.ts",
25
+ "start": "tsx src/index.ts"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.28.0",
29
+ "dotenv": "^16.5.0",
30
+ "graphql-tag": "^2.12.6",
31
+ "tslab": "^1.0.22",
32
+ "zod": "^4.3.6"
33
+ },
34
+ "devDependencies": {
35
+ "@jest/globals": "^29.7.0",
36
+ "@eslint/eslintrc": "^3",
37
+ "@eslint/js": "^9.26.0",
38
+ "@graphql-codegen/cli": "^6.2.1",
39
+ "@graphql-codegen/typescript": "^5.0.9",
40
+ "@graphql-codegen/typescript-generic-sdk": "^5.0.0",
41
+ "@graphql-codegen/typescript-operations": "^5.0.9",
42
+ "@types/jest": "^30.0.0",
43
+ "@types/node": "^20",
44
+ "@types/uuid": "^9.0.8",
45
+ "@typescript-eslint/eslint-plugin": "^8.32.0",
46
+ "@typescript-eslint/parser": "^8.32.0",
47
+ "cross-env": "^7.0.3",
48
+ "eslint": "^9",
49
+ "eslint-config-prettier": "^10.1.3",
50
+ "eslint-plugin-import": "^2.31.0",
51
+ "eslint-plugin-no-instanceof": "^1.0.1",
52
+ "eslint-plugin-unused-imports": "^4.1.4",
53
+ "globals": "^16.1.0",
54
+ "graphql": "^16.13.2",
55
+ "jest": "^29.7.0",
56
+ "jest-ts-webcompat-resolver": "^1.0.1",
57
+ "prettier": "^3.5.3",
58
+ "ts-jest": "^29.1.1",
59
+ "tsx": "^4.7.0",
60
+ "typescript": "^5",
61
+ "uuid": "^9.0.1"
62
+ },
63
+ "packageManager": "pnpm@10.9.0+sha512.0486e394640d3c1fb3c9d43d49cf92879ff74f8516959c235308f5a8f62e2e19528a65cdc2a3058f587cde71eba3d5b56327c8c33a97e4c4051ca48a10ca2d5f"
64
+ }