@systemdox/mcp-server 0.1.15 → 0.1.17
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 +3 -2
- package/dist/cli.js +242 -35
- package/dist/http.js +231 -34
- package/dist/index.js +231 -34
- package/dist/tools/get-plan-board.d.ts +27 -0
- package/dist/tools/get-plan-board.js +208 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -608,11 +608,21 @@ ${adrs}`);
|
|
|
608
608
|
|
|
609
609
|
${specs}`);
|
|
610
610
|
}
|
|
611
|
+
if (sections.length === 0) {
|
|
612
|
+
return {
|
|
613
|
+
content: [
|
|
614
|
+
{
|
|
615
|
+
type: "text",
|
|
616
|
+
text: `No planning context available. Connect repositories at ${configLink("/settings/github", await workspaceBase(apiClient2))}`
|
|
617
|
+
}
|
|
618
|
+
]
|
|
619
|
+
};
|
|
620
|
+
}
|
|
611
621
|
return {
|
|
612
622
|
content: [
|
|
613
623
|
{
|
|
614
624
|
type: "text",
|
|
615
|
-
text: sections.join("\n\n")
|
|
625
|
+
text: sections.join("\n\n")
|
|
616
626
|
}
|
|
617
627
|
]
|
|
618
628
|
};
|
|
@@ -620,8 +630,193 @@ ${specs}`);
|
|
|
620
630
|
);
|
|
621
631
|
}
|
|
622
632
|
|
|
623
|
-
// src/tools/get-
|
|
633
|
+
// src/tools/get-plan-board.ts
|
|
624
634
|
import { z as z5 } from "zod";
|
|
635
|
+
var API_ITEM_PAGE = 100;
|
|
636
|
+
var DEFAULT_LIMIT = 50;
|
|
637
|
+
var BUCKET_LABELS = {
|
|
638
|
+
inProgress: "In Progress",
|
|
639
|
+
todo: "Todo",
|
|
640
|
+
done: "Done"
|
|
641
|
+
};
|
|
642
|
+
var BUCKET_ORDER = ["inProgress", "todo", "done"];
|
|
643
|
+
var STATUS_ARG_TO_BUCKET = {
|
|
644
|
+
todo: "todo",
|
|
645
|
+
in_progress: "inProgress",
|
|
646
|
+
done: "done"
|
|
647
|
+
};
|
|
648
|
+
function bucketOf(item) {
|
|
649
|
+
const status = (item.fields?.Status ?? "").trim().toLowerCase();
|
|
650
|
+
if (status) {
|
|
651
|
+
if (status.includes("done") || status.includes("ship") || status.includes("complete")) {
|
|
652
|
+
return "done";
|
|
653
|
+
}
|
|
654
|
+
if (status.includes("progress") || status.includes("doing") || status.includes("review")) {
|
|
655
|
+
return "inProgress";
|
|
656
|
+
}
|
|
657
|
+
return "todo";
|
|
658
|
+
}
|
|
659
|
+
return item.state === "CLOSED" ? "done" : "todo";
|
|
660
|
+
}
|
|
661
|
+
function formatBoards(boards) {
|
|
662
|
+
const lines = boards.map((b) => {
|
|
663
|
+
const c = b.statusCounts ?? { todo: 0, inProgress: 0, done: 0 };
|
|
664
|
+
return `- **${b.title}** \u2014 board #${b.number} \xB7 ${b.itemCount} item(s) (todo ${c.todo} \xB7 in progress ${c.inProgress} \xB7 done ${c.done})` + (b.description ? `
|
|
665
|
+
${b.description}` : "") + `
|
|
666
|
+
${b.url}`;
|
|
667
|
+
});
|
|
668
|
+
return lines.join("\n");
|
|
669
|
+
}
|
|
670
|
+
function formatItem(item) {
|
|
671
|
+
const meta = [];
|
|
672
|
+
if (item.labels.length > 0) meta.push(`labels: ${item.labels.join(", ")}`);
|
|
673
|
+
if (item.assignees.length > 0)
|
|
674
|
+
meta.push(`assignees: ${item.assignees.join(", ")}`);
|
|
675
|
+
const epic = item.fields?.Epic;
|
|
676
|
+
if (epic) meta.push(`epic: ${epic}`);
|
|
677
|
+
return `- #${item.number} ${item.title}${item.repo ? ` \u2014 ${item.repo}` : ""}` + (meta.length > 0 ? `
|
|
678
|
+
${meta.join(" \xB7 ")}` : "") + (item.url ? `
|
|
679
|
+
${item.url}` : "");
|
|
680
|
+
}
|
|
681
|
+
function registerGetPlanBoard(server2, apiClient2) {
|
|
682
|
+
server2.registerTool(
|
|
683
|
+
"get_plan_board",
|
|
684
|
+
{
|
|
685
|
+
title: "Get Plan Board",
|
|
686
|
+
description: "Read the GitHub Projects boards connected to this workspace. With no arguments, lists the open boards with their item counts by status. With project_number, lists that board's issues grouped by status (In Progress / Todo / Done), each with issue number, title, repo, labels, assignees and URL \u2014 so you can see what is planned or in flight and pick up the right issue. Read-only: this tool cannot create, move, or close board items.",
|
|
687
|
+
inputSchema: {
|
|
688
|
+
project_number: z5.number().int().positive().optional().describe(
|
|
689
|
+
"Board number (the N in github.com/orgs/{org}/projects/N). Omit to list the open boards."
|
|
690
|
+
),
|
|
691
|
+
status: z5.enum(["todo", "in_progress", "done"]).optional().describe(
|
|
692
|
+
"With project_number, return only the items in this status group"
|
|
693
|
+
),
|
|
694
|
+
limit: z5.number().int().positive().max(200).optional().describe(
|
|
695
|
+
`Max items to list (default ${DEFAULT_LIMIT}). Group headings always show the true totals.`
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
async ({ project_number, status, limit }) => {
|
|
700
|
+
if (project_number === void 0) {
|
|
701
|
+
const boards = await apiClient2.get(
|
|
702
|
+
"/v1/planning/projects"
|
|
703
|
+
);
|
|
704
|
+
if (boards.length === 0) {
|
|
705
|
+
return {
|
|
706
|
+
content: [
|
|
707
|
+
{
|
|
708
|
+
type: "text",
|
|
709
|
+
text: `No project boards found. Connect a GitHub organisation and create a board at ${configLink("/plan", await workspaceBase(apiClient2))}`
|
|
710
|
+
}
|
|
711
|
+
]
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const open = boards.filter((b) => !b.closed);
|
|
715
|
+
if (open.length === 0) {
|
|
716
|
+
return {
|
|
717
|
+
content: [
|
|
718
|
+
{
|
|
719
|
+
type: "text",
|
|
720
|
+
text: `No open project boards (${boards.length} closed). Reopen one, or create a board at ${configLink("/plan", await workspaceBase(apiClient2))}`
|
|
721
|
+
}
|
|
722
|
+
]
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const closedNote = boards.length > open.length ? `
|
|
726
|
+
|
|
727
|
+
_${boards.length - open.length} closed board(s) not listed._` : "";
|
|
728
|
+
return {
|
|
729
|
+
content: [
|
|
730
|
+
{
|
|
731
|
+
type: "text",
|
|
732
|
+
text: `${open.length} open project board(s):
|
|
733
|
+
|
|
734
|
+
${formatBoards(open)}` + closedNote + `
|
|
735
|
+
|
|
736
|
+
Call get_plan_board with project_number to list a board's issues.`
|
|
737
|
+
}
|
|
738
|
+
]
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
const response = await apiClient2.get(
|
|
742
|
+
`/v1/planning/projects/${project_number}/items`
|
|
743
|
+
);
|
|
744
|
+
const allItems = response.items ?? [];
|
|
745
|
+
const boardName = response.projectTitle ? `**${response.projectTitle}** \u2014 board #${project_number}` : `Board #${project_number}`;
|
|
746
|
+
if (allItems.length === 0) {
|
|
747
|
+
return {
|
|
748
|
+
content: [
|
|
749
|
+
{
|
|
750
|
+
type: "text",
|
|
751
|
+
text: `${boardName} has no issues, or it is not visible to this workspace's GitHub connection. Check the board at ${configLink("/plan", await workspaceBase(apiClient2))}`
|
|
752
|
+
}
|
|
753
|
+
]
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const grouped = {
|
|
757
|
+
inProgress: [],
|
|
758
|
+
todo: [],
|
|
759
|
+
done: []
|
|
760
|
+
};
|
|
761
|
+
for (const item of allItems) grouped[bucketOf(item)].push(item);
|
|
762
|
+
const wanted = status ? [STATUS_ARG_TO_BUCKET[status]] : BUCKET_ORDER;
|
|
763
|
+
const shown = wanted.reduce((n, b) => n + grouped[b].length, 0);
|
|
764
|
+
if (shown === 0) {
|
|
765
|
+
return {
|
|
766
|
+
content: [
|
|
767
|
+
{
|
|
768
|
+
type: "text",
|
|
769
|
+
text: `${boardName}: no items with status "${status}" (of ${allItems.length} item(s) on the board).`
|
|
770
|
+
}
|
|
771
|
+
]
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const cap = limit ?? DEFAULT_LIMIT;
|
|
775
|
+
let budget = cap;
|
|
776
|
+
const sections = [];
|
|
777
|
+
let rendered = 0;
|
|
778
|
+
for (const b of wanted) {
|
|
779
|
+
const items = grouped[b];
|
|
780
|
+
if (items.length === 0) continue;
|
|
781
|
+
const slice = items.slice(0, Math.max(budget, 0));
|
|
782
|
+
budget -= slice.length;
|
|
783
|
+
rendered += slice.length;
|
|
784
|
+
const omitted = items.length - slice.length;
|
|
785
|
+
sections.push(
|
|
786
|
+
`### ${BUCKET_LABELS[b]} (${items.length})
|
|
787
|
+
` + (slice.length > 0 ? slice.map(formatItem).join("\n") : "_(not listed \u2014 item limit reached)_") + (omitted > 0 && slice.length > 0 ? `
|
|
788
|
+
_\u2026${omitted} more not listed._` : "")
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const notes = [];
|
|
792
|
+
if (rendered < shown) {
|
|
793
|
+
notes.push(
|
|
794
|
+
`_Listing ${rendered} of ${shown} matching item(s) \u2014 raise limit (max 200) or filter by status to see the rest._`
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
if (allItems.length >= API_ITEM_PAGE) {
|
|
798
|
+
notes.push(
|
|
799
|
+
`_The API reads the board's first ${API_ITEM_PAGE} items; a larger board may have more._`
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
content: [
|
|
804
|
+
{
|
|
805
|
+
type: "text",
|
|
806
|
+
text: `${boardName} \u2014 ${allItems.length} item(s)
|
|
807
|
+
|
|
808
|
+
` + sections.join("\n\n") + (notes.length > 0 ? `
|
|
809
|
+
|
|
810
|
+
${notes.join("\n")}` : "")
|
|
811
|
+
}
|
|
812
|
+
]
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/tools/get-context.ts
|
|
819
|
+
import { z as z6 } from "zod";
|
|
625
820
|
|
|
626
821
|
// src/lib/scoring.ts
|
|
627
822
|
var WEIGHT_PATH = 0.4;
|
|
@@ -1188,13 +1383,13 @@ function registerGetContext(server2, apiClient2) {
|
|
|
1188
1383
|
title: "Get Context",
|
|
1189
1384
|
description: "Intelligent context aggregator: fetches checks, specs, docs, and tech stack, ranks them by relevance to your current task, and returns the full text of the highest-ranked items within a token budget \u2014 each with the ID needed to fetch the rest. Use this instead of calling individual tools when you want curated, relevant context.",
|
|
1190
1385
|
inputSchema: {
|
|
1191
|
-
task_description:
|
|
1386
|
+
task_description: z6.string().describe(
|
|
1192
1387
|
"What you're working on (e.g., 'add a new REST endpoint for user preferences')"
|
|
1193
1388
|
),
|
|
1194
|
-
file_paths:
|
|
1389
|
+
file_paths: z6.array(z6.string()).optional().describe(
|
|
1195
1390
|
"File paths being edited \u2014 used for language/category detection"
|
|
1196
1391
|
),
|
|
1197
|
-
max_tokens:
|
|
1392
|
+
max_tokens: z6.number().optional().describe("Maximum token budget for the response (default: 4000)")
|
|
1198
1393
|
}
|
|
1199
1394
|
},
|
|
1200
1395
|
async ({ task_description, file_paths, max_tokens }) => {
|
|
@@ -1211,7 +1406,7 @@ function registerGetContext(server2, apiClient2) {
|
|
|
1211
1406
|
}
|
|
1212
1407
|
|
|
1213
1408
|
// src/tools/create-guardrail.ts
|
|
1214
|
-
import { z as
|
|
1409
|
+
import { z as z7 } from "zod";
|
|
1215
1410
|
var DESCRIPTION2 = "Create a new check in SystemDox. Use this when you discover a pattern that should be enforced (banned pattern, required pattern, coding rule).";
|
|
1216
1411
|
var CATEGORIES = [
|
|
1217
1412
|
"imports",
|
|
@@ -1224,13 +1419,13 @@ var CATEGORIES = [
|
|
|
1224
1419
|
"data-access"
|
|
1225
1420
|
];
|
|
1226
1421
|
var inputSchema2 = () => ({
|
|
1227
|
-
title:
|
|
1228
|
-
description:
|
|
1229
|
-
severity:
|
|
1230
|
-
category:
|
|
1231
|
-
pattern:
|
|
1232
|
-
fix:
|
|
1233
|
-
fitness_test:
|
|
1422
|
+
title: z7.string().describe("Short title for the check"),
|
|
1423
|
+
description: z7.string().describe("Detailed description of what the check enforces and why"),
|
|
1424
|
+
severity: z7.enum(["critical", "high", "medium", "low"]).describe("Severity level"),
|
|
1425
|
+
category: z7.enum(CATEGORIES).optional().describe("Category (defaults to conventions)"),
|
|
1426
|
+
pattern: z7.string().optional().describe("Bad code pattern that should be avoided"),
|
|
1427
|
+
fix: z7.string().optional().describe("Correct code pattern to use instead"),
|
|
1428
|
+
fitness_test: z7.string().optional().describe("Shell command to detect violations in CI")
|
|
1234
1429
|
});
|
|
1235
1430
|
function registerCreateGuardrail(server2, apiClient2) {
|
|
1236
1431
|
const handler = async ({
|
|
@@ -1296,7 +1491,7 @@ function registerCreateGuardrail(server2, apiClient2) {
|
|
|
1296
1491
|
}
|
|
1297
1492
|
|
|
1298
1493
|
// src/tools/update-spec.ts
|
|
1299
|
-
import { z as
|
|
1494
|
+
import { z as z8 } from "zod";
|
|
1300
1495
|
function registerUpdateSpec(server2, apiClient2) {
|
|
1301
1496
|
server2.registerTool(
|
|
1302
1497
|
"update_spec",
|
|
@@ -1304,12 +1499,12 @@ function registerUpdateSpec(server2, apiClient2) {
|
|
|
1304
1499
|
title: "Update Spec",
|
|
1305
1500
|
description: "Update an existing SystemDox spec's proposal, tasks, or requirements. Only draft specs can be modified \u2014 published specs must be versioned first.",
|
|
1306
1501
|
inputSchema: {
|
|
1307
|
-
spec_id:
|
|
1308
|
-
section:
|
|
1309
|
-
content:
|
|
1502
|
+
spec_id: z8.string().describe("ID of the spec to update"),
|
|
1503
|
+
section: z8.enum(["proposal", "tasks", "requirements"]).describe("Which section to update"),
|
|
1504
|
+
content: z8.string().describe(
|
|
1310
1505
|
"New markdown content for the section. For requirements, provide the full requirement content."
|
|
1311
1506
|
),
|
|
1312
|
-
requirement_name:
|
|
1507
|
+
requirement_name: z8.string().optional().describe(
|
|
1313
1508
|
"Name of the requirement (required when section is 'requirements')"
|
|
1314
1509
|
)
|
|
1315
1510
|
},
|
|
@@ -1359,7 +1554,7 @@ function registerUpdateSpec(server2, apiClient2) {
|
|
|
1359
1554
|
}
|
|
1360
1555
|
|
|
1361
1556
|
// src/tools/report-decision.ts
|
|
1362
|
-
import { z as
|
|
1557
|
+
import { z as z9 } from "zod";
|
|
1363
1558
|
function registerReportDecision(server2, apiClient2) {
|
|
1364
1559
|
server2.registerTool(
|
|
1365
1560
|
"report_decision",
|
|
@@ -1367,11 +1562,11 @@ function registerReportDecision(server2, apiClient2) {
|
|
|
1367
1562
|
title: "Report Decision",
|
|
1368
1563
|
description: "Record an architecture decision made during development. Creates a draft ADR in SystemDox for human review.",
|
|
1369
1564
|
inputSchema: {
|
|
1370
|
-
title:
|
|
1371
|
-
context:
|
|
1372
|
-
decision:
|
|
1373
|
-
consequences:
|
|
1374
|
-
related_files:
|
|
1565
|
+
title: z9.string().describe("Decision title (e.g., 'Use WebSocket for real-time updates')"),
|
|
1566
|
+
context: z9.string().describe("What prompted this decision \u2014 the problem or constraint"),
|
|
1567
|
+
decision: z9.string().describe("What was decided and why"),
|
|
1568
|
+
consequences: z9.string().describe("What this decision means going forward \u2014 trade-offs, follow-up work"),
|
|
1569
|
+
related_files: z9.array(z9.string()).optional().describe("File paths related to this decision")
|
|
1375
1570
|
},
|
|
1376
1571
|
annotations: {
|
|
1377
1572
|
readOnlyHint: false
|
|
@@ -1422,7 +1617,7 @@ Status: draft \u2014 review in SystemDox before publishing.`
|
|
|
1422
1617
|
}
|
|
1423
1618
|
|
|
1424
1619
|
// src/tools/get-project-rules.ts
|
|
1425
|
-
import { z as
|
|
1620
|
+
import { z as z10 } from "zod";
|
|
1426
1621
|
var CATEGORY_LABELS = {
|
|
1427
1622
|
deployment: "Deployment",
|
|
1428
1623
|
architecture: "Architecture",
|
|
@@ -1438,7 +1633,7 @@ function registerGetProjectRules(server2, apiClient2) {
|
|
|
1438
1633
|
title: "Get Project Rules",
|
|
1439
1634
|
description: "Retrieve project rules defined in SystemDox Standards. These are team-defined principles covering deployment, architecture, security, testing, and process. Filter by category to get only relevant rules.",
|
|
1440
1635
|
inputSchema: {
|
|
1441
|
-
category:
|
|
1636
|
+
category: z10.enum(["deployment", "architecture", "security", "testing", "process", "other"]).optional().describe("Filter by rule category")
|
|
1442
1637
|
}
|
|
1443
1638
|
},
|
|
1444
1639
|
async ({ category }) => {
|
|
@@ -1490,7 +1685,7 @@ function registerGetProjectRules(server2, apiClient2) {
|
|
|
1490
1685
|
}
|
|
1491
1686
|
|
|
1492
1687
|
// src/tools/create-project-rule.ts
|
|
1493
|
-
import { z as
|
|
1688
|
+
import { z as z11 } from "zod";
|
|
1494
1689
|
function registerCreateProjectRule(server2, apiClient2) {
|
|
1495
1690
|
server2.registerTool(
|
|
1496
1691
|
"create_project_rule",
|
|
@@ -1498,10 +1693,10 @@ function registerCreateProjectRule(server2, apiClient2) {
|
|
|
1498
1693
|
title: "Create Project Rule",
|
|
1499
1694
|
description: "Create a new project rule in SystemDox Standards. Use this when you discover a team principle or convention that should be enforced across the project.",
|
|
1500
1695
|
inputSchema: {
|
|
1501
|
-
text:
|
|
1696
|
+
text: z11.string().describe(
|
|
1502
1697
|
"The rule statement \u2014 one enforceable principle per rule (e.g. 'All deployments MUST go through GitHub Actions workflows')"
|
|
1503
1698
|
),
|
|
1504
|
-
category:
|
|
1699
|
+
category: z11.enum(["deployment", "architecture", "security", "testing", "process", "other"]).describe("Rule category")
|
|
1505
1700
|
},
|
|
1506
1701
|
annotations: {
|
|
1507
1702
|
readOnlyHint: false
|
|
@@ -1572,7 +1767,7 @@ ${text}`
|
|
|
1572
1767
|
}
|
|
1573
1768
|
|
|
1574
1769
|
// src/tools/create-prompt-template.ts
|
|
1575
|
-
import { z as
|
|
1770
|
+
import { z as z12 } from "zod";
|
|
1576
1771
|
function registerCreatePromptTemplate(server2, apiClient2) {
|
|
1577
1772
|
server2.registerTool(
|
|
1578
1773
|
"create_prompt_template",
|
|
@@ -1580,9 +1775,9 @@ function registerCreatePromptTemplate(server2, apiClient2) {
|
|
|
1580
1775
|
title: "Create Prompt Template",
|
|
1581
1776
|
description: "Create a new reusable prompt template in SystemDox Standards. Use this to store preconfigured prompts for common AI tasks like code reviews, documentation generation, or analysis frameworks.",
|
|
1582
1777
|
inputSchema: {
|
|
1583
|
-
name:
|
|
1584
|
-
description:
|
|
1585
|
-
prompt:
|
|
1778
|
+
name: z12.string().describe("Short name for the template"),
|
|
1779
|
+
description: z12.string().optional().describe("Brief description of when to use this template"),
|
|
1780
|
+
prompt: z12.string().describe("The full prompt text that Gen AI will use when generating content")
|
|
1586
1781
|
},
|
|
1587
1782
|
annotations: {
|
|
1588
1783
|
readOnlyHint: false
|
|
@@ -1611,16 +1806,18 @@ function registerCreatePromptTemplate(server2, apiClient2) {
|
|
|
1611
1806
|
}
|
|
1612
1807
|
|
|
1613
1808
|
// src/server.ts
|
|
1809
|
+
var SERVER_VERSION = true ? "0.1.17" : "0.0.0-dev";
|
|
1614
1810
|
function createServer(apiClient2) {
|
|
1615
1811
|
const server2 = new McpServer({
|
|
1616
1812
|
name: "systemdox-mcp-server",
|
|
1617
|
-
version:
|
|
1813
|
+
version: SERVER_VERSION
|
|
1618
1814
|
});
|
|
1619
1815
|
registerGetGuardrails(server2, apiClient2);
|
|
1620
1816
|
registerGetSpecs(server2, apiClient2);
|
|
1621
1817
|
registerGetDocs(server2, apiClient2);
|
|
1622
1818
|
registerGetTechStack(server2, apiClient2);
|
|
1623
1819
|
registerGetPlanningContext(server2, apiClient2);
|
|
1820
|
+
registerGetPlanBoard(server2, apiClient2);
|
|
1624
1821
|
registerGetContext(server2, apiClient2);
|
|
1625
1822
|
registerGetProjectRules(server2, apiClient2);
|
|
1626
1823
|
registerGetPromptTemplates(server2, apiClient2);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { SystemDoxApiClient } from "../lib/api-client.js";
|
|
3
|
+
interface BoardItem {
|
|
4
|
+
id: string;
|
|
5
|
+
number: number;
|
|
6
|
+
title: string;
|
|
7
|
+
state: string;
|
|
8
|
+
url: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
labels: string[];
|
|
11
|
+
assignees: string[];
|
|
12
|
+
fields: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
type Bucket = "todo" | "inProgress" | "done";
|
|
15
|
+
/**
|
|
16
|
+
* Bucket one board item by its Status column.
|
|
17
|
+
*
|
|
18
|
+
* Boards name their columns differently, so match loosely — the same
|
|
19
|
+
* done/ship/complete and progress/doing/review matching the backend uses for
|
|
20
|
+
* the board-level counts (planning_api `_status_counts`). Where that helper
|
|
21
|
+
* treats a *missing* Status as todo, this falls back to the issue's own
|
|
22
|
+
* open/closed state, so a closed issue with no Status is never presented to an
|
|
23
|
+
* agent as available work.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bucketOf(item: Pick<BoardItem, "state" | "fields">): Bucket;
|
|
26
|
+
export declare function registerGetPlanBoard(server: McpServer, apiClient: SystemDoxApiClient): void;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/tools/get-plan-board.ts
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// src/lib/config-links.ts
|
|
7
|
+
async function workspaceBase(apiClient) {
|
|
8
|
+
try {
|
|
9
|
+
const access = await apiClient.get(
|
|
10
|
+
"/v1/docs/portal-access"
|
|
11
|
+
);
|
|
12
|
+
const slug = (access.host ?? "").split(".")[0];
|
|
13
|
+
return slug ? `https://${slug}.systemdox.com` : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function configLink(path, base) {
|
|
19
|
+
return base ? `${base}${path}` : `${path} in your SystemDox workspace`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/tools/get-plan-board.ts
|
|
23
|
+
var API_ITEM_PAGE = 100;
|
|
24
|
+
var DEFAULT_LIMIT = 50;
|
|
25
|
+
var BUCKET_LABELS = {
|
|
26
|
+
inProgress: "In Progress",
|
|
27
|
+
todo: "Todo",
|
|
28
|
+
done: "Done"
|
|
29
|
+
};
|
|
30
|
+
var BUCKET_ORDER = ["inProgress", "todo", "done"];
|
|
31
|
+
var STATUS_ARG_TO_BUCKET = {
|
|
32
|
+
todo: "todo",
|
|
33
|
+
in_progress: "inProgress",
|
|
34
|
+
done: "done"
|
|
35
|
+
};
|
|
36
|
+
function bucketOf(item) {
|
|
37
|
+
const status = (item.fields?.Status ?? "").trim().toLowerCase();
|
|
38
|
+
if (status) {
|
|
39
|
+
if (status.includes("done") || status.includes("ship") || status.includes("complete")) {
|
|
40
|
+
return "done";
|
|
41
|
+
}
|
|
42
|
+
if (status.includes("progress") || status.includes("doing") || status.includes("review")) {
|
|
43
|
+
return "inProgress";
|
|
44
|
+
}
|
|
45
|
+
return "todo";
|
|
46
|
+
}
|
|
47
|
+
return item.state === "CLOSED" ? "done" : "todo";
|
|
48
|
+
}
|
|
49
|
+
function formatBoards(boards) {
|
|
50
|
+
const lines = boards.map((b) => {
|
|
51
|
+
const c = b.statusCounts ?? { todo: 0, inProgress: 0, done: 0 };
|
|
52
|
+
return `- **${b.title}** \u2014 board #${b.number} \xB7 ${b.itemCount} item(s) (todo ${c.todo} \xB7 in progress ${c.inProgress} \xB7 done ${c.done})` + (b.description ? `
|
|
53
|
+
${b.description}` : "") + `
|
|
54
|
+
${b.url}`;
|
|
55
|
+
});
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
function formatItem(item) {
|
|
59
|
+
const meta = [];
|
|
60
|
+
if (item.labels.length > 0) meta.push(`labels: ${item.labels.join(", ")}`);
|
|
61
|
+
if (item.assignees.length > 0)
|
|
62
|
+
meta.push(`assignees: ${item.assignees.join(", ")}`);
|
|
63
|
+
const epic = item.fields?.Epic;
|
|
64
|
+
if (epic) meta.push(`epic: ${epic}`);
|
|
65
|
+
return `- #${item.number} ${item.title}${item.repo ? ` \u2014 ${item.repo}` : ""}` + (meta.length > 0 ? `
|
|
66
|
+
${meta.join(" \xB7 ")}` : "") + (item.url ? `
|
|
67
|
+
${item.url}` : "");
|
|
68
|
+
}
|
|
69
|
+
function registerGetPlanBoard(server, apiClient) {
|
|
70
|
+
server.registerTool(
|
|
71
|
+
"get_plan_board",
|
|
72
|
+
{
|
|
73
|
+
title: "Get Plan Board",
|
|
74
|
+
description: "Read the GitHub Projects boards connected to this workspace. With no arguments, lists the open boards with their item counts by status. With project_number, lists that board's issues grouped by status (In Progress / Todo / Done), each with issue number, title, repo, labels, assignees and URL \u2014 so you can see what is planned or in flight and pick up the right issue. Read-only: this tool cannot create, move, or close board items.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
project_number: z.number().int().positive().optional().describe(
|
|
77
|
+
"Board number (the N in github.com/orgs/{org}/projects/N). Omit to list the open boards."
|
|
78
|
+
),
|
|
79
|
+
status: z.enum(["todo", "in_progress", "done"]).optional().describe(
|
|
80
|
+
"With project_number, return only the items in this status group"
|
|
81
|
+
),
|
|
82
|
+
limit: z.number().int().positive().max(200).optional().describe(
|
|
83
|
+
`Max items to list (default ${DEFAULT_LIMIT}). Group headings always show the true totals.`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
async ({ project_number, status, limit }) => {
|
|
88
|
+
if (project_number === void 0) {
|
|
89
|
+
const boards = await apiClient.get(
|
|
90
|
+
"/v1/planning/projects"
|
|
91
|
+
);
|
|
92
|
+
if (boards.length === 0) {
|
|
93
|
+
return {
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: "text",
|
|
97
|
+
text: `No project boards found. Connect a GitHub organisation and create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const open = boards.filter((b) => !b.closed);
|
|
103
|
+
if (open.length === 0) {
|
|
104
|
+
return {
|
|
105
|
+
content: [
|
|
106
|
+
{
|
|
107
|
+
type: "text",
|
|
108
|
+
text: `No open project boards (${boards.length} closed). Reopen one, or create a board at ${configLink("/plan", await workspaceBase(apiClient))}`
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const closedNote = boards.length > open.length ? `
|
|
114
|
+
|
|
115
|
+
_${boards.length - open.length} closed board(s) not listed._` : "";
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: "text",
|
|
120
|
+
text: `${open.length} open project board(s):
|
|
121
|
+
|
|
122
|
+
${formatBoards(open)}` + closedNote + `
|
|
123
|
+
|
|
124
|
+
Call get_plan_board with project_number to list a board's issues.`
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const response = await apiClient.get(
|
|
130
|
+
`/v1/planning/projects/${project_number}/items`
|
|
131
|
+
);
|
|
132
|
+
const allItems = response.items ?? [];
|
|
133
|
+
const boardName = response.projectTitle ? `**${response.projectTitle}** \u2014 board #${project_number}` : `Board #${project_number}`;
|
|
134
|
+
if (allItems.length === 0) {
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: `${boardName} has no issues, or it is not visible to this workspace's GitHub connection. Check the board at ${configLink("/plan", await workspaceBase(apiClient))}`
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const grouped = {
|
|
145
|
+
inProgress: [],
|
|
146
|
+
todo: [],
|
|
147
|
+
done: []
|
|
148
|
+
};
|
|
149
|
+
for (const item of allItems) grouped[bucketOf(item)].push(item);
|
|
150
|
+
const wanted = status ? [STATUS_ARG_TO_BUCKET[status]] : BUCKET_ORDER;
|
|
151
|
+
const shown = wanted.reduce((n, b) => n + grouped[b].length, 0);
|
|
152
|
+
if (shown === 0) {
|
|
153
|
+
return {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: "text",
|
|
157
|
+
text: `${boardName}: no items with status "${status}" (of ${allItems.length} item(s) on the board).`
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const cap = limit ?? DEFAULT_LIMIT;
|
|
163
|
+
let budget = cap;
|
|
164
|
+
const sections = [];
|
|
165
|
+
let rendered = 0;
|
|
166
|
+
for (const b of wanted) {
|
|
167
|
+
const items = grouped[b];
|
|
168
|
+
if (items.length === 0) continue;
|
|
169
|
+
const slice = items.slice(0, Math.max(budget, 0));
|
|
170
|
+
budget -= slice.length;
|
|
171
|
+
rendered += slice.length;
|
|
172
|
+
const omitted = items.length - slice.length;
|
|
173
|
+
sections.push(
|
|
174
|
+
`### ${BUCKET_LABELS[b]} (${items.length})
|
|
175
|
+
` + (slice.length > 0 ? slice.map(formatItem).join("\n") : "_(not listed \u2014 item limit reached)_") + (omitted > 0 && slice.length > 0 ? `
|
|
176
|
+
_\u2026${omitted} more not listed._` : "")
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const notes = [];
|
|
180
|
+
if (rendered < shown) {
|
|
181
|
+
notes.push(
|
|
182
|
+
`_Listing ${rendered} of ${shown} matching item(s) \u2014 raise limit (max 200) or filter by status to see the rest._`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (allItems.length >= API_ITEM_PAGE) {
|
|
186
|
+
notes.push(
|
|
187
|
+
`_The API reads the board's first ${API_ITEM_PAGE} items; a larger board may have more._`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
content: [
|
|
192
|
+
{
|
|
193
|
+
type: "text",
|
|
194
|
+
text: `${boardName} \u2014 ${allItems.length} item(s)
|
|
195
|
+
|
|
196
|
+
` + sections.join("\n\n") + (notes.length > 0 ? `
|
|
197
|
+
|
|
198
|
+
${notes.join("\n")}` : "")
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
export {
|
|
206
|
+
bucketOf,
|
|
207
|
+
registerGetPlanBoard
|
|
208
|
+
};
|
package/package.json
CHANGED