@bgx4k3p/huly-mcp-server 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/src/mcp.mjs ADDED
@@ -0,0 +1,1140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Huly MCP Server - stdio transport entry point for Claude Code.
4
+ *
5
+ * Uses the shared ConnectionPool for multi-workspace support.
6
+ * Each tool accepts an optional 'workspace' parameter.
7
+ * Exposes MCP Resources for projects and issues.
8
+ */
9
+
10
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
11
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
12
+ import {
13
+ CallToolRequestSchema,
14
+ ListToolsRequestSchema,
15
+ ListResourcesRequestSchema,
16
+ ReadResourceRequestSchema,
17
+ ListResourceTemplatesRequestSchema,
18
+ } from '@modelcontextprotocol/sdk/types.js';
19
+
20
+ import { pool } from './pool.mjs';
21
+ import { HulyClient } from './client.mjs';
22
+
23
+ const HULY_URL = process.env.HULY_URL || 'http://localhost:8087';
24
+ const HULY_TOKEN = process.env.HULY_TOKEN;
25
+ const HULY_EMAIL = process.env.HULY_EMAIL;
26
+ const HULY_PASSWORD = process.env.HULY_PASSWORD;
27
+ const HULY_CREDS = HULY_TOKEN ? { token: HULY_TOKEN } : { email: HULY_EMAIL, password: HULY_PASSWORD };
28
+
29
+ // Optional workspace property added to every tool
30
+ const workspaceProp = {
31
+ workspace: {
32
+ type: 'string',
33
+ description: 'Workspace slug (optional, uses HULY_WORKSPACE env var if omitted). Use list_workspaces to discover available workspace slugs.'
34
+ }
35
+ };
36
+
37
+ // Tool definitions with enriched descriptions for AI agents
38
+ const TOOLS = [
39
+ // ── Account & Workspace Management ──────────────────────
40
+
41
+ {
42
+ name: 'list_workspaces',
43
+ description: 'List all workspaces accessible to the authenticated user. Returns each workspace\'s slug, name, mode (active/archived), and creation date. Use this to discover available workspaces before specifying a workspace parameter on other tools.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {},
47
+ required: []
48
+ }
49
+ },
50
+ {
51
+ name: 'get_workspace_info',
52
+ description: 'Get detailed info about a specific workspace by slug. Returns name, mode, version, creation date, and usage info.',
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ workspace: { type: 'string', description: 'Workspace slug' }
57
+ },
58
+ required: ['workspace']
59
+ }
60
+ },
61
+ {
62
+ name: 'create_workspace',
63
+ description: 'Create a new workspace. Returns the new workspace slug and ID. WARNING: This is a significant operation — confirm with the user before proceeding.',
64
+ inputSchema: {
65
+ type: 'object',
66
+ properties: {
67
+ name: { type: 'string', description: 'Workspace display name' }
68
+ },
69
+ required: ['name']
70
+ }
71
+ },
72
+ {
73
+ name: 'update_workspace_name',
74
+ description: 'Rename an existing workspace. Only changes the display name, not the slug.',
75
+ inputSchema: {
76
+ type: 'object',
77
+ properties: {
78
+ workspace: { type: 'string', description: 'Workspace slug' },
79
+ name: { type: 'string', description: 'New display name' }
80
+ },
81
+ required: ['workspace', 'name']
82
+ }
83
+ },
84
+ {
85
+ name: 'delete_workspace',
86
+ description: 'Permanently delete a workspace and all its data. WARNING: This is irreversible — confirm with the user before proceeding.',
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: {
90
+ workspace: { type: 'string', description: 'Workspace slug to delete' }
91
+ },
92
+ required: ['workspace']
93
+ }
94
+ },
95
+ {
96
+ name: 'get_workspace_members',
97
+ description: 'List all members of a workspace with their roles. Returns member ID, name, email, and role (OWNER/MAINTAINER/MEMBER/GUEST).',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ workspace: { type: 'string', description: 'Workspace slug' }
102
+ },
103
+ required: ['workspace']
104
+ }
105
+ },
106
+ {
107
+ name: 'update_workspace_role',
108
+ description: 'Change a member\'s role in a workspace. Roles: OWNER, MAINTAINER, MEMBER, GUEST.',
109
+ inputSchema: {
110
+ type: 'object',
111
+ properties: {
112
+ workspace: { type: 'string', description: 'Workspace slug' },
113
+ email: { type: 'string', description: 'Member email address' },
114
+ role: { type: 'string', description: 'New role: OWNER, MAINTAINER, MEMBER, GUEST' }
115
+ },
116
+ required: ['workspace', 'email', 'role']
117
+ }
118
+ },
119
+ {
120
+ name: 'get_account_info',
121
+ description: 'Get the current authenticated user\'s account info including ID, name, and social IDs.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {},
125
+ required: []
126
+ }
127
+ },
128
+ {
129
+ name: 'get_user_profile',
130
+ description: 'Get the current user\'s profile including name, avatar, city, and country.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {},
134
+ required: []
135
+ }
136
+ },
137
+ {
138
+ name: 'set_my_profile',
139
+ description: 'Update the current user\'s profile. Only specify the fields you want to change.',
140
+ inputSchema: {
141
+ type: 'object',
142
+ properties: {
143
+ name: { type: 'string', description: 'New display name' },
144
+ city: { type: 'string', description: 'City' },
145
+ country: { type: 'string', description: 'Country' }
146
+ },
147
+ required: []
148
+ }
149
+ },
150
+ {
151
+ name: 'change_password',
152
+ description: 'Change the current user\'s password. Requires the current password (from HULY_PASSWORD env var) and a new password.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ newPassword: { type: 'string', description: 'New password' }
157
+ },
158
+ required: ['newPassword']
159
+ }
160
+ },
161
+ {
162
+ name: 'change_username',
163
+ description: 'Change the current user\'s username/display name at the account level.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ newUsername: { type: 'string', description: 'New username' }
168
+ },
169
+ required: ['newUsername']
170
+ }
171
+ },
172
+
173
+ // ── Invites ──────────────────────────────────────────────
174
+
175
+ {
176
+ name: 'send_invite',
177
+ description: 'Send an email invite to join a workspace. Specify the workspace slug, the invitee\'s email, and an optional role (default: MEMBER).',
178
+ inputSchema: {
179
+ type: 'object',
180
+ properties: {
181
+ workspace: { type: 'string', description: 'Workspace slug' },
182
+ email: { type: 'string', description: 'Email address to invite' },
183
+ role: { type: 'string', description: 'Role: OWNER, MAINTAINER, MEMBER, GUEST (default: MEMBER)' }
184
+ },
185
+ required: ['workspace', 'email']
186
+ }
187
+ },
188
+ {
189
+ name: 'resend_invite',
190
+ description: 'Resend a pending workspace invitation.',
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ workspace: { type: 'string', description: 'Workspace slug' },
195
+ email: { type: 'string', description: 'Email of the pending invitee' }
196
+ },
197
+ required: ['workspace', 'email']
198
+ }
199
+ },
200
+ {
201
+ name: 'create_invite_link',
202
+ description: 'Create a shareable invite link for a workspace. Returns the link URL. Default expiry: 48 hours.',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {
206
+ workspace: { type: 'string', description: 'Workspace slug' },
207
+ role: { type: 'string', description: 'Role for invitees: OWNER, MAINTAINER, MEMBER, GUEST (default: MEMBER)' },
208
+ expireHours: { type: 'number', description: 'Link expiry in hours (default: 48)' }
209
+ },
210
+ required: ['workspace']
211
+ }
212
+ },
213
+
214
+ // ── Integrations ─────────────────────────────────────────
215
+
216
+ {
217
+ name: 'list_integrations',
218
+ description: 'List all integrations configured for the account.',
219
+ inputSchema: {
220
+ type: 'object',
221
+ properties: {},
222
+ required: []
223
+ }
224
+ },
225
+ {
226
+ name: 'get_integration',
227
+ description: 'Get details of a specific integration by ID.',
228
+ inputSchema: {
229
+ type: 'object',
230
+ properties: {
231
+ integrationId: { type: 'string', description: 'Integration ID' }
232
+ },
233
+ required: ['integrationId']
234
+ }
235
+ },
236
+ {
237
+ name: 'create_integration',
238
+ description: 'Create a new integration. Pass integration-specific data as the "data" object.',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ data: { type: 'object', description: 'Integration configuration data' }
243
+ },
244
+ required: ['data']
245
+ }
246
+ },
247
+ {
248
+ name: 'update_integration',
249
+ description: 'Update an existing integration by ID.',
250
+ inputSchema: {
251
+ type: 'object',
252
+ properties: {
253
+ integrationId: { type: 'string', description: 'Integration ID' },
254
+ data: { type: 'object', description: 'Updated integration configuration data' }
255
+ },
256
+ required: ['integrationId', 'data']
257
+ }
258
+ },
259
+ {
260
+ name: 'delete_integration',
261
+ description: 'Delete an integration by ID. This is irreversible.',
262
+ inputSchema: {
263
+ type: 'object',
264
+ properties: {
265
+ integrationId: { type: 'string', description: 'Integration ID' }
266
+ },
267
+ required: ['integrationId']
268
+ }
269
+ },
270
+
271
+ // ── Mailboxes ────────────────────────────────────────────
272
+
273
+ {
274
+ name: 'list_mailboxes',
275
+ description: 'List all mailboxes configured for the account.',
276
+ inputSchema: {
277
+ type: 'object',
278
+ properties: {},
279
+ required: []
280
+ }
281
+ },
282
+ {
283
+ name: 'create_mailbox',
284
+ description: 'Create a new mailbox. Pass mailbox configuration as the "data" object.',
285
+ inputSchema: {
286
+ type: 'object',
287
+ properties: {
288
+ data: { type: 'object', description: 'Mailbox configuration data' }
289
+ },
290
+ required: ['data']
291
+ }
292
+ },
293
+ {
294
+ name: 'delete_mailbox',
295
+ description: 'Delete a mailbox by ID.',
296
+ inputSchema: {
297
+ type: 'object',
298
+ properties: {
299
+ mailboxId: { type: 'string', description: 'Mailbox ID' }
300
+ },
301
+ required: ['mailboxId']
302
+ }
303
+ },
304
+
305
+ // ── Person / Social ID ──────────────────────────────────
306
+
307
+ {
308
+ name: 'find_person_by_social_key',
309
+ description: 'Find a person record by their social key (e.g., email, GitHub handle).',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ socialKey: { type: 'string', description: 'Social key to search for' }
314
+ },
315
+ required: ['socialKey']
316
+ }
317
+ },
318
+ {
319
+ name: 'get_social_ids',
320
+ description: 'Get all social IDs (email, GitHub, etc.) linked to the current user\'s account.',
321
+ inputSchema: {
322
+ type: 'object',
323
+ properties: {},
324
+ required: []
325
+ }
326
+ },
327
+ {
328
+ name: 'add_email_social_id',
329
+ description: 'Link an additional email address to a person\'s account.',
330
+ inputSchema: {
331
+ type: 'object',
332
+ properties: {
333
+ targetEmail: { type: 'string', description: 'Email address to link' }
334
+ },
335
+ required: ['targetEmail']
336
+ }
337
+ },
338
+
339
+ // ── Subscriptions ────────────────────────────────────────
340
+
341
+ {
342
+ name: 'list_subscriptions',
343
+ description: 'List all subscriptions for the current account.',
344
+ inputSchema: {
345
+ type: 'object',
346
+ properties: {},
347
+ required: []
348
+ }
349
+ },
350
+
351
+ // ── Workspace-Level Tools ──────────────────────────────
352
+
353
+ {
354
+ name: 'list_projects',
355
+ description: 'List all projects in the Huly workspace. Returns each project\'s identifier (e.g., "PROJ"), display name, and total issue count. Use this first to discover available projects before querying issues.',
356
+ inputSchema: {
357
+ type: 'object',
358
+ properties: { ...workspaceProp },
359
+ required: []
360
+ }
361
+ },
362
+ {
363
+ name: 'get_project',
364
+ description: 'Get details for a single project by its identifier (e.g., "PROJ"). Returns identifier, name, description, and issue count. Use list_projects first if you don\'t know the identifier.',
365
+ inputSchema: {
366
+ type: 'object',
367
+ properties: {
368
+ identifier: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
369
+ ...workspaceProp
370
+ },
371
+ required: ['identifier']
372
+ }
373
+ },
374
+ {
375
+ name: 'list_issues',
376
+ description: 'List issues in a project with optional filtering. Returns id (e.g., "PROJ-42"), title, status, priority, labels, and milestone for each issue. Supports filtering by status (Backlog/Todo/In Progress/Done/Canceled), priority (urgent/high/medium/low/none), label name, and milestone name. Default limit is 50, sorted by most recently modified. Use search_issues instead for full-text search across projects.',
377
+ inputSchema: {
378
+ type: 'object',
379
+ properties: {
380
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
381
+ status: { type: 'string', description: 'Filter by status: Backlog, Todo, In Progress, Done, Canceled' },
382
+ priority: { type: 'string', description: 'Filter by priority: urgent, high, medium, low, none' },
383
+ label: { type: 'string', description: 'Filter by label name (exact match)' },
384
+ milestone: { type: 'string', description: 'Filter by milestone name (exact match)' },
385
+ limit: { type: 'number', description: 'Maximum number of issues to return (default: 50)' },
386
+ ...workspaceProp
387
+ },
388
+ required: ['project']
389
+ }
390
+ },
391
+ {
392
+ name: 'get_issue',
393
+ description: 'Get full details for a specific issue by its identifier (e.g., "PROJ-42"). Returns title, description (markdown), status, priority, labels, parent issue, child count, milestone, and timestamps. Use this when you need the full description or detailed metadata for a single issue.',
394
+ inputSchema: {
395
+ type: 'object',
396
+ properties: {
397
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
398
+ ...workspaceProp
399
+ },
400
+ required: ['issueId']
401
+ }
402
+ },
403
+ {
404
+ name: 'create_issue',
405
+ description: 'Create a new issue in a project. Returns the new issue ID (e.g., "PROJ-43"). Supports markdown in the description field. Priority defaults to "none", status defaults to "Todo". Use list_task_types to discover available types (Issue, Epic, Bug, etc.) before specifying a type. For creating multiple issues at once, use batch_create_issues instead.',
406
+ inputSchema: {
407
+ type: 'object',
408
+ properties: {
409
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
410
+ title: { type: 'string', description: 'Issue title' },
411
+ description: { type: 'string', description: 'Issue description (Markdown supported)' },
412
+ priority: { type: 'string', description: 'Priority: urgent, high, medium, low, none (default: none)' },
413
+ status: { type: 'string', description: 'Initial status (default: Todo). Use list_statuses to see options.' },
414
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label names to apply. Labels are auto-created if they don\'t exist.' },
415
+ type: { type: 'string', description: 'Task type name (e.g., "Issue", "Epic", "Bug"). Use list_task_types to see available types.' },
416
+ ...workspaceProp
417
+ },
418
+ required: ['project', 'title']
419
+ }
420
+ },
421
+ {
422
+ name: 'update_issue',
423
+ description: 'Update one or more fields on an existing issue. Only specify the fields you want to change — omitted fields are left unchanged. Returns a list of which fields were updated. Use list_statuses to discover valid status names.',
424
+ inputSchema: {
425
+ type: 'object',
426
+ properties: {
427
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
428
+ title: { type: 'string', description: 'New title' },
429
+ description: { type: 'string', description: 'New description (Markdown supported)' },
430
+ priority: { type: 'string', description: 'New priority: urgent, high, medium, low, none' },
431
+ status: { type: 'string', description: 'New status: Backlog, Todo, In Progress, Done, Canceled' },
432
+ type: { type: 'string', description: 'New task type name (e.g., "Issue", "Epic", "Bug")' },
433
+ ...workspaceProp
434
+ },
435
+ required: ['issueId']
436
+ }
437
+ },
438
+ {
439
+ name: 'add_label',
440
+ description: 'Add a label to an issue. The label is auto-created if it doesn\'t exist yet. Returns a confirmation message. No-op if the label is already attached.',
441
+ inputSchema: {
442
+ type: 'object',
443
+ properties: {
444
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
445
+ label: { type: 'string', description: 'Label name to add' },
446
+ ...workspaceProp
447
+ },
448
+ required: ['issueId', 'label']
449
+ }
450
+ },
451
+ {
452
+ name: 'remove_label',
453
+ description: 'Remove a label from an issue. Returns a confirmation or a message if the label was not found on the issue.',
454
+ inputSchema: {
455
+ type: 'object',
456
+ properties: {
457
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
458
+ label: { type: 'string', description: 'Label name to remove' },
459
+ ...workspaceProp
460
+ },
461
+ required: ['issueId', 'label']
462
+ }
463
+ },
464
+ {
465
+ name: 'list_labels',
466
+ description: 'List all available labels in the workspace. Returns each label\'s name and hex color. Use this to discover existing labels before adding them to issues.',
467
+ inputSchema: {
468
+ type: 'object',
469
+ properties: { ...workspaceProp },
470
+ required: []
471
+ }
472
+ },
473
+ {
474
+ name: 'create_label',
475
+ description: 'Create a new label for tagging issues. Returns the label ID. No-op if a label with that name already exists. Color is optional (default: teal #4ECDC4).',
476
+ inputSchema: {
477
+ type: 'object',
478
+ properties: {
479
+ name: { type: 'string', description: 'Label name' },
480
+ color: { type: 'number', description: 'Label color as hex number (e.g., 0xFF6B6B). Default: 0x4ECDC4 (teal)' },
481
+ ...workspaceProp
482
+ },
483
+ required: ['name']
484
+ }
485
+ },
486
+ {
487
+ name: 'add_relation',
488
+ description: 'Add a bidirectional "related to" relationship between two issues. Use this for issues that are related but not blocking each other. For dependencies, use add_blocked_by instead.',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
493
+ relatedToIssueId: { type: 'string', description: 'The issue to relate to (e.g., "PROJ-99")' },
494
+ ...workspaceProp
495
+ },
496
+ required: ['issueId', 'relatedToIssueId']
497
+ }
498
+ },
499
+ {
500
+ name: 'add_blocked_by',
501
+ description: 'Add a "blocked by" dependency between two issues. The first issue (issueId) is marked as blocked by the second (blockedByIssueId). Use this for hard dependencies where one issue cannot proceed until another is done. For soft relationships, use add_relation instead.',
502
+ inputSchema: {
503
+ type: 'object',
504
+ properties: {
505
+ issueId: { type: 'string', description: 'Issue that is blocked (e.g., "PROJ-42")' },
506
+ blockedByIssueId: { type: 'string', description: 'The blocking issue (e.g., "PROJ-99")' },
507
+ ...workspaceProp
508
+ },
509
+ required: ['issueId', 'blockedByIssueId']
510
+ }
511
+ },
512
+ {
513
+ name: 'set_parent',
514
+ description: 'Set the parent issue for a child issue, creating a hierarchy (e.g., link a task to an epic). The child appears as a sub-issue under the parent. Use this to build work breakdown structures.',
515
+ inputSchema: {
516
+ type: 'object',
517
+ properties: {
518
+ issueId: { type: 'string', description: 'Child issue identifier (e.g., "PROJ-42")' },
519
+ parentIssueId: { type: 'string', description: 'Parent issue identifier (e.g., "PROJ-1" for an epic)' },
520
+ ...workspaceProp
521
+ },
522
+ required: ['issueId', 'parentIssueId']
523
+ }
524
+ },
525
+ {
526
+ name: 'list_task_types',
527
+ description: 'List all available task types for a project (e.g., Issue, Epic, Bug). Returns type ID, name, and description. Use this before create_issue or update_issue when you need to specify a task type.',
528
+ inputSchema: {
529
+ type: 'object',
530
+ properties: {
531
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
532
+ ...workspaceProp
533
+ },
534
+ required: ['project']
535
+ }
536
+ },
537
+ {
538
+ name: 'list_statuses',
539
+ description: 'List all available issue statuses in the workspace. Returns status ID, name, category, and color. Common statuses: Backlog, Todo, In Progress, Done, Canceled. Use this to discover exact status names before filtering or updating issues.',
540
+ inputSchema: {
541
+ type: 'object',
542
+ properties: { ...workspaceProp },
543
+ required: []
544
+ }
545
+ },
546
+ {
547
+ name: 'list_milestones',
548
+ description: 'List all milestones in a project, sorted by target date. Returns name, description, status (Planned/In Progress/Completed/Canceled), and target date. Supports optional status filtering.',
549
+ inputSchema: {
550
+ type: 'object',
551
+ properties: {
552
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
553
+ status: { type: 'string', description: 'Filter by status: Planned, In Progress, Completed, Canceled' },
554
+ ...workspaceProp
555
+ },
556
+ required: ['project']
557
+ }
558
+ },
559
+ {
560
+ name: 'get_milestone',
561
+ description: 'Get details for a specific milestone by name, including the count of issues assigned to it. Use list_milestones first if you don\'t know the exact name.',
562
+ inputSchema: {
563
+ type: 'object',
564
+ properties: {
565
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
566
+ name: { type: 'string', description: 'Milestone name/label (exact match, case-insensitive)' },
567
+ ...workspaceProp
568
+ },
569
+ required: ['project', 'name']
570
+ }
571
+ },
572
+ {
573
+ name: 'create_milestone',
574
+ description: 'Create a new milestone in a project. Returns the milestone ID. No-op if a milestone with that name already exists. Target date defaults to 30 days from now if not specified.',
575
+ inputSchema: {
576
+ type: 'object',
577
+ properties: {
578
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
579
+ name: { type: 'string', description: 'Milestone name/label' },
580
+ description: { type: 'string', description: 'Milestone description' },
581
+ targetDate: { type: 'string', description: 'Target date (ISO 8601 format, e.g., "2025-03-01"). Default: 30 days from now.' },
582
+ status: { type: 'string', description: 'Initial status: Planned, In Progress, Completed, Canceled (default: Planned)' },
583
+ ...workspaceProp
584
+ },
585
+ required: ['project', 'name']
586
+ }
587
+ },
588
+ {
589
+ name: 'set_milestone',
590
+ description: 'Set or clear the milestone on an issue. Pass a milestone name to assign, or omit/empty to clear. Use list_milestones to discover available milestone names.',
591
+ inputSchema: {
592
+ type: 'object',
593
+ properties: {
594
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
595
+ milestone: { type: 'string', description: 'Milestone name to set, or empty/null to clear' },
596
+ ...workspaceProp
597
+ },
598
+ required: ['issueId']
599
+ }
600
+ },
601
+ {
602
+ name: 'assign_issue',
603
+ description: 'Assign an issue to a workspace member by name or email. Pass an empty string to unassign. Uses fuzzy matching on member names — use list_members first if unsure of the exact name.',
604
+ inputSchema: {
605
+ type: 'object',
606
+ properties: {
607
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
608
+ assignee: { type: 'string', description: 'Member name or email. Empty string to unassign.' },
609
+ ...workspaceProp
610
+ },
611
+ required: ['issueId', 'assignee']
612
+ }
613
+ },
614
+ {
615
+ name: 'list_members',
616
+ description: 'List all active workspace members. Returns each member\'s ID, name, email, role, and position. Use this to discover member names before assigning issues.',
617
+ inputSchema: {
618
+ type: 'object',
619
+ properties: { ...workspaceProp },
620
+ required: []
621
+ }
622
+ },
623
+ {
624
+ name: 'add_comment',
625
+ description: 'Add a comment to an issue. The comment is plain text (not markdown). Returns the comment ID. Use list_comments to see existing comments on an issue.',
626
+ inputSchema: {
627
+ type: 'object',
628
+ properties: {
629
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
630
+ text: { type: 'string', description: 'Comment text' },
631
+ ...workspaceProp
632
+ },
633
+ required: ['issueId', 'text']
634
+ }
635
+ },
636
+ {
637
+ name: 'list_comments',
638
+ description: 'List all comments on an issue, sorted chronologically (oldest first). Returns comment ID, text, and timestamps.',
639
+ inputSchema: {
640
+ type: 'object',
641
+ properties: {
642
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
643
+ ...workspaceProp
644
+ },
645
+ required: ['issueId']
646
+ }
647
+ },
648
+ {
649
+ name: 'set_due_date',
650
+ description: 'Set or clear the due date on an issue. Pass an ISO 8601 date string to set, or omit/empty to clear. Returns confirmation with the date value.',
651
+ inputSchema: {
652
+ type: 'object',
653
+ properties: {
654
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
655
+ dueDate: { type: 'string', description: 'Due date (ISO 8601, e.g., "2026-04-01"). Empty to clear.' },
656
+ ...workspaceProp
657
+ },
658
+ required: ['issueId']
659
+ }
660
+ },
661
+ {
662
+ name: 'set_estimation',
663
+ description: 'Set the time estimation on an issue in hours. This represents the expected effort to complete the issue. Use log_time to record actual time spent.',
664
+ inputSchema: {
665
+ type: 'object',
666
+ properties: {
667
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
668
+ hours: { type: 'number', description: 'Estimated hours (e.g., 4.5)' },
669
+ ...workspaceProp
670
+ },
671
+ required: ['issueId', 'hours']
672
+ }
673
+ },
674
+ {
675
+ name: 'log_time',
676
+ description: 'Log actual time spent working on an issue. Adds to the issue\'s cumulative reported time. Use set_estimation to set expected effort. Returns the new total reported time.',
677
+ inputSchema: {
678
+ type: 'object',
679
+ properties: {
680
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
681
+ hours: { type: 'number', description: 'Hours spent (e.g., 2.5)' },
682
+ description: { type: 'string', description: 'Description of work done' },
683
+ ...workspaceProp
684
+ },
685
+ required: ['issueId', 'hours']
686
+ }
687
+ },
688
+ {
689
+ name: 'search_issues',
690
+ description: 'Full-text search across issue titles in all projects (or a specific project). Returns matching issues with id, title, status, and priority. Use this when you need to find issues by keyword. For structured filtering (by status, priority, label), use list_issues instead.',
691
+ inputSchema: {
692
+ type: 'object',
693
+ properties: {
694
+ query: { type: 'string', description: 'Search text (matches against issue titles)' },
695
+ project: { type: 'string', description: 'Optional project identifier to limit search scope' },
696
+ limit: { type: 'number', description: 'Max results (default: 20)' },
697
+ ...workspaceProp
698
+ },
699
+ required: ['query']
700
+ }
701
+ },
702
+
703
+ // ── New Tools (Tier 1–2) ────────────────────────────────────
704
+
705
+ {
706
+ name: 'get_my_issues',
707
+ description: 'Get all issues assigned to the currently authenticated user (identified by HULY_EMAIL). Returns id, title, status, priority, labels, due date, estimation, and last modified time. Great for "what\'s on my plate?" queries. Supports optional project and status filters.',
708
+ inputSchema: {
709
+ type: 'object',
710
+ properties: {
711
+ project: { type: 'string', description: 'Optional project identifier to filter by' },
712
+ status: { type: 'string', description: 'Optional status filter: Backlog, Todo, In Progress, Done, Canceled' },
713
+ limit: { type: 'number', description: 'Max results (default: 50)' },
714
+ ...workspaceProp
715
+ },
716
+ required: []
717
+ }
718
+ },
719
+ {
720
+ name: 'batch_create_issues',
721
+ description: 'Create multiple issues in a single operation. Much more efficient than calling create_issue in a loop. Pass an array of issue objects, each with at least a title. Returns a summary with all created issues and any errors. Use this for breaking down epics, importing tasks, or creating sprint backlogs.',
722
+ inputSchema: {
723
+ type: 'object',
724
+ properties: {
725
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
726
+ issues: {
727
+ type: 'array',
728
+ items: {
729
+ type: 'object',
730
+ properties: {
731
+ title: { type: 'string', description: 'Issue title (required)' },
732
+ description: { type: 'string', description: 'Markdown description' },
733
+ priority: { type: 'string', description: 'Priority: urgent, high, medium, low, none' },
734
+ status: { type: 'string', description: 'Initial status (default: Todo)' },
735
+ labels: { type: 'array', items: { type: 'string' }, description: 'Label names' },
736
+ type: { type: 'string', description: 'Task type (e.g., "Issue", "Bug")' }
737
+ },
738
+ required: ['title']
739
+ },
740
+ description: 'Array of issue objects to create'
741
+ },
742
+ ...workspaceProp
743
+ },
744
+ required: ['project', 'issues']
745
+ }
746
+ },
747
+ {
748
+ name: 'move_issue',
749
+ description: 'Move an issue from its current project to a different project. The issue gets a new identifier in the target project (e.g., "OLD-42" becomes "NEW-15"). Use this during triage to route issues to the correct team.',
750
+ inputSchema: {
751
+ type: 'object',
752
+ properties: {
753
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
754
+ targetProject: { type: 'string', description: 'Target project identifier (e.g., "NEWPROJ")' },
755
+ ...workspaceProp
756
+ },
757
+ required: ['issueId', 'targetProject']
758
+ }
759
+ },
760
+ {
761
+ name: 'summarize_project',
762
+ description: 'Get a comprehensive project summary with aggregated metrics. Returns: total issue count, breakdown by status and priority, list of overdue issues, unassigned issue count, milestone overview, and time tracking totals (estimated vs. reported hours). Use this for standup summaries, sprint reviews, or project health checks. Much more efficient than fetching all issues and computing stats manually.',
763
+ inputSchema: {
764
+ type: 'object',
765
+ properties: {
766
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
767
+ ...workspaceProp
768
+ },
769
+ required: ['project']
770
+ }
771
+ },
772
+ {
773
+ name: 'get_issue_history',
774
+ description: 'Get the activity timeline for an issue including comments, time logs, sub-issues, and labels. Returns events sorted chronologically. Use this to understand what has happened on an issue, for status updates, or to answer "what changed since yesterday?".',
775
+ inputSchema: {
776
+ type: 'object',
777
+ properties: {
778
+ issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' },
779
+ ...workspaceProp
780
+ },
781
+ required: ['issueId']
782
+ }
783
+ },
784
+ {
785
+ name: 'create_issues_from_template',
786
+ description: 'Create a structured set of issues from a predefined template. Available templates: "feature" (epic + design/implement/test/docs/review), "bug" (bug + reproduce/root-cause/fix/regression-test), "sprint" (planning/standup/review/retro ceremonies), "release" (epic + freeze/QA/changelog/staging/prod/verify). Templates auto-create parent-child hierarchies. Pass a title param to customize issue names.',
787
+ inputSchema: {
788
+ type: 'object',
789
+ properties: {
790
+ project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' },
791
+ template: { type: 'string', description: 'Template name: feature, bug, sprint, release' },
792
+ title: { type: 'string', description: 'Custom title/name for the template items (e.g., "User Authentication" for a feature template)' },
793
+ version: { type: 'string', description: 'Version string (used by release template, e.g., "v2.1.0")' },
794
+ ...workspaceProp
795
+ },
796
+ required: ['project', 'template']
797
+ }
798
+ }
799
+ ];
800
+
801
+ /**
802
+ * Route a tool call to the appropriate HulyClient method.
803
+ * @param {string} name - Tool name
804
+ * @param {Object} args - Tool arguments
805
+ * @returns {Promise<Object>}
806
+ */
807
+ async function handleToolCall(name, args) {
808
+ // Account-level tools (no workspace needed)
809
+ switch (name) {
810
+ case 'list_workspaces':
811
+ return await HulyClient.listWorkspaces(HULY_URL, HULY_CREDS);
812
+
813
+ case 'get_workspace_info':
814
+ return await HulyClient.getWorkspaceInfo(HULY_URL, HULY_CREDS, args.workspace);
815
+
816
+ case 'create_workspace':
817
+ return await HulyClient.createWorkspace(HULY_URL, HULY_CREDS, args.name);
818
+
819
+ case 'update_workspace_name':
820
+ return await HulyClient.updateWorkspaceName(HULY_URL, HULY_CREDS, args.workspace, args.name);
821
+
822
+ case 'delete_workspace':
823
+ return await HulyClient.deleteWorkspace(HULY_URL, HULY_CREDS, args.workspace);
824
+
825
+ case 'get_workspace_members':
826
+ return await HulyClient.getWorkspaceMembers(HULY_URL, HULY_CREDS, args.workspace);
827
+
828
+ case 'update_workspace_role':
829
+ return await HulyClient.updateWorkspaceRole(HULY_URL, HULY_CREDS, args.workspace, args.email, args.role);
830
+
831
+ case 'get_account_info':
832
+ return await HulyClient.getAccountInfo(HULY_URL, HULY_CREDS);
833
+
834
+ case 'get_user_profile':
835
+ return await HulyClient.getUserProfile(HULY_URL, HULY_CREDS);
836
+
837
+ case 'set_my_profile':
838
+ return await HulyClient.setMyProfile(HULY_URL, HULY_CREDS, args.name, args.city, args.country);
839
+
840
+ case 'change_password':
841
+ return await HulyClient.changePassword(HULY_URL, HULY_CREDS, args.newPassword);
842
+
843
+ case 'change_username':
844
+ return await HulyClient.changeUsername(HULY_URL, HULY_CREDS, args.newUsername);
845
+
846
+ // ── Invites ──────────────────────────────────────────────
847
+ case 'send_invite':
848
+ return await HulyClient.sendInvite(HULY_URL, HULY_CREDS, args.workspace, args.email, args.role);
849
+
850
+ case 'resend_invite':
851
+ return await HulyClient.resendInvite(HULY_URL, HULY_CREDS, args.workspace, args.email);
852
+
853
+ case 'create_invite_link':
854
+ return await HulyClient.createInviteLink(HULY_URL, HULY_CREDS, args.workspace, args.role, args.expireHours);
855
+
856
+ // ── Integrations ─────────────────────────────────────────
857
+ case 'list_integrations':
858
+ return await HulyClient.listIntegrations(HULY_URL, HULY_CREDS);
859
+
860
+ case 'get_integration':
861
+ return await HulyClient.getIntegration(HULY_URL, HULY_CREDS, args.integrationId);
862
+
863
+ case 'create_integration':
864
+ return await HulyClient.createIntegration(HULY_URL, HULY_CREDS, args.data);
865
+
866
+ case 'update_integration':
867
+ return await HulyClient.updateIntegration(HULY_URL, HULY_CREDS, args.integrationId, args.data);
868
+
869
+ case 'delete_integration':
870
+ return await HulyClient.deleteIntegration(HULY_URL, HULY_CREDS, args.integrationId);
871
+
872
+ // ── Mailboxes ────────────────────────────────────────────
873
+ case 'list_mailboxes':
874
+ return await HulyClient.getMailboxes(HULY_URL, HULY_CREDS);
875
+
876
+ case 'create_mailbox':
877
+ return await HulyClient.createMailbox(HULY_URL, HULY_CREDS, args.data);
878
+
879
+ case 'delete_mailbox':
880
+ return await HulyClient.deleteMailbox(HULY_URL, HULY_CREDS, args.mailboxId);
881
+
882
+ // ── Person / Social ID ──────────────────────────────────
883
+ case 'find_person_by_social_key':
884
+ return await HulyClient.findPersonBySocialKey(HULY_URL, HULY_CREDS, args.socialKey);
885
+
886
+ case 'get_social_ids':
887
+ return await HulyClient.getSocialIds(HULY_URL, HULY_CREDS);
888
+
889
+ case 'add_email_social_id':
890
+ return await HulyClient.addEmailSocialId(HULY_URL, HULY_CREDS, args.targetEmail);
891
+
892
+ // ── Subscriptions ────────────────────────────────────────
893
+ case 'list_subscriptions':
894
+ return await HulyClient.getSubscriptions(HULY_URL, HULY_CREDS);
895
+ }
896
+
897
+ // Workspace-level tools
898
+ const client = await pool.getClient(args.workspace);
899
+
900
+ const wsHandler = async () => {
901
+ switch (name) {
902
+ case 'list_projects':
903
+ return await client.listProjects();
904
+
905
+ case 'get_project':
906
+ return await client.getProject(args.identifier);
907
+
908
+ case 'list_issues':
909
+ return await client.listIssues(
910
+ args.project, args.status, args.priority,
911
+ args.label, args.milestone, args.limit
912
+ );
913
+
914
+ case 'get_issue':
915
+ return await client.getIssue(args.issueId);
916
+
917
+ case 'create_issue':
918
+ return await client.createIssue(
919
+ args.project, args.title, args.description,
920
+ args.priority, args.status, args.labels, args.type
921
+ );
922
+
923
+ case 'update_issue':
924
+ return await client.updateIssue(
925
+ args.issueId, args.title, args.description,
926
+ args.priority, args.status, args.type
927
+ );
928
+
929
+ case 'add_label':
930
+ return await client.addLabel(args.issueId, args.label);
931
+
932
+ case 'remove_label':
933
+ return await client.removeLabel(args.issueId, args.label);
934
+
935
+ case 'list_labels':
936
+ return await client.listLabels();
937
+
938
+ case 'create_label':
939
+ return await client.createLabel(args.name, args.color);
940
+
941
+ case 'add_relation':
942
+ return await client.addRelation(args.issueId, args.relatedToIssueId);
943
+
944
+ case 'add_blocked_by':
945
+ return await client.addBlockedBy(args.issueId, args.blockedByIssueId);
946
+
947
+ case 'set_parent':
948
+ return await client.setParent(args.issueId, args.parentIssueId);
949
+
950
+ case 'list_task_types':
951
+ return await client.listTaskTypes(args.project);
952
+
953
+ case 'list_statuses':
954
+ return await client.listStatuses();
955
+
956
+ case 'list_milestones':
957
+ return await client.listMilestones(args.project, args.status);
958
+
959
+ case 'get_milestone':
960
+ return await client.getMilestone(args.project, args.name);
961
+
962
+ case 'create_milestone':
963
+ return await client.createMilestone(
964
+ args.project, args.name, args.description,
965
+ args.targetDate, args.status
966
+ );
967
+
968
+ case 'set_milestone':
969
+ return await client.setMilestone(args.issueId, args.milestone);
970
+
971
+ case 'assign_issue':
972
+ return await client.assignIssue(args.issueId, args.assignee);
973
+
974
+ case 'list_members':
975
+ return await client.listMembers();
976
+
977
+ case 'add_comment':
978
+ return await client.addComment(args.issueId, args.text);
979
+
980
+ case 'list_comments':
981
+ return await client.listComments(args.issueId);
982
+
983
+ case 'set_due_date':
984
+ return await client.setDueDate(args.issueId, args.dueDate);
985
+
986
+ case 'set_estimation':
987
+ return await client.setEstimation(args.issueId, args.hours);
988
+
989
+ case 'log_time':
990
+ return await client.logTime(args.issueId, args.hours, args.description);
991
+
992
+ case 'search_issues':
993
+ return await client.searchIssues(args.query, args.project, args.limit);
994
+
995
+ // ── New tools ──────────────────────────────────────────
996
+
997
+ case 'get_my_issues':
998
+ return await client.getMyIssues(args.project, args.status, args.limit);
999
+
1000
+ case 'batch_create_issues':
1001
+ return await client.batchCreateIssues(args.project, args.issues);
1002
+
1003
+ case 'move_issue':
1004
+ return await client.moveIssue(args.issueId, args.targetProject);
1005
+
1006
+ case 'summarize_project':
1007
+ return await client.summarizeProject(args.project);
1008
+
1009
+ case 'get_issue_history':
1010
+ return await client.getIssueHistory(args.issueId);
1011
+
1012
+ case 'create_issues_from_template':
1013
+ return await client.createIssuesFromTemplate(
1014
+ args.project, args.template, { title: args.title, version: args.version }
1015
+ );
1016
+
1017
+ default:
1018
+ throw new Error(`Unknown tool: ${name}`);
1019
+ }
1020
+ };
1021
+
1022
+ return await client.withReconnect(wsHandler);
1023
+ }
1024
+
1025
+ // Create and run the MCP server
1026
+ const server = new Server(
1027
+ { name: 'huly-mcp-server', version: '2.0.0' },
1028
+ { capabilities: { tools: {}, resources: {} } }
1029
+ );
1030
+
1031
+ // ── Tools ──────────────────────────────────────────────────────
1032
+
1033
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1034
+ return { tools: TOOLS };
1035
+ });
1036
+
1037
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1038
+ const { name, arguments: args } = request.params;
1039
+
1040
+ try {
1041
+ const result = await handleToolCall(name, args || {});
1042
+ return {
1043
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
1044
+ };
1045
+ } catch (error) {
1046
+ return {
1047
+ content: [{ type: 'text', text: JSON.stringify({ error: error.message }) }],
1048
+ isError: true
1049
+ };
1050
+ }
1051
+ });
1052
+
1053
+ // ── Resources ──────────────────────────────────────────────────
1054
+
1055
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
1056
+ return {
1057
+ resourceTemplates: [
1058
+ {
1059
+ uriTemplate: 'huly://projects/{identifier}',
1060
+ name: 'Huly Project',
1061
+ description: 'A project in the Huly workspace',
1062
+ mimeType: 'application/json'
1063
+ },
1064
+ {
1065
+ uriTemplate: 'huly://issues/{issueId}',
1066
+ name: 'Huly Issue',
1067
+ description: 'An issue in the Huly workspace (e.g., huly://issues/PROJ-42)',
1068
+ mimeType: 'application/json'
1069
+ }
1070
+ ]
1071
+ };
1072
+ });
1073
+
1074
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
1075
+ try {
1076
+ const client = await pool.getClient();
1077
+ const projects = await client.withReconnect(() => client.listProjects());
1078
+ return {
1079
+ resources: projects.map(p => ({
1080
+ uri: `huly://projects/${p.identifier}`,
1081
+ name: `${p.identifier}: ${p.name}`,
1082
+ description: `Project with ${p.issueCount} issues`,
1083
+ mimeType: 'application/json'
1084
+ }))
1085
+ };
1086
+ } catch (error) {
1087
+ return { resources: [] };
1088
+ }
1089
+ });
1090
+
1091
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1092
+ const { uri } = request.params;
1093
+
1094
+ // Match huly://projects/{identifier}
1095
+ const projectMatch = uri.match(/^huly:\/\/projects\/([A-Z0-9]+)$/i);
1096
+ if (projectMatch) {
1097
+ const client = await pool.getClient();
1098
+ const result = await client.withReconnect(() =>
1099
+ client.getProject(projectMatch[1])
1100
+ );
1101
+ return {
1102
+ contents: [{
1103
+ uri,
1104
+ mimeType: 'application/json',
1105
+ text: JSON.stringify(result, null, 2)
1106
+ }]
1107
+ };
1108
+ }
1109
+
1110
+ // Match huly://issues/{issueId}
1111
+ const issueMatch = uri.match(/^huly:\/\/issues\/([A-Z0-9]+-\d+)$/i);
1112
+ if (issueMatch) {
1113
+ const client = await pool.getClient();
1114
+ const result = await client.withReconnect(() =>
1115
+ client.getIssue(issueMatch[1])
1116
+ );
1117
+ return {
1118
+ contents: [{
1119
+ uri,
1120
+ mimeType: 'application/json',
1121
+ text: JSON.stringify(result, null, 2)
1122
+ }]
1123
+ };
1124
+ }
1125
+
1126
+ throw new Error(`Unknown resource URI: ${uri}`);
1127
+ });
1128
+
1129
+ // ── Start ──────────────────────────────────────────────────────
1130
+
1131
+ async function main() {
1132
+ const transport = new StdioServerTransport();
1133
+ await server.connect(transport);
1134
+ console.error('Huly MCP Server v2.0.0 running on stdio (32 tools, resources enabled)');
1135
+ }
1136
+
1137
+ main().catch((error) => {
1138
+ console.error('Fatal error:', error);
1139
+ process.exit(1);
1140
+ });