@calybur/mcp 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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @calybur/mcp
2
+
3
+ Official MCP server for [Calybur](https://www.calybur.com) — connect Cursor, Claude Desktop, or any MCP client to your Malaysian payroll and HR data.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 20+
8
+ - Calybur **Starter** plan or above
9
+ - API key with `read` scope (create at **Settings → API Keys**)
10
+
11
+ ## Quick Start (Cursor)
12
+
13
+ Add to `.cursor/mcp.json`:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "calybur": {
19
+ "command": "npx",
20
+ "args": ["-y", "@calybur/mcp@latest"],
21
+ "env": {
22
+ "CALYBUR_API_KEY": "ezg_live_your_key_here",
23
+ "CALYBUR_API_BASE_URL": "https://YOUR_PROJECT.supabase.co/functions/v1",
24
+ "CALYBUR_SUPABASE_PUBLISHABLE_KEY": "your_supabase_publishable_key",
25
+ "CALYBUR_PLAN": "starter"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Restart Cursor and enable the Calybur MCP tools.
33
+
34
+ ## Environment Variables
35
+
36
+ | Variable | Required | Description |
37
+ |----------|----------|-------------|
38
+ | `CALYBUR_API_KEY` | Yes | Partner API key (`ezg_live_*`) |
39
+ | `CALYBUR_API_BASE_URL` | Yes | Supabase Edge Functions base URL |
40
+ | `CALYBUR_SUPABASE_PUBLISHABLE_KEY` | Yes | Supabase publishable key (same as app frontend) |
41
+ | `CALYBUR_PLAN` | No | `starter` (default), `professional`, or `enterprise` |
42
+
43
+ ## Tools (MVP — read-only)
44
+
45
+ | Tool | Purpose |
46
+ |------|---------|
47
+ | `calybur_workforce` | Employees, departments, positions, locations |
48
+ | `calybur_leave` | Balances, requests, policies, holidays |
49
+ | `calybur_payroll` | Periods, runs, payslips |
50
+ | `calybur_attendance` | Events and summaries |
51
+ | `calybur_commission` | Rules and calculations |
52
+ | `calybur_reports` | Payroll summary / statutory metadata |
53
+ | `calybur_status` | API key + rate budget check |
54
+ | `calybur_docs` | API and Malaysia statutory cheat sheet |
55
+
56
+ ## Example Prompts
57
+
58
+ - "Use calybur_status to verify my API connection"
59
+ - "List active employees with calybur_workforce"
60
+ - "Show March payroll periods with calybur_payroll"
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ cd packages/calybur-mcp
66
+ npm install
67
+ npm test
68
+ npm run build
69
+ CALYBUR_API_KEY=... CALYBUR_API_BASE_URL=... npm run dev
70
+ ```
71
+
72
+ ## Rate Limits
73
+
74
+ | Plan | API limit | MCP client budget (90%) |
75
+ |------|-----------|-------------------------|
76
+ | Starter | 100/min | 90/min |
77
+ | Professional | 500/min | 450/min |
78
+ | Enterprise | 2000/min | 1800/min |
79
+
80
+ ## License
81
+
82
+ MIT
83
+
84
+ ## Publishing
85
+
86
+ See [PUBLISHING.md](./PUBLISHING.md). Published package: `@calybur/mcp` on npm.
@@ -0,0 +1,13 @@
1
+ import type { CalyburConfig } from './config.js';
2
+ import type { RateBudget } from './rate-budget.js';
3
+ export declare class CalyburClient {
4
+ private readonly config;
5
+ private readonly budget;
6
+ constructor(config: CalyburConfig, budget: RateBudget);
7
+ get(functionPath: string, params?: Record<string, string>): Promise<unknown>;
8
+ statusSnapshot(): {
9
+ plan: import("./config.js").CalyburPlan;
10
+ budget_remaining: number;
11
+ budget_reset_at: string;
12
+ };
13
+ }
package/dist/client.js ADDED
@@ -0,0 +1,37 @@
1
+ import { CalyburApiError } from './errors.js';
2
+ export class CalyburClient {
3
+ config;
4
+ budget;
5
+ constructor(config, budget) {
6
+ this.config = config;
7
+ this.budget = budget;
8
+ }
9
+ async get(functionPath, params = {}) {
10
+ if (!this.budget.tryConsume()) {
11
+ throw new CalyburApiError('Client rate budget exceeded. Wait for window reset.', 429);
12
+ }
13
+ const query = new URLSearchParams(params).toString();
14
+ const url = `${this.config.baseUrl}/${functionPath}${query ? `?${query}` : ''}`;
15
+ const response = await fetch(url, {
16
+ method: 'GET',
17
+ headers: {
18
+ 'x-api-key': this.config.apiKey,
19
+ apikey: this.config.publishableKey,
20
+ Authorization: `Bearer ${this.config.publishableKey}`,
21
+ Accept: 'application/json',
22
+ },
23
+ });
24
+ const body = await response.json().catch(() => ({}));
25
+ if (!response.ok) {
26
+ throw new CalyburApiError(body.error || `HTTP ${response.status}`, response.status, body);
27
+ }
28
+ return body;
29
+ }
30
+ statusSnapshot() {
31
+ return {
32
+ plan: this.config.plan,
33
+ budget_remaining: this.budget.remaining(),
34
+ budget_reset_at: this.budget.resetAt().toISOString(),
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,9 @@
1
+ export type CalyburPlan = 'starter' | 'professional' | 'enterprise';
2
+ export interface CalyburConfig {
3
+ apiKey: string;
4
+ baseUrl: string;
5
+ publishableKey: string;
6
+ plan: CalyburPlan;
7
+ }
8
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): CalyburConfig;
9
+ export declare function defaultLimitForPlan(plan: CalyburPlan): number;
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ const VALID_PLANS = ['starter', 'professional', 'enterprise'];
2
+ export function loadConfig(env = process.env) {
3
+ const apiKey = env.CALYBUR_API_KEY?.trim();
4
+ const baseUrl = env.CALYBUR_API_BASE_URL?.trim().replace(/\/$/, '');
5
+ const publishableKey = env.CALYBUR_SUPABASE_PUBLISHABLE_KEY?.trim() ||
6
+ env.CALYBUR_SUPABASE_ANON_KEY?.trim() ||
7
+ '';
8
+ const planRaw = (env.CALYBUR_PLAN?.trim().toLowerCase() || 'starter');
9
+ if (!apiKey) {
10
+ throw new Error('CALYBUR_API_KEY is required');
11
+ }
12
+ if (!apiKey.startsWith('ezg_')) {
13
+ throw new Error('CALYBUR_API_KEY must start with ezg_');
14
+ }
15
+ if (!baseUrl) {
16
+ throw new Error('CALYBUR_API_BASE_URL is required');
17
+ }
18
+ if (!publishableKey) {
19
+ throw new Error('CALYBUR_SUPABASE_PUBLISHABLE_KEY is required (Supabase gateway auth for Edge Functions)');
20
+ }
21
+ if (!VALID_PLANS.includes(planRaw)) {
22
+ throw new Error(`CALYBUR_PLAN must be one of: ${VALID_PLANS.join(', ')}`);
23
+ }
24
+ return { apiKey, baseUrl, publishableKey, plan: planRaw };
25
+ }
26
+ export function defaultLimitForPlan(plan) {
27
+ if (plan === 'enterprise')
28
+ return 100;
29
+ if (plan === 'professional')
30
+ return 50;
31
+ return 20;
32
+ }
@@ -0,0 +1,6 @@
1
+ export declare class CalyburApiError extends Error {
2
+ readonly status: number;
3
+ readonly body?: unknown | undefined;
4
+ constructor(message: string, status: number, body?: unknown | undefined);
5
+ }
6
+ export declare function formatApiError(error: CalyburApiError): string;
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ export class CalyburApiError extends Error {
2
+ status;
3
+ body;
4
+ constructor(message, status, body) {
5
+ super(message);
6
+ this.status = status;
7
+ this.body = body;
8
+ this.name = 'CalyburApiError';
9
+ }
10
+ }
11
+ export function formatApiError(error) {
12
+ if (error.status === 401) {
13
+ const body = error.body;
14
+ if (body?.code === 'UNAUTHORIZED_NO_AUTH_HEADER') {
15
+ return 'Missing Supabase publishable key. Set CALYBUR_SUPABASE_PUBLISHABLE_KEY in MCP config.';
16
+ }
17
+ return 'API key invalid or expired. Regenerate at Calybur Settings → API Keys.';
18
+ }
19
+ if (error.status === 403) {
20
+ const body = error.body;
21
+ if (body?.upgrade_required) {
22
+ return 'API access requires Starter plan or higher. Upgrade at Calybur Settings → Billing.';
23
+ }
24
+ return body?.error || 'Insufficient permissions. Check API key scopes in Settings → API Keys.';
25
+ }
26
+ if (error.status === 429) {
27
+ return 'Rate limit exceeded. Wait 60 seconds and reduce parallel tool calls.';
28
+ }
29
+ if (error.status === 404) {
30
+ return 'Resource not found.';
31
+ }
32
+ return error.message || 'Calybur API error. Retry once or contact support@calybur.com.';
33
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { CalyburClient } from './client.js';
4
+ export declare function createCalyburMcpServer(client: CalyburClient): McpServer;
package/dist/index.js ADDED
@@ -0,0 +1,131 @@
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 { CalyburClient } from './client.js';
6
+ import { loadConfig } from './config.js';
7
+ import { RateBudget } from './rate-budget.js';
8
+ import { handleAttendance } from './tools/attendance.js';
9
+ import { handleCommission } from './tools/commission.js';
10
+ import { handleDocs } from './tools/docs.js';
11
+ import { handleLeave } from './tools/leave.js';
12
+ import { handlePayroll } from './tools/payroll.js';
13
+ import { handleReports } from './tools/reports.js';
14
+ import { handleStatus } from './tools/status.js';
15
+ import { handleWorkforce } from './tools/workforce.js';
16
+ const SERVER_INSTRUCTIONS = `Calybur MCP connects AI clients to Malaysian payroll and HR data via the Calybur Partner API.
17
+
18
+ Guidelines:
19
+ - Prefer read tools before write operations (write tools ship in a later release).
20
+ - Batch queries; avoid redundant parallel tool calls.
21
+ - Default PII masking is on — IC, bank, and email fields are partially redacted.
22
+ - Starter plan: 100 API req/min — use list limits of 20 or lower.
23
+ - Malaysian statutory terms: EPF, SOCSO, EIS, PCB.
24
+ - Requires Starter+ plan and an API key with appropriate scopes from Calybur Settings → API Keys.`;
25
+ const sharedPaginationSchema = {
26
+ page: z.number().int().min(1).optional().describe('Page number (default 1)'),
27
+ limit: z.number().int().min(1).max(100).optional().describe('Page size (Starter default 20)'),
28
+ mask_pii: z.boolean().optional().describe('Mask IC, bank, email fields (default true)'),
29
+ };
30
+ export function createCalyburMcpServer(client) {
31
+ const server = new McpServer({
32
+ name: 'calybur',
33
+ version: '0.1.0',
34
+ }, {
35
+ instructions: SERVER_INSTRUCTIONS,
36
+ });
37
+ server.registerTool('calybur_workforce', {
38
+ title: 'Calybur Workforce',
39
+ description: 'Query employees, departments, positions, and work locations from Calybur.',
40
+ inputSchema: z.object({
41
+ action: z.enum([
42
+ 'list_employees',
43
+ 'get_employee',
44
+ 'list_departments',
45
+ 'list_positions',
46
+ 'list_work_locations',
47
+ ]),
48
+ employee_id: z.string().uuid().optional(),
49
+ ...sharedPaginationSchema,
50
+ }),
51
+ }, async (input) => handleWorkforce(client, input));
52
+ server.registerTool('calybur_leave', {
53
+ title: 'Calybur Leave',
54
+ description: 'Query leave balances, requests, policies, and public holidays.',
55
+ inputSchema: z.object({
56
+ action: z.enum(['list_balances', 'list_requests', 'list_policies', 'list_holidays']),
57
+ employee_id: z.string().uuid().optional(),
58
+ status: z.string().optional(),
59
+ year: z.number().int().optional(),
60
+ ...sharedPaginationSchema,
61
+ }),
62
+ }, async (input) => handleLeave(client, input));
63
+ server.registerTool('calybur_payroll', {
64
+ title: 'Calybur Payroll',
65
+ description: 'Query payroll periods, run status, and payslips.',
66
+ inputSchema: z.object({
67
+ action: z.enum(['list_periods', 'get_run', 'list_payslips']),
68
+ period_id: z.string().uuid().optional(),
69
+ status: z.string().optional(),
70
+ ...sharedPaginationSchema,
71
+ }),
72
+ }, async (input) => handlePayroll(client, input));
73
+ server.registerTool('calybur_attendance', {
74
+ title: 'Calybur Attendance',
75
+ description: 'Query attendance events and summaries.',
76
+ inputSchema: z.object({
77
+ action: z.enum(['list_events', 'list_summaries']),
78
+ employee_id: z.string().uuid().optional(),
79
+ status: z.string().optional(),
80
+ from: z.string().optional().describe('YYYY-MM-DD'),
81
+ to: z.string().optional().describe('YYYY-MM-DD'),
82
+ ...sharedPaginationSchema,
83
+ }),
84
+ }, async (input) => handleAttendance(client, input));
85
+ server.registerTool('calybur_commission', {
86
+ title: 'Calybur Commission',
87
+ description: 'Query commission rules and calculation results.',
88
+ inputSchema: z.object({
89
+ action: z.enum(['list_rules', 'list_calculations']),
90
+ status: z.string().optional(),
91
+ ...sharedPaginationSchema,
92
+ }),
93
+ }, async (input) => handleCommission(client, input));
94
+ server.registerTool('calybur_reports', {
95
+ title: 'Calybur Reports',
96
+ description: 'Fetch payroll summary or statutory export metadata for a period.',
97
+ inputSchema: z.object({
98
+ action: z.enum(['payroll_summary', 'statutory_metadata']),
99
+ period_id: z.string().uuid(),
100
+ mask_pii: z.boolean().optional(),
101
+ }),
102
+ }, async (input) => handleReports(client, input));
103
+ server.registerTool('calybur_status', {
104
+ title: 'Calybur Status',
105
+ description: 'Check API key validity and remaining MCP client rate budget.',
106
+ inputSchema: z.object({}),
107
+ }, async () => handleStatus(client));
108
+ server.registerTool('calybur_docs', {
109
+ title: 'Calybur Docs',
110
+ description: 'Search bundled Calybur API and Malaysia statutory documentation.',
111
+ inputSchema: z.object({
112
+ query: z.string().min(1),
113
+ }),
114
+ }, async ({ query }) => handleDocs(query));
115
+ return server;
116
+ }
117
+ async function main() {
118
+ const config = loadConfig();
119
+ const budget = new RateBudget(config.plan);
120
+ const client = new CalyburClient(config, budget);
121
+ const server = createCalyburMcpServer(client);
122
+ const transport = new StdioServerTransport();
123
+ await server.connect(transport);
124
+ }
125
+ const isDirectRun = process.argv[1]?.includes('index');
126
+ if (isDirectRun) {
127
+ main().catch((error) => {
128
+ console.error(error);
129
+ process.exit(1);
130
+ });
131
+ }
@@ -0,0 +1,3 @@
1
+ export declare function maskValue(field: string, value: unknown): unknown;
2
+ export declare function maskRecord(record: Record<string, unknown>): Record<string, unknown>;
3
+ export declare function maybeMask(data: unknown, enabled: boolean): unknown;
@@ -0,0 +1,66 @@
1
+ const PII_FIELDS = new Set([
2
+ 'ic_number',
3
+ 'nric',
4
+ 'bank_account',
5
+ 'bank_account_number',
6
+ 'epf_number',
7
+ 'socso_number',
8
+ ]);
9
+ function shouldMaskField(key) {
10
+ const normalized = key.toLowerCase();
11
+ return (PII_FIELDS.has(normalized) ||
12
+ normalized.includes('ic') ||
13
+ normalized.includes('bank') ||
14
+ normalized.includes('email'));
15
+ }
16
+ export function maskValue(field, value) {
17
+ if (typeof value !== 'string' || !value)
18
+ return value;
19
+ const key = field.toLowerCase();
20
+ if (key.includes('ic') || key === 'nric') {
21
+ return value.replace(/^(\d{6})-?\d{2}-?(\d{4})$/, '$1-**-$2');
22
+ }
23
+ if (key.includes('bank')) {
24
+ return value.length <= 4 ? '****' : `${'*'.repeat(value.length - 4)}${value.slice(-4)}`;
25
+ }
26
+ if (key.includes('email')) {
27
+ const [local, domain] = value.split('@');
28
+ if (!domain)
29
+ return value;
30
+ return `${local.slice(0, 2)}***@${domain}`;
31
+ }
32
+ return value;
33
+ }
34
+ export function maskRecord(record) {
35
+ const out = {};
36
+ for (const [key, value] of Object.entries(record)) {
37
+ if (Array.isArray(value)) {
38
+ out[key] = value.map((item) => typeof item === 'object' && item !== null
39
+ ? maskRecord(item)
40
+ : item);
41
+ }
42
+ else if (typeof value === 'object' && value !== null) {
43
+ out[key] = maskRecord(value);
44
+ }
45
+ else if (shouldMaskField(key)) {
46
+ out[key] = maskValue(key, value);
47
+ }
48
+ else {
49
+ out[key] = value;
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ export function maybeMask(data, enabled) {
55
+ if (!enabled)
56
+ return data;
57
+ if (Array.isArray(data)) {
58
+ return data.map((row) => typeof row === 'object' && row !== null
59
+ ? maskRecord(row)
60
+ : row);
61
+ }
62
+ if (typeof data === 'object' && data !== null) {
63
+ return maskRecord(data);
64
+ }
65
+ return data;
66
+ }
@@ -0,0 +1,11 @@
1
+ import type { CalyburPlan } from './config.js';
2
+ export declare class RateBudget {
3
+ private used;
4
+ private windowStart;
5
+ private readonly max;
6
+ constructor(plan: CalyburPlan);
7
+ private rollWindow;
8
+ tryConsume(): boolean;
9
+ remaining(): number;
10
+ resetAt(): Date;
11
+ }
@@ -0,0 +1,35 @@
1
+ const PLAN_LIMITS = {
2
+ starter: 100,
3
+ professional: 500,
4
+ enterprise: 2000,
5
+ };
6
+ const HEADROOM = 0.9;
7
+ const WINDOW_MS = 60_000;
8
+ export class RateBudget {
9
+ used = 0;
10
+ windowStart = Date.now();
11
+ max;
12
+ constructor(plan) {
13
+ this.max = Math.floor(PLAN_LIMITS[plan] * HEADROOM);
14
+ }
15
+ rollWindow(now = Date.now()) {
16
+ if (now - this.windowStart >= WINDOW_MS) {
17
+ this.used = 0;
18
+ this.windowStart = now;
19
+ }
20
+ }
21
+ tryConsume() {
22
+ this.rollWindow();
23
+ if (this.used >= this.max)
24
+ return false;
25
+ this.used += 1;
26
+ return true;
27
+ }
28
+ remaining() {
29
+ this.rollWindow();
30
+ return Math.max(0, this.max - this.used);
31
+ }
32
+ resetAt() {
33
+ return new Date(this.windowStart + WINDOW_MS);
34
+ }
35
+ }
@@ -0,0 +1,2 @@
1
+ export declare function hasScope(scopes: string[], required: string): boolean;
2
+ export declare function assertScope(scopes: string[], required: string): void;
@@ -0,0 +1,8 @@
1
+ export function hasScope(scopes, required) {
2
+ return scopes.includes('admin') || scopes.includes(required);
3
+ }
4
+ export function assertScope(scopes, required) {
5
+ if (!hasScope(scopes, required)) {
6
+ throw new Error(`Missing required scope "${required}". Update your API key scopes at Calybur Settings → API Keys.`);
7
+ }
8
+ }
@@ -0,0 +1,14 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export type AttendanceAction = 'list_events' | 'list_summaries';
4
+ export interface AttendanceInput {
5
+ action: AttendanceAction;
6
+ employee_id?: string;
7
+ status?: string;
8
+ from?: string;
9
+ to?: string;
10
+ page?: number;
11
+ limit?: number;
12
+ mask_pii?: boolean;
13
+ }
14
+ export declare function handleAttendance(client: CalyburClient, input: AttendanceInput): Promise<ToolTextResult>;
@@ -0,0 +1,19 @@
1
+ import { extractApiData, maskToolPayload, paginationParams, withToolHandler, } from './shared.js';
2
+ export async function handleAttendance(client, input) {
3
+ return withToolHandler(async () => {
4
+ const params = paginationParams(input.page, input.limit);
5
+ if (input.employee_id)
6
+ params.employee_id = input.employee_id;
7
+ if (input.status)
8
+ params.status = input.status;
9
+ if (input.from)
10
+ params.from = input.from;
11
+ if (input.to)
12
+ params.to = input.to;
13
+ const route = input.action === 'list_events'
14
+ ? 'api-v1-attendance/events'
15
+ : 'api-v1-attendance/summaries';
16
+ const raw = await client.get(route, params);
17
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
18
+ });
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export type CommissionAction = 'list_rules' | 'list_calculations';
4
+ export interface CommissionInput {
5
+ action: CommissionAction;
6
+ status?: string;
7
+ page?: number;
8
+ limit?: number;
9
+ mask_pii?: boolean;
10
+ }
11
+ export declare function handleCommission(client: CalyburClient, input: CommissionInput): Promise<ToolTextResult>;
@@ -0,0 +1,13 @@
1
+ import { extractApiData, maskToolPayload, paginationParams, withToolHandler, } from './shared.js';
2
+ export async function handleCommission(client, input) {
3
+ return withToolHandler(async () => {
4
+ const params = paginationParams(input.page, input.limit);
5
+ if (input.status)
6
+ params.status = input.status;
7
+ const route = input.action === 'list_rules'
8
+ ? 'api-v1-commission/rules'
9
+ : 'api-v1-commission/calculations';
10
+ const raw = await client.get(route, params);
11
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
12
+ });
13
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolTextResult } from './shared.js';
2
+ export declare function handleDocs(query: string): ToolTextResult;
@@ -0,0 +1,41 @@
1
+ import { okResult } from './shared.js';
2
+ const DOC_SNIPPETS = [
3
+ {
4
+ title: 'API Quickstart',
5
+ keywords: ['api', 'key', 'auth', 'scope', 'starter'],
6
+ body: `Calybur Partner API uses x-api-key header. Requires Starter plan or above.
7
+ Scopes: read (query), write (create/update), admin (full), plus domain scopes like payroll:run, leave:approve.
8
+ Create keys at Calybur Settings → API Keys.`,
9
+ },
10
+ {
11
+ title: 'Malaysia Statutory (EPF/SOCSO/EIS/PCB)',
12
+ keywords: ['epf', 'socso', 'eis', 'pcb', 'statutory', 'malaysia', 'kwsp', 'perkeso'],
13
+ body: `Malaysia payroll statutory components:
14
+ - EPF (KWSP): employee + employer provident fund
15
+ - SOCSO (Perkeso): social security
16
+ - EIS (SIP): employment insurance
17
+ - PCB (MTD): monthly tax deduction via LHDN schedules`,
18
+ },
19
+ {
20
+ title: 'MCP Setup',
21
+ keywords: ['mcp', 'cursor', 'claude', 'setup', 'config'],
22
+ body: `Configure Cursor MCP with npx @calybur/mcp and env vars:
23
+ CALYBUR_API_KEY, CALYBUR_API_BASE_URL (Supabase functions/v1 base), optional CALYBUR_PLAN (starter|professional|enterprise).`,
24
+ },
25
+ {
26
+ title: 'Rate Limits',
27
+ keywords: ['rate', 'limit', '429', 'starter', 'professional', 'enterprise'],
28
+ body: `API rate limits per company: Starter 100/min, Professional 500/min, Enterprise 2000/min.
29
+ MCP client reserves 10% headroom. Prefer read tools and batch queries on Starter.`,
30
+ },
31
+ ];
32
+ export function handleDocs(query) {
33
+ const haystack = query.toLowerCase();
34
+ const matches = DOC_SNIPPETS.filter((doc) => doc.title.toLowerCase().includes(haystack) ||
35
+ doc.keywords.some((keyword) => haystack.includes(keyword) || keyword.includes(haystack)));
36
+ const results = (matches.length > 0 ? matches : DOC_SNIPPETS).map((doc) => ({
37
+ title: doc.title,
38
+ body: doc.body,
39
+ }));
40
+ return okResult({ query, results });
41
+ }
@@ -0,0 +1,13 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export type LeaveAction = 'list_balances' | 'list_requests' | 'list_policies' | 'list_holidays';
4
+ export interface LeaveInput {
5
+ action: LeaveAction;
6
+ employee_id?: string;
7
+ status?: string;
8
+ year?: number;
9
+ page?: number;
10
+ limit?: number;
11
+ mask_pii?: boolean;
12
+ }
13
+ export declare function handleLeave(client: CalyburClient, input: LeaveInput): Promise<ToolTextResult>;
@@ -0,0 +1,20 @@
1
+ import { extractApiData, maskToolPayload, paginationParams, withToolHandler, } from './shared.js';
2
+ const ROUTE_MAP = {
3
+ list_balances: 'api-v1-leave/balances',
4
+ list_requests: 'api-v1-leave/requests',
5
+ list_policies: 'api-v1-leave/policies',
6
+ list_holidays: 'api-v1-leave/public-holidays',
7
+ };
8
+ export async function handleLeave(client, input) {
9
+ return withToolHandler(async () => {
10
+ const params = paginationParams(input.page, input.limit);
11
+ if (input.employee_id)
12
+ params.employee_id = input.employee_id;
13
+ if (input.status)
14
+ params.status = input.status;
15
+ if (input.year)
16
+ params.year = String(input.year);
17
+ const raw = await client.get(ROUTE_MAP[input.action], params);
18
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
19
+ });
20
+ }
@@ -0,0 +1,12 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export type PayrollAction = 'list_periods' | 'get_run' | 'list_payslips';
4
+ export interface PayrollInput {
5
+ action: PayrollAction;
6
+ period_id?: string;
7
+ status?: string;
8
+ page?: number;
9
+ limit?: number;
10
+ mask_pii?: boolean;
11
+ }
12
+ export declare function handlePayroll(client: CalyburClient, input: PayrollInput): Promise<ToolTextResult>;
@@ -0,0 +1,27 @@
1
+ import { extractApiData, maskToolPayload, paginationParams, withToolHandler, } from './shared.js';
2
+ export async function handlePayroll(client, input) {
3
+ return withToolHandler(async () => {
4
+ const params = paginationParams(input.page, input.limit);
5
+ if (input.status)
6
+ params.status = input.status;
7
+ if (input.action === 'list_periods') {
8
+ const raw = await client.get('api-v1-payroll/periods', params);
9
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
10
+ }
11
+ if (input.action === 'get_run') {
12
+ const raw = await client.get('api-v1-payroll/runs', params);
13
+ let data = extractApiData(raw);
14
+ if (input.period_id && Array.isArray(data)) {
15
+ data = data.filter((row) => {
16
+ if (!row || typeof row !== 'object')
17
+ return false;
18
+ const record = row;
19
+ return record.id === input.period_id || record.payroll_period_id === input.period_id;
20
+ });
21
+ }
22
+ return maskToolPayload(data, input.mask_pii, input.action);
23
+ }
24
+ const raw = await client.get('api-v1-payroll/payslips', params);
25
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
26
+ });
27
+ }
@@ -0,0 +1,9 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export type ReportsAction = 'payroll_summary' | 'statutory_metadata';
4
+ export interface ReportsInput {
5
+ action: ReportsAction;
6
+ period_id: string;
7
+ mask_pii?: boolean;
8
+ }
9
+ export declare function handleReports(client: CalyburClient, input: ReportsInput): Promise<ToolTextResult>;
@@ -0,0 +1,11 @@
1
+ import { extractApiData, maskToolPayload, withToolHandler, } from './shared.js';
2
+ export async function handleReports(client, input) {
3
+ return withToolHandler(async () => {
4
+ if (!input.period_id) {
5
+ throw new Error('period_id is required for report actions');
6
+ }
7
+ const suffix = input.action === 'payroll_summary' ? 'summary' : 'statutory';
8
+ const raw = await client.get(`api-v1-payroll/reports/${input.period_id}/${suffix}`);
9
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
10
+ });
11
+ }
@@ -0,0 +1,16 @@
1
+ export type ToolTextResult = {
2
+ content: Array<{
3
+ type: 'text';
4
+ text: string;
5
+ }>;
6
+ isError?: boolean;
7
+ };
8
+ export declare function okResult(payload: unknown): ToolTextResult;
9
+ export declare function errResult(message: string): ToolTextResult;
10
+ export declare function withToolHandler(fn: () => Promise<unknown>): Promise<ToolTextResult>;
11
+ export declare function extractApiData(body: unknown): unknown;
12
+ export declare function paginationParams(page?: number, limit?: number): Record<string, string>;
13
+ export declare function maskToolPayload(data: unknown, maskPii: boolean | undefined, action: string): {
14
+ action: string;
15
+ data: unknown;
16
+ };
@@ -0,0 +1,40 @@
1
+ import { CalyburApiError, formatApiError } from '../errors.js';
2
+ import { maybeMask } from '../pii-mask.js';
3
+ export function okResult(payload) {
4
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
5
+ }
6
+ export function errResult(message) {
7
+ return { content: [{ type: 'text', text: message }], isError: true };
8
+ }
9
+ export async function withToolHandler(fn) {
10
+ try {
11
+ return okResult(await fn());
12
+ }
13
+ catch (error) {
14
+ const message = error instanceof CalyburApiError ? formatApiError(error) : String(error);
15
+ return errResult(message);
16
+ }
17
+ }
18
+ export function extractApiData(body) {
19
+ if (!body || typeof body !== 'object')
20
+ return body;
21
+ const record = body;
22
+ if ('data' in record)
23
+ return record.data;
24
+ if ('employees' in record)
25
+ return record.employees;
26
+ return body;
27
+ }
28
+ export function paginationParams(page, limit) {
29
+ const params = {
30
+ page: String(page ?? 1),
31
+ limit: String(limit ?? 20),
32
+ };
33
+ return params;
34
+ }
35
+ export function maskToolPayload(data, maskPii, action) {
36
+ return {
37
+ action,
38
+ data: maybeMask(data, maskPii !== false),
39
+ };
40
+ }
@@ -0,0 +1,3 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export declare function handleStatus(client: CalyburClient): Promise<ToolTextResult>;
@@ -0,0 +1,12 @@
1
+ import { CalyburApiError, formatApiError } from '../errors.js';
2
+ import { okResult, errResult } from './shared.js';
3
+ export async function handleStatus(client) {
4
+ try {
5
+ await client.get('api-v1-employees', { limit: '1', page: '1' });
6
+ return okResult({ ok: true, ...client.statusSnapshot() });
7
+ }
8
+ catch (error) {
9
+ const message = error instanceof CalyburApiError ? formatApiError(error) : String(error);
10
+ return errResult(JSON.stringify({ ok: false, error: message }, null, 2));
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ import type { CalyburClient } from '../client.js';
2
+ import { type ToolTextResult } from './shared.js';
3
+ export type WorkforceAction = 'list_employees' | 'get_employee' | 'list_departments' | 'list_positions' | 'list_work_locations';
4
+ export interface WorkforceInput {
5
+ action: WorkforceAction;
6
+ employee_id?: string;
7
+ page?: number;
8
+ limit?: number;
9
+ mask_pii?: boolean;
10
+ }
11
+ export declare function handleWorkforce(client: CalyburClient, input: WorkforceInput): Promise<ToolTextResult>;
@@ -0,0 +1,21 @@
1
+ import { extractApiData, maskToolPayload, paginationParams, withToolHandler, } from './shared.js';
2
+ const FUNCTION_MAP = {
3
+ list_employees: 'api-v1-employees',
4
+ list_departments: 'api-v1-departments',
5
+ list_positions: 'api-v1-positions',
6
+ list_work_locations: 'api-v1-work-locations',
7
+ };
8
+ export async function handleWorkforce(client, input) {
9
+ return withToolHandler(async () => {
10
+ const params = paginationParams(input.page, input.limit);
11
+ if (input.action === 'get_employee') {
12
+ if (!input.employee_id) {
13
+ throw new Error('employee_id is required for get_employee');
14
+ }
15
+ const raw = await client.get(`api-v1-employees/${input.employee_id}`);
16
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
17
+ }
18
+ const raw = await client.get(FUNCTION_MAP[input.action], params);
19
+ return maskToolPayload(extractApiData(raw), input.mask_pii, input.action);
20
+ });
21
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@calybur/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Calybur payroll and HR Partner API",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Calybur",
8
+ "homepage": "https://www.calybur.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/CreaiTechnology/ezgaji-payroll.git",
12
+ "directory": "packages/calybur-mcp"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/CreaiTechnology/ezgaji-payroll/issues"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "calybur",
20
+ "payroll",
21
+ "hrms",
22
+ "malaysia",
23
+ "model-context-protocol"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "bin": {
29
+ "calybur-mcp": "./dist/index.js"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "files": ["dist", "README.md"],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsx src/index.ts",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "prepublishOnly": "npm run test && npm run build",
39
+ "pack:dry": "npm run build && npm pack --dry-run"
40
+ },
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.12.1",
46
+ "zod": "^3.24.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.10.0",
50
+ "tsx": "^4.19.0",
51
+ "typescript": "^5.7.0",
52
+ "vitest": "^3.0.0"
53
+ }
54
+ }