@thehammer/danx-dashboard-mcp 0.1.156 → 0.1.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/field-tree.js +22 -13
- package/dist/handlers.js +23 -64
- package/dist/http-client.js +16 -0
- package/dist/index.js +14 -47
- package/dist/version-floor.js +128 -0
- package/package.json +1 -1
package/dist/field-tree.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-3426 — the shared recursive FIELD-TREE shape.
|
|
3
3
|
*
|
|
4
|
-
* The dashboard's single-card
|
|
5
|
-
* `issue_list
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
4
|
+
* The dashboard's single-card, batch card and plan reads (`issue_get`,
|
|
5
|
+
* `issue_list`, `plan_get` — DX-3428) take a nested JSON tree instead of a
|
|
6
|
+
* flat field-GROUP list: each key names a field or relation of the resource
|
|
7
|
+
* at that level, and each value is either `true` (the field, or a relation's
|
|
8
|
+
* child with its default fields), a non-negative integer (a cursor-paged
|
|
9
|
+
* relation's own `limit` argument), an opaque cursor STRING (a paged
|
|
10
|
+
* relation's `before` — DX-3428: not every relation's cursor is a plain
|
|
11
|
+
* integer, e.g. a plan event's visibility-scanned row id), a non-empty
|
|
12
|
+
* STRING or ARRAY OF STRINGS (a relation's own declared, non-paging argument
|
|
13
|
+
* — e.g. `records`' `kind`, `events`' `kinds`/`origin`/`writer`), or another
|
|
12
14
|
* field tree (a relation's child with a NAMED field selection). Example:
|
|
13
15
|
*
|
|
14
|
-
* {"description": true, "ac": true, "problems": {"solutions": {"steps": true}, "decisions": true}, "comments": {"limit": 10}}
|
|
16
|
+
* {"description": true, "ac": true, "problems": {"solutions": {"steps": true}, "decisions": true}, "comments": {"limit": 10}, "records": {"kind": "goal"}}
|
|
15
17
|
*
|
|
16
18
|
* One schema, one module — every tool that accepts a field tree imports THIS
|
|
17
19
|
* export rather than redeclaring the shape, so the recursion is defined once
|
|
@@ -28,8 +30,15 @@
|
|
|
28
30
|
import { z } from "zod";
|
|
29
31
|
/**
|
|
30
32
|
* The shared schema. A record whose values are `true`, a non-negative
|
|
31
|
-
* integer,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
33
|
+
* integer, a non-empty string, a non-empty array of non-empty strings, or
|
|
34
|
+
* the same schema recursively — generic on purpose (no issue/plan-specific
|
|
35
|
+
* field names baked in here) so every tool that reads a resource tree
|
|
36
|
+
* reuses it unchanged.
|
|
34
37
|
*/
|
|
35
|
-
export const fieldTreeSchema = z.lazy(() => z.record(z.union([
|
|
38
|
+
export const fieldTreeSchema = z.lazy(() => z.record(z.union([
|
|
39
|
+
z.literal(true),
|
|
40
|
+
z.number().int().nonnegative(),
|
|
41
|
+
z.string().min(1),
|
|
42
|
+
z.array(z.string().min(1)).min(1),
|
|
43
|
+
fieldTreeSchema,
|
|
44
|
+
])));
|
package/dist/handlers.js
CHANGED
|
@@ -65,21 +65,6 @@ export const ISSUE_BATCH_GET_MAX = 100;
|
|
|
65
65
|
* `__tests__/handlers.test.ts` asserts the two agree.
|
|
66
66
|
*/
|
|
67
67
|
export const LIST_PAGE_MAX_LIMIT = 1000;
|
|
68
|
-
/**
|
|
69
|
-
* `plan_get`'s `cards_limit` when omitted — mirrors the server's
|
|
70
|
-
* `src/issues/list-page.ts#PLAN_GET_CARDS_DEFAULT_LIMIT`;
|
|
71
|
-
* `__tests__/handlers.test.ts` asserts the two agree.
|
|
72
|
-
*/
|
|
73
|
-
export const PLAN_GET_CARDS_DEFAULT_LIMIT = 200;
|
|
74
|
-
/**
|
|
75
|
-
* DX-3027 — `plan_get`'s `events_limit` default/max — mirrors the server's
|
|
76
|
-
* `src/issues/plans-routes.ts#PLAN_GET_EVENTS_DEFAULT_LIMIT` /
|
|
77
|
-
* `PLAN_GET_EVENTS_MAX_LIMIT`. Deliberately smaller than `LIST_PAGE_MAX_LIMIT`
|
|
78
|
-
* above — an event page is read far more often, and a wide page defeats the
|
|
79
|
-
* point of a cursor. `__tests__/handlers.test.ts` asserts the two agree.
|
|
80
|
-
*/
|
|
81
|
-
export const PLAN_GET_EVENTS_DEFAULT_LIMIT = 50;
|
|
82
|
-
export const PLAN_GET_EVENTS_MAX_LIMIT = 200;
|
|
83
68
|
/**
|
|
84
69
|
* Fetch one card via `GET /api/issues/:id`, or many via
|
|
85
70
|
* `GET /api/issues/batch?ids=...` (DX-2727) — a real batch read that keeps
|
|
@@ -1024,32 +1009,17 @@ export async function issueAttach(client, args, deps = {}) {
|
|
|
1024
1009
|
const PLANS_BASE_PATH = "/api/plans";
|
|
1025
1010
|
const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
|
|
1026
1011
|
/**
|
|
1027
|
-
* DX-
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
* `
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
"records:goal",
|
|
1039
|
-
"records:rule",
|
|
1040
|
-
"records:caveat",
|
|
1041
|
-
"architecture",
|
|
1042
|
-
"sessions",
|
|
1043
|
-
"notes",
|
|
1044
|
-
"events",
|
|
1045
|
-
];
|
|
1046
|
-
/**
|
|
1047
|
-
* DX-3027 — every kind `plan_get`'s `events_kinds` filter accepts. The ONE
|
|
1048
|
-
* copy in this package (the `plan_get` zod enum in `index.ts` reads this
|
|
1049
|
-
* const), duplicated from the server's `src/issues/db/plan-events.ts#PLAN_EVENT_KINDS`
|
|
1050
|
-
* because the published package cannot import server source at runtime.
|
|
1051
|
-
* `__tests__/handlers.test.ts` asserts the two are equal, so drift fails a
|
|
1052
|
-
* test rather than a live call.
|
|
1012
|
+
* DX-3428 — every kind the plan resource's `events` relation accepts as a
|
|
1013
|
+
* `kinds` filter (`{"events": {"kinds": [...]}}`). The ONE copy in this
|
|
1014
|
+
* package (the `resource_fields`-describable `events` relation validates the
|
|
1015
|
+
* same set server-side), duplicated from the server's
|
|
1016
|
+
* `src/issues/db/plan-events.ts#PLAN_EVENT_KINDS` because the published
|
|
1017
|
+
* package cannot import server source at runtime — kept here purely for a
|
|
1018
|
+
* CONSUMER'S typed convenience (`PlanEventKind`); the MCP tool schema itself
|
|
1019
|
+
* no longer enumerates it (DX-3428 moved `plan_get` onto the generic,
|
|
1020
|
+
* resource-agnostic `fieldTreeSchema` every field-tree tool shares — see
|
|
1021
|
+
* `field-tree.ts`). `__tests__/handlers.test.ts` asserts the two are equal,
|
|
1022
|
+
* so drift fails a test rather than a live call.
|
|
1053
1023
|
*/
|
|
1054
1024
|
export const PLAN_EVENT_KINDS = [
|
|
1055
1025
|
"comment_added",
|
|
@@ -1087,10 +1057,11 @@ export const PLAN_EVENT_KINDS = [
|
|
|
1087
1057
|
"plan_auto_sign_off_changed",
|
|
1088
1058
|
];
|
|
1089
1059
|
/**
|
|
1090
|
-
* DX-
|
|
1060
|
+
* DX-3428 — every origin the `events` relation's `origin` filter accepts.
|
|
1091
1061
|
* Mirrors the server's `src/issues/db/plan-events.ts#PlanEventOrigin`
|
|
1092
1062
|
* (itself `ActivityOrigin`) — same drift protection as `PLAN_EVENT_KINDS`
|
|
1093
|
-
* above
|
|
1063
|
+
* above, and likewise kept only for a consumer's typed convenience (not read
|
|
1064
|
+
* by the MCP tool schema any more).
|
|
1094
1065
|
*/
|
|
1095
1066
|
export const PLAN_EVENT_ORIGINS = ["operator", "agent", "machine"];
|
|
1096
1067
|
/**
|
|
@@ -1113,31 +1084,19 @@ export async function planList(client, args = {}) {
|
|
|
1113
1084
|
}
|
|
1114
1085
|
/**
|
|
1115
1086
|
* One plan — its cheap scalars by default, or opt into its cards, its goals
|
|
1116
|
-
* + rules + caveats, its architecture sections,
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1087
|
+
* + rules + caveats, its architecture sections, its sessions and its event
|
|
1088
|
+
* ledger via a field TREE (DX-3428, replacing the retired flat field-GROUP
|
|
1089
|
+
* list). One call rather than several, which is what keeps this tool
|
|
1090
|
+
* surface small enough to be worth an agent's context even once every part
|
|
1091
|
+
* of a plan is opt-in rather than always-on.
|
|
1120
1092
|
*/
|
|
1121
1093
|
export async function planGet(client, args = {}) {
|
|
1122
1094
|
const query = {};
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
query.cards_offset = args.cards_offset;
|
|
1128
|
-
if (args.cards_limit !== undefined)
|
|
1129
|
-
query.cards_limit = args.cards_limit;
|
|
1130
|
-
if (args.events_limit !== undefined)
|
|
1131
|
-
query.events_limit = args.events_limit;
|
|
1132
|
-
if (args.events_before !== undefined)
|
|
1133
|
-
query.events_before = args.events_before;
|
|
1134
|
-
if (args.events_kinds !== undefined && args.events_kinds.length > 0) {
|
|
1135
|
-
query.events_kinds = args.events_kinds.join(",");
|
|
1095
|
+
// DX-3428 — the tree travels as ONE JSON-encoded query param, exactly like
|
|
1096
|
+
// `issue_get`/`issue_list`'s own `fields` (see `issueGet` above).
|
|
1097
|
+
if (args.fields !== undefined && Object.keys(args.fields).length > 0) {
|
|
1098
|
+
query.fields = JSON.stringify(args.fields);
|
|
1136
1099
|
}
|
|
1137
|
-
if (args.events_origin !== undefined)
|
|
1138
|
-
query.events_origin = args.events_origin;
|
|
1139
|
-
if (args.events_writer !== undefined)
|
|
1140
|
-
query.events_writer = args.events_writer;
|
|
1141
1100
|
return client.request({
|
|
1142
1101
|
method: "GET",
|
|
1143
1102
|
path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
|
package/dist/http-client.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
|
|
1
2
|
export class DashboardHttpClient {
|
|
2
3
|
config;
|
|
3
4
|
fetchImpl;
|
|
5
|
+
// DX-3597 — true once this instance has made ONE request, regardless of
|
|
6
|
+
// outcome. Drives the "first call OR any 4xx" version-floor check below.
|
|
7
|
+
hasCheckedVersionFloor = false;
|
|
4
8
|
constructor(config, fetchImpl = fetch) {
|
|
5
9
|
this.config = config;
|
|
6
10
|
this.fetchImpl = fetchImpl;
|
|
@@ -50,6 +54,18 @@ export class DashboardHttpClient {
|
|
|
50
54
|
catch (err) {
|
|
51
55
|
throw new Error(`[danx-dashboard-mcp] network failure on ${args.method} ${url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
52
56
|
}
|
|
57
|
+
// DX-3597 — proactive on the first call this instance ever makes
|
|
58
|
+
// (regardless of outcome: a stale client can otherwise look fine for a
|
|
59
|
+
// while on routes whose shape didn't change), and on every subsequent
|
|
60
|
+
// 4xx (a response that might otherwise read as an ordinary caller
|
|
61
|
+
// error when it's really "you're running an old MCP"). Throws
|
|
62
|
+
// McpOutdatedError instead of returning below when this package is
|
|
63
|
+
// behind the floor — see version-floor.ts's own doc.
|
|
64
|
+
const isFirstCall = !this.hasCheckedVersionFloor;
|
|
65
|
+
this.hasCheckedVersionFloor = true;
|
|
66
|
+
if (isFirstCall || (res.status >= 400 && res.status < 500)) {
|
|
67
|
+
assertVersionFloorSatisfied(res.headers.get(MCP_MIN_VERSION_HEADER));
|
|
68
|
+
}
|
|
53
69
|
const text = await res.text();
|
|
54
70
|
let parsed = null;
|
|
55
71
|
if (text !== "") {
|
package/dist/index.js
CHANGED
|
@@ -108,7 +108,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
108
108
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
109
109
|
import { z } from "zod";
|
|
110
110
|
import { DashboardHttpClient } from "./http-client.js";
|
|
111
|
-
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet,
|
|
111
|
+
import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, dispatchTranscriptSearch, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, qualityGateInstruction, resourceFields, } from "./handlers.js";
|
|
112
112
|
import { PRIORITY_TIER_WORDS } from "./priority.js";
|
|
113
113
|
import { fieldTreeSchema } from "./field-tree.js";
|
|
114
114
|
function readEnvOrDie(name) {
|
|
@@ -915,62 +915,29 @@ strictTool("plan_list", "List every plan, and learn which plan THIS session is c
|
|
|
915
915
|
.optional()
|
|
916
916
|
.describe("Filter to one computed status: awaiting-session, planning, building, awaiting-sign-off, complete. Omit for every plan."),
|
|
917
917
|
}, async (args) => jsonResult(await planList(client, args)));
|
|
918
|
-
strictTool("plan_get",
|
|
918
|
+
strictTool("plan_get",
|
|
919
|
+
// DX-3428 — the retired flat field-GROUP list (`cards`, `records:<kind>`,
|
|
920
|
+
// `cards_offset`/`cards_limit`, `events_*`) is replaced by a JSON field
|
|
921
|
+
// TREE against the plan resource — the SAME `fieldTreeSchema` `issue_get`/
|
|
922
|
+
// `issue_list` already take (`./field-tree.ts`). Every former query param
|
|
923
|
+
// now lives INSIDE the tree as a relation argument.
|
|
924
|
+
"Read a plan. Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{id, ref, name, created_at, signed_off_at, signed_off_by, auto_sign_off, boards, card_count, bucket_counts, status, session, sessionListenerAttached}` — no cards, records, or architecture body. `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass a `fields` tree to opt into: `cards` (member cards — the ISSUE resource itself, so any of `issue_get`'s own fields/relations may be nested under it, e.g. `{\"cards\": {\"title\": true, \"status\": true}}`; cursor-paged via `{\"limit\": N, \"before\": \"<cursor>\"}`, response carries a sibling `cards_page: {limit, total, next_cursor}`), `records` (every goal+rule+caveat) or `{\"records\": {\"kind\": \"goal\"}}` / `{\"kind\": [\"goal\",\"rule\"]}` (narrow to one or more kinds, cheaper), `architecture_sections` (`[{id, plan_id, content_hash, title, content, sort_order, created_at, updated_at}]`), `sessions` (every session connected to the plan), `notes` (the latest milestone-timeline page), `events` (the plan's durable event ledger — every human action and bridge message; cursor-paged via `{\"limit\": N, \"before\": \"<cursor>\"}` plus the filter args `{\"kinds\": [...], \"origin\": \"...\", \"writer\": \"...\"}`; response carries `events` rows plus a sibling `events_page: {limit, total, next_cursor}`; `next_cursor` null on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). Call `resource_fields({resource:\"plan\"})` for the full, current list of what a tree may name — never guess a name; unknown → 400 `unknown_field`. `session`/`sessionListenerAttached` ride every response regardless (not part of the tree — they describe YOUR session, not the plan). `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `content_hash`/`contentHash` back as `base_hash`.", {
|
|
919
925
|
plan_id: z
|
|
920
926
|
.number()
|
|
921
927
|
.int()
|
|
922
928
|
.positive()
|
|
923
929
|
.optional()
|
|
924
930
|
.describe("A plan id from `plan_list`. Omit to read the plan this session is connected to."),
|
|
925
|
-
fields:
|
|
926
|
-
.array(z.enum(PLAN_FIELD_GROUPS))
|
|
927
|
-
.optional()
|
|
928
|
-
.describe("Opt-in field-GROUPS: cards, records (every kind) or records:goal/records:rule/records:caveat (one kind), architecture, sessions, notes, events. Absent/empty = cheap scalars only (plan, boards, cardCount, bucketCounts) plus session state."),
|
|
929
|
-
cards_offset: z
|
|
930
|
-
.number()
|
|
931
|
-
.int()
|
|
932
|
-
.nonnegative()
|
|
933
|
-
.optional()
|
|
934
|
-
.describe("Where the `cards` page starts (default 0). Requires `fields:[\"cards\"]`. Page with cards_offset while cards_offset + cards.length < cards_total."),
|
|
935
|
-
cards_limit: z
|
|
936
|
-
.number()
|
|
937
|
-
.int()
|
|
938
|
-
.positive()
|
|
939
|
-
.max(LIST_PAGE_MAX_LIMIT)
|
|
940
|
-
.optional()
|
|
941
|
-
.describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields:[\"cards\"]`."),
|
|
942
|
-
events_limit: z
|
|
943
|
-
.number()
|
|
944
|
-
.int()
|
|
945
|
-
.positive()
|
|
946
|
-
.max(PLAN_GET_EVENTS_MAX_LIMIT)
|
|
947
|
-
.optional()
|
|
948
|
-
.describe("how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields` to include `events`."),
|
|
949
|
-
events_before: z
|
|
950
|
-
.string()
|
|
951
|
-
.min(1)
|
|
952
|
-
.optional()
|
|
953
|
-
.describe("an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
|
|
954
|
-
events_kinds: z
|
|
955
|
-
.array(z.enum(PLAN_EVENT_KINDS))
|
|
956
|
-
.optional()
|
|
957
|
-
.describe("only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
|
|
958
|
-
events_origin: z
|
|
959
|
-
.enum(PLAN_EVENT_ORIGINS)
|
|
960
|
-
.optional()
|
|
961
|
-
.describe("only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
|
|
962
|
-
events_writer: z
|
|
963
|
-
.string()
|
|
964
|
-
.min(1)
|
|
931
|
+
fields: fieldTreeSchema
|
|
965
932
|
.optional()
|
|
966
|
-
.describe("
|
|
933
|
+
.describe("A field tree (see tool description); absent/empty = cheap scalars only. `resource_fields({resource:\"plan\"})` names every valid key."),
|
|
967
934
|
}, async (args) => jsonResult(await planGet(client, args)));
|
|
968
935
|
strictTool("plan_create", "Create a new, empty plan. Global — not board-scoped. Adds no cards, records, or architecture sections, and does not connect any session (call `plan_connect` separately). Returns `{plan: {id, ref, name, createdAt}}` — `ref` is the short reference (`PLN-<id>`). Use `plan.id` with `plan_connect` to start working on it, or `plan_get({plan_id})` to browse.", {
|
|
969
936
|
name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
|
|
970
937
|
}, async (args) => jsonResult(await planCreate(client, args)));
|
|
971
938
|
strictTool("plan_connect",
|
|
972
939
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
973
|
-
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields:
|
|
940
|
+
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the `plan_get({fields: {...}})` + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards every plan WRITE tool acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape `plan_list`/`plan_get` report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself.", {
|
|
974
941
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
975
942
|
title: z
|
|
976
943
|
.string()
|
|
@@ -994,7 +961,7 @@ async (args) => {
|
|
|
994
961
|
}),
|
|
995
962
|
});
|
|
996
963
|
});
|
|
997
|
-
strictTool("plan_add_record", "Add a goal, rule or caveat to your connected plan (see `kind`'s own values below for what each means — never progress/status/session notes, those are card comments). `body` is one plain statement, at most 250 characters (400 if longer); detail goes in markdown `context`. Allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; not connected → `plan_connect` first. Returns the created record plus `records_count` (that kind's live count, not the whole list) — read the list with `plan_get({fields:
|
|
964
|
+
strictTool("plan_add_record", "Add a goal, rule or caveat to your connected plan (see `kind`'s own values below for what each means — never progress/status/session notes, those are card comments). `body` is one plain statement, at most 250 characters (400 if longer); detail goes in markdown `context`. Allocates a permanent reference (`G-1`, `R-4`, `CAV-12`). Takes no plan id; not connected → `plan_connect` first. Returns the created record plus `records_count` (that kind's live count, not the whole list) — read the list with `plan_get({fields: {records: {kind: \"<kind>\"}}})`.", {
|
|
998
965
|
kind: z.enum(["goal", "rule", "caveat"]).describe("goal = outcome, rule = constraint, caveat = architecture trade-off."),
|
|
999
966
|
body: z.string().min(1).describe("One plain statement, at most 250 characters. Details go in `context`."),
|
|
1000
967
|
context: z.string().optional().describe("Markdown detail behind the statement: evidence, history, examples."),
|
|
@@ -1016,7 +983,7 @@ strictTool("plan_delete_record", 'Soft-delete a goal/rule/caveat; its reference
|
|
|
1016
983
|
record_id: z.number().int().positive().describe("The record id to delete."),
|
|
1017
984
|
content_hash: z.string().describe("The record's `contentHash` from your last read. Required."),
|
|
1018
985
|
}, async (args) => jsonResult(await planDeleteRecord(client, args)));
|
|
1019
|
-
strictTool("plan_add_note", "Write a milestone note to a plan's timeline — a MILESTONE, not a log: use for a card (or group) finishing, an important decision, or a meaningful goal/rule/caveat/architecture change; routine progress stays a card comment. Terse: `title` at most 60 characters, `body` at most 250 (400 if too long, naming the limit). Links resolve on read into their target (a card's title, a record's ref+body, a section's title); an unknown card, unparseable/foreign record ref, or unknown/foreign section id is refused 400 naming which one. A card link needn't be a plan member; a record/section link must belong to THIS plan. `author` is stamped server-side. Takes an explicit `plan_id` (like `plan_remove_card`/`plan_rename`) so a dispatched worker with no plan connection can still write. Unknown plan → 404. Returns the new note plus `notes_count` (the plan's total live count, not the latest page) — read the timeline with `plan_get({fields:
|
|
986
|
+
strictTool("plan_add_note", "Write a milestone note to a plan's timeline — a MILESTONE, not a log: use for a card (or group) finishing, an important decision, or a meaningful goal/rule/caveat/architecture change; routine progress stays a card comment. Terse: `title` at most 60 characters, `body` at most 250 (400 if too long, naming the limit). Links resolve on read into their target (a card's title, a record's ref+body, a section's title); an unknown card, unparseable/foreign record ref, or unknown/foreign section id is refused 400 naming which one. A card link needn't be a plan member; a record/section link must belong to THIS plan. `author` is stamped server-side. Takes an explicit `plan_id` (like `plan_remove_card`/`plan_rename`) so a dispatched worker with no plan connection can still write. Unknown plan → 404. Returns the new note plus `notes_count` (the plan's total live count, not the latest page) — read the timeline with `plan_get({fields: {notes: true}})`.", {
|
|
1020
987
|
plan_id: z.number().int().positive().describe("The plan id, from `plan_list`."),
|
|
1021
988
|
title: z.string().min(1).describe("At most 60 characters."),
|
|
1022
989
|
body: z.string().min(1).describe("The wrap-up, at most 250 characters."),
|
|
@@ -1042,7 +1009,7 @@ strictTool("plan_delete_note", 'Soft-delete a plan note. `content_hash` must be
|
|
|
1042
1009
|
note_id: z.number().int().positive().describe("The note id to delete."),
|
|
1043
1010
|
content_hash: z.string().describe("The note's `contentHash` from your last read. Required."),
|
|
1044
1011
|
}, async (args) => jsonResult(await planDeleteNote(client, args)));
|
|
1045
|
-
strictTool("plan_add_card", "Add an existing card, from ANY board, to the plan this session is connected to. Idempotent (re-adding is a no-op; a card may sit in several plans). Adds MEMBERSHIP only, never edits the card. Takes no plan id — resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns `{card_id, member: true, cards_count}` (the plan's total member-card count, not the full list — a plan can hold hundreds; read it with `plan_get({fields:
|
|
1012
|
+
strictTool("plan_add_card", "Add an existing card, from ANY board, to the plan this session is connected to. Idempotent (re-adding is a no-op; a card may sit in several plans). Adds MEMBERSHIP only, never edits the card. Takes no plan id — resolved from your connected session. Not connected → `{error: \"session_not_connected\"}`. Unknown card → 404. Returns `{card_id, member: true, cards_count}` (the plan's total member-card count, not the full list — a plan can hold hundreds; read it with `plan_get({fields: {cards: true}})`, cursor-paged).", {
|
|
1046
1013
|
card_id: z.string().min(1).describe("An existing card id, e.g. `DX-2683`."),
|
|
1047
1014
|
}, async (args) => jsonResult(await planAddCard(client, args)));
|
|
1048
1015
|
strictTool("plan_remove_card", "Remove a card from a plan — sibling of `plan_add_card`, any board. Idempotent (removing a non-member is a no-op). Removes MEMBERSHIP only — never touches the card itself or its membership in other plans. Unlike `plan_add_card`, takes an EXPLICIT `plan_id` — you may remove from any plan you can name. Unknown plan → 404. Returns `{card_id, member: false, cards_count}` (see `plan_add_card`).", {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3597 — this package's half of the MCP-version-floor guard whose
|
|
3
|
+
* server-side half shipped in DX-3525 (`src/mcp-contract.ts`,
|
|
4
|
+
* `src/dashboard/serve-request.ts`, `src/resources/field-tree.ts`, all in
|
|
5
|
+
* the main danxbot repo — this package cannot import them, see below).
|
|
6
|
+
*
|
|
7
|
+
* The dashboard stamps `X-Danx-Mcp-Min-Version` on every routed `/api/*`
|
|
8
|
+
* response with its currently-advertised floor (`MIN_MCP_VERSION` in
|
|
9
|
+
* `src/mcp-contract.ts` — DX-3597 made that floor track the version where
|
|
10
|
+
* an MCP-exposed contract last changed, not "whatever's currently
|
|
11
|
+
* published"; see that file's module doc for the full decision).
|
|
12
|
+
*
|
|
13
|
+
* `DashboardHttpClient.request()` (`./http-client.ts`) reads this header on
|
|
14
|
+
* the FIRST call this process makes (regardless of outcome — a proactive
|
|
15
|
+
* check, since a stale client can otherwise appear to work for a while on
|
|
16
|
+
* routes whose shape didn't change) and on every subsequent 4xx (a response
|
|
17
|
+
* that might otherwise read as an ordinary caller error, when it's really
|
|
18
|
+
* "you're running an old MCP"). When this package's own version is behind
|
|
19
|
+
* the advertised floor, it throws `McpOutdatedError` instead of returning
|
|
20
|
+
* the ordinary envelope — the same "throw rather than fabricate/silently
|
|
21
|
+
* pass through" contract `http-client.ts` already applies to a 5xx/network
|
|
22
|
+
* failure (see that file's own doc): an outdated MCP is exactly that class
|
|
23
|
+
* of failure, nothing the calling tool can meaningfully work around.
|
|
24
|
+
*
|
|
25
|
+
* Everything here is duplicated, not imported, from the server's
|
|
26
|
+
* `src/mcp-contract.ts` — this package is an INDEPENDENTLY PUBLISHED npm
|
|
27
|
+
* artifact (DX-2103) and cannot depend on the danxbot service's own `src/`
|
|
28
|
+
* tree. Keep the header name, the package name, and the version-compare
|
|
29
|
+
* behavior in agreement with that file by hand when either changes.
|
|
30
|
+
*/
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
/** Must match `MCP_MIN_VERSION_HEADER` in danxbot's `src/mcp-contract.ts` verbatim. */
|
|
35
|
+
export const MCP_MIN_VERSION_HEADER = "X-Danx-Mcp-Min-Version";
|
|
36
|
+
/** Must match `MCP_PACKAGE_NAME` in danxbot's `src/mcp-contract.ts` verbatim. */
|
|
37
|
+
export const MCP_PACKAGE_NAME = "@thehammer/danx-dashboard-mcp";
|
|
38
|
+
/**
|
|
39
|
+
* Numeric-dotted version compare (major.minor.patch, ...) — mirrors
|
|
40
|
+
* `compareMcpVersions` in danxbot's `src/mcp-contract.ts` behavior
|
|
41
|
+
* byte-for-byte (duplicated, not imported — see module doc). Both sides of
|
|
42
|
+
* this contract have only ever used plain numeric dotted versions, so this
|
|
43
|
+
* throws rather than silently falling back to a lexical compare, which
|
|
44
|
+
* would misorder "0.1.9" vs "0.1.10".
|
|
45
|
+
*/
|
|
46
|
+
export function compareMcpVersions(a, b) {
|
|
47
|
+
const parse = (v) => {
|
|
48
|
+
if (!/^\d+(\.\d+)*$/.test(v)) {
|
|
49
|
+
throw new Error(`[danx-dashboard-mcp] "${v}" is not a plain numeric dotted version (major.minor.patch)`);
|
|
50
|
+
}
|
|
51
|
+
return v.split(".").map(Number);
|
|
52
|
+
};
|
|
53
|
+
const pa = parse(a);
|
|
54
|
+
const pb = parse(b);
|
|
55
|
+
const len = Math.max(pa.length, pb.length);
|
|
56
|
+
for (let i = 0; i < len; i++) {
|
|
57
|
+
const da = pa[i] ?? 0;
|
|
58
|
+
const db = pb[i] ?? 0;
|
|
59
|
+
if (da !== db)
|
|
60
|
+
return da - db;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
let cachedOwnVersion;
|
|
65
|
+
/**
|
|
66
|
+
* Reads this package's OWN version from its `package.json` — always present
|
|
67
|
+
* next to `dist/` (or `src/`, under `npm run dev`'s `tsx`) at runtime even
|
|
68
|
+
* though `files: ["dist","README.md"]` excludes it from the tarball
|
|
69
|
+
* MANIFEST: npm always includes `package.json` in what it publishes and
|
|
70
|
+
* what it installs, regardless of `files` (the same guarantee `README` /
|
|
71
|
+
* `LICENSE` / the `main` entry get) — this reads the copy npm actually
|
|
72
|
+
* installed next to this module, not danxbot's own source tree. Both
|
|
73
|
+
* `dist/version-floor.js` and `src/version-floor.ts` sit exactly one
|
|
74
|
+
* directory below the package root, so `../package.json` resolves
|
|
75
|
+
* correctly under both the published (dist) and dev (`tsx src/index.ts`)
|
|
76
|
+
* shapes. Memoized — the version cannot change mid-process.
|
|
77
|
+
*/
|
|
78
|
+
export function readOwnVersion() {
|
|
79
|
+
if (cachedOwnVersion !== undefined)
|
|
80
|
+
return cachedOwnVersion;
|
|
81
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
82
|
+
const pkgJsonPath = join(here, "..", "package.json");
|
|
83
|
+
const raw = readFileSync(pkgJsonPath, "utf8");
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
if (typeof parsed.version !== "string") {
|
|
86
|
+
throw new Error(`[danx-dashboard-mcp] could not read this package's own version from ${pkgJsonPath}`);
|
|
87
|
+
}
|
|
88
|
+
cachedOwnVersion = parsed.version;
|
|
89
|
+
return cachedOwnVersion;
|
|
90
|
+
}
|
|
91
|
+
/** Test-only escape hatch — real callers never need to override the memoized read. */
|
|
92
|
+
export function __resetOwnVersionCacheForTests() {
|
|
93
|
+
cachedOwnVersion = undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Thrown by `assertVersionFloorSatisfied` when this package is running
|
|
97
|
+
* behind the server's advertised floor. Carries structured `have`/`need`
|
|
98
|
+
* fields (not just a prose message) so a caller that wants to branch on
|
|
99
|
+
* "is this specifically an outdated-MCP failure" can check `error.code`
|
|
100
|
+
* rather than pattern-match the message text.
|
|
101
|
+
*/
|
|
102
|
+
export class McpOutdatedError extends Error {
|
|
103
|
+
code = "mcp_outdated";
|
|
104
|
+
have;
|
|
105
|
+
need;
|
|
106
|
+
constructor(have, need) {
|
|
107
|
+
super(`[danx-dashboard-mcp] mcp_outdated: this MCP is running ${MCP_PACKAGE_NAME}@${have}, but the dashboard ` +
|
|
108
|
+
`requires >= ${need}. Restart your Claude Code session to pick up the published update.`);
|
|
109
|
+
this.name = "McpOutdatedError";
|
|
110
|
+
this.have = have;
|
|
111
|
+
this.need = need;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Compares this package's own version against the floor a response header
|
|
116
|
+
* advertised. No-ops (returns) when compatible, or when `headerValue` is
|
|
117
|
+
* `null`/empty (an old-enough server, or a test stub, that never stamped
|
|
118
|
+
* one — nothing to compare against). Throws `McpOutdatedError` when this
|
|
119
|
+
* package is behind.
|
|
120
|
+
*/
|
|
121
|
+
export function assertVersionFloorSatisfied(headerValue) {
|
|
122
|
+
if (!headerValue)
|
|
123
|
+
return;
|
|
124
|
+
const have = readOwnVersion();
|
|
125
|
+
if (compareMcpVersions(have, headerValue) < 0) {
|
|
126
|
+
throw new McpOutdatedError(have, headerValue);
|
|
127
|
+
}
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.158",
|
|
4
4
|
"description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|