@jorgeluismlima/teamwork-mcp 1.0.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.
Files changed (3) hide show
  1. package/README.md +79 -0
  2. package/dist/index.js +369 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Teamwork MCP Server
2
+
3
+ A Model Context Protocol (MCP) server that connects to the Teamwork API, allowing AI agents to interact with projects, time entries, people, and companies.
4
+
5
+ ## Functionalities (Tools)
6
+
7
+ This server exposes the following tools:
8
+
9
+ ### Projects & Time Tracking
10
+ * **`list_projects`**: Listings projects, filtering by search term.
11
+ * **`create_time_entry`**: Creates a time entry (timelog) for a specific project.
12
+ * **`get_time_entries`**: Retrieves time entries for a project, with optional date filtering.
13
+ * **`total_count_of_active_projects`**: Returns the total count of active projects.
14
+ * **`total_billable_time_per_project`**: Returns total billable minutes per project (optional date range).
15
+ * **`get_health_stats`**: Returns project health metrics (Good, Bad, OK, etc.), with various filters.
16
+ * **`get_project_time_totals`**: Returns total hours/minutes for a project (optionally filtered by user).
17
+
18
+ ### People & Companies
19
+ * **`get_project_people`**: Lists all people associated with a project.
20
+ * **`get_person`**: Retrieves detailed information about a specific person (user).
21
+ * **`list_companies`**: Lists companies (clients).
22
+ * **`get_company`**: Retrieves details of a specific company.
23
+
24
+ ## Configuration
25
+
26
+ To use this server, you need to configure the following environment variables:
27
+
28
+ * **`TEAMWORK_SITE_NAME`**: Your Teamwork site name/subdomain (e.g., `agencyname`).
29
+ * **`TEAMWORK_USERNAME`**: Your Teamwork username or email.
30
+ * **`TEAMWORK_PASSWORD`**: Your Teamwork password or API Key.
31
+
32
+ ## Prerequisites
33
+
34
+ Before using this MCP server, ensure you have:
35
+
36
+ * **Node.js**: Version 18 or higher installed. [Download Node.js](https://nodejs.org/)
37
+ * **Teamwork Account**: You must have an active Teamwork account.
38
+ * **API Credentials**: You need your Teamwork Site Name and an API Key (or password) to authenticate.
39
+
40
+ ## Installation & Running
41
+
42
+ 1. **Install dependencies:**
43
+ ```bash
44
+ npm install
45
+ ```
46
+
47
+ 2. **Build the project:**
48
+ ```bash
49
+ npm run build
50
+ ```
51
+
52
+ 3. **Run the server:**
53
+ ```bash
54
+ node dist/index.js
55
+ ```
56
+
57
+ ## Client Configuration (Example)
58
+
59
+ To add this server to an MCP client (like Claude Desktop or an IDE extension), add the following to your MCP configuration file:
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "teamwork": {
65
+ "command": "node",
66
+ "args": [
67
+ "c:/path/to/teamwork-mcp/dist/index.js"
68
+ ],
69
+ "env": {
70
+ "TEAMWORK_SITE_NAME": "your-site-name",
71
+ "TEAMWORK_USERNAME": "your-username",
72
+ "TEAMWORK_PASSWORD": "your-password-or-api-key"
73
+ }
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ > **Note:** Replace `c:/path/to/teamwork-mcp/dist/index.js` with the absolute path to the built `index.js` file on your system.
package/dist/index.js ADDED
@@ -0,0 +1,369 @@
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 { z } from "zod";
5
+ import axios from "axios";
6
+ import dotenv from "dotenv";
7
+ const originalWrite = process.stdout.write.bind(process.stdout);
8
+ // @ts-ignore
9
+ process.stdout.write = () => true;
10
+ dotenv.config();
11
+ // @ts-ignore
12
+ process.stdout.write = originalWrite;
13
+ const TEAMWORK_SITE_NAME = process.env.TEAMWORK_SITE_NAME;
14
+ const TEAMWORK_USERNAME = process.env.TEAMWORK_USERNAME;
15
+ const TEAMWORK_PASSWORD = process.env.TEAMWORK_PASSWORD;
16
+ if (!TEAMWORK_SITE_NAME || !TEAMWORK_USERNAME || !TEAMWORK_PASSWORD) {
17
+ console.error("Missing TEAMWORK_SITE_NAME, TEAMWORK_USERNAME, or TEAMWORK_PASSWORD environment variables");
18
+ process.exit(1);
19
+ }
20
+ const BASE_URL = `https://${TEAMWORK_SITE_NAME}.teamwork.com/projects/api/v3`;
21
+ const server = new McpServer({
22
+ name: "teamwork-mcp",
23
+ version: "1.0.0",
24
+ });
25
+ const axiosInstance = axios.create({
26
+ baseURL: BASE_URL,
27
+ auth: {
28
+ username: TEAMWORK_USERNAME,
29
+ password: TEAMWORK_PASSWORD,
30
+ },
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ Accept: "application/json",
34
+ },
35
+ });
36
+ // Helper to handle API errors
37
+ const handleApiError = (error) => {
38
+ if (axios.isAxiosError(error)) {
39
+ return {
40
+ content: [
41
+ {
42
+ type: "text",
43
+ text: `Teamwork API Error: ${error.response?.status} - ${JSON.stringify(error.response?.data || error.message)}`,
44
+ },
45
+ ],
46
+ isError: true,
47
+ };
48
+ }
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: `Unexpected Error: ${error instanceof Error ? error.message : String(error)}`,
54
+ },
55
+ ],
56
+ isError: true,
57
+ };
58
+ };
59
+ server.tool("list_projects", {
60
+ searchTerm: z.string().optional().describe("Filter by project name"),
61
+ page: z.number().optional().describe("Page number"),
62
+ }, async ({ searchTerm, page }) => {
63
+ try {
64
+ const params = {};
65
+ if (searchTerm)
66
+ params.searchTerm = searchTerm;
67
+ if (page)
68
+ params.page = page;
69
+ const response = await axiosInstance.get("/projects.json", { params });
70
+ const simplifiedProjects = response.data.projects.map((p) => ({
71
+ id: p.id,
72
+ name: p.name,
73
+ description: p.description,
74
+ status: p.status,
75
+ company: p.company,
76
+ createdOn: p.createdOn,
77
+ lastUpdated: p.lastUpdated
78
+ }));
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text",
83
+ text: JSON.stringify({ projects: simplifiedProjects, meta: response.data.meta }, null, 2),
84
+ },
85
+ ],
86
+ };
87
+ }
88
+ catch (error) {
89
+ return handleApiError(error);
90
+ }
91
+ });
92
+ server.tool("create_time_entry", {
93
+ projectId: z.number().describe("The ID of the project"),
94
+ description: z.string().describe("Description of the time entry"),
95
+ date: z.string().regex(/^\d{4}-?\d{2}-?\d{2}$/).describe("Date in YYYYMMDD or YYYY-MM-DD format"),
96
+ time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).describe("Time in HH:MM or HH:MM:SS format"),
97
+ hours: z.number().min(0).describe("Number of hours"),
98
+ minutes: z.number().min(0).max(59).describe("Number of minutes"),
99
+ isbillable: z.boolean().describe("Whether the time is billable"),
100
+ personId: z.number().optional().describe("ID of the person (optional)"),
101
+ }, async ({ projectId, description, date, time, hours, minutes, isbillable, personId }) => {
102
+ try {
103
+ const payload = {
104
+ timelog: {
105
+ description,
106
+ date,
107
+ time,
108
+ hours: hours,
109
+ minutes: minutes,
110
+ isBillable: isbillable,
111
+ },
112
+ };
113
+ if (personId) {
114
+ payload.timelog.personId = personId;
115
+ }
116
+ const response = await axiosInstance.post(`/projects/${projectId}/time.json`, payload);
117
+ return {
118
+ content: [
119
+ {
120
+ type: "text",
121
+ text: JSON.stringify(response.data, null, 2),
122
+ },
123
+ ],
124
+ };
125
+ }
126
+ catch (error) {
127
+ return handleApiError(error);
128
+ }
129
+ });
130
+ server.tool("get_time_entries", {
131
+ projectId: z.number().describe("The ID of the project"),
132
+ page: z.number().optional().describe("Page number"),
133
+ fromDate: z.string().optional().describe("Start date (YYYYMMDD)"),
134
+ toDate: z.string().optional().describe("End date (YYYYMMDD)"),
135
+ }, async ({ projectId, page, fromDate, toDate }) => {
136
+ try {
137
+ const params = {};
138
+ if (page)
139
+ params.page = page;
140
+ if (fromDate)
141
+ params.fromDate = fromDate;
142
+ if (toDate)
143
+ params.toDate = toDate;
144
+ const response = await axiosInstance.get(`/projects/${projectId}/time.json`, { params });
145
+ return {
146
+ content: [
147
+ {
148
+ type: "text",
149
+ text: JSON.stringify(response.data, null, 2),
150
+ },
151
+ ],
152
+ };
153
+ }
154
+ catch (error) {
155
+ return handleApiError(error);
156
+ }
157
+ });
158
+ server.tool("get_project_people", {
159
+ projectId: z.number().describe("The ID of the project"),
160
+ }, async ({ projectId }) => {
161
+ try {
162
+ const response = await axiosInstance.get(`/projects/${projectId}/people.json`);
163
+ return {
164
+ content: [
165
+ {
166
+ type: "text",
167
+ text: JSON.stringify(response.data, null, 2),
168
+ },
169
+ ],
170
+ };
171
+ }
172
+ catch (error) {
173
+ return handleApiError(error);
174
+ }
175
+ });
176
+ server.tool("get_person", {
177
+ personId: z.number().describe("The ID of the person"),
178
+ }, async ({ personId }) => {
179
+ try {
180
+ const response = await axiosInstance.get(`/people/${personId}.json`);
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: JSON.stringify(response.data, null, 2),
186
+ },
187
+ ],
188
+ };
189
+ }
190
+ catch (error) {
191
+ return handleApiError(error);
192
+ }
193
+ });
194
+ server.tool("get_project_time_totals", {
195
+ projectId: z.number().describe("The ID of the project"),
196
+ userId: z.number().optional().describe("Filter by user ID"),
197
+ fromDate: z.string().optional().describe("Start date (YYYYMMDD)"),
198
+ toDate: z.string().optional().describe("End date (YYYYMMDD)"),
199
+ }, async ({ projectId, userId, fromDate, toDate }) => {
200
+ try {
201
+ const params = {};
202
+ if (userId)
203
+ params.userId = userId;
204
+ if (fromDate)
205
+ params.fromDate = fromDate;
206
+ if (toDate)
207
+ params.toDate = toDate;
208
+ const response = await axiosInstance.get(`/projects/${projectId}/time/total.json`, { params });
209
+ return {
210
+ content: [
211
+ {
212
+ type: "text",
213
+ text: JSON.stringify(response.data, null, 2),
214
+ },
215
+ ],
216
+ };
217
+ }
218
+ catch (error) {
219
+ return handleApiError(error);
220
+ }
221
+ });
222
+ server.tool("total_count_of_active_projects", {}, async () => {
223
+ try {
224
+ const response = await axiosInstance.get("/projects/metrics/active.json");
225
+ return {
226
+ content: [
227
+ {
228
+ type: "text",
229
+ text: JSON.stringify(response.data, null, 2),
230
+ },
231
+ ],
232
+ };
233
+ }
234
+ catch (error) {
235
+ return handleApiError(error);
236
+ }
237
+ });
238
+ server.tool("total_billable_time_per_project", {
239
+ startDate: z.string().optional().describe("Start date (YYYYMMDD)"),
240
+ endDate: z.string().optional().describe("End date (YYYYMMDD)"),
241
+ orderMode: z.string().optional().describe("Order mode (asc, desc). Default: desc"),
242
+ }, async ({ startDate, endDate, orderMode }) => {
243
+ try {
244
+ const params = {};
245
+ if (startDate)
246
+ params.startDate = startDate;
247
+ if (endDate)
248
+ params.endDate = endDate;
249
+ if (orderMode)
250
+ params.orderMode = orderMode;
251
+ const response = await axiosInstance.get("/projects/metrics/billable.json", {
252
+ params,
253
+ });
254
+ return {
255
+ content: [
256
+ {
257
+ type: "text",
258
+ text: JSON.stringify(response.data, null, 2),
259
+ },
260
+ ],
261
+ };
262
+ }
263
+ catch (error) {
264
+ return handleApiError(error);
265
+ }
266
+ });
267
+ server.tool("get_health_stats", {
268
+ projectStatus: z.string().optional().describe("Filter by project status (active, current, late, upcoming, completed, deleted)"),
269
+ onlyStarredProjects: z.boolean().optional().describe("Filter by starred projects only"),
270
+ matchAllProjectTags: z.boolean().optional().describe("Match all project tags"),
271
+ projectTagIds: z.array(z.number()).optional().describe("Filter by project tag ids"),
272
+ projectStatuses: z.array(z.string()).optional().describe("Filter by project statuses"),
273
+ projectOwnerIds: z.array(z.number()).optional().describe("Filter by project owner ids"),
274
+ projectIds: z.array(z.number()).optional().describe("Filter by project ids"),
275
+ projectHealths: z.array(z.number()).optional().describe("Project health (0: not set, 1: bad, 2: ok, 3: good)"),
276
+ projectCompanyIds: z.array(z.number()).optional().describe("Filter by company ids"),
277
+ projectCategoryIds: z.array(z.number()).optional().describe("Filter by project category ids"),
278
+ }, async (args) => {
279
+ try {
280
+ const params = {};
281
+ if (args.projectStatus)
282
+ params.projectStatus = args.projectStatus;
283
+ if (args.onlyStarredProjects !== undefined)
284
+ params.onlyStarredProjects = args.onlyStarredProjects;
285
+ if (args.matchAllProjectTags !== undefined)
286
+ params.matchAllProjectTags = args.matchAllProjectTags;
287
+ // Helper to join arrays for API
288
+ const joinParam = (arr) => arr ? arr.join(',') : undefined;
289
+ if (args.projectTagIds)
290
+ params.projectTagIds = joinParam(args.projectTagIds);
291
+ if (args.projectStatuses)
292
+ params.projectStatuses = joinParam(args.projectStatuses);
293
+ if (args.projectOwnerIds)
294
+ params.projectOwnerIds = joinParam(args.projectOwnerIds);
295
+ if (args.projectIds)
296
+ params.projectIds = joinParam(args.projectIds);
297
+ if (args.projectHealths)
298
+ params.projectHealths = joinParam(args.projectHealths);
299
+ if (args.projectCompanyIds)
300
+ params.projectCompanyIds = joinParam(args.projectCompanyIds);
301
+ if (args.projectCategoryIds)
302
+ params.projectCategoryIds = joinParam(args.projectCategoryIds);
303
+ const response = await axiosInstance.get("/projects/metrics/healths.json", {
304
+ params,
305
+ });
306
+ return {
307
+ content: [
308
+ {
309
+ type: "text",
310
+ text: JSON.stringify(response.data, null, 2),
311
+ },
312
+ ],
313
+ };
314
+ }
315
+ catch (error) {
316
+ return handleApiError(error);
317
+ }
318
+ });
319
+ server.tool("list_companies", {
320
+ page: z.number().optional().describe("Page number"),
321
+ searchTerm: z.string().optional().describe("Filter by search term"),
322
+ }, async ({ page, searchTerm }) => {
323
+ try {
324
+ const params = {};
325
+ if (page)
326
+ params.page = page;
327
+ if (searchTerm)
328
+ params.searchTerm = searchTerm;
329
+ const response = await axiosInstance.get("/companies.json", { params });
330
+ return {
331
+ content: [
332
+ {
333
+ type: "text",
334
+ text: JSON.stringify(response.data, null, 2),
335
+ },
336
+ ],
337
+ };
338
+ }
339
+ catch (error) {
340
+ return handleApiError(error);
341
+ }
342
+ });
343
+ server.tool("get_company", {
344
+ companyId: z.number().describe("The ID of the company"),
345
+ }, async ({ companyId }) => {
346
+ try {
347
+ const response = await axiosInstance.get(`/companies/${companyId}.json`);
348
+ return {
349
+ content: [
350
+ {
351
+ type: "text",
352
+ text: JSON.stringify(response.data, null, 2),
353
+ },
354
+ ],
355
+ };
356
+ }
357
+ catch (error) {
358
+ return handleApiError(error);
359
+ }
360
+ });
361
+ async function main() {
362
+ const transport = new StdioServerTransport();
363
+ await server.connect(transport);
364
+ console.error("Teamwork MCP Server running on stdio");
365
+ }
366
+ main().catch((error) => {
367
+ console.error("Fatal error in main loop:", error);
368
+ process.exit(1);
369
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@jorgeluismlima/teamwork-mcp",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "1.0.0",
7
+ "description": "MCP Server for Teamwork",
8
+ "main": "dist/index.js",
9
+ "bin": {
10
+ "teamwork-mcp": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "type": "module",
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "start": "node dist/index.js",
19
+ "dev": "tsc --watch"
20
+ },
21
+ "keywords": [],
22
+ "author": "",
23
+ "license": "ISC",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.0.1",
26
+ "axios": "^1.6.0",
27
+ "dotenv": "^16.4.5",
28
+ "zod": "^3.23.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^20.0.0",
32
+ "typescript": "^5.0.0"
33
+ }
34
+ }