@t4dhg/mcp-factorial 1.1.0 → 2.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.
package/dist/index.js CHANGED
@@ -4,68 +4,58 @@
4
4
  *
5
5
  * Provides access to employee and organizational data from FactorialHR
6
6
  * through the Model Context Protocol for use with Claude Code and other MCP clients.
7
+ *
8
+ * Features:
9
+ * - 22 tools for employees, teams, locations, contracts, time off, attendance, documents, and job catalog
10
+ * - Pagination support for all list operations
11
+ * - Caching for improved performance
12
+ * - Retry logic with exponential backoff
13
+ * - Runtime validation with Zod schemas
7
14
  */
8
- import { config } from 'dotenv';
9
- import { existsSync } from 'fs';
10
- import { join } from 'path';
11
- // Load environment variables from .env file
12
- // Priority: ENV_FILE_PATH > cwd/.env > home/.env
13
- function loadEnv() {
14
- // 1. Check if explicit path provided
15
- if (process.env.ENV_FILE_PATH && existsSync(process.env.ENV_FILE_PATH)) {
16
- config({ path: process.env.ENV_FILE_PATH });
17
- return;
18
- }
19
- // 2. Check current working directory
20
- const cwdEnv = join(process.cwd(), '.env');
21
- if (existsSync(cwdEnv)) {
22
- config({ path: cwdEnv });
23
- return;
24
- }
25
- // 3. Check home directory
26
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
27
- const homeEnv = join(homeDir, '.env');
28
- if (existsSync(homeEnv)) {
29
- config({ path: homeEnv });
30
- return;
31
- }
32
- // 4. Check common project locations
33
- const commonPaths = [
34
- join(homeDir, 'turborepo', '.env'),
35
- join(homeDir, 'projects', '.env'),
36
- ];
37
- for (const envPath of commonPaths) {
38
- if (existsSync(envPath)) {
39
- config({ path: envPath });
40
- return;
41
- }
42
- }
43
- // Fall back to default dotenv behavior
44
- config();
45
- }
15
+ import { loadEnv } from './config.js';
16
+ // Load environment variables before other imports
46
17
  loadEnv();
47
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
48
19
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
49
20
  import * as z from 'zod';
50
- import { listEmployees, getEmployee, searchEmployees, listTeams, getTeam, listLocations, getLocation, listContracts, } from './api.js';
21
+ import {
22
+ // Employees
23
+ listEmployees, getEmployee, searchEmployees,
24
+ // Teams
25
+ listTeams, getTeam,
26
+ // Locations
27
+ listLocations, getLocation,
28
+ // Contracts
29
+ listContracts,
30
+ // Time Off
31
+ listLeaves, getLeave, listLeaveTypes, getLeaveType, listAllowances,
32
+ // Shifts
33
+ listShifts, getShift,
34
+ // Documents
35
+ listFolders, getFolder, listDocuments, getDocument,
36
+ // Job Catalog
37
+ listJobRoles, getJobRole, listJobLevels, } from './api.js';
38
+ import { formatPaginationInfo } from './pagination.js';
51
39
  const server = new McpServer({
52
40
  name: 'factorial-hr',
53
- version: '1.0.0',
41
+ version: '2.0.0',
54
42
  });
55
43
  // ============================================================================
56
44
  // Employee Tools
57
45
  // ============================================================================
