@nestr/mcp 0.1.73 → 0.1.89

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 (45) hide show
  1. package/build/api/client.d.ts +30 -2
  2. package/build/api/client.d.ts.map +1 -1
  3. package/build/api/client.js +38 -3
  4. package/build/api/client.js.map +1 -1
  5. package/build/help/articles.d.ts +146 -0
  6. package/build/help/articles.d.ts.map +1 -0
  7. package/build/help/articles.js +574 -0
  8. package/build/help/articles.js.map +1 -0
  9. package/build/help/cross-links.d.ts +21 -0
  10. package/build/help/cross-links.d.ts.map +1 -0
  11. package/build/help/cross-links.js +61 -0
  12. package/build/help/cross-links.js.map +1 -0
  13. package/build/help/topics.d.ts.map +1 -1
  14. package/build/help/topics.js +312 -12
  15. package/build/help/topics.js.map +1 -1
  16. package/build/http.d.ts +4 -13
  17. package/build/http.d.ts.map +1 -1
  18. package/build/http.js +341 -89
  19. package/build/http.js.map +1 -1
  20. package/build/oauth/client-info.d.ts +58 -0
  21. package/build/oauth/client-info.d.ts.map +1 -0
  22. package/build/oauth/client-info.js +68 -0
  23. package/build/oauth/client-info.js.map +1 -0
  24. package/build/oauth/config.d.ts +19 -0
  25. package/build/oauth/config.d.ts.map +1 -1
  26. package/build/oauth/config.js +12 -0
  27. package/build/oauth/config.js.map +1 -1
  28. package/build/server.d.ts +7 -0
  29. package/build/server.d.ts.map +1 -1
  30. package/build/server.js +23 -4
  31. package/build/server.js.map +1 -1
  32. package/build/skills/tension-processing.d.ts.map +1 -1
  33. package/build/skills/tension-processing.js +11 -1
  34. package/build/skills/tension-processing.js.map +1 -1
  35. package/build/tools/index.d.ts +493 -13
  36. package/build/tools/index.d.ts.map +1 -1
  37. package/build/tools/index.js +509 -58
  38. package/build/tools/index.js.map +1 -1
  39. package/build/tools/validation.d.ts +42 -0
  40. package/build/tools/validation.d.ts.map +1 -0
  41. package/build/tools/validation.js +97 -0
  42. package/build/tools/validation.js.map +1 -0
  43. package/package.json +2 -1
  44. package/web/index.html +25 -0
  45. package/web/styles.css +62 -0
@@ -7,6 +7,25 @@ import { NestrApiError } from "../api/client.js";
7
7
  import { appResources } from "../apps/index.js";
8
8
  import { getCorrelationId } from "../util/request-context.js";
9
9
  import { VERSION } from "../version.js";
10
+ import { PRIME_LABELS, PrimeLabelConflictError, validatePrimeLabels, ensureMeetingModifier } from "./validation.js";
11
+ // Tools exposed on the PUBLIC (unauthenticated) MCP surface. These three make
12
+ // zero authenticated Nestr API calls: nestr_help and nestr_diagnose never touch
13
+ // the API, and nestr_get_me is short-circuited to a guest payload in public mode
14
+ // (see _handleToolCall). Everything else stays behind auth on POST /mcp.
15
+ export const PUBLIC_TOOL_NAMES = new Set([
16
+ "nestr_help",
17
+ "nestr_diagnose",
18
+ "nestr_get_me",
19
+ ]);
20
+ // Guest identity returned by nestr_get_me on the public surface. No Nestr API
21
+ // call is made — this is a fixed payload telling the agent it has product-help
22
+ // access only and how to unlock workspace tools.
23
+ export const PUBLIC_GUEST_ME = {
24
+ authMode: "public",
25
+ user: null,
26
+ mode: "guest",
27
+ hint: "Guest mode: product help only. Add AI credit / sign in for workspace tools.",
28
+ };
10
29
  // MCP Apps UI metadata for tools that can render in the completable list app.
11
30
  // IMPORTANT: Only use for completable items (tasks, projects, todos, inbox items).
12
31
  // Do NOT use for structural nests like roles, circles, metrics, policies, etc.
@@ -84,6 +103,24 @@ const HINT_URL_PATTERNS = [
84
103
  return result;
85
104
  },
86
105
  },
106
+ // /nests/{id}/search?search=... → nestr_search scoped to that nest with in:{id}
107
+ {
108
+ pattern: /^\/nests\/([^/]+)\/search$/,
109
+ tool: "nestr_search",
110
+ params: (m, sp, workspaceId) => {
111
+ const search = sp.get("search") || "";
112
+ const result = { query: `in:${m[1]} ${search}`.trim() };
113
+ if (workspaceId)
114
+ result.workspaceId = workspaceId;
115
+ return result;
116
+ },
117
+ },
118
+ // /workspaces/{id}/search?search=... → nestr_search at workspace scope
119
+ {
120
+ pattern: /^\/workspaces\/([^/]+)\/search$/,
121
+ tool: "nestr_search",
122
+ params: (m, sp) => ({ workspaceId: m[1], query: sp.get("search") || "" }),
123
+ },
87
124
  // /nests/{id}/posts → nestr_get_comments
88
125
  { pattern: /^\/nests\/([^/]+)\/posts$/, tool: "nestr_get_comments", params: (m) => ({ nestId: m[1] }) },
89
126
  // /nests/{id}/tensions → nestr_list_tensions
@@ -91,6 +128,119 @@ const HINT_URL_PATTERNS = [
91
128
  // /nests/{id} → nestr_get_nest (must be last — catches all /nests/{id} patterns)
92
129
  { pattern: /^\/nests\/([^/]+)$/, tool: "nestr_get_nest", params: (m) => ({ nestId: m[1] }) },
93
130
  ];
