@catlabtech/mycal-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.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +297 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { resolve, dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import {
|
|
11
|
+
filterHolidays,
|
|
12
|
+
findHolidaysByDate,
|
|
13
|
+
findNextHoliday,
|
|
14
|
+
resolveStateCode,
|
|
15
|
+
isWeekend,
|
|
16
|
+
getDayOfWeekName,
|
|
17
|
+
getWeekendDayNames,
|
|
18
|
+
countBusinessDays,
|
|
19
|
+
findSchoolTermByDate,
|
|
20
|
+
findSchoolHolidayByDate,
|
|
21
|
+
isSchoolDay as checkSchoolDay
|
|
22
|
+
} from "@catlabtech/mycal-core";
|
|
23
|
+
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
var DATA_DIR = resolve(__dirname2, "../../../data");
|
|
25
|
+
var states = JSON.parse(readFileSync(resolve(DATA_DIR, "states.json"), "utf-8"));
|
|
26
|
+
function loadYear(dir, year) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(resolve(DATA_DIR, dir, `${year}.json`), "utf-8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function loadSchool(file, year) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(resolve(DATA_DIR, "school", `${file}-${year}.json`), "utf-8"));
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
var server = new McpServer({
|
|
41
|
+
name: "Malaysia Calendar API",
|
|
42
|
+
version: "0.1.0"
|
|
43
|
+
});
|
|
44
|
+
server.tool(
|
|
45
|
+
"get_malaysia_holidays",
|
|
46
|
+
"Get Malaysia public holidays. Covers all 16 states + 3 FTs including Islamic holidays with tentative/confirmed status.",
|
|
47
|
+
{
|
|
48
|
+
year: z.number().int().describe("Year (e.g. 2026)"),
|
|
49
|
+
state: z.string().optional().describe("State code or alias (e.g. 'selangor', 'KL', 'penang')"),
|
|
50
|
+
type: z.enum(["federal", "state", "islamic", "islamic_state", "replacement", "adhoc"]).optional(),
|
|
51
|
+
status: z.enum(["confirmed", "tentative", "announced", "cancelled"]).optional(),
|
|
52
|
+
month: z.number().int().min(1).max(12).optional()
|
|
53
|
+
},
|
|
54
|
+
async ({ year, state, type, status, month }) => {
|
|
55
|
+
const holidays = loadYear("holidays", year);
|
|
56
|
+
let stateCode;
|
|
57
|
+
if (state) {
|
|
58
|
+
const resolved = resolveStateCode(state, states);
|
|
59
|
+
stateCode = resolved?.code;
|
|
60
|
+
}
|
|
61
|
+
const filtered = filterHolidays(holidays, { year, month, state: stateCode, type, status });
|
|
62
|
+
return { content: [{ type: "text", text: JSON.stringify({ total: filtered.length, holidays: filtered }, null, 2) }] };
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
server.tool(
|
|
66
|
+
"check_malaysia_holiday",
|
|
67
|
+
"Check if a specific date is a public holiday, weekend, or working day in Malaysia. Returns holiday details, school day status. Use when user asks 'is X a holiday?', 'do I work on X?'.",
|
|
68
|
+
{
|
|
69
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date in YYYY-MM-DD format"),
|
|
70
|
+
state: z.string().optional().describe("State code or alias")
|
|
71
|
+
},
|
|
72
|
+
async ({ date, state: stateQuery }) => {
|
|
73
|
+
const stateObj = stateQuery ? resolveStateCode(stateQuery, states) : states.find((s) => s.code === "kuala-lumpur");
|
|
74
|
+
if (!stateObj) return { content: [{ type: "text", text: `Unknown state: ${stateQuery}` }] };
|
|
75
|
+
const year = parseInt(date.slice(0, 4));
|
|
76
|
+
const holidays = loadYear("holidays", year);
|
|
77
|
+
const schoolTerms = loadSchool("terms", year);
|
|
78
|
+
const schoolHols = loadSchool("holidays", year);
|
|
79
|
+
const dayHolidays = findHolidaysByDate(date, holidays, stateObj.code);
|
|
80
|
+
const weekend = isWeekend(date, stateObj);
|
|
81
|
+
const isHoliday = dayHolidays.length > 0;
|
|
82
|
+
const schoolDay = checkSchoolDay(date, schoolTerms, schoolHols, stateObj.group, isHoliday, weekend, stateObj.code);
|
|
83
|
+
const result = {
|
|
84
|
+
date,
|
|
85
|
+
dayOfWeek: getDayOfWeekName(date),
|
|
86
|
+
isHoliday,
|
|
87
|
+
isWeekend: weekend,
|
|
88
|
+
isWorkingDay: !weekend && !isHoliday,
|
|
89
|
+
isSchoolDay: schoolDay,
|
|
90
|
+
holidays: dayHolidays.map((h) => ({ name: h.name, type: h.type, status: h.status })),
|
|
91
|
+
state: { code: stateObj.code, group: stateObj.group }
|
|
92
|
+
};
|
|
93
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
server.tool(
|
|
97
|
+
"next_malaysia_holiday",
|
|
98
|
+
"Find the next upcoming public holiday in Malaysia. Use when user asks 'when is the next holiday?'.",
|
|
99
|
+
{
|
|
100
|
+
state: z.string().optional().describe("State code or alias"),
|
|
101
|
+
afterDate: z.string().optional().describe("Find holidays after this date (YYYY-MM-DD). Defaults to today."),
|
|
102
|
+
type: z.enum(["federal", "state", "islamic", "islamic_state", "replacement", "adhoc"]).optional(),
|
|
103
|
+
count: z.number().int().min(1).max(10).optional().describe("Number of holidays to return (default 1)")
|
|
104
|
+
},
|
|
105
|
+
async ({ state, afterDate, type, count }) => {
|
|
106
|
+
const after = afterDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
107
|
+
const year = parseInt(after.slice(0, 4));
|
|
108
|
+
const holidays = loadYear("holidays", year);
|
|
109
|
+
const stateCode = state ? resolveStateCode(state, states)?.code : void 0;
|
|
110
|
+
const next = findNextHoliday(after, holidays, stateCode, type, count ?? 1);
|
|
111
|
+
return { content: [{ type: "text", text: JSON.stringify(next, null, 2) }] };
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
server.tool(
|
|
115
|
+
"malaysia_business_days",
|
|
116
|
+
"Count business/working days between two dates for a Malaysian state. Excludes weekends and public holidays.",
|
|
117
|
+
{
|
|
118
|
+
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Start date"),
|
|
119
|
+
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("End date"),
|
|
120
|
+
state: z.string().describe("State code or alias")
|
|
121
|
+
},
|
|
122
|
+
async ({ start, end, state: stateQuery }) => {
|
|
123
|
+
const stateObj = resolveStateCode(stateQuery, states);
|
|
124
|
+
if (!stateObj) return { content: [{ type: "text", text: `Unknown state: ${stateQuery}` }] };
|
|
125
|
+
const year = parseInt(start.slice(0, 4));
|
|
126
|
+
const holidays = loadYear("holidays", year);
|
|
127
|
+
const result = countBusinessDays(start, end, stateObj, holidays);
|
|
128
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
server.tool(
|
|
132
|
+
"malaysia_long_weekends",
|
|
133
|
+
"Find all long weekends (3+ consecutive non-working days) in Malaysia for a given year.",
|
|
134
|
+
{
|
|
135
|
+
year: z.number().int().describe("Year"),
|
|
136
|
+
state: z.string().optional().describe("State code or alias (default: kuala-lumpur)")
|
|
137
|
+
},
|
|
138
|
+
async ({ year, state }) => {
|
|
139
|
+
const stateObj = state ? resolveStateCode(state, states) : states.find((s) => s.code === "kuala-lumpur");
|
|
140
|
+
if (!stateObj) return { content: [{ type: "text", text: `Unknown state` }] };
|
|
141
|
+
const holidays = loadYear("holidays", year);
|
|
142
|
+
const stateHolidays = filterHolidays(holidays, { state: stateObj.code, year });
|
|
143
|
+
const holidayDates = new Set(stateHolidays.map((h) => h.date));
|
|
144
|
+
const longWeekends = [];
|
|
145
|
+
let current = `${year}-01-01`;
|
|
146
|
+
const yearEnd = `${year}-12-31`;
|
|
147
|
+
while (current <= yearEnd) {
|
|
148
|
+
if (!isWeekend(current, stateObj) && !holidayDates.has(current)) {
|
|
149
|
+
const d = /* @__PURE__ */ new Date(current + "T12:00:00Z");
|
|
150
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
151
|
+
current = d.toISOString().slice(0, 10);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const start = current;
|
|
155
|
+
const hols = [];
|
|
156
|
+
let count = 0;
|
|
157
|
+
while (current <= yearEnd && (isWeekend(current, stateObj) || holidayDates.has(current))) {
|
|
158
|
+
if (holidayDates.has(current)) {
|
|
159
|
+
const h = stateHolidays.find((h2) => h2.date === current);
|
|
160
|
+
if (h) hols.push(h.name.en);
|
|
161
|
+
}
|
|
162
|
+
count++;
|
|
163
|
+
const d = /* @__PURE__ */ new Date(current + "T12:00:00Z");
|
|
164
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
165
|
+
current = d.toISOString().slice(0, 10);
|
|
166
|
+
}
|
|
167
|
+
if (count >= 3) {
|
|
168
|
+
const d = /* @__PURE__ */ new Date(current + "T12:00:00Z");
|
|
169
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
170
|
+
longWeekends.push({ start, end: d.toISOString().slice(0, 10), days: count, holidays: hols });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { content: [{ type: "text", text: JSON.stringify({ total: longWeekends.length, longWeekends }, null, 2) }] };
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
server.tool(
|
|
177
|
+
"list_malaysia_states",
|
|
178
|
+
"List all 16 Malaysian states + 3 Federal Territories with weekend configurations and group assignments.",
|
|
179
|
+
{},
|
|
180
|
+
async () => {
|
|
181
|
+
const summary = states.map((s) => ({
|
|
182
|
+
code: s.code,
|
|
183
|
+
name: s.name.en,
|
|
184
|
+
type: s.type,
|
|
185
|
+
group: s.group,
|
|
186
|
+
weekendDays: getWeekendDayNames(s, "2026-01-01")
|
|
187
|
+
}));
|
|
188
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
server.tool(
|
|
192
|
+
"resolve_malaysia_state",
|
|
193
|
+
"Resolve a state name or alias (e.g. 'KL', 'Penang', 'JB') to the canonical state code.",
|
|
194
|
+
{ query: z.string().describe("State name or alias to resolve") },
|
|
195
|
+
async ({ query }) => {
|
|
196
|
+
const state = resolveStateCode(query, states);
|
|
197
|
+
if (!state) return { content: [{ type: "text", text: `No state matching "${query}". Available: ${states.map((s) => s.code).join(", ")}` }] };
|
|
198
|
+
return { content: [{ type: "text", text: JSON.stringify({ canonical: state.code, name: state.name, group: state.group }, null, 2) }] };
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
server.tool(
|
|
202
|
+
"malaysia_holiday_changes",
|
|
203
|
+
"Get recent changes to Malaysia holiday data (new holidays, confirmations, ad-hoc announcements).",
|
|
204
|
+
{ limit: z.number().int().optional().describe("Max entries (default 10)") },
|
|
205
|
+
async ({ limit }) => {
|
|
206
|
+
return { content: [{ type: "text", text: JSON.stringify({ message: "Changelog not yet populated. Data is current as of gazette GN-33499/33500/33501 (28 Aug 2025).", entries: [] }, null, 2) }] };
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
server.tool(
|
|
210
|
+
"malaysia_school_terms",
|
|
211
|
+
"Get Malaysia school term dates and day counts. Group A = Kedah/Kelantan/Terengganu, Group B = all other states.",
|
|
212
|
+
{
|
|
213
|
+
year: z.number().int().describe("Year"),
|
|
214
|
+
group: z.enum(["A", "B"]).optional().describe("School group (default B)")
|
|
215
|
+
},
|
|
216
|
+
async ({ year, group }) => {
|
|
217
|
+
const g = group ?? "B";
|
|
218
|
+
const terms = loadSchool("terms", year).filter((t) => t.group === g);
|
|
219
|
+
return { content: [{ type: "text", text: JSON.stringify({ group: g, total: terms.length, terms }, null, 2) }] };
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
server.tool(
|
|
223
|
+
"malaysia_school_holidays",
|
|
224
|
+
"Get Malaysia school holidays including cuti penggal, mid-year break, year-end break, and KPM bonus holidays.",
|
|
225
|
+
{
|
|
226
|
+
year: z.number().int().describe("Year"),
|
|
227
|
+
group: z.enum(["A", "B"]).optional().describe("School group (default B)")
|
|
228
|
+
},
|
|
229
|
+
async ({ year, group }) => {
|
|
230
|
+
const g = group ?? "B";
|
|
231
|
+
const hols = loadSchool("holidays", year).filter((h) => h.group === g);
|
|
232
|
+
return { content: [{ type: "text", text: JSON.stringify({ group: g, total: hols.length, holidays: hols }, null, 2) }] };
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
server.tool(
|
|
236
|
+
"malaysia_exams",
|
|
237
|
+
"Get Malaysia public exam schedule \u2014 SPM, STPM, MUET, PT3. Includes results announcement dates.",
|
|
238
|
+
{
|
|
239
|
+
year: z.number().int().describe("Year"),
|
|
240
|
+
type: z.enum(["spm", "stpm", "muet", "pt3", "stam", "other"]).optional()
|
|
241
|
+
},
|
|
242
|
+
async ({ year, type }) => {
|
|
243
|
+
let exams = loadSchool("exams", year);
|
|
244
|
+
if (type) exams = exams.filter((e) => e.type === type);
|
|
245
|
+
return { content: [{ type: "text", text: JSON.stringify({ total: exams.length, exams }, null, 2) }] };
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
server.tool(
|
|
249
|
+
"malaysia_is_school_day",
|
|
250
|
+
"Check if a date is a school day in Malaysia. Accepts state (auto-resolves to group) or group directly. Combines public holidays, school holidays, and weekend status.",
|
|
251
|
+
{
|
|
252
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Date in YYYY-MM-DD"),
|
|
253
|
+
state: z.string().optional().describe("State code or alias (auto-resolves to group)"),
|
|
254
|
+
group: z.enum(["A", "B"]).optional().describe("School group directly")
|
|
255
|
+
},
|
|
256
|
+
async ({ date, state: stateQuery, group: groupParam }) => {
|
|
257
|
+
let stateObj;
|
|
258
|
+
let g;
|
|
259
|
+
if (stateQuery) {
|
|
260
|
+
stateObj = resolveStateCode(stateQuery, states) ?? void 0;
|
|
261
|
+
if (!stateObj) return { content: [{ type: "text", text: `Unknown state: ${stateQuery}` }] };
|
|
262
|
+
g = stateObj.group;
|
|
263
|
+
} else {
|
|
264
|
+
g = groupParam ?? "B";
|
|
265
|
+
stateObj = states.find((s) => s.group === g);
|
|
266
|
+
}
|
|
267
|
+
const year = parseInt(date.slice(0, 4));
|
|
268
|
+
const holidays = loadYear("holidays", year);
|
|
269
|
+
const schoolTerms = loadSchool("terms", year);
|
|
270
|
+
const schoolHols = loadSchool("holidays", year);
|
|
271
|
+
const isHoliday = findHolidaysByDate(date, holidays, stateObj?.code).length > 0;
|
|
272
|
+
const weekend = stateObj ? isWeekend(date, stateObj) : false;
|
|
273
|
+
const schoolDay = checkSchoolDay(date, schoolTerms, schoolHols, g, isHoliday, weekend, stateObj?.code);
|
|
274
|
+
const term = findSchoolTermByDate(date, schoolTerms, g);
|
|
275
|
+
const schoolHoliday = findSchoolHolidayByDate(date, schoolHols, g, stateObj?.code);
|
|
276
|
+
return {
|
|
277
|
+
content: [{
|
|
278
|
+
type: "text",
|
|
279
|
+
text: JSON.stringify({
|
|
280
|
+
date,
|
|
281
|
+
dayOfWeek: getDayOfWeekName(date),
|
|
282
|
+
isSchoolDay: schoolDay,
|
|
283
|
+
isPublicHoliday: isHoliday,
|
|
284
|
+
isWeekend: weekend,
|
|
285
|
+
group: g,
|
|
286
|
+
term: term ? { id: term.id, term: term.term } : null,
|
|
287
|
+
holiday: schoolHoliday ? { id: schoolHoliday.id, name: schoolHoliday.name, type: schoolHoliday.type } : null
|
|
288
|
+
}, null, 2)
|
|
289
|
+
}]
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
async function main() {
|
|
294
|
+
const transport = new StdioServerTransport();
|
|
295
|
+
await server.connect(transport);
|
|
296
|
+
}
|
|
297
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@catlabtech/mycal-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP (Model Context Protocol) server exposing the Malaysia Calendar API as 12 tools for Claude, ChatGPT, and other AI agents",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"malaysia",
|
|
7
|
+
"calendar",
|
|
8
|
+
"holidays",
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol",
|
|
11
|
+
"claude",
|
|
12
|
+
"ai"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/Junhui20/malaysia-calendar-api.git",
|
|
18
|
+
"directory": "packages/mcp-server"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://mycal.pages.dev/docs/mcp-server/what-is-mcp/",
|
|
21
|
+
"bugs": "https://github.com/Junhui20/malaysia-calendar-api/issues",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"mycal-mcp-server": "./dist/index.js",
|
|
25
|
+
"mycal-mcp": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
37
|
+
"zod": "^3.25.76",
|
|
38
|
+
"@catlabtech/mycal-core": "0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.5.0",
|
|
42
|
+
"tsup": "^8.0.0",
|
|
43
|
+
"tsx": "^4.21.0",
|
|
44
|
+
"typescript": "^5.7.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup",
|
|
48
|
+
"dev": "tsx src/index.ts"
|
|
49
|
+
}
|
|
50
|
+
}
|