timezest-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.
@@ -0,0 +1,68 @@
1
+ import axios from 'axios';
2
+ import { subDays, addDays, getUnixTime } from 'date-fns';
3
+ export class TimeZestClient {
4
+ apiKey;
5
+ baseUrl = 'https://api.timezest.com/v1';
6
+ appointmentTypes = new Map();
7
+ constructor(apiKey) {
8
+ this.apiKey = apiKey;
9
+ }
10
+ async fetchAllPages(endpoint, params = {}) {
11
+ const results = [];
12
+ let page = 1;
13
+ let url = `${this.baseUrl}${endpoint}`;
14
+ while (true) {
15
+ const response = await axios.get(url, {
16
+ params: { ...params, page },
17
+ headers: { Authorization: `Bearer ${this.apiKey}` }
18
+ });
19
+ const data = response.data;
20
+ if (Array.isArray(data)) {
21
+ results.push(...data);
22
+ if (data.length < 50)
23
+ break; // TimeZest pagination limit
24
+ }
25
+ else {
26
+ // Unexpected format
27
+ break;
28
+ }
29
+ page++;
30
+ }
31
+ return results;
32
+ }
33
+ async getAppointmentTypes() {
34
+ if (this.appointmentTypes.size > 0)
35
+ return this.appointmentTypes;
36
+ try {
37
+ const types = await this.fetchAllPages('/appointment_types');
38
+ types.forEach((t) => {
39
+ this.appointmentTypes.set(t.id, t.name);
40
+ });
41
+ return this.appointmentTypes;
42
+ }
43
+ catch (error) {
44
+ console.error('Error fetching appointment types:', error);
45
+ return new Map();
46
+ }
47
+ }
48
+ /**
49
+ * Fetches the standard 45-day window (14 days back, 30 forward) by created_at.
50
+ */
51
+ async getStandardWindowRequests() {
52
+ const now = new Date();
53
+ const start = subDays(now, 14);
54
+ const end = addDays(now, 30);
55
+ // API limits filters to created_at
56
+ const params = {
57
+ created_at_after: getUnixTime(start),
58
+ created_at_before: getUnixTime(end)
59
+ };
60
+ try {
61
+ return await this.fetchAllPages('/scheduling_requests', params);
62
+ }
63
+ catch (error) {
64
+ console.error('Error fetching scheduling requests:', error);
65
+ return [];
66
+ }
67
+ }
68
+ }
package/build/index.js ADDED
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { TimeZestClient } from "./client.js";
6
+ import { transformAppointment } from "./utils/transform.js";
7
+ import { matchEngineer, filterByDateRange } from "./utils/filter.js";
8
+ import { format, parseISO, isSameDay, formatDistanceToNow } from 'date-fns';
9
+ import { formatInTimeZone } from 'date-fns-tz';
10
+ const API_KEY = process.env.TIMEZEST_API_KEY || '';
11
+ if (!API_KEY) {
12
+ process.stderr.write("Error: TIMEZEST_API_KEY is required.\n");
13
+ process.exit(1);
14
+ }
15
+ const client = new TimeZestClient(API_KEY);
16
+ const server = new Server({
17
+ name: "timezest-mcp",
18
+ version: "1.0.0",
19
+ }, {
20
+ capabilities: {
21
+ tools: {},
22
+ },
23
+ });
24
+ const TOOLS = [
25
+ {
26
+ name: "get_todays_appointments",
27
+ description: "The morning briefing tool. Returns confirmed appointments for today grouped by engineer, with pending unbooked requests listed separately.",
28
+ inputSchema: {
29
+ type: "object",
30
+ properties: {
31
+ timezone: { type: "string", description: "Default: America/Chicago" }
32
+ }
33
+ }
34
+ },
35
+ {
36
+ name: "list_appointments",
37
+ description: "Flexible query across any date range with optional engineer and status filters.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ start_date: { type: "string", description: "YYYY-MM-DD" },
42
+ end_date: { type: "string", description: "YYYY-MM-DD" },
43
+ engineer_name: { type: "string", description: "Partial match, case-insensitive" },
44
+ status: { type: "string", enum: ["scheduled", "sent", "new", "cancelled"] },
45
+ timezone: { type: "string", description: "Default: America/Chicago" }
46
+ },
47
+ required: ["start_date", "end_date"]
48
+ }
49
+ },
50
+ {
51
+ name: "get_engineer_schedule",
52
+ description: "All upcoming appointments for a named engineer, sorted by time.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ engineer_name: { type: "string" },
57
+ days_ahead: { type: "number", default: 7 },
58
+ include_pending: { type: "boolean", default: true }
59
+ },
60
+ required: ["engineer_name"]
61
+ }
62
+ },
63
+ {
64
+ name: "list_pending_requests",
65
+ description: "All unbooked invitations (sent and new). Returns age in hours/days.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ days_back: { type: "number", default: 14 },
70
+ engineer_name: { type: "string" },
71
+ older_than_hours: { type: "number" }
72
+ }
73
+ }
74
+ },
75
+ {
76
+ name: "find_appointment_by_ticket",
77
+ description: "Find appointments linked to a ConnectWise ticket number.",
78
+ inputSchema: {
79
+ type: "object",
80
+ properties: {
81
+ ticket_number: { type: "string", description: "e.g. 964400 or #964400" }
82
+ },
83
+ required: ["ticket_number"]
84
+ }
85
+ },
86
+ {
87
+ name: "get_appointment_types",
88
+ description: "List all appointment type definitions.",
89
+ inputSchema: { type: "object", properties: {} }
90
+ },
91
+ {
92
+ name: "get_appointment_stats",
93
+ description: "Aggregate summary of appointment data.",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {
97
+ days_back: { type: "number", default: 14 },
98
+ days_forward: { type: "number", default: 30 }
99
+ }
100
+ }
101
+ },
102
+ {
103
+ name: "list_cancelled_appointments",
104
+ description: "Cancelled requests with scheduling URLs for rebooking.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ days_back: { type: "number", default: 7 },
109
+ engineer_name: { type: "string" }
110
+ }
111
+ }
112
+ }
113
+ ];
114
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
115
+ tools: TOOLS,
116
+ }));
117
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
118
+ const { name, arguments: args } = request.params;
119
+ const appointmentTypes = await client.getAppointmentTypes();
120
+ const rawRequests = await client.getStandardWindowRequests();
121
+ const tz = args?.timezone || process.env.TIMEZEST_DEFAULT_TZ || 'America/Chicago';
122
+ const all = rawRequests.map(r => transformAppointment(r, appointmentTypes, tz));
123
+ switch (name) {
124
+ case "get_todays_appointments": {
125
+ const today = new Date();
126
+ const scheduled = all.filter(a => a.status === 'scheduled' && a.start_time && isSameDay(parseISO(a.start_time), today));
127
+ const pending = all.filter(a => (a.status === 'sent' || a.status === 'new'));
128
+ const grouped = {};
129
+ scheduled.forEach(s => {
130
+ if (!grouped[s.engineer])
131
+ grouped[s.engineer] = [];
132
+ grouped[s.engineer].push(`${s.start_time_local} ${s.appointment_type} - ${s.end_user_name}, ${s.ticket_number}`);
133
+ });
134
+ let response = `Today's Briefing (${formatInTimeZone(today, tz, 'yyyy-MM-dd')})\n\nConfirmed:\n`;
135
+ if (Object.keys(grouped).length === 0)
136
+ response += " None\n";
137
+ for (const [eng, apps] of Object.entries(grouped)) {
138
+ response += ` ${eng}:\n`;
139
+ apps.forEach(a => response += ` ${a}\n`);
140
+ }
141
+ response += `\nPending (Not yet booked):\n`;
142
+ if (pending.length === 0)
143
+ response += " None\n";
144
+ pending.forEach(p => {
145
+ response += ` ${p.engineer} — ${p.end_user_name} (${p.status}) — sent ${format(parseISO(p.created_at), 'MMM dd')} [${formatDistanceToNow(parseISO(p.created_at))} old]\n`;
146
+ });
147
+ return { content: [{ type: "text", text: response }] };
148
+ }
149
+ case "list_appointments": {
150
+ const start = args?.start_date;
151
+ const end = args?.end_date;
152
+ const engName = args?.engineer_name;
153
+ const status = args?.status;
154
+ let filtered = all;
155
+ if (start && end)
156
+ filtered = filterByDateRange(filtered, start, end);
157
+ if (engName)
158
+ filtered = filtered.filter(a => matchEngineer(a, engName));
159
+ if (status)
160
+ filtered = filtered.filter(a => a.status === status);
161
+ return { content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }] };
162
+ }
163
+ case "get_engineer_schedule": {
164
+ const engName = args?.engineer_name;
165
+ const includePending = args?.include_pending !== false;
166
+ let filtered = all.filter(a => matchEngineer(a, engName));
167
+ if (!includePending)
168
+ filtered = filtered.filter(a => a.status === 'scheduled');
169
+ filtered.sort((a, b) => {
170
+ const timeA = a.start_time || a.created_at;
171
+ const timeB = b.start_time || b.created_at;
172
+ return timeA.localeCompare(timeB);
173
+ });
174
+ return { content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }] };
175
+ }
176
+ case "list_pending_requests": {
177
+ const engName = args?.engineer_name;
178
+ const hours = args?.older_than_hours;
179
+ let pending = all.filter(a => a.status === 'sent' || a.status === 'new');
180
+ if (engName)
181
+ pending = pending.filter(a => matchEngineer(a, engName));
182
+ if (hours)
183
+ pending = pending.filter(a => a.age_hours >= hours);
184
+ return { content: [{ type: "text", text: JSON.stringify(pending, null, 2) }] };
185
+ }
186
+ case "find_appointment_by_ticket": {
187
+ const tNum = (args?.ticket_number).replace('#', '');
188
+ const filtered = all.filter(a => a.ticket_number?.replace('#', '') === tNum);
189
+ return { content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }] };
190
+ }
191
+ case "get_appointment_types": {
192
+ const sorted = Array.from(appointmentTypes.entries()).sort((a, b) => a[1].localeCompare(b[1]));
193
+ let text = "ID | Name\n---|---\n";
194
+ sorted.forEach(([id, name]) => {
195
+ text += `${id} | ${name}\n`;
196
+ });
197
+ return { content: [{ type: "text", text }] };
198
+ }
199
+ case "get_appointment_stats": {
200
+ const stats = {
201
+ total: all.length,
202
+ by_status: { scheduled: 0, sent: 0, new: 0, cancelled: 0 },
203
+ by_engineer: {},
204
+ today: { confirmed: 0, pending: 0, total_minutes: 0 },
205
+ oldest_pending_days: 0,
206
+ type_breakdown: {}
207
+ };
208
+ const today = new Date();
209
+ all.forEach(a => {
210
+ // @ts-ignore
211
+ if (stats.by_status[a.status] !== undefined)
212
+ stats.by_status[a.status]++;
213
+ if (!stats.by_engineer[a.engineer])
214
+ stats.by_engineer[a.engineer] = { scheduled: 0, pending: 0 };
215
+ if (a.status === 'scheduled')
216
+ stats.by_engineer[a.engineer].scheduled++;
217
+ else if (a.status === 'sent' || a.status === 'new')
218
+ stats.by_engineer[a.engineer].pending++;
219
+ if (a.status === 'scheduled' && a.start_time && isSameDay(parseISO(a.start_time), today)) {
220
+ stats.today.confirmed++;
221
+ stats.today.total_minutes += a.duration_mins;
222
+ }
223
+ else if ((a.status === 'sent' || a.status === 'new') && isSameDay(parseISO(a.created_at), today)) {
224
+ stats.today.pending++;
225
+ }
226
+ if (a.status === 'sent' || a.status === 'new') {
227
+ const days = Math.floor(a.age_hours / 24);
228
+ if (days > stats.oldest_pending_days)
229
+ stats.oldest_pending_days = days;
230
+ }
231
+ stats.type_breakdown[a.appointment_type] = (stats.type_breakdown[a.appointment_type] || 0) + 1;
232
+ });
233
+ return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] };
234
+ }
235
+ case "list_cancelled_appointments": {
236
+ const engName = args?.engineer_name;
237
+ let cancelled = all.filter(a => a.status === 'cancelled');
238
+ if (engName)
239
+ cancelled = cancelled.filter(a => matchEngineer(a, engName));
240
+ return { content: [{ type: "text", text: JSON.stringify(cancelled, null, 2) }] };
241
+ }
242
+ default:
243
+ throw new Error(`Unknown tool: ${name}`);
244
+ }
245
+ });
246
+ async function main() {
247
+ const transport = new StdioServerTransport();
248
+ await server.connect(transport);
249
+ process.stderr.write("TimeZest MCP Server started.\n");
250
+ }
251
+ main().catch((error) => {
252
+ console.error("Fatal error:", error);
253
+ process.exit(1);
254
+ });
@@ -0,0 +1,20 @@
1
+ import { isWithinInterval, parseISO } from 'date-fns';
2
+ export function matchEngineer(appointment, query) {
3
+ const normQuery = query.toLowerCase();
4
+ const engineerName = appointment.engineer.toLowerCase();
5
+ if (engineerName.includes(normQuery))
6
+ return true;
7
+ if (appointment.team_name?.toLowerCase().includes(normQuery))
8
+ return true;
9
+ return false;
10
+ }
11
+ export function filterByDateRange(appointments, startDate, endDate) {
12
+ const start = parseISO(startDate);
13
+ const end = parseISO(endDate);
14
+ return appointments.filter(a => {
15
+ if (!a.start_time)
16
+ return false;
17
+ const date = parseISO(a.start_time);
18
+ return isWithinInterval(date, { start, end });
19
+ });
20
+ }
@@ -0,0 +1,49 @@
1
+ import { formatInTimeZone } from 'date-fns-tz';
2
+ import { differenceInHours, fromUnixTime } from 'date-fns';
3
+ export function transformAppointment(raw, appointmentTypes, timezone = 'America/Chicago') {
4
+ const createdAt = fromUnixTime(raw.created_at);
5
+ const updatedAt = fromUnixTime(raw.updated_at);
6
+ const startTime = raw.selected_start_time ? fromUnixTime(raw.selected_start_time) : null;
7
+ // Engineer logic as per implementation plan
8
+ let engineer = 'Unassigned';
9
+ let engineerId = 'unassigned';
10
+ if (raw.scheduled_agents && raw.scheduled_agents.length > 0) {
11
+ engineer = raw.scheduled_agents[0].name;
12
+ engineerId = raw.scheduled_agents[0].id;
13
+ }
14
+ else if (raw.resources && raw.resources.length > 0) {
15
+ // Only use resources for assignment name if it is an agent
16
+ const firstResource = raw.resources[0];
17
+ if (firstResource.object === 'agent') {
18
+ engineer = firstResource.name;
19
+ engineerId = firstResource.id;
20
+ }
21
+ }
22
+ const dispatchedFromTeam = raw.resources?.some((r) => r.object === 'team') ?? false;
23
+ const teamName = raw.resources?.find((r) => r.object === 'team')?.name;
24
+ // Ticket logic
25
+ const ticket = raw.associated_entities?.find((e) => e.type === 'connectwise_psa/service_ticket' || e.type === 'connectwise_psa/project_ticket');
26
+ return {
27
+ id: raw.id,
28
+ status: raw.status,
29
+ appointment_type: appointmentTypes.get(raw.appointment_type_id) ?? 'Unknown',
30
+ appointment_type_id: raw.appointment_type_id,
31
+ engineer,
32
+ engineer_id: engineerId,
33
+ dispatched_from_team: dispatchedFromTeam,
34
+ team_name: teamName,
35
+ start_time: startTime ? startTime.toISOString() : null,
36
+ start_time_local: startTime ? formatInTimeZone(startTime, timezone, 'yyyy-MM-dd h:mm a zzz') : null,
37
+ duration_mins: raw.duration_mins,
38
+ end_user_name: raw.end_user_name,
39
+ end_user_email: raw.end_user_email,
40
+ ticket_number: ticket?.number ?? null,
41
+ ticket_type: ticket?.type === 'connectwise_psa/service_ticket' ? 'service_ticket' : (ticket?.type === 'connectwise_psa/project_ticket' ? 'project_ticket' : null),
42
+ company_id: raw.associated_entities?.find((e) => e.type === 'connectwise_psa/company')?.id ?? null,
43
+ contact_id: raw.associated_entities?.find((e) => e.type === 'connectwise_psa/contact')?.id ?? null,
44
+ scheduling_url: raw.scheduling_url,
45
+ created_at: createdAt.toISOString(),
46
+ updated_at: updatedAt.toISOString(),
47
+ age_hours: differenceInHours(new Date(), createdAt)
48
+ };
49
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "timezest-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP Server for TimeZest scheduling API",
5
+ "main": "build/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "timezest-mcp": "build/index.js"
9
+ },
10
+ "files": [
11
+ "build"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "watch": "tsc -w",
19
+ "prepublishOnly": "npm run build",
20
+ "start": "node build/index.js"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^0.6.0",
24
+ "axios": "^1.6.0",
25
+ "date-fns": "^3.0.0",
26
+ "date-fns-tz": "^3.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.0.0",
30
+ "typescript": "^5.0.0"
31
+ }
32
+ }