131
+ const HINT_ENDPOINT_TOOL_MAPPINGS = [
132
+ {
133
+ method: "POST",
134
+ pattern: /^\/nests\/?$/,
135
+ tool: "nestr_create_nest",
136
+ pathParamNames: [],
137
+ bodyParams: new Set([
138
+ "parentId", "title", "description", "purpose", "labels",
139
+ "fields", "users", "accountabilities", "domains", "workspaceId",
140
+ ]),
141
+ },
142
+ {
143
+ method: "POST",
144
+ pattern: /^\/nests\/([^/]+)\/tensions\/?$/,
145
+ tool: "nestr_create_tension",
146
+ pathParamNames: ["nestId"],
147
+ bodyParams: new Set(["title", "description", "feeling", "needs"]),
148
+ },
149
+ {
150
+ method: "POST",
151
+ pattern: /^\/nests\/([^/]+)\/tensions\/([^/]+)\/parts\/?$/,
152
+ tool: "nestr_add_tension_part",
153
+ pathParamNames: ["nestId", "tensionId"],
154
+ bodyParams: new Set([
155
+ "_id", "title", "labels", "description", "purpose",
156
+ "parentId", "users", "due", "accountabilities", "domains",
157
+ "roleId", // election mode
158
+ ]),
159
+ },
160
+ // PATCH /parts (body has _id) — propose a change to an existing item.
161
+ // Same tool as POST /parts (which proposes a new item); the _id discriminates.
162
+ {
163
+ method: "PATCH",
164
+ pattern: /^\/nests\/([^/]+)\/tensions\/([^/]+)\/parts\/?$/,
165
+ tool: "nestr_add_tension_part",
166
+ pathParamNames: ["nestId", "tensionId"],
167
+ bodyParams: new Set([
168
+ "_id", "title", "labels", "description", "purpose",
169
+ "parentId", "users", "due", "accountabilities", "domains",
170
+ ]),
171
+ },
172
+ // DELETE /parts (body has _id) — propose deletion of an existing item.
173
+ // Same tool, with removeNest:true to disambiguate from a change proposal.
174
+ {
175
+ method: "DELETE",
176
+ pattern: /^\/nests\/([^/]+)\/tensions\/([^/]+)\/parts\/?$/,
177
+ tool: "nestr_add_tension_part",
178
+ pathParamNames: ["nestId", "tensionId"],
179
+ bodyParams: new Set(["_id"]),
180
+ extraParams: { removeNest: true },
181
+ },
182
+ {
183
+ method: "DELETE",
184
+ pattern: /^\/nests\/([^/]+)\/tensions\/([^/]+)\/?$/,
185
+ tool: "nestr_delete_tension",
186
+ pathParamNames: ["nestId", "tensionId"],
187
+ bodyParams: new Set([]),
188
+ },
189
+ ];
190
+ /** Strip optional host + /api prefix so we match against canonical routes. */
191
+ function normalizeEndpointPath(path) {
192
+ const hostStripped = path.replace(/^https?:\/\/[^/]+/, "");
193
+ return hostStripped.replace(/^\/api(?=\/)/, "");
194
+ }
195
+ /**
196
+ * Translate one API hint endpoint into an MCP tool-call suggestion.
197
+ * Returns null for routes we don't have a mapping for — never guesses a tool.
198
+ */
199
+ export function translateEndpoint(endpoint) {
200
+ if (!endpoint || typeof endpoint !== "object")
201
+ return null;
202
+ const method = (endpoint.method || "").toUpperCase();
203
+ if (!method)
204
+ return null;
205
+ const path = normalizeEndpointPath(endpoint.path || "");
206
+ for (const mapping of HINT_ENDPOINT_TOOL_MAPPINGS) {
207
+ if (mapping.method !== method)
208
+ continue;
209
+ const match = path.match(mapping.pattern);
210
+ if (!match)
211
+ continue;
212
+ const parametersExample = {};
213
+ mapping.pathParamNames.forEach((name, i) => {
214
+ parametersExample[name] = match[i + 1];
215
+ });
216
+ if (mapping.extraParams)
217
+ Object.assign(parametersExample, mapping.extraParams);
218
+ const droppedFields = [];
219
+ const body = endpoint.body_example;
220
+ if (body && typeof body === "object" && !Array.isArray(body)) {
221
+ for (const [key, value] of Object.entries(body)) {
222
+ if (mapping.bodyParams.has(key)) {
223
+ parametersExample[key] = value;
224
+ }
225
+ else {
226
+ droppedFields.push(key);
227
+ }
228
+ }
229
+ }
230
+ const toolCall = {
231
+ tool: mapping.tool,
232
+ purpose: endpoint.purpose,
233
+ parametersExample,
234
+ };
235
+ if (droppedFields.length > 0) {
236
+ toolCall.notes =
237
+ `Body fields not exposed by ${mapping.tool} (set these manually if needed): ` +
238
+ droppedFields.join(", ");
239
+ }
240
+ return toolCall;
241
+ }
242
+ return null;
243
+ }
94
244
  // Enrich hints with tool call parameters so models can act on hints directly.
95
245
  // Extracts workspaceId from nest ancestors (last element) for search-based hints.
