@bgx4k3p/huly-mcp-server 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # Huly MCP Server
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@bgx4k3p/huly-mcp-server?logo=npm&logoColor=white)](https://www.npmjs.com/package/@bgx4k3p/huly-mcp-server)
3
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
5
  [![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white)](https://nodejs.org)
5
6
  [![MCP](https://img.shields.io/badge/MCP-compatible-blue)](https://modelcontextprotocol.io)
@@ -0,0 +1 @@
1
+ Platform specific binary for msgpackr-extract on linux OS with x64 architecture
@@ -1,8 +1,8 @@
1
1
  {
2
- "name": "@msgpackr-extract/msgpackr-extract-darwin-x64",
2
+ "name": "@msgpackr-extract/msgpackr-extract-linux-x64",
3
3
  "version": "3.0.3",
4
4
  "os": [
5
- "darwin"
5
+ "linux"
6
6
  ],
7
7
  "cpu": [
8
8
  "x64"
@@ -13,5 +13,5 @@
13
13
  "type": "git",
14
14
  "url": "http://github.com/kriszyp/msgpackr-extract"
15
15
  },
16
- "description": "Platform specific binary for msgpackr-extract on darwin OS with x64 architecture"
16
+ "description": "Platform specific binary for msgpackr-extract on linux OS with x64 architecture"
17
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bgx4k3p/huly-mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for Huly issue tracking with stdio and Streamable HTTP transports",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,8 +38,10 @@
38
38
  "test": "HULY_TRANSPORT=ws node --test test/integration.test.mjs && HULY_TRANSPORT=rest node --test test/integration.test.mjs",
39
39
  "test:ws": "HULY_TRANSPORT=ws node --test test/integration.test.mjs",
40
40
  "test:rest": "HULY_TRANSPORT=rest node --test test/integration.test.mjs",
41
+ "lint": "eslint src/ scripts/",
41
42
  "postinstall": "node scripts/patch-sdk.mjs",
42
- "pack": "node scripts/pack.mjs"
43
+ "pack": "node scripts/pack.mjs",
44
+ "version:sync": "node -e \"const{readFileSync:r,writeFileSync:w}=require('fs');const v=r('VERSION','utf-8').trim();const f='package.json';const j=JSON.parse(r(f,'utf-8'));j.version=v;w(f,JSON.stringify(j,null,2)+'\\n');console.log('Synced version '+v+' to package.json')\""
43
45
  },
44
46
  "dependencies": {
45
47
  "@hcengineering/api-client": "^0.7.3",
@@ -58,5 +60,8 @@
58
60
  "@hcengineering/tags",
59
61
  "@hcengineering/task",
60
62
  "@hcengineering/tracker"
61
- ]
63
+ ],
64
+ "devDependencies": {
65
+ "eslint": "^10.1.0"
66
+ }
62
67
  }
package/src/client.mjs CHANGED
@@ -7,10 +7,12 @@
7
7
  import {
8
8
  PRIORITY_MAP, PRIORITY_NAMES,
9
9
  MILESTONE_STATUS_MAP, MILESTONE_STATUS_NAMES,
10
- COLOR_PALETTE, resolveColor,
10
+ resolveColor,
11
11
  DONE_CATEGORY, LOST_CATEGORY, STATUS_CATEGORY_NAMES,
12
12
  DEFAULT_LABEL_CATEGORY, DEFAULT_LABEL_COLOR,
13
13
  PAGE_SIZE, MAX_BATCH_SIZE, AUTH_CACHE_TTL_MS, DEFAULT_MILESTONE_DAYS,
14
+ DEFAULT_PAGE_SIZE, DEFAULT_DETAIL_PAGE_SIZE,
15
+ encodeCursor, decodeCursor,
14
16
  nameMatch, strictGet, withExtra,
15
17
  toCollaboratorMarkup, fromCollaboratorMarkup,
16
18
  toMarkup, fromMarkup
@@ -749,7 +751,11 @@ export class HulyClient {
749
751
  if (projectType?.tasks?.length) {
750
752
  const taskTypes = await client.findAll(task.class.TaskType, {});
751
753
  const scoped = taskTypes.filter(tt => projectType.tasks.includes(tt._id));
752
- if (scoped.length) return scoped[0]._id;
754
+ if (scoped.length) {
755
+ // Prefer the type named "Task" (the common default), not the first in list (often Epic)
756
+ const taskType = scoped.find(tt => nameMatch(tt.name || tt._id.split(':').pop(), 'Task'));
757
+ return (taskType || scoped[0])._id;
758
+ }
753
759
  }
754
760
 
755
761
  throw new Error(
@@ -785,18 +791,23 @@ export class HulyClient {
785
791
  * The SDK's findAll has a server-side page limit. If limit exceeds
786
792
  * the page size, this fetches multiple pages using createdOn cursor.
787
793
  */
794
+ /**
795
+ * Paginated findAll with cursor support.
796
+ * Fetches from the SDK in PAGE_SIZE batches, returns { items, nextCursor? }.
797
+ * @param {Object} client - SDK client
798
+ * @param {string} _class - Document class
799
+ * @param {Object} query - Query filter
800
+ * @param {Object} options - { limit, cursor, ...findAllOptions }
801
+ * @returns {{ items: Object[], nextCursor?: string }}
802
+ */
788
803
  async _paginatedFindAll(client, _class, query, options = {}) {
789
- const limit = options.limit || PAGE_SIZE;
804
+ const limit = options.limit || DEFAULT_PAGE_SIZE;
805
+ let lastCreatedOn = options.cursor
806
+ ? decodeCursor(options.cursor).createdOn
807
+ : undefined;
790
808
 
791
- // If within a single page, just fetch directly
792
- if (limit <= PAGE_SIZE) {
793
- return await client.findAll(_class, query, options);
794
- }
795
-
796
- // Fetch in pages using createdOn as cursor
797
809
  const allResults = [];
798
- let remaining = limit;
799
- let lastCreatedOn = undefined;
810
+ let remaining = limit + 1; // fetch one extra to detect next page
800
811
 
801
812
  while (remaining > 0) {
802
813
  const pageLimit = Math.min(remaining, PAGE_SIZE);
@@ -817,11 +828,42 @@ export class HulyClient {
817
828
  remaining -= page.length;
818
829
  lastCreatedOn = page[page.length - 1].createdOn;
819
830
 
820
- // If we got less than requested, no more pages
821
831
  if (page.length < pageLimit) break;
822
832
  }
823
833
 
824
- return allResults;
834
+ // If we got more than limit, there are more results
835
+ if (allResults.length > limit) {
836
+ const items = allResults.slice(0, limit);
837
+ return { items, nextCursor: encodeCursor(items[items.length - 1].createdOn) };
838
+ }
839
+
840
+ return { items: allResults };
841
+ }
842
+
843
+ /**
844
+ * In-memory cursor pagination for small collections fetched via findAll.
845
+ * Filters by cursor, sorts by createdOn desc, slices to limit.
846
+ * @param {Object[]} allResults - Full result set from findAll
847
+ * @param {Object} options - { cursor?, limit? }
848
+ * @returns {{ items: Object[], nextCursor?: string }}
849
+ */
850
+ _cursoredFindAll(allResults, options = {}) {
851
+ const { cursor, limit = DEFAULT_PAGE_SIZE } = options;
852
+ let items = [...allResults];
853
+
854
+ if (cursor) {
855
+ const { createdOn } = decodeCursor(cursor);
856
+ items = items.filter(r => r.createdOn < createdOn);
857
+ }
858
+
859
+ items.sort((a, b) => b.createdOn - a.createdOn);
860
+
861
+ if (items.length > limit) {
862
+ const page = items.slice(0, limit);
863
+ return { items: page, nextCursor: encodeCursor(page[page.length - 1].createdOn) };
864
+ }
865
+
866
+ return { items };
825
867
  }
826
868
 
827
869
  async _findEmployeeByName(client, name) {
@@ -921,8 +963,7 @@ export class HulyClient {
921
963
  const projects = await client.findAll(tracker.class.Project, {});
922
964
 
923
965
  if (!options.include_details) {
924
- // Count issues per project efficiently using the project's own sequence counter
925
- return projects.map(project => withExtra(project, {
966
+ const enriched = projects.map(project => withExtra(project, {
926
967
  id: project._id,
927
968
  identifier: project.identifier,
928
969
  name: project.name || project.identifier,
@@ -934,6 +975,7 @@ export class HulyClient {
934
975
  createdOn: project.createdOn,
935
976
  modifiedOn: project.modifiedOn
936
977
  }));
978
+ return this._cursoredFindAll(enriched, options);
937
979
  }
938
980
 
939
981
  // Detailed mode: batch fetch all related data once, then group by project
@@ -960,7 +1002,7 @@ export class HulyClient {
960
1002
  componentsByProject.get(c.space).push(c);
961
1003
  }
962
1004
 
963
- return limitedProjects.map(project => {
1005
+ const detailed = limitedProjects.map(project => {
964
1006
  const projMilestones = (milestonesByProject.get(project._id) || []).map(m => ({
965
1007
  name: m.label,
966
1008
  status: strictGet(MILESTONE_STATUS_NAMES, m.status, 'Milestone status'),
@@ -993,6 +1035,7 @@ export class HulyClient {
993
1035
  labels: projLabels
994
1036
  });
995
1037
  });
1038
+ return this._cursoredFindAll(detailed, options);
996
1039
  }
997
1040
 
998
1041
  /**
@@ -1067,9 +1110,9 @@ export class HulyClient {
1067
1110
  * @param {number} [limit=500] - Maximum number of issues
1068
1111
  * @returns {Promise<Object[]>}
1069
1112
  */
1070
- async listIssues(project, status, priority, label, milestone, limit, include_details = false) {
1113
+ async listIssues(project, status, priority, label, milestone, limit, include_details = false, cursor) {
1071
1114
  if (limit === undefined || limit === null) {
1072
- limit = include_details ? 50 : 500;
1115
+ limit = include_details ? DEFAULT_DETAIL_PAGE_SIZE : DEFAULT_PAGE_SIZE;
1073
1116
  }
1074
1117
  const client = await this._getClient();
1075
1118
 
@@ -1112,10 +1155,11 @@ export class HulyClient {
1112
1155
  }
1113
1156
  }
1114
1157
 
1115
- let issues = await this._paginatedFindAll(client, tracker.class.Issue, query, {
1116
- limit,
1117
- sort: { modifiedOn: -1 }
1158
+ const fetchResult = await this._paginatedFindAll(client, tracker.class.Issue, query, {
1159
+ limit, cursor
1118
1160
  });
1161
+ const issues = fetchResult.items;
1162
+ const nextCursor = fetchResult.nextCursor;
1119
1163
 
1120
1164
  let labelFilter = null;
1121
1165
  if (label) {
@@ -1267,7 +1311,9 @@ export class HulyClient {
1267
1311
  result.push(withExtra(issue, entry));
1268
1312
  }
1269
1313
 
1270
- return result;
1314
+ const response = { items: result };
1315
+ if (nextCursor) response.nextCursor = nextCursor;
1316
+ return response;
1271
1317
  }
1272
1318
 
1273
1319
  /**
@@ -1650,20 +1696,21 @@ export class HulyClient {
1650
1696
  * List all available labels for issues.
1651
1697
  * @returns {Promise<Object[]>}
1652
1698
  */
1653
- async listLabels() {
1699
+ async listLabels(options = {}) {
1654
1700
  const client = await this._getClient();
1655
1701
 
1656
1702
  const tagElements = await client.findAll(tags.class.TagElement, {
1657
1703
  targetClass: tracker.class.Issue
1658
1704
  });
1659
1705
 
1660
- return tagElements.map(t => withExtra(t, {
1706
+ const enriched = tagElements.map(t => withExtra(t, {
1661
1707
  id: t._id,
1662
1708
  name: t.title,
1663
1709
  description: t.description || '',
1664
1710
  color: t.color ? `#${t.color.toString(16).padStart(6, '0')}` : null,
1665
1711
  category: t.category || null
1666
1712
  }));
1713
+ return this._cursoredFindAll(enriched, options);
1667
1714
  }
1668
1715
 
1669
1716
  /**
@@ -1894,7 +1941,7 @@ export class HulyClient {
1894
1941
  * @param {string} projectIdent - Project identifier
1895
1942
  * @returns {Promise<Object[]>}
1896
1943
  */
1897
- async listTaskTypes(projectIdent) {
1944
+ async listTaskTypes(projectIdent, options = {}) {
1898
1945
  const client = await this._getClient();
1899
1946
 
1900
1947
  const project = await client.findOne(tracker.class.Project, {
@@ -1923,7 +1970,7 @@ export class HulyClient {
1923
1970
  );
1924
1971
  }
1925
1972
 
1926
- return typesToReturn.map(tt => ({
1973
+ const enriched = typesToReturn.map(tt => ({
1927
1974
  id: tt._id,
1928
1975
  name: tt.name || tt._id.split(':').pop(),
1929
1976
  description: fromMarkup(tt.description),
@@ -1934,6 +1981,7 @@ export class HulyClient {
1934
1981
  statusCategories: tt.statusCategories || [],
1935
1982
  statuses: tt.statuses || []
1936
1983
  }));
1984
+ return this._cursoredFindAll(enriched, options);
1937
1985
  }
1938
1986
 
1939
1987
  /**
@@ -1942,20 +1990,21 @@ export class HulyClient {
1942
1990
  * @param {string} [taskTypeName] - Task type name to scope statuses (e.g., "Task", "Epic")
1943
1991
  * @returns {Promise<Object[]>}
1944
1992
  */
1945
- async listStatuses(projectIdent, taskTypeName) {
1993
+ async listStatuses(projectIdent, taskTypeName, options = {}) {
1946
1994
  const client = await this._getClient();
1947
1995
 
1948
1996
  const allStatuses = await client.findAll(tracker.class.IssueStatus, {});
1949
1997
 
1950
1998
  // If no scoping requested, return all
1951
1999
  if (!projectIdent && !taskTypeName) {
1952
- return allStatuses.map(s => ({
2000
+ const enriched = allStatuses.map(s => ({
1953
2001
  id: s._id,
1954
2002
  name: s.name,
1955
2003
  category: STATUS_CATEGORY_NAMES[s.category] || s.category,
1956
2004
  color: s.color,
1957
2005
  description: fromMarkup(s.description)
1958
2006
  }));
2007
+ return this._cursoredFindAll(enriched, options);
1959
2008
  }
1960
2009
 
1961
2010
  // Get task types scoped to this project
@@ -1996,13 +2045,14 @@ export class HulyClient {
1996
2045
  ? allStatuses.filter(s => statusIds.has(s._id))
1997
2046
  : allStatuses;
1998
2047
 
1999
- return scopedStatuses.map(s => ({
2048
+ const enriched = scopedStatuses.map(s => ({
2000
2049
  id: s._id,
2001
2050
  name: s.name,
2002
2051
  category: STATUS_CATEGORY_NAMES[s.category] || s.category,
2003
2052
  color: s.color,
2004
2053
  description: s.description || ''
2005
2054
  }));
2055
+ return this._cursoredFindAll(enriched, options);
2006
2056
  }
2007
2057
 
2008
2058
  /**
@@ -2036,7 +2086,7 @@ export class HulyClient {
2036
2086
  });
2037
2087
 
2038
2088
  if (!options.include_details) {
2039
- return milestones.map(m => withExtra(m, {
2089
+ const enriched = milestones.map(m => withExtra(m, {
2040
2090
  id: m._id,
2041
2091
  name: m.label,
2042
2092
  description: fromMarkup(m.description),
@@ -2044,6 +2094,7 @@ export class HulyClient {
2044
2094
  targetDate: m.targetDate ? new Date(m.targetDate).toISOString().split('T')[0] : null,
2045
2095
  comments: m.comments || 0
2046
2096
  }));
2097
+ return this._cursoredFindAll(enriched, options);
2047
2098
  }
2048
2099
 
2049
2100
  // Detailed mode: batch fetch all issues for the project, then group by milestone
@@ -2059,7 +2110,7 @@ export class HulyClient {
2059
2110
  }
2060
2111
  }
2061
2112
 
2062
- return milestones.map(m => {
2113
+ const detailed = milestones.map(m => {
2063
2114
  const mIssues = (issuesByMilestone.get(m._id) || []).map(i => ({
2064
2115
  id: `${project.identifier}-${i.number}`,
2065
2116
  title: i.title,
@@ -2077,6 +2128,7 @@ export class HulyClient {
2077
2128
  issues: mIssues
2078
2129
  });
2079
2130
  });
2131
+ return this._cursoredFindAll(detailed, options);
2080
2132
  }
2081
2133
 
2082
2134
  /**
@@ -2253,16 +2305,17 @@ export class HulyClient {
2253
2305
  * List all active workspace members.
2254
2306
  * @returns {Promise<Object[]>}
2255
2307
  */
2256
- async listMembers() {
2308
+ async listMembers(options = {}) {
2257
2309
  const client = await this._getClient();
2258
2310
  const employees = await client.findAll(contactPlugin.mixin.Employee, { active: true });
2259
- return employees.map(e => withExtra(e, {
2311
+ const enriched = employees.map(e => withExtra(e, {
2260
2312
  id: e._id,
2261
2313
  name: e.name,
2262
2314
  email: e.channels?.[0]?.value || null,
2263
2315
  role: e.role || 'USER',
2264
2316
  position: e.position || null
2265
2317
  }));
2318
+ return this._cursoredFindAll(enriched, options);
2266
2319
  }
2267
2320
 
2268
2321
  /**
@@ -2326,7 +2379,7 @@ export class HulyClient {
2326
2379
  * @param {string} issueId - Issue identifier
2327
2380
  * @returns {Promise<Object[]>}
2328
2381
  */
2329
- async listComments(issueId) {
2382
+ async listComments(issueId, options = {}) {
2330
2383
  const client = await this._getClient();
2331
2384
  const { issue } = await this._parseAndFindIssue(client, issueId);
2332
2385
 
@@ -2334,13 +2387,14 @@ export class HulyClient {
2334
2387
  attachedTo: issue._id
2335
2388
  }, { sort: { createdOn: 1 } });
2336
2389
 
2337
- return comments.map(c => withExtra(c, {
2390
+ const enriched = comments.map(c => withExtra(c, {
2338
2391
  id: c._id,
2339
2392
  text: fromMarkup(c.message),
2340
2393
  createdBy: c.createdBy || null,
2341
2394
  createdOn: c.createdOn,
2342
2395
  modifiedOn: c.modifiedOn
2343
2396
  }));
2397
+ return this._cursoredFindAll(enriched, options);
2344
2398
  }
2345
2399
 
2346
2400
  /**
@@ -2574,10 +2628,10 @@ export class HulyClient {
2574
2628
  }
2575
2629
  }
2576
2630
 
2577
- let issues = await this._paginatedFindAll(client, tracker.class.Issue, query, {
2578
- limit,
2579
- sort: { modifiedOn: -1 }
2631
+ const fetchResult = await this._paginatedFindAll(client, tracker.class.Issue, query, {
2632
+ limit
2580
2633
  });
2634
+ const issues = fetchResult.items;
2581
2635
 
2582
2636
  const projects = await client.findAll(tracker.class.Project, {});
2583
2637
  const projMap = new Map(projects.map(p => [p._id, p.identifier]));
@@ -3367,7 +3421,7 @@ export class HulyClient {
3367
3421
 
3368
3422
  // ── Components ──────────────────────────────────────────────
3369
3423
 
3370
- async listComponents(projectIdent) {
3424
+ async listComponents(projectIdent, options = {}) {
3371
3425
  const client = await this._getClient();
3372
3426
  const project = await client.findOne(tracker.class.Project, {
3373
3427
  identifier: projectIdent.toUpperCase()
@@ -3376,12 +3430,13 @@ export class HulyClient {
3376
3430
 
3377
3431
  const components = await client.findAll(tracker.class.Component, { space: project._id });
3378
3432
 
3379
- return components.map(c => withExtra(c, {
3433
+ const enriched = components.map(c => withExtra(c, {
3380
3434
  id: c._id,
3381
3435
  name: c.label,
3382
3436
  description: fromMarkup(c.description),
3383
3437
  lead: c.lead || null
3384
3438
  }));
3439
+ return this._cursoredFindAll(enriched, options);
3385
3440
  }
3386
3441
 
3387
3442
  async createComponent(projectIdent, name, description, lead, format) {
@@ -3486,7 +3541,7 @@ export class HulyClient {
3486
3541
 
3487
3542
  // ── Time Reports ────────────────────────────────────────────
3488
3543
 
3489
- async listTimeReports(issueId) {
3544
+ async listTimeReports(issueId, options = {}) {
3490
3545
  const client = await this._getClient();
3491
3546
  const { issue } = await this._parseAndFindIssue(client, issueId);
3492
3547
 
@@ -3494,12 +3549,13 @@ export class HulyClient {
3494
3549
  attachedTo: issue._id
3495
3550
  }, { sort: { date: -1 } });
3496
3551
 
3497
- return reports.map(r => withExtra(r, {
3552
+ const enriched = reports.map(r => withExtra(r, {
3498
3553
  id: r._id,
3499
3554
  hours: r.value,
3500
3555
  description: fromMarkup(r.description),
3501
3556
  date: r.date ? new Date(r.date).toISOString() : null
3502
3557
  }));
3558
+ return this._cursoredFindAll(enriched, options);
3503
3559
  }
3504
3560
 
3505
3561
  async deleteTimeReport(reportId) {
@@ -3508,17 +3564,9 @@ export class HulyClient {
3508
3564
  const report = await client.findOne(tracker.class.TimeSpendReport, { _id: reportId });
3509
3565
  if (!report) throw new Error(`Time report not found: ${reportId}`);
3510
3566
 
3511
- // Update the issue's reportedTime
3512
- if (report.attachedTo) {
3513
- const issue = await client.findOne(tracker.class.Issue, { _id: report.attachedTo });
3514
- if (issue) {
3515
- const newReported = Math.max(0, (issue.reportedTime || 0) - (report.value || 0));
3516
- await client.updateDoc(tracker.class.Issue, issue.space, issue._id, {
3517
- reportedTime: newReported
3518
- });
3519
- }
3520
- }
3521
-
3567
+ // The transactor automatically decrements reportedTime on the issue
3568
+ // when a TimeSpendReport is removed via removeCollection — do NOT
3569
+ // manually update reportedTime here or it gets decremented twice.
3522
3570
  await client.removeCollection(tracker.class.TimeSpendReport, report.space, report._id, report.attachedTo, report.attachedToClass, report.collection);
3523
3571
 
3524
3572
  return {
package/src/dispatch.mjs CHANGED
@@ -78,11 +78,11 @@ export const accountTools = {
78
78
  */
79
79
  export const workspaceTools = {
80
80
  list_projects: (a, c) =>
81
- c.listProjects({ include_details: a.include_details }),
81
+ c.listProjects({ include_details: a.include_details, cursor: a.cursor, limit: a.limit }),
82
82
  get_project: (a, c) =>
83
83
  c.getProject(a.project, { include_details: a.include_details }),
84
84
  list_issues: (a, c) =>
85
- c.listIssues(a.project, a.status, a.priority, a.label, a.milestone, a.limit, a.include_details),
85
+ c.listIssues(a.project, a.status, a.priority, a.label, a.milestone, a.limit, a.include_details, a.cursor),
86
86
  get_issue: (a, c) =>
87
87
  c.getIssue(a.issueId, { include_details: a.include_details }),
88
88
  create_issue: (a, c) =>
@@ -113,7 +113,7 @@ export const workspaceTools = {
113
113
  // Labels
114
114
  add_label: (a, c) => c.addLabel(a.issueId, a.label),
115
115
  remove_label: (a, c) => c.removeLabel(a.issueId, a.label),
116
- list_labels: (a, c) => c.listLabels(),
116
+ list_labels: (a, c) => c.listLabels({ cursor: a.cursor, limit: a.limit }),
117
117
  create_label: (a, c) => c.createLabel(a.name, a.color, a.description),
118
118
  update_label: (a, c) =>
119
119
  c.updateLabel(a.name, { newName: a.newName, color: a.color, description: a.description }),
@@ -121,14 +121,14 @@ export const workspaceTools = {
121
121
  // Relations
122
122
  add_relation: (a, c) => c.addRelation(a.issueId, a.relatedToIssueId),
123
123
  add_blocked_by: (a, c) => c.addBlockedBy(a.issueId, a.blockedByIssueId),
124
- set_parent: (a, c) => c.setParent(a.issueId, a.parentIssueId),
124
+ set_parent: (a, c) => c.setParent(a.issueId, a.parentId),
125
125
 
126
126
  // Task types & statuses
127
- list_task_types: (a, c) => c.listTaskTypes(a.project),
128
- list_statuses: (a, c) => c.listStatuses(a.project, a.taskType),
127
+ list_task_types: (a, c) => c.listTaskTypes(a.project, { cursor: a.cursor, limit: a.limit }),
128
+ list_statuses: (a, c) => c.listStatuses(a.project, a.taskType, { cursor: a.cursor, limit: a.limit }),
129
129
 
130
130
  // Milestones
131
- list_milestones: (a, c) => c.listMilestones(a.project, a.status, { include_details: a.include_details }),
131
+ list_milestones: (a, c) => c.listMilestones(a.project, a.status, { include_details: a.include_details, cursor: a.cursor, limit: a.limit }),
132
132
  get_milestone: (a, c) => c.getMilestone(a.project, a.name, { include_details: a.include_details }),
133
133
  create_milestone: (a, c) =>
134
134
  c.createMilestone(a.project, a.name, a.description, a.targetDate, a.status, a.descriptionFormat),
@@ -141,17 +141,17 @@ export const workspaceTools = {
141
141
  delete_milestone: (a, c) => c.deleteMilestone(a.project, a.name),
142
142
 
143
143
  // Members
144
- list_members: (a, c) => c.listMembers(),
144
+ list_members: (a, c) => c.listMembers({ cursor: a.cursor, limit: a.limit }),
145
145
 
146
146
  // Comments
147
147
  add_comment: (a, c) => c.addComment(a.issueId, a.text, a.format),
148
- list_comments: (a, c) => c.listComments(a.issueId),
148
+ list_comments: (a, c) => c.listComments(a.issueId, { cursor: a.cursor, limit: a.limit }),
149
149
  update_comment: (a, c) => c.updateComment(a.issueId, a.commentId, a.text, a.format),
150
150
  delete_comment: (a, c) => c.deleteComment(a.issueId, a.commentId),
151
151
 
152
152
  // Time tracking
153
153
  log_time: (a, c) => c.logTime(a.issueId, a.hours, a.description, a.descriptionFormat, a.date, a.employee),
154
- list_time_reports: (a, c) => c.listTimeReports(a.issueId),
154
+ list_time_reports: (a, c) => c.listTimeReports(a.issueId, { cursor: a.cursor, limit: a.limit }),
155
155
  delete_time_report: (a, c) => c.deleteTimeReport(a.reportId),
156
156
 
157
157
  // Projects
@@ -166,7 +166,7 @@ export const workspaceTools = {
166
166
  delete_project: (a, c) => c.deleteProject(a.project),
167
167
 
168
168
  // Components
169
- list_components: (a, c) => c.listComponents(a.project),
169
+ list_components: (a, c) => c.listComponents(a.project, { cursor: a.cursor, limit: a.limit }),
170
170
  create_component: (a, c) =>
171
171
  c.createComponent(a.project, a.name, a.description, a.lead, a.descriptionFormat),
172
172
  update_component: (a, c) =>
package/src/helpers.mjs CHANGED
@@ -100,6 +100,30 @@ export const PAGE_SIZE = 500;
100
100
  export const MAX_BATCH_SIZE = 500;
101
101
  export const AUTH_CACHE_TTL_MS = 600000;
102
102
  export const DEFAULT_MILESTONE_DAYS = 30;
103
+ export const DEFAULT_PAGE_SIZE = 50;
104
+ export const DEFAULT_DETAIL_PAGE_SIZE = 20;
105
+
106
+ /**
107
+ * Encode a pagination cursor from a createdOn timestamp.
108
+ * Returns an opaque base64url string.
109
+ */
110
+ export function encodeCursor(createdOn) {
111
+ return Buffer.from(JSON.stringify({ createdOn })).toString('base64url');
112
+ }
113
+
114
+ /**
115
+ * Decode a pagination cursor back to { createdOn }.
116
+ * Throws on invalid input.
117
+ */
118
+ export function decodeCursor(cursor) {
119
+ try {
120
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString());
121
+ if (typeof parsed.createdOn !== 'number') throw new Error();
122
+ return parsed;
123
+ } catch {
124
+ throw new Error('Invalid pagination cursor');
125
+ }
126
+ }
103
127
 
104
128
  /**
105
129
  * Resolve a color value: name ("blue"), palette index (9), or RGB (0x5E6AD2).
package/src/mcpShared.mjs CHANGED
@@ -31,6 +31,18 @@ const workspaceProp = {
31
31
  }
32
32
  };
33
33
 
34
+ // Pagination properties for all list tools
35
+ const paginationProps = {
36
+ cursor: {
37
+ type: 'string',
38
+ description: 'Opaque pagination cursor from a previous response\'s nextCursor field. Omit for the first page.'
39
+ },
40
+ limit: {
41
+ type: 'number',
42
+ description: 'Maximum items per page (default: 50, or 20 with include_details)'
43
+ }
44
+ };
45
+
34
46
  // ── Tool Definitions ──────────────────────────────────────────
35
47
 
36
48
  export { workspaceProp };
@@ -112,9 +124,9 @@ export function createMcpServer(capabilities = {}) {
112
124
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
113
125
  try {
114
126
  const client = await pool.getClient();
115
- const projects = await client.withReconnect(() => client.listProjects());
127
+ const result = await client.withReconnect(() => client.listProjects());
116
128
  return {
117
- resources: projects.map(p => ({
129
+ resources: result.items.map(p => ({
118
130
  uri: `huly://projects/${p.identifier}`,
119
131
  name: `${p.identifier}: ${p.name}`,
120
132
  description: `Project with ${p.issueCount} issues`,
@@ -324,7 +336,7 @@ function getToolDefinitions() {
324
336
  {
325
337
  name: 'list_projects',
326
338
  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. Set include_details=true to also fetch milestones, components, labels, and member names for each project (limited to 20 projects).',
327
- inputSchema: { type: 'object', properties: { include_details: { type: 'boolean', description: 'Include milestones, components, labels, and members for each project (default: false). Limits to 20 projects.' }, ...workspaceProp }, required: [] }
339
+ inputSchema: { type: 'object', properties: { include_details: { type: 'boolean', description: 'Include milestones, components, labels, and members for each project (default: false). Limits to 20 projects.' }, ...paginationProps, ...workspaceProp }, required: [] }
328
340
  },
329
341
  {
330
342
  name: 'get_project',
@@ -333,8 +345,8 @@ function getToolDefinitions() {
333
345
  },
334
346
  {
335
347
  name: 'list_issues',
336
- description: 'List issues in a project with optional filtering. Returns id, title, status, priority, type (Task/Epic/Bug), assignee, component, labels, milestone, parent issue, childCount, dueDate, estimation, reportedTime, createdOn, modifiedOn, and completedAt for each issue. Does NOT include full descriptions use get_issue to read resolved markdown descriptions (important for migrations). Supports filtering by status, priority, label, and milestone. Default limit 500, auto-paginates. Use search_issues for full-text search across projects.',
337
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' }, status: { type: 'string', description: 'Filter by status: Backlog, Todo, In Progress, Done, Canceled' }, priority: { type: 'string', description: 'Filter by priority: urgent, high, medium, low, none' }, label: { type: 'string', description: 'Filter by label name (exact match)' }, milestone: { type: 'string', description: 'Filter by milestone name (exact match)' }, limit: { type: 'number', description: 'Maximum number of issues to return (default: 500)' }, include_details: { type: 'boolean', description: 'Include full details: descriptions, comments, time reports, relations, and children. Reduces default limit to 50.' }, ...workspaceProp }, required: ['project'] }
348
+ description: 'List issues in a project with optional filtering and cursor-based pagination. Returns { items, nextCursor? }. Pass nextCursor from a previous response to get the next page. Default page size: 50 (20 with include_details).',
349
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier (e.g., "PROJ")' }, status: { type: 'string', description: 'Filter by status: Backlog, Todo, In Progress, Done, Canceled' }, priority: { type: 'string', description: 'Filter by priority: urgent, high, medium, low, none' }, label: { type: 'string', description: 'Filter by label name (exact match)' }, milestone: { type: 'string', description: 'Filter by milestone name (exact match)' }, include_details: { type: 'boolean', description: 'Include full details: descriptions, comments, time reports, relations, and children. Reduces default page size to 20.' }, ...paginationProps, ...workspaceProp }, required: ['project'] }
338
350
  },
339
351
  {
340
352
  name: 'get_issue',
@@ -363,8 +375,8 @@ function getToolDefinitions() {
363
375
  },
364
376
  {
365
377
  name: 'list_labels',
366
- 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.',
367
- inputSchema: { type: 'object', properties: { ...workspaceProp }, required: [] }
378
+ description: 'List all available labels in the workspace. Returns { items, nextCursor? }. Each label has name and hex color.',
379
+ inputSchema: { type: 'object', properties: { ...paginationProps, ...workspaceProp }, required: [] }
368
380
  },
369
381
  {
370
382
  name: 'create_label',
@@ -459,8 +471,8 @@ function getToolDefinitions() {
459
471
  // ── Components ───────────────────────────────────────────
460
472
  {
461
473
  name: 'list_components',
462
- description: 'List all components in a project.',
463
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, ...workspaceProp }, required: ['project'] }
474
+ description: 'List all components in a project. Returns { items, nextCursor? }.',
475
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, ...paginationProps, ...workspaceProp }, required: ['project'] }
464
476
  },
465
477
  {
466
478
  name: 'get_component',
@@ -470,12 +482,12 @@ function getToolDefinitions() {
470
482
  {
471
483
  name: 'create_component',
472
484
  description: 'Create a new component in a project.',
473
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, name: { type: 'string', description: 'Component name' }, description: { type: 'string', description: 'Component description' }, ...workspaceProp }, required: ['project', 'name'] }
485
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, name: { type: 'string', description: 'Component name' }, description: { type: 'string', description: 'Component description' }, lead: { type: 'string', description: 'Lead member name' }, ...workspaceProp }, required: ['project', 'name'] }
474
486
  },
475
487
  {
476
488
  name: 'update_component',
477
- description: 'Update a component\'s name or description.',
478
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, name: { type: 'string', description: 'Current component name' }, newName: { type: 'string', description: 'New component name' }, description: { type: 'string', description: 'New description' }, ...workspaceProp }, required: ['project', 'name'] }
489
+ description: 'Update a component\'s name, description, or lead.',
490
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, name: { type: 'string', description: 'Current component name' }, newName: { type: 'string', description: 'New component name' }, description: { type: 'string', description: 'New description' }, lead: { type: 'string', description: 'Lead member name (empty string to clear)' }, ...workspaceProp }, required: ['project', 'name'] }
479
491
  },
480
492
  {
481
493
  name: 'delete_component',
@@ -486,8 +498,8 @@ function getToolDefinitions() {
486
498
  // ── Milestones ───────────────────────────────────────────
487
499
  {
488
500
  name: 'list_milestones',
489
- description: 'List all milestones in a project. Returns name, status, date range, and issue count.',
490
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, include_details: { type: 'boolean', description: 'Include issue list for each milestone' }, ...workspaceProp }, required: ['project'] }
501
+ description: 'List all milestones in a project. Returns { items, nextCursor? }.',
502
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, include_details: { type: 'boolean', description: 'Include issue list for each milestone' }, ...paginationProps, ...workspaceProp }, required: ['project'] }
491
503
  },
492
504
  {
493
505
  name: 'get_milestone',
@@ -518,8 +530,8 @@ function getToolDefinitions() {
518
530
  // ── Members ──────────────────────────────────────────────
519
531
  {
520
532
  name: 'list_members',
521
- description: 'List all active members of the workspace with their names and roles.',
522
- inputSchema: { type: 'object', properties: { ...workspaceProp }, required: [] }
533
+ description: 'List all active members of the workspace. Returns { items, nextCursor? }.',
534
+ inputSchema: { type: 'object', properties: { ...paginationProps, ...workspaceProp }, required: [] }
523
535
  },
524
536
  {
525
537
  name: 'get_member',
@@ -530,8 +542,8 @@ function getToolDefinitions() {
530
542
  // ── Comments ─────────────────────────────────────────────
531
543
  {
532
544
  name: 'list_comments',
533
- description: 'List all comments on an issue, ordered chronologically.',
534
- inputSchema: { type: 'object', properties: { issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' }, ...workspaceProp }, required: ['issueId'] }
545
+ description: 'List comments on an issue. Returns { items, nextCursor? }.',
546
+ inputSchema: { type: 'object', properties: { issueId: { type: 'string', description: 'Issue identifier (e.g., "PROJ-42")' }, ...paginationProps, ...workspaceProp }, required: ['issueId'] }
535
547
  },
536
548
  {
537
549
  name: 'get_comment',
@@ -557,8 +569,8 @@ function getToolDefinitions() {
557
569
  // ── Metadata ─────────────────────────────────────────────
558
570
  {
559
571
  name: 'list_statuses',
560
- description: 'List all issue statuses available in a project, grouped by category (Backlog, Unstarted, Active, Won, Lost).',
561
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, ...workspaceProp }, required: ['project'] }
572
+ description: 'List issue statuses available in a project. Returns { items, nextCursor? }.',
573
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, ...paginationProps, ...workspaceProp }, required: ['project'] }
562
574
  },
563
575
  {
564
576
  name: 'get_status',
@@ -567,8 +579,8 @@ function getToolDefinitions() {
567
579
  },
568
580
  {
569
581
  name: 'list_task_types',
570
- description: 'List all task types available in a project (e.g., Issue, Epic, Bug).',
571
- inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, ...workspaceProp }, required: ['project'] }
582
+ description: 'List task types available in a project. Returns { items, nextCursor? }.',
583
+ inputSchema: { type: 'object', properties: { project: { type: 'string', description: 'Project identifier' }, ...paginationProps, ...workspaceProp }, required: ['project'] }
572
584
  },
573
585
  {
574
586
  name: 'get_task_type',
@@ -584,8 +596,8 @@ function getToolDefinitions() {
584
596
  },
585
597
  {
586
598
  name: 'list_time_reports',
587
- description: 'List all time reports for an issue.',
588
- inputSchema: { type: 'object', properties: { issueId: { type: 'string', description: 'Issue identifier' }, ...workspaceProp }, required: ['issueId'] }
599
+ description: 'List time reports for an issue. Returns { items, nextCursor? }.',
600
+ inputSchema: { type: 'object', properties: { issueId: { type: 'string', description: 'Issue identifier' }, ...paginationProps, ...workspaceProp }, required: ['issueId'] }
589
601
  },
590
602
  {
591
603
  name: 'get_time_report',
package/src/server.mjs CHANGED
@@ -279,7 +279,7 @@ function shutdown(signal) {
279
279
  timeout.unref();
280
280
 
281
281
  // Close all sessions
282
- for (const [sid, session] of sessions) {
282
+ for (const session of sessions.values()) {
283
283
  session.transport.close().catch(() => {});
284
284
  session.server.close().catch(() => {});
285
285
  }
@@ -1 +0,0 @@
1
- Platform specific binary for msgpackr-extract on darwin OS with x64 architecture