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.
Files changed (3) hide show
  1. package/README.md +14 -9
  2. package/dist/main.js +159 -27
  3. 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 environment of the process that launches it,
19
- and refuses to start if either value is missing or malformed — including an unexpanded
20
- `${PIPEBRAIN_TOKEN}` placeholder, which is the failure that otherwise looks like a
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 tokenPrefixSchema = external_exports.string().regex(/^(plt|pbt)_[0-9A-Za-z]{4}$/, {
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
- }).refine((v) => v.title !== void 0 || v.body !== void 0 || v.summary !== void 0 || v.area !== void 0 || v.crossCutting !== void 0, {
15695
- message: "empty edit: supply at least one of `title`, `body`, `summary`, `area`, or `crossCutting`"
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), or deliverable_links (add/remove content-deliverable links, add pins at the current revision). Changing type away from epic is rejected while the epic has criteria (\xA77). 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.',
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 cross_cutting 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 is an empty edit (400) (\xA77). 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.",
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); the empty-edit refine is enforced by
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 (upload_asset/download_asset), never through this result.",
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; use download_asset to write the bytes to a local file.",
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
- registerTool("upload_asset", {
17088
- 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.",
17089
- inputSchema: uploadAssetInputSchema.shape
17090
- }, (args) => run(() => uploadAssetFromPath(client2, apiBaseUrl, args)));
17091
- registerTool("download_asset", {
17092
- 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).",
17093
- inputSchema: downloadAssetInputSchema.shape
17094
- }, (args) => run(() => downloadAssetToPath(client2, args)));
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 (UNEXPANDED_PLACEHOLDER.test(value)) {
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 (!TOKEN_PREFIXES.some((prefix) => value.startsWith(prefix))) {
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.16.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, { version: VERSION });
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.16.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/pipeline.git",
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"