pipebrain-mcp 0.16.0 → 0.17.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 +14 -9
- package/dist/main.js +159 -27
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -9,16 +9,24 @@ This package is the **server only**. The command line tool is a separate package
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
Register it with Claude Code (no install step — `npx` fetches it)
|
|
12
|
+
Register it with Claude Code (no install step — `npx` fetches it), at **user scope** so
|
|
13
|
+
every repo on the machine gets the tools:
|
|
13
14
|
|
|
14
15
|
```
|
|
15
|
-
claude mcp add pipebrain -- npx -y pipebrain-mcp
|
|
16
|
+
claude mcp add pipebrain -s user -e PIPEBRAIN_API_URL=https://api.pipebrain.dev -e 'PIPEBRAIN_TOKEN=${PIPEBRAIN_TOKEN}' -- npx -y pipebrain-mcp@latest
|
|
16
17
|
```
|
|
17
18
|
|
|
18
|
-
The server reads its configuration from the
|
|
19
|
-
and refuses to start if either value is
|
|
20
|
-
`${PIPEBRAIN_TOKEN}` placeholder, which is
|
|
21
|
-
revoked token.
|
|
19
|
+
Both `-e` flags are required, not decoration. The server reads its configuration from the
|
|
20
|
+
environment of the process that launches it, and refuses to start if either value is
|
|
21
|
+
missing or malformed — including an unexpanded `${PIPEBRAIN_TOKEN}` placeholder, which is
|
|
22
|
+
the failure that otherwise looks like a revoked token. There is no default API URL (see
|
|
23
|
+
the table below), so a registration that passes neither flag produces a server that never
|
|
24
|
+
starts.
|
|
25
|
+
|
|
26
|
+
The single quotes around the token are load-bearing: they keep `${PIPEBRAIN_TOKEN}` a
|
|
27
|
+
literal *reference* in Claude's config instead of expanding it as you type, so the token
|
|
28
|
+
value itself never lands in a config file — the server resolves it from your environment
|
|
29
|
+
at each launch.
|
|
22
30
|
|
|
23
31
|
## Environment
|
|
24
32
|
|
|
@@ -27,9 +35,6 @@ revoked token.
|
|
|
27
35
|
| `PIPEBRAIN_TOKEN` | Your `pbt_…` API token (Settings → Organization → API tokens). A pre-rename `plt_…` token still works. |
|
|
28
36
|
| `PIPEBRAIN_API_URL` | API base URL, e.g. `https://api.pipebrain.dev`. Required — there is no default, so a session can always tell which environment it is bound to. |
|
|
29
37
|
|
|
30
|
-
The pre-rename names `PIPELINE_TOKEN` and `PIPELINE_API_URL` are still honored as
|
|
31
|
-
fallbacks, indefinitely. When both are set, `PIPEBRAIN_*` wins.
|
|
32
|
-
|
|
33
38
|
Secret VALUES never cross this server: the secrets tools return metadata only, and there
|
|
34
39
|
is deliberately no value-returning tool. Use `ppb secrets` (from `pipebrain-cli`) for the
|
|
35
40
|
reveal plane.
|
package/dist/main.js
CHANGED
|
@@ -14988,6 +14988,7 @@ var orgRoleSchema = external_exports.enum(["OWNER", "ADMIN", "CONTRIBUTOR"]);
|
|
|
14988
14988
|
var orgKindSchema = external_exports.enum(["personal", "shared"]);
|
|
14989
14989
|
var inviteRoleSchema = external_exports.enum(["ADMIN", "CONTRIBUTOR"]);
|
|
14990
14990
|
var requirementLifecycleSchema = external_exports.enum(["draft", "approved", "amended"]);
|
|
14991
|
+
var decisionStatusSchema = external_exports.enum(["proposed", "accepted", "superseded"]);
|
|
14991
14992
|
var platformSchema = external_exports.enum(["bsky", "x", "reddit", "hn", "blog"]);
|
|
14992
14993
|
var assetStatusSchema = external_exports.enum(["pending", "active", "rejected"]);
|
|
14993
14994
|
var assetLinkTargetSchema = external_exports.enum([
|
|
@@ -15040,7 +15041,8 @@ var widgetOriginSchema = external_exports.string().trim().transform((raw) => raw
|
|
|
15040
15041
|
});
|
|
15041
15042
|
var emailSchema = external_exports.email({ message: "enter a valid email address" });
|
|
15042
15043
|
var passwordSchema = external_exports.string().min(8, { message: "password must be at least 8 characters" }).max(128, { message: "password must be at most 128 characters" });
|
|
15043
|
-
var
|
|
15044
|
+
var ACCEPTED_TOKEN_PREFIXES = ["pbt_", "plt_"];
|
|
15045
|
+
var tokenPrefixSchema = external_exports.string().regex(new RegExp(`^(?:${ACCEPTED_TOKEN_PREFIXES.join("|")})[0-9A-Za-z]{4}$`), {
|
|
15044
15046
|
message: "token prefix must be `pbt_` (or the legacy `plt_`) followed by 4 base62 characters"
|
|
15045
15047
|
});
|
|
15046
15048
|
var SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
@@ -15060,6 +15062,9 @@ var issueRefSchema = external_exports.string().min(1, { message: "issue referenc
|
|
|
15060
15062
|
var requirementRefSchema = external_exports.string().min(1, {
|
|
15061
15063
|
message: "requirement reference must not be empty \u2014 use a display id (`DWB-R4`) or uuid"
|
|
15062
15064
|
});
|
|
15065
|
+
var decisionRefSchema = external_exports.string().min(1, {
|
|
15066
|
+
message: "decision reference must not be empty \u2014 use a display id (`DWB-D7`) or uuid"
|
|
15067
|
+
});
|
|
15063
15068
|
var contentRefSchema = external_exports.string().min(1, {
|
|
15064
15069
|
message: "content reference must not be empty \u2014 use a display id (`DWB-C3`) or uuid"
|
|
15065
15070
|
});
|
|
@@ -15603,6 +15608,10 @@ var deliverableLinkChangesSchema = external_exports.strictObject({
|
|
|
15603
15608
|
add: external_exports.array(deliverableRefSchema).optional(),
|
|
15604
15609
|
remove: external_exports.array(deliverableRefSchema).optional()
|
|
15605
15610
|
});
|
|
15611
|
+
var decisionLinkChangesSchema = external_exports.strictObject({
|
|
15612
|
+
add: external_exports.array(decisionRefSchema).optional(),
|
|
15613
|
+
remove: external_exports.array(decisionRefSchema).optional()
|
|
15614
|
+
});
|
|
15606
15615
|
var updateIssueInputSchema = external_exports.strictObject({
|
|
15607
15616
|
issue: issueRefSchema,
|
|
15608
15617
|
title: external_exports.string().min(1, { message: "issue title must not be empty" }).optional(),
|
|
@@ -15613,7 +15622,8 @@ var updateIssueInputSchema = external_exports.strictObject({
|
|
|
15613
15622
|
environment: external_exports.string().min(1, { message: "environment must not be empty" }).optional(),
|
|
15614
15623
|
parent: issueRefSchema.nullable().optional(),
|
|
15615
15624
|
requirementLinks: requirementLinkChangesSchema.optional(),
|
|
15616
|
-
deliverableLinks: deliverableLinkChangesSchema.optional()
|
|
15625
|
+
deliverableLinks: deliverableLinkChangesSchema.optional(),
|
|
15626
|
+
decisionLinks: decisionLinkChangesSchema.optional()
|
|
15617
15627
|
});
|
|
15618
15628
|
var setStatusInputSchema = external_exports.strictObject({
|
|
15619
15629
|
issue: issueRefSchema,
|
|
@@ -15690,14 +15700,41 @@ var updateRequirementInputSchema = external_exports.strictObject({
|
|
|
15690
15700
|
body: markdownSchema.optional(),
|
|
15691
15701
|
area: external_exports.string().optional(),
|
|
15692
15702
|
note: external_exports.string().min(1, { message: "note must not be empty \u2014 omit it if there is nothing to record" }).optional(),
|
|
15693
|
-
crossCutting: external_exports.boolean().optional()
|
|
15694
|
-
|
|
15695
|
-
|
|
15703
|
+
crossCutting: external_exports.boolean().optional(),
|
|
15704
|
+
decisionLinks: decisionLinkChangesSchema.optional()
|
|
15705
|
+
}).refine((v) => v.title !== void 0 || v.body !== void 0 || v.summary !== void 0 || v.area !== void 0 || v.crossCutting !== void 0 || v.decisionLinks !== void 0, {
|
|
15706
|
+
message: "empty edit: supply at least one of `title`, `body`, `summary`, `area`, `crossCutting`, or `decisionLinks`"
|
|
15696
15707
|
});
|
|
15697
15708
|
var approveRequirementInputSchema = external_exports.strictObject({
|
|
15698
15709
|
requirement: requirementRefSchema,
|
|
15699
15710
|
revision: positiveIntSchema.optional()
|
|
15700
15711
|
});
|
|
15712
|
+
var createDecisionInputSchema = external_exports.strictObject({
|
|
15713
|
+
project: projectRefSchema,
|
|
15714
|
+
title: external_exports.string().min(1, { message: "decision title must not be empty" }),
|
|
15715
|
+
body: markdownSchema.min(1, { message: "decision body must not be empty" })
|
|
15716
|
+
});
|
|
15717
|
+
var getDecisionInputSchema = external_exports.strictObject({
|
|
15718
|
+
decision: decisionRefSchema
|
|
15719
|
+
});
|
|
15720
|
+
var updateDecisionInputSchema = external_exports.strictObject({
|
|
15721
|
+
decision: decisionRefSchema,
|
|
15722
|
+
title: external_exports.string().min(1, { message: "decision title must not be empty" }).optional(),
|
|
15723
|
+
body: markdownSchema.min(1, { message: "decision body must not be empty" }).optional(),
|
|
15724
|
+
status: decisionStatusSchema.optional()
|
|
15725
|
+
}).refine((v) => v.title !== void 0 || v.body !== void 0 || v.status !== void 0, {
|
|
15726
|
+
message: "empty edit: supply at least one of `title`, `body`, or `status`"
|
|
15727
|
+
});
|
|
15728
|
+
var supersedeDecisionInputSchema = external_exports.strictObject({
|
|
15729
|
+
decision: decisionRefSchema,
|
|
15730
|
+
replacedBy: decisionRefSchema
|
|
15731
|
+
});
|
|
15732
|
+
var listDecisionsFilterSchema = external_exports.strictObject({
|
|
15733
|
+
project: projectRefSchema.optional(),
|
|
15734
|
+
text: external_exports.string().optional(),
|
|
15735
|
+
limit: external_exports.number().int().min(1, { message: "limit must be at least 1" }).max(500, { message: "limit must be at most 500" }).optional(),
|
|
15736
|
+
offset: external_exports.number().int().nonnegative().optional()
|
|
15737
|
+
});
|
|
15701
15738
|
var claimedByFilterSchema = external_exports.union([
|
|
15702
15739
|
external_exports.literal("me"),
|
|
15703
15740
|
external_exports.literal("unclaimed"),
|
|
@@ -15965,6 +16002,15 @@ function scopesForTool(tool) {
|
|
|
15965
16002
|
});
|
|
15966
16003
|
}
|
|
15967
16004
|
|
|
16005
|
+
// ../../libs/agent-shared/dist/token-shape.js
|
|
16006
|
+
function isAcceptedTokenShape(value) {
|
|
16007
|
+
return ACCEPTED_TOKEN_PREFIXES.some((prefix) => value.startsWith(prefix));
|
|
16008
|
+
}
|
|
16009
|
+
var UNEXPANDED_PLACEHOLDER = /^\$(\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)$/;
|
|
16010
|
+
function isUnexpandedPlaceholder(value) {
|
|
16011
|
+
return UNEXPANDED_PLACEHOLDER.test(value);
|
|
16012
|
+
}
|
|
16013
|
+
|
|
15968
16014
|
// ../../libs/api-client/dist/errors.js
|
|
15969
16015
|
var ApiError = class extends Error {
|
|
15970
16016
|
status;
|
|
@@ -16248,6 +16294,42 @@ var ApiClient = class _ApiClient {
|
|
|
16248
16294
|
body: revision === void 0 ? {} : { revision }
|
|
16249
16295
|
});
|
|
16250
16296
|
}
|
|
16297
|
+
// --- decision records (PIPE-84) --------------------------------------------
|
|
16298
|
+
//
|
|
16299
|
+
// Five routes, no DELETE: PIPE-73 made four record kinds deletable and this epic
|
|
16300
|
+
// puts itself out of that scope explicitly, so a decision record is superseded
|
|
16301
|
+
// rather than removed — which leaves the original readable and says what replaced
|
|
16302
|
+
// it. There is deliberately no `deleteDecision` method here, and there is no route
|
|
16303
|
+
// for one to call.
|
|
16304
|
+
createDecision(input) {
|
|
16305
|
+
return this.request("POST", "/api/v1/decisions", { body: input });
|
|
16306
|
+
}
|
|
16307
|
+
listDecisions(filter = {}) {
|
|
16308
|
+
return this.requestList("GET", "/api/v1/decisions", { query: filter });
|
|
16309
|
+
}
|
|
16310
|
+
getDecision(ref) {
|
|
16311
|
+
return this.request("GET", `/api/v1/decisions/${enc(ref)}`);
|
|
16312
|
+
}
|
|
16313
|
+
/**
|
|
16314
|
+
* The proposed-stage title/body edit AND the move to `accepted`, on the one route
|
|
16315
|
+
* (contract §5). Both refusals are the server's: an edit once the record is
|
|
16316
|
+
* `accepted`/`superseded`, and any transition that is not the next step in
|
|
16317
|
+
* `proposed → accepted → superseded`, each surface as a typed 422.
|
|
16318
|
+
*/
|
|
16319
|
+
updateDecision(ref, fields) {
|
|
16320
|
+
return this.request("PATCH", `/api/v1/decisions/${enc(ref)}`, { body: fields });
|
|
16321
|
+
}
|
|
16322
|
+
/**
|
|
16323
|
+
* Supersedes `ref` with the record at `replacedBy`, in one call, because a
|
|
16324
|
+
* superseded record with nowhere to point is a dead end for whoever opens it. This
|
|
16325
|
+
* is the ONLY way into `superseded` — {@link updateDecision} refuses that target
|
|
16326
|
+
* rather than write a record that names no replacement.
|
|
16327
|
+
*/
|
|
16328
|
+
supersedeDecision(ref, replacedBy) {
|
|
16329
|
+
return this.request("POST", `/api/v1/decisions/${enc(ref)}/supersede`, {
|
|
16330
|
+
body: { replacedBy }
|
|
16331
|
+
});
|
|
16332
|
+
}
|
|
16251
16333
|
// --- content ---------------------------------------------------------------
|
|
16252
16334
|
createContent(input) {
|
|
16253
16335
|
return this.request("POST", "/api/v1/content", { body: input });
|
|
@@ -16454,6 +16536,22 @@ var ApiClient = class _ApiClient {
|
|
|
16454
16536
|
query: { q }
|
|
16455
16537
|
});
|
|
16456
16538
|
}
|
|
16539
|
+
// --- the waiting queue -----------------------------------------------------
|
|
16540
|
+
/**
|
|
16541
|
+
* Every gate awaiting a person across ALL projects of the caller's organisation
|
|
16542
|
+
* (PIPE-87): revisions awaiting approval, questions awaiting an answer, and epics
|
|
16543
|
+
* in `verify` with a criterion not yet passed, longest wait first.
|
|
16544
|
+
*
|
|
16545
|
+
* Takes NO project and NO org: a queue you point at a project is the per-project
|
|
16546
|
+
* surface this replaces, and org scope is the client's own (`X-Org-Id` for a
|
|
16547
|
+
* session, the token's org for a bearer caller). `mine` narrows to the gates whose
|
|
16548
|
+
* work the viewer claimed — plus the requirement gates, which have no claimant at
|
|
16549
|
+
* all and would otherwise be waited on by nobody; omitting it is the wide view.
|
|
16550
|
+
*/
|
|
16551
|
+
listWaiting(filter) {
|
|
16552
|
+
const query = filter?.mine === true ? { mine: "1" } : void 0;
|
|
16553
|
+
return this.requestList("GET", "/api/v1/waiting", { query });
|
|
16554
|
+
}
|
|
16457
16555
|
// --- secrets ---------------------------------------------------------------
|
|
16458
16556
|
// Metadata only: no method here returns a secret VALUE (the reveal plane is a
|
|
16459
16557
|
// separate surface). Every response type is a `*View` with no `value` field.
|
|
@@ -16753,7 +16851,7 @@ ${general}`;
|
|
|
16753
16851
|
}
|
|
16754
16852
|
|
|
16755
16853
|
// ../../libs/mcp-server/dist/server.js
|
|
16756
|
-
var SUCCESSOR_NOTICE = "NOTICE \u2014 this server is the FINAL release of the `@fvcsolutions/pipeline-mcp` package. The server moved to `pipebrain-mcp` on npmjs.org (public, no registry auth), and the CLI moved to `pipebrain-cli`, which provides the `ppb` command in place of `pipeline`. Re-register through the team bootstrap, or: `claude mcp add pipebrain -- npx -y pipebrain-mcp`. Tell the operator; this build still works and will not be updated again.\n\n";
|
|
16854
|
+
var SUCCESSOR_NOTICE = "NOTICE \u2014 this server is the FINAL release of the `@fvcsolutions/pipeline-mcp` package. The server moved to `pipebrain-mcp` on npmjs.org (public, no registry auth), and the CLI moved to `pipebrain-cli`, which provides the `ppb` command in place of `pipeline`. Re-register through the team bootstrap, or: `claude mcp add pipebrain -s user -e PIPEBRAIN_API_URL=https://api.pipebrain.dev -e 'PIPEBRAIN_TOKEN=${PIPEBRAIN_TOKEN}' -- npx -y pipebrain-mcp@latest`. Tell the operator; this build still works and will not be updated again.\n\n";
|
|
16757
16855
|
function successorNotice() {
|
|
16758
16856
|
const armed = process.env.PIPELINE_MCP_SUCCESSOR_NOTICE;
|
|
16759
16857
|
return armed === void 0 || armed === "" ? "" : SUCCESSOR_NOTICE;
|
|
@@ -16809,6 +16907,7 @@ async function resolveDeliverableTargetId(client2, projectKey, ref) {
|
|
|
16809
16907
|
return match.id;
|
|
16810
16908
|
}
|
|
16811
16909
|
function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */ new Map(), opts = {}) {
|
|
16910
|
+
const surface = opts.surface ?? "local";
|
|
16812
16911
|
const server2 = new McpServer({ name: "pipebrain", version: opts.version ?? DEFAULT_VERSION }, {
|
|
16813
16912
|
instructions: withGeneralInstructions(successorNotice() + `Pipebrain MCP server, bound to the ${apiBindingLabel(apiBaseUrl)} API at ${apiBaseUrl}. Every tool reads and writes THAT environment \u2014 confirm it is the one you intend (a local dev API vs production) before mutating. Tools are namespaced by this server's registration name in .mcp.json (e.g. pipebrain-local:* vs pipebrain:*).`, orgInstructions2)
|
|
16814
16913
|
});
|
|
@@ -16833,6 +16932,19 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
|
|
|
16833
16932
|
key: projectKeySchema
|
|
16834
16933
|
}
|
|
16835
16934
|
}, (args) => run(() => client2.createProject(args)));
|
|
16935
|
+
registerTool("list_waiting", {
|
|
16936
|
+
description: "Every gate awaiting a PERSON across ALL projects in the token\u2019s org, longest wait first: a requirement revision awaiting approval, a question awaiting an answer, and an epic in `verify` holding at least one criterion not yet passed. Each entry names its project, the ref the action is taken on (`KEY-R{n}` for a revision, the ISSUE\u2019s `KEY-{n}` for a question or an epic), and `waitingSince` \u2014 the instant the wait began, not a duration. Nothing else is a gate: work in `triage` never appears, and neither does an epic in verify whose criteria have ALL passed. READ-ONLY, and there is no tool on this surface that clears any of the three \u2014 approving a revision, answering a question and marking a manual criterion are all decisions a person takes in the web app. Use it to see what your work is blocked on before asking, rather than to act on it."
|
|
16937
|
+
// NO arguments — not even an org. Org scope arrives from the token, exactly as
|
|
16938
|
+
// it does for `list_projects`, and an org parameter would open a cross-org read
|
|
16939
|
+
// path that has no precedent anywhere on this surface. The viewer narrowing the
|
|
16940
|
+
// web queue offers is filter state on that view, not something an agent picks.
|
|
16941
|
+
}, () => (
|
|
16942
|
+
// `{ items }`, mirroring the HTTP envelope rather than unwrapping to a bare
|
|
16943
|
+
// array like the sibling list tools: AC-7 asks that an agent read the SAME set
|
|
16944
|
+
// the queue shows, and the two surfaces answering in the same shape is what
|
|
16945
|
+
// makes that comparable at a glance.
|
|
16946
|
+
run(async () => ({ items: await client2.listWaiting() }))
|
|
16947
|
+
));
|
|
16836
16948
|
registerTool("create_milestone", {
|
|
16837
16949
|
description: "Create a milestone on a project. `token` is the short ref issues point at via their `milestone` field (e.g. `M1`) and is unique per project (a duplicate is rejected); `label` is its display name (e.g. `Feature Verification`). Optional `position` orders it (0-based, defaults to last); optional `active` marks it the current milestone \u2014 AT MOST ONE milestone per project is active, so `active: true` transactionally DEMOTES the previously active one rather than failing. Returns the created milestone (\xA77).",
|
|
16838
16950
|
inputSchema: {
|
|
@@ -16934,7 +17046,7 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
|
|
|
16934
17046
|
}
|
|
16935
17047
|
}, ({ issue: issue2, ...fields }) => run(() => client2.promoteFeedback(issue2, fields)));
|
|
16936
17048
|
registerTool("update_issue", {
|
|
16937
|
-
description: 'Update any subset of an issue\u2019s title, body, type, priority, milestone, environment, parent (self/cycle rejected), requirement_links (add/remove; adding a link to an unapproved requirement is rejected),
|
|
17049
|
+
description: 'Update any subset of an issue\u2019s title, body, type, priority, milestone, environment, parent (self/cycle rejected), requirement_links (add/remove; adding a link to an unapproved requirement is rejected), deliverable_links (add/remove content-deliverable links, add pins at the current revision), or decision_links (add/remove decision records this work sits under \u2014 `KEY-D{n}`, from `list_decisions`). Changing type away from epic is rejected while the epic has criteria (\xA77). Cite the decision record a piece of work implements rather than restating what was decided in its body: the record is where the reasoning stays correct, and a body repeating it goes stale the moment the decision is superseded. Link another record by key plus title on first mention; never paste its text into a body \u2014 the source changes, the copy does not, and a stale copy gets built. A decision you need from the operator is filed as a question via `ask_questions`, never written as an "Open questions" heading inside a body \u2014 a heading prompts nobody and records no answer, so a later session cannot tell whether the decision was made. Cross-cutting requirements bind every epic automatically and arrive with `get_issue` \u2014 never restate them in a body and never add them to `requirement_links`; an explicit link pins the linked document\u2019s approved revision.',
|
|
16938
17050
|
inputSchema: updateIssueInputSchema.shape
|
|
16939
17051
|
}, ({ issue: issue2, ...fields }) => run(() => client2.updateIssue(issue2, fields)));
|
|
16940
17052
|
registerTool("set_status", {
|
|
@@ -17020,10 +17132,10 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
|
|
|
17020
17132
|
inputSchema: createRequirementInputSchema.shape
|
|
17021
17133
|
}, (args) => run(() => client2.createRequirement(args)));
|
|
17022
17134
|
registerTool("update_requirement", {
|
|
17023
|
-
description: "Update a requirement: title, body, and/or summary APPENDS a new revision (nothing is edited in place; summary versions like title/body); optional note records why. Optional area (product-area label) and
|
|
17135
|
+
description: "Update a requirement: title, body, and/or summary APPENDS a new revision (nothing is edited in place; summary versions like title/body); optional note records why. Optional area (product-area label), cross_cutting, and decision_links (add/remove decision records this rule sits under \u2014 `KEY-D{n}`, from `list_decisions`) are metadata changes (no revision) \u2014 toggling cross_cutting ON is rejected while explicit links exist; unlink first. Supplying none of title/body/summary/area/cross_cutting/decision_links is an empty edit (400) (\xA77). Cite the decision record a rule rests on rather than restating what was decided in its body: the record is where the reasoning stays correct, and a body repeating it goes stale the moment the decision is superseded. Link another record by key plus title on first mention; never paste its text into a body \u2014 the source changes, the copy does not, and a stale copy gets built.",
|
|
17024
17136
|
// Inline shape (updateRequirementInputSchema is a refine, no .shape) — add
|
|
17025
|
-
// summary/area here explicitly (§210)
|
|
17026
|
-
// the API, not this shape.
|
|
17137
|
+
// summary/area here explicitly (§210) and decisionLinks (PIPE-84 §6); the
|
|
17138
|
+
// empty-edit refine is enforced by the API, not this shape.
|
|
17027
17139
|
inputSchema: {
|
|
17028
17140
|
requirement: requirementRefSchema,
|
|
17029
17141
|
title: external_exports.string().min(1).optional(),
|
|
@@ -17031,13 +17143,30 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
|
|
|
17031
17143
|
body: markdownSchema.optional(),
|
|
17032
17144
|
area: external_exports.string().optional(),
|
|
17033
17145
|
note: external_exports.string().min(1).optional(),
|
|
17034
|
-
crossCutting: external_exports.boolean().optional()
|
|
17146
|
+
crossCutting: external_exports.boolean().optional(),
|
|
17147
|
+
decisionLinks: decisionLinkChangesSchema.optional()
|
|
17035
17148
|
}
|
|
17036
17149
|
}, ({ requirement, ...fields }) => run(() => client2.updateRequirement(requirement, fields)));
|
|
17037
17150
|
registerTool("approve_requirement", {
|
|
17038
17151
|
description: "Approve (pin) a requirement\u2019s latest revision, or a specified `revision`, recording it in approvals history and advancing the approved revision that downstream links and reads build against (\xA75, \xA77).",
|
|
17039
17152
|
inputSchema: approveRequirementInputSchema.shape
|
|
17040
17153
|
}, (args) => run(() => client2.approveRequirement(args.requirement, args.revision)));
|
|
17154
|
+
registerTool("create_decision", {
|
|
17155
|
+
description: "Record ONE decision as a project-scoped decision record (`KEY-D{n}`): `title` names the decision and `body` says what was decided and why. A new record starts `proposed`; moving it to `accepted` IS the acceptance \u2014 there is no separate approval step. Returns the created record. A decision record is written ONCE. Title and body can be corrected only while it is `proposed`; from `accepted` onward the record is frozen, and the way to change a decision is to write a NEW record and `supersede_decision` the old one \u2014 which leaves the original readable and says where the current decision went. Nothing on any surface deletes one. Search before you create: `list_decisions` takes a `text` filter matching TITLE AND BODY \u2014 run it on the distinctive nouns first. Sessions do not see each other, so re-arguing a settled question leaves two answers on the ledger with nothing saying which one holds. Link another record by key plus title on first mention; never paste its text into a body \u2014 the source changes, the copy does not, and a stale copy gets built.",
|
|
17156
|
+
inputSchema: createDecisionInputSchema.shape
|
|
17157
|
+
}, (args) => run(() => client2.createDecision(args)));
|
|
17158
|
+
registerTool("get_decision", {
|
|
17159
|
+
description: "Get a decision record by display id (`KEY-D{n}`) or uuid: title, body, lifecycle state (`proposed`/`accepted`/`superseded`), who accepted it and when, the record that replaced it, where the CURRENT decision lives when this one has been superseded, and everything that references it \u2014 every requirement and every work item, gathered from both directions.",
|
|
17160
|
+
inputSchema: getDecisionInputSchema.shape
|
|
17161
|
+
}, (args) => run(() => client2.getDecision(args.decision)));
|
|
17162
|
+
registerTool("list_decisions", {
|
|
17163
|
+
description: "List decision records filtered by `project` and `text`: display id, title, lifecycle state, the replacement when superseded, and how many requirements and work items cite each one. `text` matches over TITLE AND BODY, so search on what a decision was about rather than on what someone chose to call it. Read the decisions before re-opening a settled question: an `accepted` record is the project\u2019s answer, and arguing it again inside an issue or requirement body produces a second answer with nothing saying which one holds. If the answer genuinely has changed, record a NEW decision and `supersede_decision` the old one rather than editing anything.",
|
|
17164
|
+
inputSchema: listDecisionsFilterSchema.shape
|
|
17165
|
+
}, (args) => run(() => client2.listDecisions(args)));
|
|
17166
|
+
registerTool("supersede_decision", {
|
|
17167
|
+
description: "Replace an ACCEPTED decision record with another one: `decision` moves to `superseded` and names `replacedBy` as where the current decision now lives, so whoever opens the old record is sent to the standing one. This is the only way a decision changes \u2014 the original is never edited and never removed, and the whole chain stays readable. Refused when the record is still `proposed` (nothing has been decided yet, so correct it instead), when a record is named as its own replacement, when the replacement belongs to another project, or when the replacement\u2019s own chain already leads back to this record. Returns the superseded record.",
|
|
17168
|
+
inputSchema: supersedeDecisionInputSchema.shape
|
|
17169
|
+
}, (args) => run(() => client2.supersedeDecision(args.decision, args.replacedBy)));
|
|
17041
17170
|
registerTool("list_content", {
|
|
17042
17171
|
description: "List content drafts filtered by project, text, area, and author: display id (KEY-C{n}), title, summary (latest revision), area, author, latest revision number, deliverable count, and linked-issue count. Content is a project-scoped sibling of requirements \u2014 raw material that gets promoted into work, not an issue type.",
|
|
17043
17172
|
inputSchema: listContentFilterSchema.shape
|
|
@@ -17077,21 +17206,23 @@ function createMcpServer(client2, apiBaseUrl, orgInstructions2 = /* @__PURE__ */
|
|
|
17077
17206
|
}
|
|
17078
17207
|
}, ({ deliverable, ...fields }) => run(() => client2.promoteDeliverable(deliverable, fields)));
|
|
17079
17208
|
registerTool("list_assets", {
|
|
17080
|
-
description: "List project assets (S3-backed files of any type) filtered by project, status (pending/active/rejected), category (image/video/document), and filename text, sorted by date or size (asc/desc): id, filename, contentType, size, sha256, status, hasThumbnail, and linkCount (live references). Metadata only \u2014 the bytes move by path
|
|
17209
|
+
description: "List project assets (S3-backed files of any type) filtered by project, status (pending/active/rejected), category (image/video/document), and filename text, sorted by date or size (asc/desc): id, filename, contentType, size, sha256, status, hasThumbnail, and linkCount (live references). Metadata only \u2014 the bytes move by path, never through this result.",
|
|
17081
17210
|
inputSchema: listAssetsFilterSchema.shape
|
|
17082
17211
|
}, (args) => run(() => client2.listAssets(args)));
|
|
17083
17212
|
registerTool("get_asset", {
|
|
17084
|
-
description: "Get an asset\u2019s metadata by uuid: filename, contentType, size, sha256 (client-computed, server-verified by read-back), status, hasThumbnail, linkCount (live references), and timestamps. Metadata only \u2014 no value/URL is returned over MCP
|
|
17213
|
+
description: "Get an asset\u2019s metadata by uuid: filename, contentType, size, sha256 (client-computed, server-verified by read-back), status, hasThumbnail, linkCount (live references), and timestamps. Metadata only \u2014 no value/URL is returned over MCP. To get the bytes, use download_asset (a LOCAL server only \u2014 it writes to a path on the machine running the server) or `ppb assets download`.",
|
|
17085
17214
|
inputSchema: { asset: uuidSchema }
|
|
17086
17215
|
}, (args) => run(() => client2.getAsset(args.asset)));
|
|
17087
|
-
|
|
17088
|
-
|
|
17089
|
-
|
|
17090
|
-
|
|
17091
|
-
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
|
|
17216
|
+
if (surface === "local") {
|
|
17217
|
+
registerTool("upload_asset", {
|
|
17218
|
+
description: "Upload a LOCAL FILE (by path) as a project asset and activate it: reads and SHA-256-hashes the file locally, registers the asset, PUTs the bytes directly to storage, and completes (server verifies the digest by read-back). Returns the asset metadata (status active on success). Only the file PATH crosses the wire \u2014 the bytes never do; do NOT pass base64 or file contents. contentType is guessed from the extension when omitted. Then use link_asset to attach the returned asset to a deliverable revision or a content draft.",
|
|
17219
|
+
inputSchema: uploadAssetInputSchema.shape
|
|
17220
|
+
}, (args) => run(() => uploadAssetFromPath(client2, apiBaseUrl, args)));
|
|
17221
|
+
registerTool("download_asset", {
|
|
17222
|
+
description: "Download an ACTIVE asset\u2019s bytes to a LOCAL FILE at `path` (created/overwritten), so you can read or view an uploaded image, mockup, or document. The bytes are written to disk and re-verified against the recorded sha256; the RESULT is metadata + the written path \u2014 never the bytes or a URL. Addressed by asset uuid (from list_assets, get_asset, or get_content).",
|
|
17223
|
+
inputSchema: downloadAssetInputSchema.shape
|
|
17224
|
+
}, (args) => run(() => downloadAssetToPath(client2, args)));
|
|
17225
|
+
}
|
|
17095
17226
|
registerTool("link_asset", {
|
|
17096
17227
|
description: "Reference an active asset FROM a target. Two targets are wired: `deliverable_revision` (targetId is a content_deliverable_revision uuid, e.g. from get_content) and `content_draft` (targetId is a content_draft uuid \u2014 the asset attaches to the topic doc and is snapshot-copied onto deliverables when they are created). Rejects a non-active asset or a cross-project link.",
|
|
17097
17228
|
inputSchema: linkAssetInputSchema.shape
|
|
@@ -17185,17 +17316,15 @@ ${issues.map((issue2) => ` - ${issue2}`).join("\n")}`);
|
|
|
17185
17316
|
|
|
17186
17317
|
// src/config.ts
|
|
17187
17318
|
var import_dotenv2 = __toESM(require_main(), 1);
|
|
17188
|
-
var UNEXPANDED_PLACEHOLDER = /^\$(\{[^}]*\}|[A-Za-z_][A-Za-z0-9_]*)$/;
|
|
17189
|
-
var TOKEN_PREFIXES = ["pbt_", "plt_"];
|
|
17190
17319
|
var apiTokenSchema2 = external_exports.string().min(1).superRefine((value, ctx) => {
|
|
17191
|
-
if (
|
|
17320
|
+
if (isUnexpandedPlaceholder(value)) {
|
|
17192
17321
|
ctx.addIssue({
|
|
17193
17322
|
code: "custom",
|
|
17194
17323
|
message: `is the literal string \`${value}\` \u2014 an unexpanded shell placeholder, not a token. The variable was unset in the environment that launched this server, so nothing substituted it. Export a pbt_ token before launching, or point the launcher at a wrapper that reads your token file.`
|
|
17195
17324
|
});
|
|
17196
17325
|
return;
|
|
17197
17326
|
}
|
|
17198
|
-
if (!
|
|
17327
|
+
if (!isAcceptedTokenShape(value)) {
|
|
17199
17328
|
ctx.addIssue({
|
|
17200
17329
|
code: "custom",
|
|
17201
17330
|
message: "is not a Pipebrain API token \u2014 expected `pbt_\u2026` (legacy `plt_\u2026` accepted) (value withheld). Mint one under Settings \u2192 Organization \u2192 API tokens."
|
|
@@ -17232,7 +17361,7 @@ function loadMcpConfig() {
|
|
|
17232
17361
|
}
|
|
17233
17362
|
|
|
17234
17363
|
// src/version.ts
|
|
17235
|
-
var VERSION = "0.
|
|
17364
|
+
var VERSION = "0.17.0";
|
|
17236
17365
|
|
|
17237
17366
|
// src/main.ts
|
|
17238
17367
|
var config2 = loadMcpConfig();
|
|
@@ -17241,7 +17370,10 @@ var client = new ApiClient({
|
|
|
17241
17370
|
auth: { bearer: config2.token }
|
|
17242
17371
|
});
|
|
17243
17372
|
var orgInstructions = await fetchOrgInstructionsSafe(client);
|
|
17244
|
-
var server = createMcpServer(client, config2.apiUrl, orgInstructions, {
|
|
17373
|
+
var server = createMcpServer(client, config2.apiUrl, orgInstructions, {
|
|
17374
|
+
version: VERSION,
|
|
17375
|
+
surface: "local"
|
|
17376
|
+
});
|
|
17245
17377
|
var transport = new StdioServerTransport();
|
|
17246
17378
|
await server.connect(transport);
|
|
17247
17379
|
var notice = successorNotice();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pipebrain-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Pipebrain MCP server for Claude Code and Claude Desktop — issues, requirements, content, assets, and metadata-only secrets over the Pipebrain API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
|
-
"url": "git+https://github.com/fvcsolutions/
|
|
16
|
+
"url": "git+https://github.com/fvcsolutions/pipebrain.git",
|
|
17
17
|
"directory": "apps/mcp"
|
|
18
18
|
},
|
|
19
19
|
"publishConfig": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
27
27
|
"build": "node build.mjs",
|
|
28
28
|
"build:mcpb": "nx build pipebrain-mcp && node mcpb/build-mcpb.mjs",
|
|
29
|
-
"prepack": "npx nx build pipebrain-mcp"
|
|
29
|
+
"prepack": "npx nx build pipebrain-mcp && node ../../scripts/check-publishable.mjs"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "1.29.0"
|