timezest-mcp 1.0.5 → 1.0.7
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 +66 -33
- package/build/__tests__/client.test.js +88 -0
- package/build/__tests__/filter.test.js +66 -0
- package/build/__tests__/transform.test.js +105 -0
- package/build/client.js +31 -4
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,35 +1,37 @@
|
|
|
1
1
|
# 📅 TimeZest MCP Server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Bring your **TimeZest** scheduling data directly into Claude through the Model Context Protocol (MCP). Perform real-time scheduling lookups, engineer briefings, and ticket-linked appointment management without leaving your chat.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 🌟 Features
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
- **Morning Briefings**: Instantly get confirmed vs. pending invitations for today, grouped by engineer.
|
|
10
|
+
- **Ticket Lookups**: Look up appointments by their original ConnectWise ticket number (e.g. #964400).
|
|
11
|
+
- **Engineer Schedules**: View upcoming timelines for specific staff members.
|
|
12
|
+
- **Pending Follow-ups**: Identify aging, unbooked invitations to prevent ticket stall.
|
|
13
|
+
- **Cancellation Insights**: Quickly triage cancelled requests for rebooking.
|
|
14
|
+
- **Enterprise Ready**: Full support for timezone transformations (TZ) and automated Vitest testing suite.
|
|
13
15
|
|
|
14
16
|
---
|
|
15
17
|
|
|
16
|
-
## 🚀
|
|
18
|
+
## 🚀 Quick Start (via npm/npx)
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
The fastest way to use this server is via `npx`. No manual cloning or building is required to get started.
|
|
19
21
|
|
|
20
|
-
###
|
|
22
|
+
### 1. Configure Claude Desktop
|
|
21
23
|
Open your Claude Desktop configuration file:
|
|
22
24
|
- **Windows:** `%AppData%\Roaming\Claude\claude_desktop_config.json`
|
|
23
|
-
- **
|
|
25
|
+
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
24
26
|
|
|
25
|
-
Add
|
|
27
|
+
Add the following entry to the `mcpServers` object:
|
|
26
28
|
|
|
27
29
|
```json
|
|
28
30
|
{
|
|
29
31
|
"mcpServers": {
|
|
30
32
|
"timezest": {
|
|
31
|
-
"command": "
|
|
32
|
-
"args": ["
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "timezest-mcp@latest"],
|
|
33
35
|
"env": {
|
|
34
36
|
"TIMEZEST_API_KEY": "YOUR_API_KEY_HERE",
|
|
35
37
|
"TIMEZEST_DEFAULT_TZ": "America/Chicago"
|
|
@@ -39,50 +41,81 @@ Add this entry to the `mcpServers` section:
|
|
|
39
41
|
}
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
###
|
|
43
|
-
|
|
44
|
+
### 2. Configure via Claude Code (CLI)
|
|
45
|
+
Run the following command in your terminal to add the server globally:
|
|
44
46
|
|
|
45
47
|
```bash
|
|
46
|
-
|
|
48
|
+
# Windows
|
|
49
|
+
claude mcp add-json timezest "{\"type\": \"stdio\", \"command\": \"cmd\", \"args\": [\"/c\", \"npx\", \"-y\", \"timezest-mcp@latest\"], \"env\": {\"TIMEZEST_API_KEY\": \"YOUR_API_KEY\", \"TIMEZEST_DEFAULT_TZ\": \"America/Chicago\"}}"
|
|
50
|
+
|
|
51
|
+
# macOS / Linux
|
|
52
|
+
claude mcp add-json timezest '{"type": "stdio", "command": "npx", "args": ["-y", "timezest-mcp@latest"], "env": {"TIMEZEST_API_KEY": "YOUR_API_KEY", "TIMEZEST_DEFAULT_TZ": "America/Chicago"}}'
|
|
47
53
|
```
|
|
48
54
|
|
|
49
55
|
---
|
|
50
56
|
|
|
51
57
|
## 💡 Example Prompts
|
|
58
|
+
|
|
52
59
|
Once connected, try asking Claude:
|
|
53
60
|
- *"What's the briefing for today?"*
|
|
54
|
-
- *"Show me
|
|
61
|
+
- *"Show me Scout's schedule for next week"*
|
|
55
62
|
- *"Find any TimeZest requests for ticket #964400"*
|
|
56
|
-
- *"
|
|
57
|
-
- *"
|
|
58
|
-
- *"Show me recently cancelled appointments for Isam"*
|
|
63
|
+
- *"Provide appointment stats for the last 14 days"*
|
|
64
|
+
- *"Show me recently cancelled appointments for rebooking"*
|
|
59
65
|
|
|
60
66
|
---
|
|
61
67
|
|
|
62
68
|
## 🛠️ Available Tools
|
|
63
69
|
|
|
64
|
-
| Tool |
|
|
65
|
-
|
|
70
|
+
| Tool | Purpose |
|
|
71
|
+
|------|---------|
|
|
66
72
|
| `get_todays_appointments` | Morning briefing: Confirmed today vs. pending unbooked |
|
|
67
73
|
| `list_appointments` | Flexible range search with engineer and status filters |
|
|
68
74
|
| `get_engineer_schedule` | Sorted timeline for a specific engineer |
|
|
69
75
|
| `list_pending_requests` | Follow up on `sent` and `new` (unbooked) invitations |
|
|
70
|
-
| `find_appointment_by_ticket` | Instant lookup using
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
|
|
76
|
+
| `find_appointment_by_ticket` | Instant lookup using source ticket numbers |
|
|
77
|
+
| `get_appointment_stats` | Management summary: Counts, minutes, and aging requests |
|
|
78
|
+
| `list_cancelled_appointments` | Triage cancelled requests with rebooking URLs |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 🔧 Development & Contributing
|
|
83
|
+
|
|
84
|
+
If you wish to contribute to the project or build the server from source, follow these steps:
|
|
85
|
+
|
|
86
|
+
### Manual Installation
|
|
87
|
+
1. **Clone the repository**:
|
|
88
|
+
```bash
|
|
89
|
+
git clone https://github.com/sagarkalra-tech/TimeZest-MCP.git
|
|
90
|
+
```
|
|
91
|
+
2. **Install dependencies**:
|
|
92
|
+
```bash
|
|
93
|
+
npm install
|
|
94
|
+
```
|
|
95
|
+
3. **Build the project**:
|
|
96
|
+
```bash
|
|
97
|
+
npm run build
|
|
98
|
+
```
|
|
99
|
+
4. **Run the server locally**:
|
|
100
|
+
```bash
|
|
101
|
+
npm start
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Running Tests
|
|
105
|
+
We use **Vitest** for all our transformations and API logic. To run the suite:
|
|
106
|
+
```bash
|
|
107
|
+
npm run test
|
|
108
|
+
```
|
|
74
109
|
|
|
75
110
|
---
|
|
76
111
|
|
|
77
|
-
##
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
- `TIMEZEST_API_KEY` (Required): Your TimeZest Bearer token.
|
|
82
|
-
- `TIMEZEST_DEFAULT_TZ` (Optional): Default IANA timezone (e.g. `America/Chicago`).
|
|
83
|
-
- **Developer:** Sagar Kalra
|
|
112
|
+
## ⚙️ Configuration Variables
|
|
113
|
+
|
|
114
|
+
- `TIMEZEST_API_KEY` (Required): Your TimeZest Bearer token.
|
|
115
|
+
- `TIMEZEST_DEFAULT_TZ` (Optional): Default IANA timezone (e.g. `America/Chicago`).
|
|
84
116
|
|
|
85
117
|
---
|
|
86
118
|
|
|
87
119
|
## 📄 License
|
|
120
|
+
|
|
88
121
|
MIT © 2026 Sagar Kalra.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { TimeZestClient } from '../client.js';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
vi.mock('axios');
|
|
5
|
+
const mockedAxios = vi.mocked(axios);
|
|
6
|
+
describe('TimeZestClient', () => {
|
|
7
|
+
let client;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.clearAllMocks();
|
|
10
|
+
client = new TimeZestClient('test-api-key', { maxRetries: 1, retryDelayMs: 10 });
|
|
11
|
+
});
|
|
12
|
+
describe('getAppointmentTypes', () => {
|
|
13
|
+
it('fetches and caches appointment types', async () => {
|
|
14
|
+
mockedAxios.get.mockResolvedValueOnce({
|
|
15
|
+
data: [
|
|
16
|
+
{ id: 'apty_1', internal_name: 'Remote Access' },
|
|
17
|
+
{ id: 'apty_2', name: 'Phone Call' },
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
const types = await client.getAppointmentTypes();
|
|
21
|
+
expect(types.get('apty_1')).toBe('Remote Access');
|
|
22
|
+
expect(types.get('apty_2')).toBe('Phone Call');
|
|
23
|
+
// Second call should use cache — no additional HTTP request
|
|
24
|
+
const types2 = await client.getAppointmentTypes();
|
|
25
|
+
expect(types2.size).toBe(2);
|
|
26
|
+
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
|
27
|
+
});
|
|
28
|
+
it('prefers internal_name over name', async () => {
|
|
29
|
+
mockedAxios.get.mockResolvedValueOnce({
|
|
30
|
+
data: [{ id: 'apty_1', internal_name: 'Internal', name: 'External' }],
|
|
31
|
+
});
|
|
32
|
+
const types = await client.getAppointmentTypes();
|
|
33
|
+
expect(types.get('apty_1')).toBe('Internal');
|
|
34
|
+
});
|
|
35
|
+
it('throws a descriptive error on API failure', async () => {
|
|
36
|
+
mockedAxios.get.mockRejectedValueOnce({
|
|
37
|
+
response: { status: 401, data: { message: 'Unauthorized' } },
|
|
38
|
+
});
|
|
39
|
+
await expect(client.getAppointmentTypes()).rejects.toThrow(/401/);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('getSchedulingRequests', () => {
|
|
43
|
+
it('fetches scheduling requests with date window params', async () => {
|
|
44
|
+
mockedAxios.get.mockResolvedValueOnce({
|
|
45
|
+
data: { scheduling_requests: [{ id: 'sreq_1' }] },
|
|
46
|
+
});
|
|
47
|
+
const results = await client.getSchedulingRequests(7, 14);
|
|
48
|
+
expect(results).toHaveLength(1);
|
|
49
|
+
expect(results[0].id).toBe('sreq_1');
|
|
50
|
+
const callParams = mockedAxios.get.mock.calls[0][1]?.params;
|
|
51
|
+
expect(callParams).toHaveProperty('created_at_after');
|
|
52
|
+
expect(callParams).toHaveProperty('created_at_before');
|
|
53
|
+
expect(callParams.page).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('retry logic', () => {
|
|
57
|
+
it('retries on 429 rate limit', async () => {
|
|
58
|
+
mockedAxios.get
|
|
59
|
+
.mockRejectedValueOnce({ response: { status: 429, headers: {} } })
|
|
60
|
+
.mockResolvedValueOnce({ data: [{ id: 'apty_1', name: 'Test' }] });
|
|
61
|
+
const types = await client.getAppointmentTypes();
|
|
62
|
+
expect(types.size).toBe(1);
|
|
63
|
+
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
|
64
|
+
});
|
|
65
|
+
it('retries on 500 server error', async () => {
|
|
66
|
+
mockedAxios.get
|
|
67
|
+
.mockRejectedValueOnce({ response: { status: 500 } })
|
|
68
|
+
.mockResolvedValueOnce({ data: [{ id: 'apty_1', name: 'Test' }] });
|
|
69
|
+
const types = await client.getAppointmentTypes();
|
|
70
|
+
expect(types.size).toBe(1);
|
|
71
|
+
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
|
|
72
|
+
});
|
|
73
|
+
it('does not retry on 400 client error', async () => {
|
|
74
|
+
mockedAxios.get.mockRejectedValueOnce({
|
|
75
|
+
response: { status: 400, data: { message: 'Bad Request' } },
|
|
76
|
+
});
|
|
77
|
+
await expect(client.getAppointmentTypes()).rejects.toBeTruthy();
|
|
78
|
+
expect(mockedAxios.get).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
it('exhausts retries and throws', async () => {
|
|
81
|
+
mockedAxios.get
|
|
82
|
+
.mockRejectedValueOnce({ response: { status: 500 } })
|
|
83
|
+
.mockRejectedValueOnce({ response: { status: 500, data: { message: 'Down' } } });
|
|
84
|
+
await expect(client.getAppointmentTypes()).rejects.toThrow(/500/);
|
|
85
|
+
expect(mockedAxios.get).toHaveBeenCalledTimes(2); // 1 initial + 1 retry
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { matchEngineer, filterByDateRange } from '../utils/filter.js';
|
|
3
|
+
function makeAppointment(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'sreq_1',
|
|
6
|
+
status: 'scheduled',
|
|
7
|
+
appointment_type: 'Remote Access to PC',
|
|
8
|
+
appointment_type_id: 'apty_1',
|
|
9
|
+
engineer: 'Scout Kalra',
|
|
10
|
+
engineer_id: 'agnt_1',
|
|
11
|
+
dispatched_from_team: false,
|
|
12
|
+
start_time: '2026-03-25T15:00:00.000Z',
|
|
13
|
+
start_time_local: '2026-03-25 10:00 AM GMT-5',
|
|
14
|
+
duration_mins: 30,
|
|
15
|
+
end_user_name: 'John Doe',
|
|
16
|
+
end_user_email: 'john@example.com',
|
|
17
|
+
ticket_number: '#12345',
|
|
18
|
+
ticket_type: 'service_ticket',
|
|
19
|
+
company_id: 100,
|
|
20
|
+
contact_id: 200,
|
|
21
|
+
scheduling_url: 'https://test.timezest.com/schedule/abc',
|
|
22
|
+
created_at: '2026-03-24T12:00:00.000Z',
|
|
23
|
+
updated_at: '2026-03-24T12:00:00.000Z',
|
|
24
|
+
age_hours: 24,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
describe('matchEngineer', () => {
|
|
29
|
+
it('matches by partial engineer name (case-insensitive)', () => {
|
|
30
|
+
expect(matchEngineer(makeAppointment(), 'scout')).toBe(true);
|
|
31
|
+
expect(matchEngineer(makeAppointment(), 'Scout')).toBe(true);
|
|
32
|
+
expect(matchEngineer(makeAppointment(), 'KALRA')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('matches by team name', () => {
|
|
35
|
+
const appt = makeAppointment({ dispatched_from_team: true, team_name: 'MSP Red Team' });
|
|
36
|
+
expect(matchEngineer(appt, 'red team')).toBe(true);
|
|
37
|
+
expect(matchEngineer(appt, 'MSP')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it('returns false for non-matching query', () => {
|
|
40
|
+
expect(matchEngineer(makeAppointment(), 'Aaron')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('filterByDateRange', () => {
|
|
44
|
+
const appointments = [
|
|
45
|
+
makeAppointment({ id: '1', start_time: '2026-03-24T10:00:00.000Z' }),
|
|
46
|
+
makeAppointment({ id: '2', start_time: '2026-03-25T10:00:00.000Z' }),
|
|
47
|
+
makeAppointment({ id: '3', start_time: '2026-03-26T10:00:00.000Z' }),
|
|
48
|
+
makeAppointment({ id: '4', start_time: '2026-03-27T10:00:00.000Z' }),
|
|
49
|
+
];
|
|
50
|
+
it('filters appointments within date range', () => {
|
|
51
|
+
// end date parses as midnight, so only 3/25 10:00 AM fits within 3/25–3/26T00:00
|
|
52
|
+
const result = filterByDateRange(appointments, '2026-03-24', '2026-03-27');
|
|
53
|
+
expect(result.map(a => a.id)).toEqual(['1', '2', '3']);
|
|
54
|
+
});
|
|
55
|
+
it('returns empty array when no appointments match', () => {
|
|
56
|
+
const result = filterByDateRange(appointments, '2026-04-01', '2026-04-05');
|
|
57
|
+
expect(result).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
it('uses created_at for pending appointments with no start_time', () => {
|
|
60
|
+
const pending = [
|
|
61
|
+
makeAppointment({ id: 'p1', status: 'sent', start_time: null, created_at: '2026-03-25T10:00:00.000Z' }),
|
|
62
|
+
];
|
|
63
|
+
const result = filterByDateRange(pending, '2026-03-25', '2026-03-26');
|
|
64
|
+
expect(result.map(a => a.id)).toEqual(['p1']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { transformAppointment } from '../utils/transform.js';
|
|
3
|
+
const appointmentTypes = new Map([
|
|
4
|
+
['apty_123', 'Remote Access to PC'],
|
|
5
|
+
['apty_456', 'Phone Call'],
|
|
6
|
+
]);
|
|
7
|
+
function makeRawRequest(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
id: 'sreq_abc',
|
|
10
|
+
status: 'scheduled',
|
|
11
|
+
appointment_type_id: 'apty_123',
|
|
12
|
+
created_at: 1711382400, // 2024-03-25T16:00:00Z
|
|
13
|
+
updated_at: 1711382400,
|
|
14
|
+
selected_start_time: 1711468800, // 2024-03-26T16:00:00Z
|
|
15
|
+
duration_mins: 30,
|
|
16
|
+
end_user_name: 'John Doe',
|
|
17
|
+
end_user_email: 'john@example.com',
|
|
18
|
+
scheduling_url: 'https://test.timezest.com/schedule/abc',
|
|
19
|
+
scheduled_agents: [{ id: 'agnt_1', name: 'Scout Kalra' }],
|
|
20
|
+
resources: [{ id: 'agnt_1', name: 'Scout Kalra', object: 'agent' }],
|
|
21
|
+
associated_entities: [
|
|
22
|
+
{ type: 'connectwise_psa/service_ticket', number: '#12345' },
|
|
23
|
+
{ type: 'connectwise_psa/company', id: 100 },
|
|
24
|
+
{ type: 'connectwise_psa/contact', id: 200 },
|
|
25
|
+
],
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('transformAppointment', () => {
|
|
30
|
+
it('transforms a scheduled appointment with all fields', () => {
|
|
31
|
+
const result = transformAppointment(makeRawRequest(), appointmentTypes, 'America/Chicago');
|
|
32
|
+
expect(result.id).toBe('sreq_abc');
|
|
33
|
+
expect(result.status).toBe('scheduled');
|
|
34
|
+
expect(result.appointment_type).toBe('Remote Access to PC');
|
|
35
|
+
expect(result.engineer).toBe('Scout Kalra');
|
|
36
|
+
expect(result.engineer_id).toBe('agnt_1');
|
|
37
|
+
expect(result.duration_mins).toBe(30);
|
|
38
|
+
expect(result.end_user_name).toBe('John Doe');
|
|
39
|
+
expect(result.end_user_email).toBe('john@example.com');
|
|
40
|
+
expect(result.ticket_number).toBe('#12345');
|
|
41
|
+
expect(result.ticket_type).toBe('service_ticket');
|
|
42
|
+
expect(result.company_id).toBe(100);
|
|
43
|
+
expect(result.contact_id).toBe(200);
|
|
44
|
+
expect(result.start_time).toBeTruthy();
|
|
45
|
+
expect(result.start_time_local).toBeTruthy();
|
|
46
|
+
expect(result.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
47
|
+
expect(result.age_hours).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
it('resolves engineer from scheduled_agents first', () => {
|
|
50
|
+
const result = transformAppointment(makeRawRequest({
|
|
51
|
+
scheduled_agents: [{ id: 'agnt_A', name: 'Agent A' }],
|
|
52
|
+
resources: [{ id: 'agnt_B', name: 'Agent B', object: 'agent' }],
|
|
53
|
+
}), appointmentTypes);
|
|
54
|
+
expect(result.engineer).toBe('Agent A');
|
|
55
|
+
expect(result.engineer_id).toBe('agnt_A');
|
|
56
|
+
});
|
|
57
|
+
it('falls back to resources when no scheduled_agents', () => {
|
|
58
|
+
const result = transformAppointment(makeRawRequest({
|
|
59
|
+
scheduled_agents: [],
|
|
60
|
+
resources: [{ id: 'agnt_B', name: 'Agent B', object: 'agent' }],
|
|
61
|
+
}), appointmentTypes);
|
|
62
|
+
expect(result.engineer).toBe('Agent B');
|
|
63
|
+
});
|
|
64
|
+
it('returns Unassigned when no agent available', () => {
|
|
65
|
+
const result = transformAppointment(makeRawRequest({
|
|
66
|
+
scheduled_agents: [],
|
|
67
|
+
resources: [{ id: 'team_1', name: 'MSP Team', object: 'team' }],
|
|
68
|
+
}), appointmentTypes);
|
|
69
|
+
expect(result.engineer).toBe('Unassigned');
|
|
70
|
+
expect(result.dispatched_from_team).toBe(true);
|
|
71
|
+
expect(result.team_name).toBe('MSP Team');
|
|
72
|
+
});
|
|
73
|
+
it('handles null start_time for pending requests', () => {
|
|
74
|
+
const result = transformAppointment(makeRawRequest({
|
|
75
|
+
status: 'sent',
|
|
76
|
+
selected_start_time: null,
|
|
77
|
+
}), appointmentTypes);
|
|
78
|
+
expect(result.start_time).toBeNull();
|
|
79
|
+
expect(result.start_time_local).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
it('resolves project_ticket type', () => {
|
|
82
|
+
const result = transformAppointment(makeRawRequest({
|
|
83
|
+
associated_entities: [
|
|
84
|
+
{ type: 'connectwise_psa/project_ticket', number: '#999' },
|
|
85
|
+
],
|
|
86
|
+
}), appointmentTypes);
|
|
87
|
+
expect(result.ticket_type).toBe('project_ticket');
|
|
88
|
+
expect(result.ticket_number).toBe('#999');
|
|
89
|
+
});
|
|
90
|
+
it('handles unknown appointment type gracefully', () => {
|
|
91
|
+
const result = transformAppointment(makeRawRequest({
|
|
92
|
+
appointment_type_id: 'apty_unknown',
|
|
93
|
+
}), appointmentTypes);
|
|
94
|
+
expect(result.appointment_type).toBe('Unknown');
|
|
95
|
+
});
|
|
96
|
+
it('handles missing associated_entities', () => {
|
|
97
|
+
const result = transformAppointment(makeRawRequest({
|
|
98
|
+
associated_entities: [],
|
|
99
|
+
}), appointmentTypes);
|
|
100
|
+
expect(result.ticket_number).toBeNull();
|
|
101
|
+
expect(result.ticket_type).toBeNull();
|
|
102
|
+
expect(result.company_id).toBeNull();
|
|
103
|
+
expect(result.contact_id).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
package/build/client.js
CHANGED
|
@@ -4,17 +4,44 @@ export class TimeZestClient {
|
|
|
4
4
|
apiKey;
|
|
5
5
|
baseUrl = 'https://api.timezest.com/v1';
|
|
6
6
|
appointmentTypes = new Map();
|
|
7
|
-
|
|
7
|
+
maxRetries;
|
|
8
|
+
retryDelayMs;
|
|
9
|
+
constructor(apiKey, { maxRetries = 3, retryDelayMs = 1000 } = {}) {
|
|
8
10
|
this.apiKey = apiKey;
|
|
11
|
+
this.maxRetries = maxRetries;
|
|
12
|
+
this.retryDelayMs = retryDelayMs;
|
|
13
|
+
}
|
|
14
|
+
async fetchWithRetry(url, config) {
|
|
15
|
+
let lastError;
|
|
16
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
return await axios.get(url, config);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
lastError = error;
|
|
22
|
+
const status = error?.response?.status;
|
|
23
|
+
// Only retry on network errors, 429, or 5xx
|
|
24
|
+
if (status && status !== 429 && status < 500)
|
|
25
|
+
throw error;
|
|
26
|
+
if (attempt < this.maxRetries) {
|
|
27
|
+
const delay = status === 429
|
|
28
|
+
? (parseInt(error.response?.headers?.['retry-after'] || '0', 10) * 1000 || this.retryDelayMs * (attempt + 1))
|
|
29
|
+
: this.retryDelayMs * (attempt + 1);
|
|
30
|
+
await new Promise(r => setTimeout(r, delay));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw lastError;
|
|
9
35
|
}
|
|
10
36
|
async fetchAllPages(endpoint, params = {}) {
|
|
11
37
|
const results = [];
|
|
12
38
|
let page = 1;
|
|
13
39
|
let url = `${this.baseUrl}${endpoint}`;
|
|
14
40
|
while (true) {
|
|
15
|
-
const response = await
|
|
41
|
+
const response = await this.fetchWithRetry(url, {
|
|
16
42
|
params: { ...params, page },
|
|
17
|
-
headers: { Authorization: `Bearer ${this.apiKey}` }
|
|
43
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
44
|
+
timeout: 30000
|
|
18
45
|
});
|
|
19
46
|
const data = response.data;
|
|
20
47
|
// Handle both flat arrays and wrapped responses (e.g., { scheduling_requests: [...] })
|
|
@@ -48,7 +75,7 @@ export class TimeZestClient {
|
|
|
48
75
|
try {
|
|
49
76
|
const types = await this.fetchAllPages('/appointment_types');
|
|
50
77
|
types.forEach((t) => {
|
|
51
|
-
this.appointmentTypes.set(t.id, t.name ?? `(unnamed – ${t.id})`);
|
|
78
|
+
this.appointmentTypes.set(t.id, t.internal_name ?? t.name ?? `(unnamed – ${t.id})`);
|
|
52
79
|
});
|
|
53
80
|
return this.appointmentTypes;
|
|
54
81
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "timezest-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "MCP Server for TimeZest scheduling API — brings appointments, pending requests, and ticket-linked schedules to Claude.",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"author": "Sagar Kalra",
|
|
@@ -25,17 +25,20 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsc",
|
|
27
27
|
"watch": "tsc -w",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
28
30
|
"prepublishOnly": "npm run build",
|
|
29
31
|
"start": "node build/index.js"
|
|
30
32
|
},
|
|
31
33
|
"dependencies": {
|
|
32
|
-
"@modelcontextprotocol/sdk": "^
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
33
35
|
"axios": "^1.6.0",
|
|
34
36
|
"date-fns": "^3.0.0",
|
|
35
37
|
"date-fns-tz": "^3.0.0"
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
38
40
|
"@types/node": "^20.0.0",
|
|
39
|
-
"typescript": "^5.0.0"
|
|
41
|
+
"typescript": "^5.0.0",
|
|
42
|
+
"vitest": "^4.1.2"
|
|
40
43
|
}
|
|
41
44
|
}
|