#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequest,
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import dotenv from "dotenv";
dotenv.config();
// Environment variables required for Keka API authentication
const KEKA_CLIENT_ID = process.env.KEKA_CLIENT_ID;
const KEKA_CLIENT_SECRET = process.env.KEKA_CLIENT_SECRET;
const KEKA_REFRESH_TOKEN = process.env.KEKA_REFRESH_TOKEN;
const KEKA_API_BASE_URL = process.env.KEKA_API_BASE_URL || "https://master.kekademo.com/api/v1";
if (!KEKA_CLIENT_ID || !KEKA_CLIENT_SECRET || !KEKA_REFRESH_TOKEN) {
console.error("Required Keka OAuth credentials not found in environment variables");
process.exit(1);
}
// Type definitions for tool arguments
interface GetEmployeeProfileArgs {
employee_id: string;
}
interface GetAttendanceArgs {
employee_id: string;
start_date: string;
end_date: string;
page?: number;
page_size?: number;
}
interface ApplyLeaveArgs {
employee_id: string;
start_date: string;
end_date: string;
leave_type: string;
reason: string;
}
interface GetLeaveBalanceArgs {
employee_id: string;
}
interface GetPayslipArgs {
employee_id: string;
month: string;
year: string;
}
// New tool argument types
interface GetLeaveTypesArgs {}
interface GetLeaveBalancesArgs {
employee_id: string;
}
interface ViewLeaveHistoryArgs {
employee_id: string;
startDate?: string;
endDate?: string;
status?: string;
page?: number;
pageSize?: number;
}
interface GetUpcomingHolidaysArgs {}
// Tool definitions for our target 8 tools
const getEmployeeProfileTool: Tool = {
name: "keka_get_employee_profile",
description: "Get detailed profile information for a specific employee",
inputSchema: {
type: "object",
properties: {
employee_id: {
type: "string",
description: "The UUID of the employee",
},
},
required: ["employee_id"],
},
};
const getAttendanceTool: Tool = {
name: "keka_get_attendance",
description: "Get attendance records for an employee within a date range",
inputSchema: {
type: "object",
properties: {
employee_id: {
type: "string",
description: "The UUID of the employee",
},
start_date: {
type: "string",
description: "Start date in YYYY-MM-DD format",
},
end_date: {
type: "string",
description: "End date in YYYY-MM-DD format",
},
page: {
type: "number",
description: "Page number for pagination (default: 1)",
default: 1,
},
page_size: {
type: "number",
description: "Number of records per page (default: 100, max: 100)",
default: 100,
},
},
required: ["employee_id", "start_date", "end_date"],
},
};
const getLeaveTypesTool: Tool = {
name: "keka_get_leave_types",
description: "Get the list of available leave types",
inputSchema: {
type: "object",
properties: {},
},
};
const applyLeaveTool: Tool = {
name: "keka_apply_leave",
description: "Apply for leave for an employee",
inputSchema: {
type: "object",
properties: {
employee_id: {
type: "string",
description: "The UUID of the employee",
},
start_date: {
type: "string",
description: "Start date in YYYY-MM-DD format",
},
end_date: {
type: "string",
description: "End date in YYYY-MM-DD format",
},
leave_type: {
type: "string",
description: "UUID of the leave type",
},
reason: {
type: "string",
description: "Reason for applying leave",
},
},
required: ["employee_id", "start_date", "end_date", "leave_type", "reason"],
},
};
const getLeaveBalancesTool: Tool = {
name: "keka_get_leave_balances",
description: "Get a consolidated view of all leave balances for an employee",
inputSchema: {
type: "object",
properties: {
employee_id: {
type: "string",
description: "The UUID of the employee",
},
},
required: ["employee_id"],
},
};
const viewLeaveHistoryTool: Tool = {
name: "keka_view_leave_history",
description: "View the leave history for an employee",
inputSchema: {
type: "object",
properties: {
employee_id: {
type: "string",
description: "The UUID of the employee",
},
start_date: {
type: "string",
description: "Filter by start date (YYYY-MM-DD)",
},
end_date: {
type: "string",
description: "Filter by end date (YYYY-MM-DD)",
},
status: {
type: "string",
description: "Filter by leave request status",
},
page: {
type: "number",
description: "Page number for pagination (default: 1)",
default: 1,
},
page_size: {
type: "number",
description: "Number of records per page (default: 100)",
default: 100,
},
},
required: ["employee_id"],
},
};
const getPayslipTool: Tool = {
name: "keka_get_payslip",
description: "Get payslip for an employee for a specific month and year",
inputSchema: {
type: "object",
properties: {
employee_id: {
type: "string",
description: "The UUID of the employee",
},
month: {
type: "string",
description: "Month (1-12)",
},
year: {
type: "string",
description: "Year (YYYY)",
},
},
required: ["employee_id", "month", "year"],
},
};
const getUpcomingHolidaysTool: Tool = {
name: "keka_get_upcoming_holidays",
description: "Get the list of upcoming company holidays",
inputSchema: {
type: "object",
properties: {},
},
};
class KekaClient {
private accessToken: string | null = null;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly refreshToken: string;
private readonly baseUrl: string;
constructor(clientId: string, clientSecret: string, refreshToken: string, baseUrl: string) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.refreshToken = refreshToken;
this.baseUrl = baseUrl;
}
private async getAccessToken(): Promise<string> {
if (this.accessToken) {
return this.accessToken;
}
const tokenEndpoint = `${this.baseUrl}/oauth/token`;
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("refresh_token", this.refreshToken);
params.append("client_id", this.clientId);
params.append("client_secret", this.clientSecret);
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
const data = await response.json();
if (!response.ok || data.error) {
console.error("Error refreshing token:", data);
throw new Error(data.error_description || "Failed to refresh access token");
}
const accessToken = data.access_token;
if (!accessToken || typeof accessToken !== "string") {
throw new Error("No access token returned from Keka OAuth server");
}
this.accessToken = accessToken;
// Consider storing the expiry and refreshing proactively
return this.accessToken;
}
private async makeApiRequest(url: string, method: string = "GET", body: any = null): Promise<any> {
const accessToken = await this.getAccessToken();
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
};
const config: RequestInit = {
method,
headers,
};
if (body) {
config.body = JSON.stringify(body);
}
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json();
console.error(`API request failed for ${url} with status ${response.status}:`, errorData);
throw new Error(errorData.error || errorData.message || `API request failed with status ${response.status}`);
}
return await response.json();
}
async getEmployeeProfile(employee_id: string): Promise<any> {
const url = `${this.baseUrl}/v1/employees/${employee_id}`;
const response = await this.makeApiRequest(url);
return response.data;
}
async getAttendance(
employee_id: string,
start_date: string,
end_date: string,
page: number = 1,
page_size: number = 100
): Promise<any> {
const params = new URLSearchParams({
startDate: start_date,
endDate: end_date,
page: page.toString(),
pageSize: Math.min(page_size, 100).toString(),
});
const url = `${this.baseUrl}/v1/employees/${employee_id}/attendance?${params}`;
const response = await this.makeApiRequest(url);
return response.data;
}
async getLeaveTypes(): Promise<any> {
const url = `${this.baseUrl}/v1/leaves/types`;
const response = await this.makeApiRequest(url);
return response.data;
}
async applyLeave(
employee_id: string,
start_date: string,
end_date: string,
leave_type: string,
reason: string
): Promise<any> {
const url = `${this.baseUrl}/v1/leaves/requests`;
const body = {
employeeId: employee_id,
fromDate: start_date,
toDate: end_date,
leaveTypeId: leave_type,
reason: reason,
};
const response = await this.makeApiRequest(url, "POST", body);
return response.data;
}
async getLeaveBalances(employee_id: string): Promise<any> {
const params = new URLSearchParams({
employeeId: employee_id,
});
const url = `${this.baseUrl}/v1/leaves/balances?${params}`;
const response = await this.makeApiRequest(url);
return response.data;
}
async viewLeaveHistory(
employee_id: string,
startDate?: string,
endDate?: string,
status?: string,
page: number = 1,
pageSize: number = 100
): Promise<any> {
const params = new URLSearchParams({
employeeId: employee_id,
startDate: startDate || "",
endDate: endDate || "",
status: status || "",
page: page.toString(),
pageSize: pageSize.toString(),
});
const url = `${this.baseUrl}/v1/leaves/requests?${params}`;
const response = await this.makeApiRequest(url);
return response.data;
}
async getPayslip(employee_id: string, month: string, year: string): Promise<any> {
const params = new URLSearchParams({
employeeId: employee_id,
month: month,
year: year,
});
const url = `${this.baseUrl}/v1/payroll/payslips?${params}`;
const response = await this.makeApiRequest(url);
return response.data;
}
async getUpcomingHolidays(): Promise<any> {
const url = `${this.baseUrl}/v1/holidays`;
const response = await this.makeApiRequest(url);
return response.data;
}
}
class KekaMcpServer {
private server: Server;
private kekaClient: KekaClient;
constructor() {
this.server = new Server(
{
name: "keka-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
const clientId = KEKA_CLIENT_ID!;
const clientSecret = KEKA_CLIENT_SECRET!;
const refreshToken = KEKA_REFRESH_TOKEN!;
const baseUrl = KEKA_API_BASE_URL;
this.kekaClient = new KekaClient(clientId, clientSecret, refreshToken, baseUrl);
this.setupToolHandlers();
this.server.onerror = (error) => console.error("[MCP Error]", error);
process.on("SIGINT", async () => {
await this.server.close();
process.exit(0);
});
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
getEmployeeProfileTool,
getAttendanceTool,
getLeaveTypesTool,
applyLeaveTool,
getLeaveBalancesTool,
viewLeaveHistoryTool,
getPayslipTool,
getUpcomingHolidaysTool,
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
if (!request.params.arguments) {
throw new McpError(ErrorCode.InvalidParams, "No arguments provided");
}
switch (request.params.name) {
case "keka_get_employee_profile":
return await this.handleGetEmployeeProfile(request.params.arguments as unknown as GetEmployeeProfileArgs);
case "keka_get_attendance":
return await this.handleGetAttendance(request.params.arguments as unknown as GetAttendanceArgs);
case "keka_apply_leave":
return await this.handleApplyLeave(request.params.arguments as unknown as ApplyLeaveArgs);
case "keka_get_leave_balances":
return await this.handleGetLeaveBalances(request.params.arguments as unknown as GetLeaveBalancesArgs);
case "keka_get_payslip":
return await this.handleGetPayslip(request.params.arguments as unknown as GetPayslipArgs);
case "keka_get_upcoming_holidays":
return await this.handleGetUpcomingHolidays(request.params.arguments as unknown as GetUpcomingHolidaysArgs);
case "keka_view_leave_history":
return await this.handleViewLeaveHistory(request.params.arguments as unknown as ViewLeaveHistoryArgs);
case "keka_get_leave_types":
return await this.handleGetLeaveTypes(request.params.arguments as unknown as GetLeaveTypesArgs);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error: any) {
return {
content: [
{
type: "text",
text: `Error: ${error.message}`,
},
],
isError: true,
};
}
});
}
private async handleGetEmployeeProfile(args: GetEmployeeProfileArgs) {
try {
const response = await this.kekaClient.getEmployeeProfile(args.employee_id);
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleGetAttendance(args: GetAttendanceArgs) {
try {
const response = await this.kekaClient.getAttendance(
args.employee_id,
args.start_date,
args.end_date,
args.page,
args.page_size
);
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleGetLeaveTypes(args: GetLeaveTypesArgs) {
try {
const response = await this.kekaClient.getLeaveTypes();
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleApplyLeave(args: ApplyLeaveArgs) {
try {
const response = await this.kekaClient.applyLeave(
args.employee_id,
args.start_date,
args.end_date,
args.leave_type,
args.reason
);
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleGetLeaveBalances(args: GetLeaveBalancesArgs) {
try {
const response = await this.kekaClient.getLeaveBalances(args.employee_id);
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleViewLeaveHistory(args: ViewLeaveHistoryArgs) {
try {
const response = await this.kekaClient.viewLeaveHistory(
args.employee_id,
args.startDate,
args.endDate,
args.status,
args.page,
args.pageSize
);
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleGetPayslip(args: GetPayslipArgs) {
try {
const response = await this.kekaClient.getPayslip(
args.employee_id,
args.month,
args.year
);
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
private async handleGetUpcomingHolidays(args: GetUpcomingHolidaysArgs) {
try {
const response = await this.kekaClient.getUpcomingHolidays();
return { content: [{ type: "text", text: JSON.stringify(response) }] };
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Keka MCP server running on stdio");
}
}
const server = new KekaMcpServer();
server.run().catch(console.error);