58
46
  server.registerTool('list_employees', {
59
47
  title: 'List Employees',
60
- description: 'Get all employees from FactorialHR. Can filter by team or location.',
48
+ description: 'Get employees from FactorialHR. Can filter by team or location. Supports pagination.',
61
49
  inputSchema: {
62
50
  team_id: z.number().optional().describe('Filter by team ID'),
63
51
  location_id: z.number().optional().describe('Filter by location ID'),
52
+ page: z.number().optional().default(1).describe('Page number (default: 1)'),
53
+ limit: z.number().optional().default(100).describe('Items per page (max: 100)'),
64
54
  },
65
- }, async ({ team_id, location_id }) => {
55
+ }, async ({ team_id, location_id, page, limit }) => {
66
56
  try {
67
- const employees = await listEmployees({ team_id, location_id });
68
- const summary = employees.map(e => ({
57
+ const result = await listEmployees({ team_id, location_id, page, limit });
58
+ const summary = result.data.map(e => ({
69
59
  id: e.id,
70
60
  name: e.full_name,
71
61
  email: e.email,
@@ -75,21 +65,24 @@ server.registerTool('list_employees', {
75
65
  manager_id: e.manager_id,
76
66
  hired_on: e.hired_on,
77
67
  terminated_on: e.terminated_on,
78
- birthday_on: e.birthday_on,
79
- created_at: e.created_at,
80
68
  }));
81
69
  return {
82
70
  content: [
83
71
  {
84
72
  type: 'text',
85
- text: `Found ${employees.length} employees:\n\n${JSON.stringify(summary, null, 2)}`,
73
+ text: `Found ${result.data.length} employees (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
86
74
  },
87
75
  ],
88
76
  };
89
77
  }
90
78
  catch (error) {
91
79
  return {
92
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
80
+ content: [
81
+ {
82
+ type: 'text',
83
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
84
+ },
85
+ ],
93
86
  isError: true,
94
87
  };
95
88
  }
@@ -119,7 +112,12 @@ server.registerTool('get_employee', {
119
112
  }
120
113
  catch (error) {
121
114
  return {
122
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
115
+ content: [
116
+ {
117
+ type: 'text',
118
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
119
+ },
120
+ ],
123
121
  isError: true,
124
122
  };
125
123
  }
@@ -155,7 +153,12 @@ server.registerTool('search_employees', {
155
153
  }
156
154
  catch (error) {
157
155
  return {
158
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
156
+ content: [
157
+ {
158
+ type: 'text',
159
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
160
+ },
161
+ ],
159
162
  isError: true,
160
163
  };
161
164
  }
@@ -165,12 +168,15 @@ server.registerTool('search_employees', {
165
168
  // ============================================================================
166
169
  server.registerTool('list_teams', {
167
170
  title: 'List Teams',
168
- description: 'Get all teams in the organization.',
169
- inputSchema: {},
170
- }, async () => {
171
+ description: 'Get all teams in the organization. Supports pagination.',
172
+ inputSchema: {
173
+ page: z.number().optional().default(1).describe('Page number'),
174
+ limit: z.number().optional().default(100).describe('Items per page'),
175
+ },
176
+ }, async ({ page, limit }) => {
171
177
  try {
172
- const teams = await listTeams();
173
- const summary = teams.map(t => ({
178
+ const result = await listTeams({ page, limit });
179
+ const summary = result.data.map(t => ({
174
180
  id: t.id,
175
181
  name: t.name,
176
182
  description: t.description,
@@ -180,14 +186,19 @@ server.registerTool('list_teams', {
180
186
  content: [
181
187
  {
182
188
  type: 'text',
183
- text: `Found ${teams.length} teams:\n\n${JSON.stringify(summary, null, 2)}`,
189
+ text: `Found ${result.data.length} teams (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
184
190
  },
185
191
  ],
186
192
  };
187
193
  }
188
194
  catch (error) {
189
195
  return {
190
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
196
+ content: [
197
+ {
198
+ type: 'text',
199
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
200
+ },
201
+ ],
191
202
  isError: true,
192
203
  };
193
204
  }
@@ -212,7 +223,12 @@ server.registerTool('get_team', {
212
223
  }
213
224
  catch (error) {
214
225
  return {
215
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
226
+ content: [
227
+ {
228
+ type: 'text',
229
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
230
+ },
231
+ ],
216
232
  isError: true,
217
233
  };
218
234
  }
@@ -222,12 +238,15 @@ server.registerTool('get_team', {
222
238
  // ============================================================================
223
239
  server.registerTool('list_locations', {
224
240
  title: 'List Locations',
225
- description: 'Get all company locations.',
226
- inputSchema: {},
227
- }, async () => {
241
+ description: 'Get all company locations. Supports pagination.',
242
+ inputSchema: {
243
+ page: z.number().optional().default(1).describe('Page number'),
244
+ limit: z.number().optional().default(100).describe('Items per page'),
245
+ },
246
+ }, async ({ page, limit }) => {
228
247
  try {
229
- const locations = await listLocations();
230
- const summary = locations.map(l => ({
248
+ const result = await listLocations({ page, limit });
249
+ const summary = result.data.map(l => ({
231
250
  id: l.id,
232
251
  name: l.name,
233
252
  city: l.city,
@@ -237,14 +256,19 @@ server.registerTool('list_locations', {
237
256
  content: [
238
257
  {
239
258
  type: 'text',
240
- text: `Found ${locations.length} locations:\n\n${JSON.stringify(summary, null, 2)}`,
259
+ text: `Found ${result.data.length} locations (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
241
260
  },
242
261
  ],
243
262
  };
244
263
  }
245
264
  catch (error) {
246
265
  return {
247
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
266
+ content: [
267
+ {
268
+ type: 'text',
269
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
270
+ },
271
+ ],
248
272
  isError: true,
249
273
  };
250
274
  }
@@ -269,7 +293,12 @@ server.registerTool('get_location', {
269
293
  }
270
294
  catch (error) {
271
295
  return {
272
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
296
+ content: [
297
+ {
298
+ type: 'text',
299
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
300
+ },
301
+ ],
273
302
  isError: true,
274
303
  };
275
304
  }
@@ -285,31 +314,943 @@ server.registerTool('get_employee_contracts', {
285
314
  },
286
315
  }, async ({ employee_id }) => {
287
316
  try {
288
- const contracts = await listContracts(employee_id);
317
+ const result = await listContracts(employee_id);
318
+ return {
319
+ content: [
320
+ {
321
+ type: 'text',
322
+ text: `Found ${result.data.length} contracts:\n\n${JSON.stringify(result.data, null, 2)}`,
323
+ },
324
+ ],
325
+ };
326
+ }
327
+ catch (error) {
328
+ return {
329
+ content: [
330
+ {
331
+ type: 'text',
332
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
333
+ },
334
+ ],
335
+ isError: true,
336
+ };
337
+ }
338
+ });
339
+ // ============================================================================
340
+ // Time Off / Leave Tools
341
+ // ============================================================================
342
+ server.registerTool('list_leaves', {
343
+ title: 'List Leaves',
344
+ description: 'Get time off/leave requests. Filter by employee, status, or date range. Supports pagination.',
345
+ inputSchema: {
346
+ employee_id: z.number().optional().describe('Filter by employee ID'),
347
+ status: z
348
+ .enum(['pending', 'approved', 'declined'])
349
+ .optional()
350
+ .describe('Filter by leave status'),
351
+ start_on_gte: z
352
+ .string()
353
+ .optional()
354
+ .describe('Filter leaves starting on or after this date (YYYY-MM-DD)'),
355
+ start_on_lte: z
356
+ .string()
357
+ .optional()
358
+ .describe('Filter leaves starting on or before this date (YYYY-MM-DD)'),
359
+ page: z.number().optional().default(1).describe('Page number'),
360
+ limit: z.number().optional().default(100).describe('Items per page (max: 100)'),
361
+ },
362
+ }, async ({ employee_id, status, start_on_gte, start_on_lte, page, limit }) => {
363
+ try {
364
+ const result = await listLeaves({
365
+ employee_id,
366
+ status,
367
+ start_on_gte,
368
+ start_on_lte,
369
+ page,
370
+ limit,
371
+ });
372
+ const summary = result.data.map(l => ({
373
+ id: l.id,
374
+ employee_id: l.employee_id,
375
+ leave_type_id: l.leave_type_id,
376
+ start_on: l.start_on,
377
+ finish_on: l.finish_on,
378
+ status: l.status,
379
+ days: l.duration_attributes?.days,
380
+ }));
381
+ return {
382
+ content: [
383
+ {
384
+ type: 'text',
385
+ text: `Found ${result.data.length} leaves (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
386
+ },
387
+ ],
388
+ };
389
+ }
390
+ catch (error) {
391
+ return {
392
+ content: [
393
+ {
394
+ type: 'text',
395
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
396
+ },
397
+ ],
398
+ isError: true,
399
+ };
400
+ }
401
+ });
402
+ server.registerTool('get_leave', {
403
+ title: 'Get Leave Details',
404
+ description: 'Get detailed information about a specific leave request.',
405
+ inputSchema: {
406
+ id: z.number().describe('The leave ID'),
407
+ },
408
+ }, async ({ id }) => {
409
+ try {
410
+ const leave = await getLeave(id);
411
+ return {
412
+ content: [
413
+ {
414
+ type: 'text',
415
+ text: `Leave details:\n\n${JSON.stringify(leave, null, 2)}`,
416
+ },
417
+ ],
418
+ };
419
+ }
420
+ catch (error) {
421
+ return {
422
+ content: [
423
+ {
424
+ type: 'text',
425
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
426
+ },
427
+ ],
428
+ isError: true,
429
+ };
430
+ }
431
+ });
432
+ server.registerTool('list_leave_types', {
433
+ title: 'List Leave Types',
434
+ description: 'Get all leave types (vacation, sick leave, etc.) configured in the organization.',
435
+ inputSchema: {},
436
+ }, async () => {
437
+ try {
438
+ const types = await listLeaveTypes();
439
+ return {
440
+ content: [
441
+ {
442
+ type: 'text',
443
+ text: `Found ${types.length} leave types:\n\n${JSON.stringify(types, null, 2)}`,
444
+ },
445
+ ],
446
+ };
447
+ }
448
+ catch (error) {
449
+ return {
450
+ content: [
451
+ {
452
+ type: 'text',
453
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
454
+ },
455
+ ],
456
+ isError: true,
457
+ };
458
+ }
459
+ });
460
+ server.registerTool('get_leave_type', {
461
+ title: 'Get Leave Type',
462
+ description: 'Get details about a specific leave type.',
463
+ inputSchema: {
464
+ id: z.number().describe('The leave type ID'),
465
+ },
466
+ }, async ({ id }) => {
467
+ try {
468
+ const leaveType = await getLeaveType(id);
469
+ return {
470
+ content: [
471
+ {
472
+ type: 'text',
473
+ text: JSON.stringify(leaveType, null, 2),
474
+ },
475
+ ],
476
+ };
477
+ }
478
+ catch (error) {
479
+ return {
480
+ content: [
481
+ {
482
+ type: 'text',
483
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
484
+ },
485
+ ],
486
+ isError: true,
487
+ };
488
+ }
489
+ });
490
+ server.registerTool('list_allowances', {
491
+ title: 'List Time Off Allowances',
492
+ description: 'Get time off balances/allowances for employees. Shows available, consumed, and total days.',
493
+ inputSchema: {
494
+ employee_id: z.number().optional().describe('Filter by employee ID'),
495
+ page: z.number().optional().default(1).describe('Page number'),
496
+ limit: z.number().optional().default(100).describe('Items per page (max: 100)'),
497
+ },
498
+ }, async ({ employee_id, page, limit }) => {
499
+ try {
500
+ const result = await listAllowances({ employee_id, page, limit });
501
+ const summary = result.data.map(a => ({
502
+ id: a.id,
503
+ employee_id: a.employee_id,
504
+ leave_type_id: a.leave_type_id,
505
+ available_days: a.available_days,
506
+ consumed_days: a.consumed_days,
507
+ balance_days: a.balance_days,
508
+ valid_from: a.valid_from,
509
+ valid_to: a.valid_to,
510
+ }));
511
+ return {
512
+ content: [
513
+ {
514
+ type: 'text',
515
+ text: `Found ${result.data.length} allowances (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
516
+ },
517
+ ],
518
+ };
519
+ }
520
+ catch (error) {
521
+ return {
522
+ content: [
523
+ {
524
+ type: 'text',
525
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
526
+ },
527
+ ],
528
+ isError: true,
529
+ };
530
+ }
531
+ });
532
+ // ============================================================================
533
+ // Attendance / Shift Tools
534
+ // ============================================================================
535
+ server.registerTool('list_shifts', {
536
+ title: 'List Shifts',
537
+ description: 'Get employee attendance shifts. Filter by employee or date range. Read-only access.',
538
+ inputSchema: {
539
+ employee_id: z.number().optional().describe('Filter by employee ID'),
540
+ clock_in_gte: z
541
+ .string()
542
+ .optional()
543
+ .describe('Filter shifts clocking in after this time (ISO 8601)'),
544
+ clock_in_lte: z
545
+ .string()
546
+ .optional()
547
+ .describe('Filter shifts clocking in before this time (ISO 8601)'),
548
+ page: z.number().optional().default(1).describe('Page number'),
549
+ limit: z.number().optional().default(100).describe('Items per page (max: 100)'),
550
+ },
551
+ }, async ({ employee_id, clock_in_gte, clock_in_lte, page, limit }) => {
552
+ try {
553
+ const result = await listShifts({ employee_id, clock_in_gte, clock_in_lte, page, limit });
554
+ const summary = result.data.map(s => ({
555
+ id: s.id,
556
+ employee_id: s.employee_id,
557
+ clock_in: s.clock_in,
558
+ clock_out: s.clock_out,
559
+ worked_hours: s.worked_hours,
560
+ break_minutes: s.break_minutes,
561
+ }));
562
+ return {
563
+ content: [
564
+ {
565
+ type: 'text',
566
+ text: `Found ${result.data.length} shifts (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
567
+ },
568
+ ],
569
+ };
570
+ }
571
+ catch (error) {
572
+ return {
573
+ content: [
574
+ {
575
+ type: 'text',
576
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
577
+ },
578
+ ],
579
+ isError: true,
580
+ };
581
+ }
582
+ });
583
+ server.registerTool('get_shift', {
584
+ title: 'Get Shift Details',
585
+ description: 'Get detailed information about a specific shift.',
586
+ inputSchema: {
587
+ id: z.number().describe('The shift ID'),
588
+ },
589
+ }, async ({ id }) => {
590
+ try {
591
+ const shift = await getShift(id);
289
592
  return {
290
593
  content: [
291
594
  {
292
595
  type: 'text',
293
- text: `Found ${contracts.length} contracts:\n\n${JSON.stringify(contracts, null, 2)}`,
596
+ text: JSON.stringify(shift, null, 2),
294
597
  },
295
598
  ],
296
599
  };
297
600
  }
298
601
  catch (error) {
299
602
  return {
300
- content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` }],
603
+ content: [
604
+ {
605
+ type: 'text',
606
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
607
+ },
608
+ ],
301
609
  isError: true,
302
610
  };
303
611
  }
304
612
  });
305
613
  // ============================================================================
614
+ // Document Tools (Read-Only)
615
+ // ============================================================================
616
+ server.registerTool('list_folders', {
617
+ title: 'List Folders',
618
+ description: 'Get all document folders. Read-only access.',
619
+ inputSchema: {},
620
+ }, async () => {
621
+ try {
622
+ const folders = await listFolders();
623
+ return {
624
+ content: [
625
+ {
626
+ type: 'text',
627
+ text: `Found ${folders.length} folders:\n\n${JSON.stringify(folders, null, 2)}`,
628
+ },
629
+ ],
630
+ };
631
+ }
632
+ catch (error) {
633
+ return {
634
+ content: [
635
+ {
636
+ type: 'text',
637
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
638
+ },
639
+ ],
640
+ isError: true,
641
+ };
642
+ }
643
+ });
644
+ server.registerTool('get_folder', {
645
+ title: 'Get Folder',
646
+ description: 'Get details about a specific folder.',
647
+ inputSchema: {
648
+ id: z.number().describe('The folder ID'),
649
+ },
650
+ }, async ({ id }) => {
651
+ try {
652
+ const folder = await getFolder(id);
653
+ return {
654
+ content: [
655
+ {
656
+ type: 'text',
657
+ text: JSON.stringify(folder, null, 2),
658
+ },
659
+ ],
660
+ };
661
+ }
662
+ catch (error) {
663
+ return {
664
+ content: [
665
+ {
666
+ type: 'text',
667
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
668
+ },
669
+ ],
670
+ isError: true,
671
+ };
672
+ }
673
+ });
674
+ server.registerTool('list_documents', {
675
+ title: 'List Documents',
676
+ description: 'Get documents. Filter by folder. Read-only access.',
677
+ inputSchema: {
678
+ folder_id: z.number().optional().describe('Filter by folder ID'),
679
+ page: z.number().optional().default(1).describe('Page number'),
680
+ limit: z.number().optional().default(100).describe('Items per page (max: 100)'),
681
+ },
682
+ }, async ({ folder_id, page, limit }) => {
683
+ try {
684
+ const result = await listDocuments({ folder_id, page, limit });
685
+ const summary = result.data.map(d => ({
686
+ id: d.id,
687
+ name: d.name,
688
+ folder_id: d.folder_id,
689
+ mime_type: d.mime_type,
690
+ size_bytes: d.size_bytes,
691
+ }));
692
+ return {
693
+ content: [
694
+ {
695
+ type: 'text',
696
+ text: `Found ${result.data.length} documents (${formatPaginationInfo(result.meta)}):\n\n${JSON.stringify(summary, null, 2)}`,
697
+ },
698
+ ],
699
+ };
700
+ }
701
+ catch (error) {
702
+ return {
703
+ content: [
704
+ {
705
+ type: 'text',
706
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
707
+ },
708
+ ],
709
+ isError: true,
710
+ };
711
+ }
712
+ });
713
+ server.registerTool('get_document', {
714
+ title: 'Get Document',
715
+ description: 'Get document metadata and URL. Read-only access.',
716
+ inputSchema: {
717
+ id: z.number().describe('The document ID'),
718
+ },
719
+ }, async ({ id }) => {
720
+ try {
721
+ const document = await getDocument(id);
722
+ return {
723
+ content: [
724
+ {
725
+ type: 'text',
726
+ text: JSON.stringify(document, null, 2),
727
+ },
728
+ ],
729
+ };
730
+ }
731
+ catch (error) {
732
+ return {
733
+ content: [
734
+ {
735
+ type: 'text',
736
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
737
+ },
738
+ ],
739
+ isError: true,
740
+ };
741
+ }
742
+ });
743
+ // ============================================================================
744
+ // Job Catalog Tools
745
+ // ============================================================================
746
+ server.registerTool('list_job_roles', {
747
+ title: 'List Job Roles',
748
+ description: 'Get all job roles defined in the job catalog.',
749
+ inputSchema: {},
750
+ }, async () => {
751
+ try {
752
+ const roles = await listJobRoles();
753
+ return {
754
+ content: [
755
+ {
756
+ type: 'text',
757
+ text: `Found ${roles.length} job roles:\n\n${JSON.stringify(roles, null, 2)}`,
758
+ },
759
+ ],
760
+ };
761
+ }
762
+ catch (error) {
763
+ return {
764
+ content: [
765
+ {
766
+ type: 'text',
767
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
768
+ },
769
+ ],
770
+ isError: true,
771
+ };
772
+ }
773
+ });
774
+ server.registerTool('get_job_role', {
775
+ title: 'Get Job Role',
776
+ description: 'Get details about a specific job role.',
777
+ inputSchema: {
778
+ id: z.number().describe('The job role ID'),
779
+ },
780
+ }, async ({ id }) => {
781
+ try {
782
+ const role = await getJobRole(id);
783
+ return {
784
+ content: [
785
+ {
786
+ type: 'text',
787
+ text: JSON.stringify(role, null, 2),
788
+ },
789
+ ],
790
+ };
791
+ }
792
+ catch (error) {
793
+ return {
794
+ content: [
795
+ {
796
+ type: 'text',
797
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
798
+ },
799
+ ],
800
+ isError: true,
801
+ };
802
+ }
803
+ });
804
+ server.registerTool('list_job_levels', {
805
+ title: 'List Job Levels',
806
+ description: 'Get all job levels defined in the job catalog.',
807
+ inputSchema: {},
808
+ }, async () => {
809
+ try {
810
+ const levels = await listJobLevels();
811
+ return {
812
+ content: [
813
+ {
814
+ type: 'text',
815
+ text: `Found ${levels.length} job levels:\n\n${JSON.stringify(levels, null, 2)}`,
816
+ },
817
+ ],
818
+ };
819
+ }
820
+ catch (error) {
821
+ return {
822
+ content: [
823
+ {
824
+ type: 'text',
825
+ text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
826
+ },
827
+ ],
828
+ isError: true,
829
+ };
830
+ }
831
+ });
832
+ // ============================================================================
833
+ // MCP Resources
834
+ // ============================================================================
835
+ /**
836
+ * Build an org chart showing manager-report relationships
837
+ */
838
+ async function buildOrgChart() {
839
+ const result = await listEmployees();
840
+ const employees = result.data;
841
+ // Build a map of manager_id -> reports
842
+ const managerMap = new Map();
843
+ for (const emp of employees) {
844
+ const managerId = emp.manager_id;
845
+ if (!managerMap.has(managerId)) {
846
+ managerMap.set(managerId, []);
847
+ }
848
+ managerMap.get(managerId).push(emp);
849
+ }
850
+ // Find top-level employees (no manager)
851
+ const topLevel = managerMap.get(null) || [];
852
+ // Recursive function to build tree
853
+ function buildTree(managerId, depth = 0) {
854
+ const reports = managerMap.get(managerId) || [];
855
+ const lines = [];
856
+ for (const emp of reports) {
857
+ const indent = ' '.repeat(depth);
858
+ lines.push(`${indent}- ${emp.full_name || 'Unknown'} (${emp.role || 'No role'}) [ID: ${emp.id}]`);
859
+ lines.push(...buildTree(emp.id, depth + 1));
860
+ }
861
+ return lines;
862
+ }
863
+ const lines = ['# Organization Chart\n'];
864
+ // Add top-level employees
865
+ for (const emp of topLevel) {
866
+ lines.push(`## ${emp.full_name || 'Unknown'} (${emp.role || 'No role'}) [ID: ${emp.id}]`);
867
+ lines.push(...buildTree(emp.id, 1));
868
+ lines.push('');
869
+ }
870
+ return lines.join('\n');
871
+ }
872
+ /**
873
+ * Build employee directory organized by team
874
+ */
875
+ async function buildEmployeeDirectory() {
876
+ const [empResult, teamsResult] = await Promise.all([listEmployees(), listTeams()]);
877
+ const employees = empResult.data;
878
+ const teams = teamsResult.data;
879
+ const lines = ['# Employee Directory\n'];
880
+ // Group employees by team
881
+ const teamEmployees = new Map();
882
+ for (const emp of employees) {
883
+ const teamIds = emp.team_ids || [];
884
+ if (teamIds.length === 0) {
885
+ const key = 'no-team';
886
+ if (!teamEmployees.has(key))
887
+ teamEmployees.set(key, []);
888
+ teamEmployees.get(key).push(emp);
889
+ }
890
+ else {
891
+ for (const teamId of teamIds) {
892
+ if (!teamEmployees.has(teamId))
893
+ teamEmployees.set(teamId, []);
894
+ teamEmployees.get(teamId).push(emp);
895
+ }
896
+ }
897
+ }
898
+ // Output by team
899
+ for (const team of teams) {
900
+ const emps = teamEmployees.get(team.id) || [];
901
+ lines.push(`## ${team.name}`);
902
+ if (team.description)
903
+ lines.push(`*${team.description}*\n`);
904
+ if (emps.length === 0) {
905
+ lines.push('No employees assigned.\n');
906
+ }
907
+ else {
908
+ for (const emp of emps) {
909
+ lines.push(`- **${emp.full_name || 'Unknown'}** - ${emp.role || 'No role'}`);
910
+ if (emp.email)
911
+ lines.push(` - Email: ${emp.email}`);
912
+ }
913
+ lines.push('');
914
+ }
915
+ }
916
+ // Employees without teams
917
+ const noTeam = teamEmployees.get('no-team') || [];
918
+ if (noTeam.length > 0) {
919
+ lines.push('## Unassigned\n');
920
+ for (const emp of noTeam) {
921
+ lines.push(`- **${emp.full_name || 'Unknown'}** - ${emp.role || 'No role'}`);
922
+ if (emp.email)
923
+ lines.push(` - Email: ${emp.email}`);
924
+ }
925
+ }
926
+ return lines.join('\n');
927
+ }
928
+ /**
929
+ * Build location directory
930
+ */
931
+ async function buildLocationDirectory() {
932
+ const [locResult, empResult] = await Promise.all([listLocations(), listEmployees()]);
933
+ const locations = locResult.data;
934
+ const employees = empResult.data;
935
+ // Group employees by location
936
+ const locationEmployees = new Map();
937
+ for (const emp of employees) {
938
+ if (emp.location_id) {
939
+ if (!locationEmployees.has(emp.location_id))
940
+ locationEmployees.set(emp.location_id, []);
941
+ locationEmployees.get(emp.location_id).push(emp);
942
+ }
943
+ }
944
+ const lines = ['# Location Directory\n'];
945
+ for (const loc of locations) {
946
+ const emps = locationEmployees.get(loc.id) || [];
947
+ lines.push(`## ${loc.name}`);
948
+ const address = [loc.city, loc.state, loc.country].filter(Boolean).join(', ');
949
+ if (address)
950
+ lines.push(`📍 ${address}\n`);
951
+ lines.push(`**Employees:** ${emps.length}`);
952
+ if (emps.length > 0 && emps.length <= 10) {
953
+ for (const emp of emps) {
954
+ lines.push(`- ${emp.full_name || 'Unknown'} (${emp.role || 'No role'})`);
955
+ }
956
+ }
957
+ else if (emps.length > 10) {
958
+ lines.push(`*... ${emps.length} employees (use list_employees with location_id=${loc.id} to see all)*`);
959
+ }
960
+ lines.push('');
961
+ }
962
+ return lines.join('\n');
963
+ }
964
+ // Register static resources
965
+ server.registerResource('org-chart', 'factorial://org-chart', {
966
+ description: 'Complete organizational hierarchy showing manager-report relationships. Useful for understanding team structure.',
967
+ mimeType: 'text/markdown',
968
+ }, async () => {
969
+ return {
970
+ contents: [
971
+ {
972
+ uri: 'factorial://org-chart',
973
+ mimeType: 'text/markdown',
974
+ text: await buildOrgChart(),
975
+ },
976
+ ],
977
+ };
978
+ });
979
+ server.registerResource('employees-directory', 'factorial://employees/directory', {
980
+ description: 'Employee directory organized by team. Shows all employees with their roles and contact info.',
981
+ mimeType: 'text/markdown',
982
+ }, async () => {
983
+ return {
984
+ contents: [
985
+ {
986
+ uri: 'factorial://employees/directory',
987
+ mimeType: 'text/markdown',
988
+ text: await buildEmployeeDirectory(),
989
+ },
990
+ ],
991
+ };
992
+ });
993
+ server.registerResource('locations-directory', 'factorial://locations/directory', {
994
+ description: 'Directory of all company locations with employee counts.',
995
+ mimeType: 'text/markdown',
996
+ }, async () => {
997
+ return {
998
+ contents: [
999
+ {
1000
+ uri: 'factorial://locations/directory',
1001
+ mimeType: 'text/markdown',
1002
+ text: await buildLocationDirectory(),
1003
+ },
1004
+ ],
1005
+ };
1006
+ });
1007
+ server.registerResource('timeoff-policies', 'factorial://timeoff/policies', {
1008
+ description: 'All leave types and time off policies configured in the organization.',
1009
+ mimeType: 'application/json',
1010
+ }, async () => {
1011
+ const types = await listLeaveTypes();
1012
+ return {
1013
+ contents: [
1014
+ {
1015
+ uri: 'factorial://timeoff/policies',
1016
+ mimeType: 'application/json',
1017
+ text: JSON.stringify(types, null, 2),
1018
+ },
1019
+ ],
1020
+ };
1021
+ });
1022
+ // Register resource template for teams
1023
+ const teamTemplate = new ResourceTemplate('factorial://teams/{team_id}', {
1024
+ list: async () => {
1025
+ const result = await listTeams();
1026
+ return {
1027
+ resources: result.data.map(t => ({
1028
+ uri: `factorial://teams/${t.id}`,
1029
+ name: t.name,
1030
+ description: t.description || undefined,
1031
+ mimeType: 'application/json',
1032
+ })),
1033
+ };
1034
+ },
1035
+ });
1036
+ server.registerResource('team-details', teamTemplate, {
1037
+ description: 'Get detailed information about a specific team including all members.',
1038
+ mimeType: 'application/json',
1039
+ }, async (uri, variables) => {
1040
+ const teamId = parseInt(variables.team_id, 10);
1041
+ if (isNaN(teamId)) {
1042
+ throw new Error('Invalid team ID');
1043
+ }
1044
+ const [team, empResult] = await Promise.all([
1045
+ getTeam(teamId),
1046
+ listEmployees({ team_id: teamId }),
1047
+ ]);
1048
+ const teamDetails = {
1049
+ ...team,
1050
+ members: empResult.data.map(e => ({
1051
+ id: e.id,
1052
+ name: e.full_name,
1053
+ role: e.role,
1054
+ email: e.email,
1055
+ })),
1056
+ };
1057
+ return {
1058
+ contents: [
1059
+ {
1060
+ uri: uri.toString(),
1061
+ mimeType: 'application/json',
1062
+ text: JSON.stringify(teamDetails, null, 2),
1063
+ },
1064
+ ],
1065
+ };
1066
+ });
1067
+ // ============================================================================
1068
+ // MCP Prompts
1069
+ // ============================================================================
1070
+ server.registerPrompt('onboard-employee', {
1071
+ description: 'Generate a personalized onboarding checklist for a new employee based on their team and role.',
1072
+ argsSchema: {
1073
+ employee_id: z.string().describe('The ID of the employee to onboard'),
1074
+ },
1075
+ }, async ({ employee_id }) => {
1076
+ const empId = parseInt(employee_id, 10);
1077
+ const employee = await getEmployee(empId);
1078
+ const teamsResult = await listTeams();
1079
+ const teams = teamsResult.data;
1080
+ const employeeTeams = teams.filter(t => employee.team_ids?.includes(t.id));
1081
+ const teamNames = employeeTeams.map(t => t.name).join(', ') || 'No team assigned';
1082
+ return {
1083
+ messages: [
1084
+ {
1085
+ role: 'user',
1086
+ content: {
1087
+ type: 'text',
1088
+ text: `Please create a comprehensive onboarding checklist for the following new employee:
1089
+
1090
+ **Employee Details:**
1091
+ - Name: ${employee.full_name}
1092
+ - Role: ${employee.role || 'Not specified'}
1093
+ - Team(s): ${teamNames}
1094
+ - Start Date: ${employee.hired_on || employee.start_date || 'Not specified'}
1095
+ - Email: ${employee.email}
1096
+
1097
+ Please include:
1098
+ 1. First day essentials
1099
+ 2. First week goals
1100
+ 3. Team introductions
1101
+ 4. Tools and access setup
1102
+ 5. Key meetings to schedule
1103
+ 6. 30/60/90 day milestones
1104
+
1105
+ Tailor the checklist to their specific role and team.`,
1106
+ },
1107
+ },
1108
+ ],
1109
+ };
1110
+ });
1111
+ server.registerPrompt('analyze-org-structure', {
1112
+ description: 'Analyze the organizational structure for insights on reporting lines, team sizes, and distribution.',
1113
+ argsSchema: {
1114
+ focus_area: z
1115
+ .string()
1116
+ .optional()
1117
+ .describe('Area to focus on: reporting_lines, team_sizes, location_distribution'),
1118
+ },
1119
+ }, async ({ focus_area }) => {
1120
+ const [empResult, teamsResult, locResult] = await Promise.all([
1121
+ listEmployees(),
1122
+ listTeams(),
1123
+ listLocations(),
1124
+ ]);
1125
+ const employees = empResult.data;
1126
+ const teams = teamsResult.data;
1127
+ const locations = locResult.data;
1128
+ // Compute basic stats
1129
+ const totalEmployees = employees.length;
1130
+ const managersCount = new Set(employees.map(e => e.manager_id).filter(Boolean)).size;
1131
+ const avgTeamSize = teams.length > 0
1132
+ ? (teams.reduce((sum, t) => sum + (t.employee_ids?.length || 0), 0) / teams.length).toFixed(1)
1133
+ : 0;
1134
+ let focusPrompt = '';
1135
+ if (focus_area === 'reporting_lines') {
1136
+ focusPrompt =
1137
+ 'Focus particularly on reporting line depth, span of control, and potential bottlenecks.';
1138
+ }
1139
+ else if (focus_area === 'team_sizes') {
1140
+ focusPrompt =
1141
+ 'Focus particularly on team size distribution, under/over-staffed teams, and growth patterns.';
1142
+ }
1143
+ else if (focus_area === 'location_distribution') {
1144
+ focusPrompt =
1145
+ 'Focus particularly on geographic distribution, remote vs on-site, and location-based team composition.';
1146
+ }
1147
+ return {
1148
+ messages: [
1149
+ {
1150
+ role: 'user',
1151
+ content: {
1152
+ type: 'text',
1153
+ text: `Please analyze the following organizational structure:
1154
+
1155
+ **Summary Statistics:**
1156
+ - Total Employees: ${totalEmployees}
1157
+ - Total Teams: ${teams.length}
1158
+ - Total Locations: ${locations.length}
1159
+ - Unique Managers: ${managersCount}
1160
+ - Average Team Size: ${avgTeamSize}
1161
+
1162
+ **Teams:**
1163
+ ${teams.map(t => `- ${t.name}: ${t.employee_ids?.length || 0} members`).join('\n')}
1164
+
1165
+ **Locations:**
1166
+ ${locations.map(l => `- ${l.name} (${[l.city, l.country].filter(Boolean).join(', ')})`).join('\n')}
1167
+
1168
+ ${focusPrompt}
1169
+
1170
+ Please provide:
1171
+ 1. Key observations about the org structure
1172
+ 2. Potential areas of concern
1173
+ 3. Recommendations for improvement
1174
+ 4. Comparison to industry best practices`,
1175
+ },
1176
+ },
1177
+ ],
1178
+ };
1179
+ });
1180
+ server.registerPrompt('timeoff-report', {
1181
+ description: 'Generate a time off report for a team or date range.',
1182
+ argsSchema: {
1183
+ team_id: z.string().optional().describe('Team ID to filter by'),
1184
+ start_date: z.string().optional().describe('Start date (YYYY-MM-DD)'),
1185
+ end_date: z.string().optional().describe('End date (YYYY-MM-DD)'),
1186
+ include_pending: z.string().optional().describe('Include pending requests (true/false)'),
1187
+ },
1188
+ }, async ({ team_id, start_date, end_date, include_pending }) => {
1189
+ const teamId = team_id ? parseInt(team_id, 10) : undefined;
1190
+ const includePending = include_pending === 'true';
1191
+ // Get leaves
1192
+ const leavesResult = await listLeaves({
1193
+ start_on_gte: start_date,
1194
+ start_on_lte: end_date,
1195
+ });
1196
+ // Get leave types for names
1197
+ const leaveTypes = await listLeaveTypes();
1198
+ const leaveTypeMap = new Map(leaveTypes.map(lt => [lt.id, lt.name]));
1199
+ // Filter by team if needed
1200
+ let leaves = leavesResult.data;
1201
+ if (teamId) {
1202
+ const teamEmps = (await listEmployees({ team_id: teamId })).data;
1203
+ const teamEmpIds = new Set(teamEmps.map(e => e.id));
1204
+ leaves = leaves.filter(l => teamEmpIds.has(l.employee_id));
1205
+ }
1206
+ // Filter by status
1207
+ if (!includePending) {
1208
+ leaves = leaves.filter(l => l.status === 'approved');
1209
+ }
1210
+ // Get employee names
1211
+ const empResult = await listEmployees();
1212
+ const empMap = new Map(empResult.data.map(e => [e.id, e.full_name]));
1213
+ const leaveSummary = leaves.map(l => ({
1214
+ employee: empMap.get(l.employee_id) || `Employee ${l.employee_id}`,
1215
+ type: leaveTypeMap.get(l.leave_type_id) || `Type ${l.leave_type_id}`,
1216
+ dates: `${l.start_on} to ${l.finish_on}`,
1217
+ days: l.duration_attributes?.days || 'N/A',
1218
+ status: l.status,
1219
+ }));
1220
+ return {
1221
+ messages: [
1222
+ {
1223
+ role: 'user',
1224
+ content: {
1225
+ type: 'text',
1226
+ text: `Please generate a time off report based on the following data:
1227
+
1228
+ **Report Parameters:**
1229
+ - Date Range: ${start_date || 'All'} to ${end_date || 'All'}
1230
+ - Team: ${teamId ? `Team ${teamId}` : 'All teams'}
1231
+ - Including Pending: ${includePending ? 'Yes' : 'No'}
1232
+
1233
+ **Time Off Requests (${leaves.length} total):**
1234
+ ${JSON.stringify(leaveSummary, null, 2)}
1235
+
1236
+ Please provide:
1237
+ 1. Summary of time off by type
1238
+ 2. Peak absence periods
1239
+ 3. Coverage concerns (if any patterns suggest coverage gaps)
1240
+ 4. Recommendations for planning`,
1241
+ },
1242
+ },
1243
+ ],
1244
+ };
1245
+ });
1246
+ // ============================================================================
306
1247
  // Start Server
307
1248
  // ============================================================================
308
1249
  async function main() {
309
1250
  const transport = new StdioServerTransport();
310
1251
  await server.connect(transport);
311
1252
  }
312
- main().catch((error) => {
1253
+ main().catch(error => {
313
1254
  console.error('Fatal error:', error);
314
1255
  process.exit(1);
315
1256
  });