96
246
  export function enrichHints(data) {
@@ -111,28 +261,91 @@ export function enrichHints(data) {
111
261
  const ancestors = record.ancestors;
112
262
  const workspaceId = ancestors?.length ? ancestors[ancestors.length - 1] : undefined;
113
263
  record.hints = record.hints.map((hint) => {
114
- if (!hint.url)
115
- return hint;
116
- // Parse URL and query params — strip absolute URL prefix if present
117
- let rawUrl = hint.url;
118
- const apiPrefixMatch = rawUrl.match(/^https?:\/\/[^/]+\/api(\/.*)/);
119
- if (apiPrefixMatch)
120
- rawUrl = apiPrefixMatch[1];
121
- const [path, queryString] = rawUrl.split("?");
122
- const searchParams = new URLSearchParams(queryString || "");
123
- for (const { pattern, tool, params } of HINT_URL_PATTERNS) {
124
- const match = path.match(pattern);
125
- if (match) {
126
- return { ...hint, toolCall: { tool, params: params(match, searchParams, workspaceId) } };
264
+ const enriched = { ...hint };
265
+ // Legacy: single URL → toolCall. Kept for backwards compatibility with
266
+ // hints that pre-date the endpoints[] payload.
267
+ if (hint.url) {
268
+ let rawUrl = hint.url;
269
+ const apiPrefixMatch = rawUrl.match(/^https?:\/\/[^/]+\/api(\/.*)/);
270
+ if (apiPrefixMatch)
271
+ rawUrl = apiPrefixMatch[1];
272
+ const [path, queryString] = rawUrl.split("?");
273
+ const searchParams = new URLSearchParams(queryString || "");
274
+ let matched = false;
275
+ for (const { pattern, tool, params } of HINT_URL_PATTERNS) {
276
+ const match = path.match(pattern);
277
+ if (match) {
278
+ enriched.toolCall = { tool, params: params(match, searchParams, workspaceId) };
279
+ matched = true;
280
+ break;
281
+ }
282
+ }
283
+ if (!matched) {
284
+ console.error(`[nestr-mcp] Unrecognized hint URL pattern: "${hint.url}" (hint type: ${hint.type})`);
285
+ }
286
+ }
287
+ // New: endpoints[] → toolCalls[]. Each endpoint becomes one tool-call
288
+ // suggestion; unmapped routes are dropped silently (no invented tools).
289
+ if (Array.isArray(hint.endpoints) && hint.endpoints.length > 0) {
290
+ const toolCalls = hint.endpoints
291
+ .map((endpoint) => translateEndpoint(endpoint))
292
+ .filter((tc) => tc !== null);
293
+ if (toolCalls.length > 0) {
294
+ enriched.toolCalls = toolCalls;
127
295
  }
128
296
  }
129
- // Log unrecognized hint URLs so we can add mappings when the API adds new patterns
130
- console.error(`[nestr-mcp] Unrecognized hint URL pattern: "${hint.url}" (hint type: ${hint.type})`);
131
- return hint;
297
+ return enriched;
132
298
  });
133
299
  }
134
300
  return data;
135
301
  }
302
+ // Canonical web URL for a nest in the Nestr app.
303
+ // Pattern: /n/{parentId}/{id} when a parent context is known, /n/{id} otherwise.
304
+ // Parent 'inbox' is treated as no parent — inbox is not a navigable container.
305
+ const NESTR_WEB_BASE = "https://app.nestr.io";
306
+ function buildNestUrl(id, parentId) {
307
+ if (parentId && parentId.toLowerCase() !== "inbox") {
308
+ return `${NESTR_WEB_BASE}/n/${parentId}/${id}`;
309
+ }
310
+ return `${NESTR_WEB_BASE}/n/${id}`;
311
+ }
312
+ // Heuristic: does this object look like a nest (vs. a user, label, error, etc.)?
313
+ // Nests have _id plus at least one of parentId, ancestors, or a labels[] array.
314
+ // Workspaces qualify via labels[]; circles/roles/projects/tasks/comments via parentId.
315
+ function looksLikeNest(obj) {
316
+ if (typeof obj._id !== "string")
317
+ return false;
318
+ if ("username" in obj)
319
+ return false; // users
320
+ if (typeof obj.parentId === "string")
321
+ return true;
322
+ if (Array.isArray(obj.ancestors))
323
+ return true;
324
+ if (Array.isArray(obj.labels))
325
+ return true;
326
+ return false;
327
+ }
328
+ // Recursively add a `url` field to every nest-shaped object in the response.
329
+ // Walks arrays, wrapped { data: [...] } responses, and any nested object/array
330
+ // values. Skips non-nest shapes (users, labels, errors, tension parts).
331
+ export function addNestUrls(data) {
332
+ if (!data || typeof data !== "object")
333
+ return data;
334
+ if (Array.isArray(data)) {
335
+ return data.map((item) => addNestUrls(item));
336
+ }
337
+ const record = data;
338
+ const out = { ...record };
339
+ if (looksLikeNest(record) && typeof out.url !== "string") {
340
+ out.url = buildNestUrl(record._id, record.parentId);
341
+ }
342
+ for (const [key, value] of Object.entries(out)) {
343
+ if (value && typeof value === "object") {
344
+ out[key] = addNestUrls(value);
345
+ }
346
+ }
347
+ return out;
348
+ }
136
349
  // Coerce JSON-stringified arrays/objects before Zod validation.
137
350
  // Some MCP clients send array/object params as JSON strings (e.g., "[\"project\"]" instead of ["project"]).
138
351
  const coerceFromJson = (schema) => z.preprocess((val) => {
@@ -146,6 +359,32 @@ const coerceFromJson = (schema) => z.preprocess((val) => {
146
359
  }
147
360
  return val;
148
361
  }, schema);
362
+ // Coerce an integer-array param to number[] even when a client serialises it as
363
+ // a string — e.g. a stale/cached tool schema that doesn't know the array type
364
+ // sends "[4,5,6]", "4,5,6", or a bare 4. Non-numeric tokens are dropped and the
365
+ // wrapped schema validates the rest. Prevents "Expected array, received string".
366
+ const coerceIntArray = (schema) => z.preprocess((val) => {
367
+ const toNums = (arr) => arr.map(Number).filter(Number.isFinite);
368
+ if (typeof val === 'number')
369
+ return [val];
370
+ if (Array.isArray(val))
371
+ return toNums(val);
372
+ if (typeof val === 'string') {
373
+ const s = val.trim();
374
+ if (!s)
375
+ return undefined;
376
+ try {
377
+ const parsed = JSON.parse(s);
378
+ if (Array.isArray(parsed))
379
+ return toNums(parsed);
380
+ if (typeof parsed === 'number')
381
+ return [parsed];
382
+ }
383
+ catch { /* not JSON — fall through to delimiter split */ }
384
+ return toNums(s.replace(/[[\]]/g, '').split(/[\s,]+/).filter(Boolean));
385
+ }
386
+ return val;
387
+ }, schema);
149
388
  // Tool input schemas using Zod
150
389
  export const schemas = {
151
390
  listWorkspaces: z.object({
@@ -217,11 +456,13 @@ export const schemas = {
217
456
  }),
218
457
  addComment: z.object({
219
458
  nestId: z.string().describe("Nest ID to comment on"),
220
- body: z.string().describe("Comment text (supports HTML and @mentions: @{userId}, @{email}, @{circle})"),
459
+ body: z.string().describe("Comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)."),
460
+ labels: z.array(z.string()).optional().describe("Optional label IDs to attach to the comment at creation time (e.g., 'decision', 'question', or a custom label ID). Personal labels are auto-scoped to the authenticated user. Use nestr_list_labels / nestr_list_personal_labels to discover IDs."),
221
461
  }),
222
462
  updateComment: z.object({
223
463
  commentId: z.string().describe("Comment ID to update"),
224
- body: z.string().describe("Updated comment text (supports HTML and @mentions: @{userId}, @{email}, @{circle})"),
464
+ body: z.string().describe("Updated comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)."),
465
+ labels: z.array(z.string()).optional().describe("Optional full set of label IDs for the comment. When provided, this REPLACES the comment's existing labels. To incrementally add or remove a single label without replacing the rest, use nestr_add_label / nestr_remove_label with the commentId as the nestId."),
225
466
  }),
226
467
  deleteComment: z.object({
227
468
  commentId: z.string().describe("Comment ID to delete"),
@@ -272,8 +513,8 @@ export const schemas = {
272
513
  _listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Engineering projects\"). Omit for default."),
273
514
  }),
274
515
  getComments: z.object({
275
- nestId: z.string().describe("Nest ID to get comments from"),
276
- depth: z.number().optional().describe("Comment thread depth (default: all)"),
516
+ nestId: z.string().describe("Nest ID to get comments from. Pass a workspace ID to gather communication across the whole workspace (combine with depth='all')."),
517
+ depth: z.union([z.number(), z.literal("all")]).optional().describe("How deep below the context nest to look for comments. 0 (default) returns only comments directly on this nest; N includes comments on descendants up to N levels deep; 'all' includes comments on this nest and every descendant. Use 'all' on a workspace or circle nest to analyse large sets of communication in one call."),
277
518
  }),
278
519
  getCircle: z.object({
279
520
  workspaceId: z.string().describe("Workspace ID"),
@@ -427,11 +668,13 @@ export const schemas = {
427
668
  description: z.string().optional().describe("The primary content field — detailed information about the item. Supports Markdown and HTML."),
428
669
  purpose: z.string().optional().describe("ONLY for roles/circles — a short aspirational statement. Do NOT put detailed information here; use description instead. Supports HTML."),
429
670
  parentId: z.string().optional().describe("Parent ID — use to move/restructure items (e.g., move role to different circle)"),
430
- users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign (e.g., for role elections: assign the elected user to the role)"),
431
- due: z.string().optional().describe("Due date / re-election date (ISO format)"),
671
+ users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign. For an election (with roleId), the single user being elected, e.g. [\"userId\"]."),
672
+ due: z.string().optional().describe("Due date / re-election date (ISO format). For an election (with roleId), the term end — omit to elect without a term."),
432
673
  accountabilities: coerceFromJson(z.array(z.string())).optional().describe("Accountability titles to set on a role (replaces all — use children endpoint for individual management)"),
433
674
  domains: coerceFromJson(z.array(z.string())).optional().describe("Domain titles to set on a role (replaces all — use children endpoint for individual management)"),
434
- }),
675
+ roleId: z.string().optional().describe("Hold an ELECTION: the electable role to fill (Facilitator/Secretary/Rep Link or any electable role). Assigns or reconfirms the role's filler for a term WITHOUT changing its accountabilities/domains — provide users:[userId] (one person) and optional due (term). Do not combine with _id."),
676
+ removeNest: z.boolean().optional().describe("Set true with _id to propose deletion of the referenced governance item (when the proposal is accepted, the item is removed). Distinct from nestr_remove_tension_part, which undoes a proposal part you already added. Requires _id; other body fields are ignored."),
677
+ }).refine((data) => !data.removeNest || !!data._id, { message: "removeNest:true requires _id to identify which item to propose for deletion" }).refine((data) => !data.roleId || (Array.isArray(data.users) && data.users.length === 1), { message: "An election (roleId) requires exactly one user to elect — users: [userId]." }).refine((data) => !(data.roleId && data._id), { message: "Provide either roleId (to hold an election) or _id (to change/delete an existing item), not both." }),
435
678
  modifyTensionPart: z.object({
436
679
  nestId: z.string().describe("ID of the circle or role the tension belongs to"),
437
680
  tensionId: z.string().describe("Tension ID"),
@@ -509,8 +752,12 @@ export const schemas = {
509
752
  targetId: z.string().describe("Target nest ID to unlink"),
510
753
  }),
511
754
  help: z.object({
512
- topic: z.string().describe("Topic key (e.g., 'search', 'labels', 'tensions'). Use 'topics' for the full list."),
513
- }),
755
+ topic: z.string().optional().describe("Topic key (e.g., 'search', 'labels', 'tensions'). Use 'topics' for the full list. If the key isn't a known internal topic, it's tried as a help-article slug from nestr.io/help/articles/<slug>; the response's 'Resolved as:' line says which matched."),
756
+ search: z.string().optional().describe("Free-text query against the public help-article index (nestr.io/help/articles/*). Tolerates typos and common synonyms. Returns ranked matches, each with a title and one-line summary; fetch one with `topic: <slug>`."),
757
+ includeImages: z.boolean().optional().describe("Help-article mode only. Default false: a fetch returns markdown + a numbered image-URL list, with NO image blocks. Set true to also attach the first maxImages content screenshots as inline image content (base64, downscaled to bound token cost), in document order. Decorative images (uncaptioned, or the header/thumbnail before the first content heading) are never auto-attached — request them by index via imageIndexes. Use when the user wants to *see* how something looks. Ignored for internal topics and search."),
758
+ imageIndexes: coerceIntArray(z.array(z.number().int().nonnegative()).optional()).describe("Help-article mode only. Attach specific screenshots by their [index] from the numbered 'Images in this article' list shown in the response footer of a prior fetch. Pass an array of integers, e.g. [4,5,6]. Overrides the default selection AND the maxImages cap — exactly these indexes attach, in order (a [decorative] image attaches only when explicitly listed here). Ignored for internal topics and search."),
759
+ maxImages: coerceFromJson(z.number().int().positive().optional()).describe("Help-article mode only. Cap on how many screenshots the default selection attaches (the first N content images in document order). Default 3, max 6. Ignored when imageIndexes is provided."),
760
+ }).refine((v) => Boolean(v.topic) || Boolean(v.search), { message: "Provide either `topic` or `search`." }),
514
761
  diagnose: z.object({}).describe("No arguments — diagnose reads session state from the server."),
515
762
  };
516
763
  // Tool annotations for MCP - hints for clients on tool behavior
@@ -521,13 +768,16 @@ const destructive = { annotations: { readOnlyHint: false, destructiveHint: true
521
768
  export const toolDefinitions = [
522
769
  {
523
770
  name: "nestr_help",
524
- description: "Get detailed Nestr documentation by topic. Call before unfamiliar operations. Topics: search, labels, nest-model, inbox, daily-plan, notifications, insights, tension-processing, skills, mcp-apps, authentication, and more. Use topic 'topics' for the full list. Auth: none required.",
771
+ description: "Get Nestr documentation. Three modes: (1) internal MCP-flavoured topic pass `topic` with one of the curated keys (search, labels, nest-model, inbox, daily-plan, notifications, insights, tension-processing, skills, mcp-apps, authentication, scrum, okr, ...); use topic 'topics' for the full list. (2) Help-article fetch — pass `topic` with a slug from nestr.io/help/articles/<slug>; returns the article as markdown plus a numbered list of its images (with URLs and captions). Images are NOT attached by default. Pass `includeImages: true` to also attach the first `maxImages` (default 3, max 6) content screenshots as renderable image blocks (downscaled; decorative header/thumbnail/uncaptioned images skipped), or `imageIndexes: [..]` to attach specific ones from the numbered list (e.g. a burndown chart further down, ignoring the cap). Use images when the user wants to *see* how something looks. The tool tries internal topics first, then falls back to article fetch. (3) Help-article search — pass `search` with a free-text query; returns ranked matches, each with a title and one-line summary. Search tolerates typos and common synonyms (e.g. kanban/sprint→scrum). Every response opens with a 'Resolved as:' line stating which mode answered, and internal topics and articles cross-link to each other. Call this before unfamiliar operations. Auth: none required.",
525
772
  inputSchema: {
526
773
  type: "object",
527
774
  properties: {
528
- topic: { type: "string", description: "Topic key. Use 'topics' for the full list." },
775
+ topic: { type: "string", description: "Internal topic key or help-article slug. Use 'topics' for the full list of internal topics." },
776
+ search: { type: "string", description: "Free-text query against the public help articles. Returns slugs to fetch via `topic`." },
777
+ includeImages: { type: "boolean", description: "Help-article mode only. Default false (markdown + numbered image-URL list, no image blocks). Set true to attach the first maxImages content screenshots as inline image content (base64, downscaled). Decorative header/thumbnail/uncaptioned images are never auto-attached — use imageIndexes for those. Ignored for internal topics and search." },
778
+ imageIndexes: { type: "array", items: { type: "integer", minimum: 0 }, description: "Help-article mode only. Attach specific screenshots by their [index] from the numbered 'Images in this article' list in a prior response's footer, e.g. [4,5,6]. Overrides the default selection and the maxImages cap; attaches exactly these indexes in order. Ignored for internal topics and search." },
779
+ maxImages: { type: "integer", minimum: 1, description: "Help-article mode only. Cap on screenshots in the default selection (first N content images). Default 3, max 6. Ignored when imageIndexes is provided." },
529
780
  },
530
- required: ["topic"],
531
781
  },
532
782
  ...readOnly,
533
783
  },
@@ -660,7 +910,7 @@ export const toolDefinitions = [
660
910
  },
661
911
  {
662
912
  name: "nestr_create_nest",
663
- description: "Create a nest under a parent. Use labels to define type (e.g., ['project'], ['role']). For governance changes in established workspaces, prefer the tension flow. See nestr_help('labels') for available types.",
913
+ description: "Create a nest under a parent. Use labels to define type (e.g., ['project'], ['role']). Apply at most ONE prime label per nest (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback, userstory, sprint, epic, milestone) — they define the nest's core identity and cannot coexist. Sole exception: userstory may pair with project (userstory implies project); sprint/epic/milestone may not, and stories link to those containers via graph relations instead. For governance changes in established workspaces, prefer the tension flow. See nestr_help('labels') for available types.",
664
914
  inputSchema: {
665
915
  type: "object",
666
916
  properties: {
@@ -703,7 +953,7 @@ export const toolDefinitions = [
703
953
  },
704
954
  {
705
955
  name: "nestr_update_nest",
706
- description: "Update nest properties. Set parentId to move. Only send fields you want to change. For governance changes, prefer tensions. See nestr_help('nest-model') for fields and data namespacing.",
956
+ description: "Update nest properties. Set parentId to move. Only send fields you want to change. When replacing `labels`, keep at most ONE prime label (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback, userstory, sprint, epic, milestone) — they define the nest's core identity. Sole exception: userstory may pair with project (userstory implies project). For governance changes, prefer tensions. See nestr_help('nest-model') for fields and data namespacing.",
707
957
  inputSchema: {
708
958
  type: "object",
709
959
  properties: {
@@ -771,12 +1021,17 @@ export const toolDefinitions = [
771
1021
  },
772
1022
  {
773
1023
  name: "nestr_add_comment",
774
- description: "Add a comment to a nest. Supports HTML and @mentions (@{userId}, @{email}, @{circle}). Use for progress updates and discussion.",
1024
+ description: "Add a comment to a nest. Supports HTML and @mentions — **mentions MUST be wrapped in literal curly braces** (e.g. `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`); without the braces the user is not notified. Prefer `@{userId:roleId}` so the recipient knows which role they're being addressed in. Use for progress updates and discussion. Optionally attach labels at creation time via the `labels` parameter.",
775
1025
  inputSchema: {
776
1026
  type: "object",
777
1027
  properties: {
778
1028
  nestId: { type: "string", description: "Nest ID to comment on" },
779
- body: { type: "string", description: "Comment text (supports HTML and @mentions: @{userId}, @{email}, @{circle})" },
1029
+ body: { type: "string", description: "Comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)." },
1030
+ labels: {
1031
+ type: "array",
1032
+ items: { type: "string" },
1033
+ description: "Optional label IDs to attach to the comment at creation time (e.g., 'decision', 'question', or a custom label ID). Personal labels are auto-scoped to the authenticated user. Use nestr_list_labels / nestr_list_personal_labels to discover IDs.",
1034
+ },
780
1035
  },
781
1036
  required: ["nestId", "body"],
782
1037
  },
@@ -784,12 +1039,17 @@ export const toolDefinitions = [
784
1039
  },
785
1040
  {
786
1041
  name: "nestr_update_comment",
787
- description: "Update an existing comment's body. Supports HTML and @mentions.",
1042
+ description: "Update an existing comment's body and/or labels. Supports HTML and @mentions — **mentions MUST be wrapped in literal curly braces** (e.g. `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`); without the braces the user is not notified. When `labels` is provided it REPLACES the existing label set — use nestr_add_label / nestr_remove_label for incremental changes.",
788
1043
  inputSchema: {
789
1044
  type: "object",
790
1045
  properties: {
791
1046
  commentId: { type: "string", description: "Comment ID to update" },
792
- body: { type: "string", description: "Updated comment text (supports HTML and @mentions: @{userId}, @{email}, @{circle})" },
1047
+ body: { type: "string", description: "Updated comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)." },
1048
+ labels: {
1049
+ type: "array",
1050
+ items: { type: "string" },
1051
+ description: "Optional full set of label IDs for the comment. When provided, this REPLACES the comment's existing labels. To incrementally add or remove a single label without replacing the rest, use nestr_add_label / nestr_remove_label with the commentId as the nestId.",
1052
+ },
793
1053
  },
794
1054
  required: ["commentId", "body"],
795
1055
  },
@@ -931,12 +1191,15 @@ export const toolDefinitions = [
931
1191
  },
932
1192
  {
933
1193
  name: "nestr_get_comments",
934
- description: "Get comments and discussion history on a nest.",
1194
+ description: "Get comments and discussion history on a nest, including full nested reply threads. By default returns only comments posted directly on the given nest. Widen with depth to also include comments on descendant nests, or pass a workspace/circle nest ID with depth='all' to gather large sets of communication for analysis.",
935
1195
  inputSchema: {
936
1196
  type: "object",
937
1197
  properties: {
938
- nestId: { type: "string", description: "Nest ID to get comments from" },
939
- depth: { type: "number", description: "Comment thread depth (default: all)" },
1198
+ nestId: { type: "string", description: "Nest ID to get comments from. Pass a workspace ID to gather communication across the whole workspace (combine with depth='all')." },
1199
+ depth: {
1200
+ oneOf: [{ type: "number" }, { type: "string", enum: ["all"] }],
1201
+ description: "How deep below the context nest to look for comments. 0 (default) returns only comments directly on this nest; N includes comments on descendants up to N levels deep; 'all' includes comments on this nest and every descendant.",
1202
+ },
940
1203
  },
941
1204
  required: ["nestId"],
942
1205
  },
@@ -1184,7 +1447,7 @@ export const toolDefinitions = [
1184
1447
  // Label management
1185
1448
  {
1186
1449
  name: "nestr_add_label",
1187
- description: "Add a label to a nest. Personal labels (like 'now') are automatically scoped to the authenticated user by the API.",
1450
+ description: "Add a label to a nest. Personal labels (like 'now') are automatically scoped to the authenticated user by the API. Will reject any attempt to add a prime label (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback) to a nest that already has one — a nest can only have one core identity.",
1188
1451
  inputSchema: {
1189
1452
  type: "object",
1190
1453
  properties: {
@@ -1390,7 +1653,7 @@ export const toolDefinitions = [
1390
1653
  },
1391
1654
  {
1392
1655
  name: "nestr_add_tension_part",
1393
- description: "Add or modify a governance proposal on a tension. To add new: omit _id. To modify existing: include _id with changed fields. See nestr_help('tension-processing').",
1656
+ description: "Add a governance proposal part to a tension. Four modes: (1) propose a new item — omit _id, provide title/labels/etc.; (2) propose changes to an existing item — provide _id plus the fields to change (note: editing a role this way copies its existing accountabilities/domains into the proposal, so it reads as a full role edit); (3) propose deletion of an existing item — provide _id and removeNest:true; (4) hold an election — provide roleId (the electable role to fill) plus users:[userId] and optional due (term), which assigns/reconfirms the role's filler WITHOUT changing its accountabilities/domains. See nestr_help('tension-processing').",
1394
1657
  inputSchema: {
1395
1658
  type: "object",
1396
1659
  properties: {
@@ -1402,10 +1665,12 @@ export const toolDefinitions = [
1402
1665
  description: { type: "string", description: "The primary content field — detailed information about the item. Supports Markdown and HTML." },
1403
1666
  purpose: { type: "string", description: "ONLY for roles/circles — a short aspirational statement. Do NOT put detailed information here; use description instead. Supports HTML." },
1404
1667
  parentId: { type: "string", description: "Parent ID — use to move/restructure items (e.g., move role to different circle)" },
1405
- users: { type: "array", items: { type: "string" }, description: "User IDs to assign (e.g., for elections: assign elected user to the role)" },
1406
- due: { type: "string", description: "Due date / re-election date (ISO format)" },
1668
+ users: { type: "array", items: { type: "string" }, description: "User IDs to assign. For an election (with roleId), the single user being elected, e.g. [\"userId\"]." },
1669
+ due: { type: "string", description: "Due date / re-election date (ISO format). For an election (with roleId), the term end — omit to elect without a term." },
1407
1670
  accountabilities: { type: "array", items: { type: "string" }, description: "Accountability titles to set on a role (replaces all — use children endpoint for individual management)" },
1408
1671
  domains: { type: "array", items: { type: "string" }, description: "Domain titles to set on a role (replaces all — use children endpoint for individual management)" },
1672
+ roleId: { type: "string", description: "Hold an ELECTION: the electable role to fill (Facilitator/Secretary/Rep Link or any electable role). Assigns/reconfirms the role's filler for a term WITHOUT changing its accountabilities/domains — provide users:[userId] (one person) and optional due (term). Do not combine with _id." },
1673
+ removeNest: { type: "boolean", description: "Set true with _id to propose deletion of the referenced governance item (when the proposal is accepted, the item is removed). Distinct from nestr_remove_tension_part, which undoes a proposal part you already added." },
1409
1674
  },
1410
1675
  required: ["nestId", "tensionId"],
1411
1676
  },
@@ -1647,27 +1912,172 @@ export async function handleToolCall(client, name, args, context) {
1647
1912
  const shouldStripDescription = sanitizedArgs.stripDescription === true;
1648
1913
  const result = await _handleToolCall(client, name, sanitizedArgs, context);
1649
1914
  if (shouldStripDescription && !result.isError) {
1650
- try {
1651
- const parsed = JSON.parse(result.content[0].text);
1652
- result.content[0].text = JSON.stringify(stripDescriptionFields(parsed), null, 2);
1653
- }
1654
- catch {
1655
- // If parsing fails, return as-is
1915
+ const first = result.content[0];
1916
+ if (first && first.type === "text") {
1917
+ try {
1918
+ const parsed = JSON.parse(first.text);
1919
+ first.text = JSON.stringify(stripDescriptionFields(parsed), null, 2);
1920
+ }
1921
+ catch {
1922
+ // If parsing fails, return as-is
1923
+ }
1656
1924
  }
1657
1925
  }
1658
1926
  return result;
1659
1927
  }
1660
1928
  async function _handleToolCall(client, name, args, context) {
1661
1929
  try {
1930
+ // PUBLIC surface gate (defense in depth — the public route also filters the
1931
+ // advertised tool list). Refuse anything outside the public allow-list so a
1932
+ // hand-crafted tools/call can never reach an authenticated Nestr path, and
1933
+ // serve nestr_get_me from a fixed guest payload without an API call.
1934
+ if (context?.isPublic) {
1935
+ if (!PUBLIC_TOOL_NAMES.has(name)) {
1936
+ return formatError({
1937
+ error: true,
1938
+ code: "AUTH_SCOPE_INSUFFICIENT",
1939
+ message: `Tool '${name}' is not available on the public Nestr MCP. Guest mode exposes product help only (${[...PUBLIC_TOOL_NAMES].join(", ")}).`,
1940
+ retryable: false,
1941
+ hint: "Add AI credit / sign in and connect to the authenticated MCP endpoint to use workspace tools.",
1942
+ });
1943
+ }
1944
+ if (name === "nestr_get_me") {
1945
+ schemas.getMe.parse(args);
1946
+ return formatResult(PUBLIC_GUEST_ME);
1947
+ }
1948
+ }
1662
1949
  switch (name) {
1663
1950
  case "nestr_help": {
1664
1951
  const parsed = schemas.help.parse(args);
1665
1952
  const { HELP_TOPICS } = await import("../help/topics.js");
1666
- const content = HELP_TOPICS[parsed.topic];
1667
- if (!content) {
1668
- return { content: [{ type: "text", text: `Unknown topic: "${parsed.topic}". Call nestr_help({ topic: "topics" }) to see available topics.` }] };
1953
+ const { relatedArticlesForTopic, relatedTopicForArticle } = await import("../help/cross-links.js");
1954
+ // Search mode: query the public help-article index. Returns a ranked
1955
+ // list of slugs; the caller pulls a specific article with a second
1956
+ // call passing `topic: <slug>`.
1957
+ if (parsed.search) {
1958
+ const { loadArticleIndex, searchArticleIndex, fetchArticleMeta } = await import("../help/articles.js");
1959
+ try {
1960
+ const entries = await loadArticleIndex();
1961
+ const hits = searchArticleIndex(entries, parsed.search, 8);
1962
+ if (hits.length === 0) {
1963
+ return { content: [{ type: "text", text: `_Resolved as: help-article search._\n\nNo help articles matched "${parsed.search}". Try broader terms or a synonym, or call nestr_help({ topic: "topics" }) for internal MCP topics.` }] };
1964
+ }
1965
+ // Enrich the top hits with a title + one-line summary so the caller
1966
+ // can pick the right article without a blind fetch. Best-effort:
1967
+ // a meta-fetch failure just falls back to the bare slug for that row.
1968
+ const ENRICH = 5;
1969
+ const metas = await Promise.allSettled(hits.slice(0, ENRICH).map(h => fetchArticleMeta(h.slug)));
1970
+ const lines = hits.map((h, i) => {
1971
+ const settled = i < ENRICH ? metas[i] : undefined;
1972
+ const meta = settled?.status === "fulfilled" ? settled.value : undefined;
1973
+ const topic = relatedTopicForArticle(h.slug);
1974
+ const seeAlso = topic ? ` _(see also internal topic \`${topic}\`)_` : "";
1975
+ if (meta?.title) {
1976
+ const summary = meta.description ? ` — ${meta.description}` : "";
1977
+ return `- \`${h.slug}\` — **${meta.title}**${summary}${seeAlso}`;
1978
+ }
1979
+ return `- \`${h.slug}\` — ${h.url}${seeAlso}`;
1980
+ });
1981
+ const body = [
1982
+ `_Resolved as: help-article search._`,
1983
+ ``,
1984
+ `Found ${hits.length} help article${hits.length === 1 ? "" : "s"} for "${parsed.search}". Fetch one with nestr_help({ topic: "<slug>" }).`,
1985
+ ``,
1986
+ ...lines,
1987
+ ].join("\n");
1988
+ return { content: [{ type: "text", text: body }] };
1989
+ }
1990
+ catch (err) {
1991
+ const message = err instanceof Error ? err.message : String(err);
1992
+ return { content: [{ type: "text", text: `Help-article search failed: ${message}. Internal topics still available via nestr_help({ topic: "topics" }).` }], isError: true };
1993
+ }
1994
+ }
1995
+ // Topic mode: prefer the curated internal topic; if there's no match,
1996
+ // try the slug as a help article. Network failures on the fallback are
1997
+ // surfaced rather than masked — a stale link is more useful than a
1998
+ // generic "not found". Every branch opens with a "Resolved as:" line so
1999
+ // the caller knows which source answered, even if a slug ever shadows
2000
+ // an internal key.
2001
+ const topic = parsed.topic;
2002
+ const content = HELP_TOPICS[topic];
2003
+ if (content) {
2004
+ const related = relatedArticlesForTopic(topic);
2005
+ const footer = related.length
2006
+ ? `\n\n---\nRelated public help article${related.length === 1 ? "" : "s"} (fetch with nestr_help({ topic: "<slug>" })): ${related.map(s => `\`${s}\``).join(", ")}`
2007
+ : "";
2008
+ return { content: [{ type: "text", text: `_Resolved as: internal MCP topic "${topic}"._\n\n${content}${footer}` }] };
2009
+ }
2010
+ const { fetchArticleMarkdown, extractImages, collectArticleImages, selectImageIndexes, clampMaxImages } = await import("../help/articles.js");
2011
+ try {
2012
+ const article = await fetchArticleMarkdown(topic);
2013
+ const images = extractImages(article.markdown);
2014
+ const relatedTopic = relatedTopicForArticle(article.slug);
2015
+ // Image attachment is opt-in: only when the caller explicitly asks via
2016
+ // includeImages:true (default selection) or imageIndexes (exact
2017
+ // entries). A plain fetch returns markdown + the numbered URL list, so
2018
+ // the agent/user can decide whether the screenshots are worth the
2019
+ // tokens, then re-call to pull them. Fetch first so the text list can
2020
+ // mark which entries were attached; best-effort — failures just leave
2021
+ // the text list, which always carries every image's URL.
2022
+ const imageOpts = { indexes: parsed.imageIndexes, max: parsed.maxImages };
2023
+ const hasExplicitIndexes = (parsed.imageIndexes?.length ?? 0) > 0;
2024
+ const wantImages = parsed.includeImages === true || hasExplicitIndexes;
2025
+ const selectedCount = wantImages && images.length ? selectImageIndexes(images, imageOpts).length : 0;
2026
+ const inlined = wantImages && images.length ? await collectArticleImages(images, imageOpts) : [];
2027
+ const attached = new Set(inlined.map(img => img.index));
2028
+ // Surface a clear, prominent hint whenever the article has screenshots
2029
+ // so the caller knows they exist and how to pull them in (images are
2030
+ // opt-in). Adapts to whether any are already attached.
2031
+ const cap = clampMaxImages(parsed.maxImages);
2032
+ const contentCount = images.filter(img => !img.decorative).length;
2033
+ let imageHint = "";
2034
+ if (inlined.length > 0) {
2035
+ const more = images.length - inlined.length;
2036
+ imageHint = more > 0
2037
+ ? `_${inlined.length} screenshot${inlined.length === 1 ? "" : "s"} attached below as viewable image${inlined.length === 1 ? "" : "s"}. ${more} more are listed under the article — request any by [index] with imageIndexes:[..]._`
2038
+ : `_${inlined.length} screenshot${inlined.length === 1 ? "" : "s"} attached below as viewable image${inlined.length === 1 ? "" : "s"}._`;
2039
+ }
2040
+ else if (contentCount > 0) {
2041
+ imageHint = `_This article has ${contentCount} screenshot${contentCount === 1 ? "" : "s"} you can view — not attached by default. Re-call nestr_help with includeImages:true to attach the first ${cap}, or imageIndexes:[..] for specific ones (see the numbered list below)._`;
2042
+ }
2043
+ const parts = [
2044
+ `_Resolved as: help article "${article.slug}" (fetched from ${article.url})._`,
2045
+ ``,
2046
+ `# ${article.title || article.slug}`,
2047
+ ];
2048
+ if (article.description)
2049
+ parts.push(``, `> ${article.description}`);
2050
+ if (imageHint)
2051
+ parts.push(``, imageHint);
2052
+ parts.push(``, article.markdown);
2053
+ if (images.length) {
2054
+ parts.push(``, `---`, `Images in this article (${images.length}) — [index] is stable; [decorative] = header/thumbnail (not auto-attached). Attach with includeImages:true (first ${cap} content images) or imageIndexes:[..]:`, ...images.map((img, i) => {
2055
+ const tag = img.decorative ? " [decorative]" : "";
2056
+ const captionText = img.caption ? `"${img.caption}"` : "(no caption)";
2057
+ const mark = attached.has(i) ? " — attached inline below" : "";
2058
+ return `- [${i}]${tag} ${captionText} — ${img.url}${mark}`;
2059
+ }));
2060
+ if (wantImages && inlined.length === 0) {
2061
+ const why = selectedCount === 0
2062
+ ? (parsed.imageIndexes?.length ? "the requested imageIndexes were out of range" : "this article has no non-decorative content screenshots")
2063
+ : "the selected images could not be fetched";
2064
+ parts.push(``, `_(No images attached — ${why}.)_`);
2065
+ }
2066
+ }
2067
+ if (relatedTopic) {
2068
+ parts.push(``, `---`, `Related internal MCP topic (agent-flavoured tool-call guidance): \`${relatedTopic}\` — fetch with nestr_help({ topic: "${relatedTopic}" }).`);
2069
+ }
2070
+ parts.push(``, `---`, `Source: ${article.url}`);
2071
+ const content = [{ type: "text", text: parts.join("\n") }];
2072
+ for (const img of inlined) {
2073
+ content.push({ type: "image", data: img.data, mimeType: img.mimeType });
2074
+ }
2075
+ return { content };
2076
+ }
2077
+ catch (err) {
2078
+ const message = err instanceof Error ? err.message : String(err);
2079
+ return { content: [{ type: "text", text: `_Resolved as: not found._\n\nUnknown topic: "${topic}". Tried internal topics (call nestr_help({ topic: "topics" }) for the full list) and the help-article fetch (failed: ${message}). To search the help site instead, call nestr_help({ search: "<query>" }).` }] };
1669
2080
  }
1670
- return { content: [{ type: "text", text: content }] };
1671
2081
  }
1672
2082
  case "nestr_diagnose": {
1673
2083
  schemas.diagnose.parse(args);
@@ -1756,6 +2166,8 @@ async function _handleToolCall(client, name, args, context) {
1756
2166
  }
1757
2167
  case "nestr_create_nest": {
1758
2168
  const parsed = schemas.createNest.parse(args);
2169
+ validatePrimeLabels(parsed.labels);
2170
+ parsed.labels = ensureMeetingModifier(parsed.labels);
1759
2171
  const hasGovernanceLabels = parsed.labels?.some(l => ["role", "circle"].includes(l));
1760
2172
  const hasInlineGovernance = parsed.accountabilities?.length || parsed.domains?.length;
1761
2173
  // Route to self-organization API when creating roles/circles with accountabilities/domains
@@ -1794,6 +2206,8 @@ async function _handleToolCall(client, name, args, context) {
1794
2206
  }
1795
2207
  case "nestr_update_nest": {
1796
2208
  const parsed = schemas.updateNest.parse(args);
2209
+ validatePrimeLabels(parsed.labels);
2210
+ parsed.labels = ensureMeetingModifier(parsed.labels);
1797
2211
  const hasInlineGovernance = parsed.accountabilities?.length || parsed.domains?.length;
1798
2212
  // Route to self-organization API when updating roles/circles with accountabilities/domains
1799
2213
  if (hasInlineGovernance && parsed.workspaceId) {
@@ -1840,13 +2254,16 @@ async function _handleToolCall(client, name, args, context) {
1840
2254
  }
1841
2255
  case "nestr_add_comment": {
1842
2256
  const parsed = schemas.addComment.parse(args);
1843
- const post = await client.createPost(parsed.nestId, parsed.body);
2257
+ const post = await client.createPost(parsed.nestId, parsed.body, {
2258
+ labels: parsed.labels,
2259
+ });
1844
2260
  return formatResult({ message: "Comment added successfully", post });
1845
2261
  }
1846
2262
  case "nestr_update_comment": {
1847
2263
  const parsed = schemas.updateComment.parse(args);
1848
2264
  const updated = await client.updateNest(parsed.commentId, {
1849
2265
  title: parsed.body,
2266
+ ...(parsed.labels !== undefined ? { labels: parsed.labels } : {}),
1850
2267
  });
1851
2268
  return formatResult({ message: "Comment updated successfully", comment: updated });
1852
2269
  }
@@ -2036,6 +2453,13 @@ async function _handleToolCall(client, name, args, context) {
2036
2453
  // Label management
2037
2454
  case "nestr_add_label": {
2038
2455
  const parsed = schemas.addLabel.parse(args);
2456
+ // Only fetch existing labels when applying a prime label — for all
2457
+ // other labels there's no possible conflict, so skip the extra call.
2458
+ if (PRIME_LABELS.has(parsed.labelId)) {
2459
+ const existing = await client.getNest(parsed.nestId);
2460
+ const existingNest = Array.isArray(existing) ? existing[0] : existing;
2461
+ validatePrimeLabels([...(existingNest?.labels ?? []), parsed.labelId]);
2462
+ }
2039
2463
  const nest = await client.addLabel(parsed.nestId, parsed.labelId);
2040
2464
  return formatResult({ message: `Label '${parsed.labelId}' added successfully`, nest: compactResponse(nest) });
2041
2465
  }
@@ -2177,17 +2601,17 @@ async function _handleToolCall(client, name, args, context) {
2177
2601
  description: parsed.description,
2178
2602
  ...(Object.keys(fields).length > 0 ? { fields } : {}),
2179
2603
  });
2180
- return formatResult({ message: "Tension created successfully", tension });
2604
+ return formatResult({ message: "Tension created successfully", tension: enrichHints(tension) });
2181
2605
  }
2182
2606
  case "nestr_get_tension": {
2183
2607
  const parsed = schemas.getTension.parse(args);
2184
2608
  const tension = await client.getTension(parsed.nestId, parsed.tensionId, { cleanText: true });
2185
- return formatResult(tension);
2609
+ return formatResult(enrichHints(tension));
2186
2610
  }
2187
2611
  case "nestr_list_tensions": {
2188
2612
  const parsed = schemas.listTensions.parse(args);
2189
2613
  const tensions = await client.listTensions(parsed.nestId, parsed.search, { limit: parsed.limit, order: parsed.order, cleanText: true });
2190
- return formatResult(compactResponse(tensions));
2614
+ return formatResult(compactResponse(enrichHints(tensions)));
2191
2615
  }
2192
2616
  case "nestr_update_tension": {
2193
2617
  const parsed = schemas.updateTension.parse(args);
@@ -2215,8 +2639,24 @@ async function _handleToolCall(client, name, args, context) {
2215
2639
  }
2216
2640
  case "nestr_add_tension_part": {
2217
2641
  const parsed = schemas.addTensionPart.parse(args);
2218
- const { nestId, tensionId, ...body } = parsed;
2219
- if (body._id) {
2642
+ const { nestId, tensionId, removeNest, roleId, ...body } = parsed;
2643
+ if (roleId) {
2644
+ // Hold an election: assign/reconfirm the role's filler for a term without
2645
+ // changing its accountabilities/domains. Reuses `users` (the elected person)
2646
+ // and `due` (the term). The schema guarantees exactly one user here.
2647
+ const part = await client.createElection(nestId, tensionId, {
2648
+ roleId,
2649
+ users: body.users ?? [],
2650
+ ...(body.due !== undefined ? { due: body.due } : {}),
2651
+ });
2652
+ return formatResult({ message: "Election added to the tension successfully", part });
2653
+ }
2654
+ else if (body._id && removeNest === true) {
2655
+ // Propose deletion of an existing structural item.
2656
+ const part = await client.proposeTensionDeletion(nestId, tensionId, body._id);
2657
+ return formatResult({ message: "Deletion proposal added successfully", part });
2658
+ }
2659
+ else if (body._id) {
2220
2660
  // Propose change to existing item (existing children auto-copied if accountabilities/domains not provided)
2221
2661
  const part = await client.proposeTensionChange(nestId, tensionId, body);
2222
2662
  return formatResult({ message: "Change proposal added successfully", part });
@@ -2321,6 +2761,17 @@ async function _handleToolCall(client, name, args, context) {
2321
2761
  correlationId: getCorrelationId(),
2322
2762
  });
2323
2763
  }
2764
+ // Prime-label conflicts (e.g. ['project', 'tension'] on one nest)
2765
+ if (error instanceof PrimeLabelConflictError) {
2766
+ return formatError({
2767
+ error: true,
2768
+ code: "VALIDATION",
2769
+ message: error.message,
2770
+ retryable: false,
2771
+ hint: `Prime labels (one per nest): ${[...PRIME_LABELS].join(", ")}. Sole allowed pair: userstory+project (userstory implies project). Drop one label and retry, or create separate nests linked via nestr_add_graph_link (stories link to containers via userstory_sprint / userstory_epic / userstory_milestone).`,
2772
+ correlationId: getCorrelationId(),
2773
+ });
2774
+ }
2324
2775
  // Handle other errors
2325
2776
  const message = error instanceof Error ? error.message : "Unknown error";
2326
2777
  return formatError({
@@ -2355,7 +2806,7 @@ function formatResult(data) {
2355
2806
  content: [
2356
2807
  {
2357
2808
  type: "text",
2358
- text: JSON.stringify(data, null, 2),
2809
+ text: JSON.stringify(addNestUrls(data), null, 2),
2359
2810
  },
2360
2811
  ],
2361
2812
  };