@nestr/mcp 0.1.104 → 0.1.106

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.
@@ -356,6 +356,7 @@ const HINT_ENDPOINT_TOOL_MAPPINGS = [
356
356
  bodyParams: new Set([
357
357
  "_id", "title", "labels", "description", "purpose",
358
358
  "parentId", "users", "due", "accountabilities", "domains",
359
+ "role", // operational output: the role the work is asked of
359
360
  "roleId", // election mode
360
361
  ]),
361
362
  },
@@ -369,6 +370,7 @@ const HINT_ENDPOINT_TOOL_MAPPINGS = [
369
370
  bodyParams: new Set([
370
371
  "_id", "title", "labels", "description", "purpose",
371
372
  "parentId", "users", "due", "accountabilities", "domains",
373
+ "role",
372
374
  ]),
373
375
  },
374
376
  // DELETE /parts (body has _id) — propose deletion of an existing item.
@@ -490,6 +492,93 @@ export function nestrWebBase(apiBase) {
490
492
  return base.replace(/\/api\/?$/, "").replace(/\/+$/, "") || "https://app.nestr.io";
491
493
  }
492
494
  const NESTR_WEB_BASE = nestrWebBase(process.env.NESTR_API_BASE);
495
+ /**
496
+ * Turn the tool-level `hints` argument into what the API should be sent.
497
+ *
498
+ * `false` means none. A level passes through. `undefined` and `true` both take the
499
+ * caller-appropriate default, which differs by call shape on purpose: a single read
500
+ * wants the teaching prose, a listing wants it once from nestr_help rather than once
501
+ * per row.
502
+ */
503
+ export function resolveHintLevel(value, fallback) {
504
+ if (value === false)
505
+ return false;
506
+ if (value === "full" || value === "summary")
507
+ return value;
508
+ return fallback;
509
+ }
510
+ /**
511
+ * Append `strict:true` unless the caller already asked for it.
512
+ *
513
+ * Without it an unrecognised operator, label or field filter is silently dropped and the
514
+ * search returns a broader result that looks like a real answer. That is fine for
515
+ * browsing and wrong for counting, so the tool exposes it as a flag rather than making
516
+ * every caller remember the operator.
517
+ */
518
+ export function withStrict(query, strict) {
519
+ if (!strict)
520
+ return query;
521
+ if (/(^|\s)strict:true(\s|$)/i.test(query))
522
+ return query;
523
+ return `${query} strict:true`;
524
+ }
525
+ /**
526
+ * Reduce an OpenAPI document to something a model can read.
527
+ *
528
+ * The whole document is far too large to return, and returning nothing useful is how a
529
+ * caller ends up guessing whether an endpoint exists. So: an operation index by default,
530
+ * one operation in full when asked for by path, and a keyword filter in between. The
531
+ * counts matter as much as the rows — "0 of 94 operations match 'duration'" is the
532
+ * answer to "does the API store meeting duration", and it is a real negative rather than
533
+ * a failed search.
534
+ */
535
+ export function summariseApiSpec(spec, filter = {}) {
536
+ const paths = (spec?.paths || {});
537
+ const allPaths = Object.keys(paths);
538
+ if (filter.path) {
539
+ const match = allPaths.find((p) => { return p === filter.path; })
540
+ || allPaths.find((p) => { return p.toLowerCase() === filter.path.toLowerCase(); });
541
+ if (!match) {
542
+ return {
543
+ found: false,
544
+ requested: filter.path,
545
+ totalPaths: allPaths.length,
546
+ note: "No such path in this deployment's spec. This is a definitive negative, not a failed lookup.",
547
+ // Intentionally broad rather than precise: the first path segment, so
548
+ // `/nests/{id}/nonsense` suggests everything under `/nests/`. A caller who got the
549
+ // path wrong usually has the resource right, and a wide list they can scan beats a
550
+ // narrow one that misses the route they meant. Capped so it stays readable.
551
+ didYouMean: allPaths.filter((p) => { return p.includes(filter.path.split("/")[1] || ""); }).slice(0, 10),
552
+ };
553
+ }
554
+ return { found: true, path: match, operations: paths[match] };
555
+ }
556
+ const rows = [];
557
+ for (const p of allPaths) {
558
+ for (const [method, op] of Object.entries(paths[p] || {})) {
559
+ if (typeof op !== "object" || op === null)
560
+ continue;
561
+ const summary = (op.summary || op.description || "").split("\n")[0].slice(0, 160);
562
+ rows.push({ method: method.toUpperCase(), path: p, summary });
563
+ }
564
+ }
565
+ const needle = filter.search?.toLowerCase();
566
+ const matched = needle
567
+ ? rows.filter((r) => {
568
+ return r.path.toLowerCase().includes(needle) || r.summary.toLowerCase().includes(needle);
569
+ })
570
+ : rows;
571
+ return {
572
+ totalOperations: rows.length,
573
+ matchedOperations: matched.length,
574
+ ...(needle ? { query: filter.search } : {}),
575
+ ...(needle && matched.length === 0
576
+ ? { note: `No operation mentions "${filter.search}". This deployment does not serve one, which is a definitive answer rather than a failed search.` }
577
+ : {}),
578
+ operations: matched.slice(0, 200),
579
+ ...(matched.length > 200 ? { truncated: true } : {}),
580
+ };
581
+ }
493
582
  export function enrichHints(data) {
494
583
  if (!data || typeof data !== "object")
495
584
  return data;
@@ -563,9 +652,78 @@ export function enrichHints(data) {
563
652
  // `data` would discard both the enriched payload and the enriched envelope hints.
564
653
  return subject;
565
654
  }
566
- function buildNestUrl(id, parentId) {
655
+ // Which of the PARENT's tabs holds a child carrying this label, keyed by the
656
+ // API-facing label name (the API strips `circleplus-` and renames
657
+ // prepared-tension to tension, so these are the names that actually arrive).
658
+ //
659
+ // This is the reverse of the `labels: [...]` declarations the tab definitions
660
+ // already carry in slashme-online `packages/nestr_circleplus/lib/tabs.js`: the
661
+ // Projects tab declares it holds project / individual-action / sprint / epic,
662
+ // the Roles tab declares role / circle, and so on. A few tabs (Goals, Todos,
663
+ // Metrics on a circle) express the same containment as a `searchTerm` rather
664
+ // than a labels array, so those entries are read off the search and written
665
+ // here by hand.
666
+ //
667
+ // Why a static map rather than asking the server which tab contains this nest:
668
+ // the precise answer needs the PARENT's resolved tab set, and getTabs() on the
669
+ // server evaluates the circleplus tab callback (a walk through getWorkspace,
670
+ // getData and TAPi18n). translateNest runs per row on every search and children
671
+ // response, so that is a per-row cost on the hot path for a link most rows never
672
+ // need. A wrong guess costs nothing: listview_lists falls back to the nest's
673
+ // default tab when the hash names no available tab, which is exactly the
674
+ // hashless behaviour we have today.
675
+ const LABEL_CONTAINING_TAB = {
676
+ project: "projects",
677
+ "individual-action": "projects",
678
+ sprint: "projects",
679
+ epic: "projects",
680
+ task: "tasks",
681
+ role: "roles",
682
+ circle: "roles",
683
+ "anchor-circle": "roles",
684
+ domain: "policies",
685
+ policy: "policies",
686
+ meeting: "meetings",
687
+ governance: "meetings",
688
+ tactical: "meetings",
689
+ tension: "meetings",
690
+ metric: "metrics",
691
+ checklist: "checklists",
692
+ goal: "goals",
693
+ result: "goals",
694
+ skill: "skills",
695
+ feedback: "feedback",
696
+ note: "notes",
697
+ };
698
+ // The tab on the parent that a person opens to SEE this nest in its list.
699
+ // First label wins, so a scrum story labelled ["project", "userstory"] resolves
700
+ // through `project` and lands on Projects.
701
+ export function containingTabHash(labels) {
702
+ if (!Array.isArray(labels))
703
+ return undefined;
704
+ for (const label of labels) {
705
+ if (typeof label !== "string")
706
+ continue;
707
+ const tab = LABEL_CONTAINING_TAB[label];
708
+ if (tab)
709
+ return tab;
710
+ }
711
+ return undefined;
712
+ }
713
+ // A nest URL without a `#` is NOT a stable link. listview_lists reads the tab
714
+ // from localStorage `<nestId>_preferred_header`, so the same link opens whichever
715
+ // tab that particular person last used on that particular nest, and a first-time
716
+ // visitor gets the container's first tab — which for a circle is Structure >
717
+ // About, not the work they were sent to look at. Carrying the hash is what makes
718
+ // the link mean the same thing to everyone.
719
+ //
720
+ // The hash belongs to the LEFT pane, which in the two-id form is the parent, so
721
+ // it is only appended there. On the bare `/n/{id}` form the hash would select a
722
+ // tab on the nest ITSELF, and `#projects` on a project means nothing.
723
+ function buildNestUrl(id, parentId, labels) {
567
724
  if (parentId && parentId.toLowerCase() !== "inbox") {
568
- return `${NESTR_WEB_BASE}/n/${parentId}/${id}`;
725
+ const tab = containingTabHash(labels);
726
+ return `${NESTR_WEB_BASE}/n/${parentId}/${id}${tab ? `#${tab}` : ""}`;
569
727
  }
570
728
  return `${NESTR_WEB_BASE}/n/${id}`;
571
729
  }
@@ -597,7 +755,7 @@ export function addNestUrls(data) {
597
755
  const record = data;
598
756
  const out = { ...record };
599
757
  if (looksLikeNest(record) && typeof out.url !== "string") {
600
- out.url = buildNestUrl(record._id, record.parentId);
758
+ out.url = buildNestUrl(record._id, record.parentId, record.labels);
601
759
  }
602
760
  for (const [key, value] of Object.entries(out)) {
603
761
  if (value && typeof value === "object") {
@@ -652,9 +810,27 @@ const MENTION_DESC = "Supports HTML and @mentions. Mentions MUST use literal cur
652
810
  const PRIME_LABEL_RULE = "At most ONE prime label per nest (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback, userstory, sprint, epic, milestone): they are the nest's identity and cannot coexist. Only exception: userstory may pair with project.";
653
811
  const PURPOSE_DESC = "Only for workspaces, circles and roles: a short aspirational statement. Details belong in description, not here. Supports HTML.";
654
812
  const CONTENT_DESC = "The primary content field: details, context, acceptance criteria. Structured data goes in fields, progress in comments. Supports Markdown and HTML.";
813
+ const HINTS_DESC = "Contextual hints. 'summary' keeps the per-nest signal (type, severity, count, url, and the "
814
+ + "`query` that finds every other nest with the same problem) and drops the fixed teaching prose "
815
+ + "and endpoint list. 'full' is the whole payload. false for none.";
655
816
  const STRIP_DESCRIPTION = "Strip description fields to shrink the response. Use for bulk or index reads.";
656
817
  const SORT_DESCRIPTION = "Sort field: title, createdAt, updatedAt, due, activityAt, order. Prefix '-' to reverse. For 'recently active' use '-activityAt' (includes children), not '-updatedAt' (own edits only).";
657
818
  // Tool input schemas using Zod
819
+ /**
820
+ * `hints` is a level, not a flag. `summary` keeps what varies per nest and drops the
821
+ * teaching prose and endpoint list, which are identical for every nest of a type; `full`
822
+ * is the whole payload. Booleans still work: the API reads a bare `true` on a listing as
823
+ * `summary`, which is the difference between one paragraph and fifty copies of it.
824
+ */
825
+ const hintLevelSchema = z.union([z.boolean(), z.enum(["full", "summary"])]).optional();
826
+ const hintFilterSchemas = {
827
+ hintTypes: coerceFromJson(z.array(z.string())).optional()
828
+ .describe("Keep only these hint types, e.g. ['project_waiting_no_reason','unassigned_role']."),
829
+ minSeverity: z.enum(["info", "suggestion", "warning", "alert"]).optional()
830
+ .describe("Drop hints below this severity. 'warning' is the useful floor when sweeping."),
831
+ };
832
+ const linkedUsersSchema = z.boolean().optional()
833
+ .describe("Resolve every user id in the results to a full user, returned once in linked.users.");
658
834
  export const schemas = {
659
835
  listWorkspaces: z.object({
660
836
  search: z.string().optional().describe("Search query to filter workspaces"),
@@ -719,6 +895,8 @@ export const schemas = {
719
895
  search: z.object({
720
896
  workspaceId: z.string().describe("Workspace ID to search in"),
721
897
  query: z.string().describe("Search query"),
898
+ strict: z.boolean().optional().describe("Reject the search if any operator, label or field filter in it was not recognised, instead of silently returning a broader result. Use it whenever you are counting rather than browsing: without it a typo'd filter reads as a real, larger answer."),
899
+ linkedUsers: linkedUsersSchema,
722
900
  sort: z.string().optional().describe(`${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.`),
723
901
  limit: z.number().optional().describe("Max results per page. Omit on first call to see meta.total count."),
724
902
  page: z.number().optional().describe("Page number (1-indexed) for pagination"),
@@ -727,7 +905,8 @@ export const schemas = {
727
905
  getNest: z.object({
728
906
  nestId: z.string().describe("Nest ID. Supports comma-separated IDs to fetch multiple nests in one call (e.g., 'id1,id2,id3') — returns an array instead of a single object. Keep total URL under 2000 chars to avoid HTTP limits."),
729
907
  fieldsMetaData: z.boolean().optional().describe("Set to true to include field schema metadata (e.g., available options for project.status)"),
730
- hints: z.boolean().optional().describe("Include contextual hints on each nest (default: true). Hints surface actionable signals like unassigned roles, stale projects, or unread comments. Set to false for bulk lookups where you only need structural data, not contextual guidance."),
908
+ hints: hintLevelSchema.describe("Contextual hints: 'full' (default on a single read), 'summary', or false. 'summary' keeps type, severity, count, url and the sibling query, and drops the teaching prose and endpoints. Hints surface signals like unassigned roles, stale projects or unread comments."),
909
+ ...hintFilterSchemas,
731
910
  provenance: z.boolean().optional().describe("Single-nest only. Include field/property provenance: which label (and circle context) defines each field and property."),
732
911
  rights: z.boolean().optional().describe("Single-nest only. Include the caller's composed rights on the nest plus a deny trace naming the profiles that block each op."),
733
912
  forUser: z.string().optional().describe("Single nest, with rights=true. Rights for this user id instead of the caller. Caller must be a nest admin."),
@@ -740,12 +919,25 @@ export const schemas = {
740
919
  }),
741
920
  getNestChildren: z.object({
742
921
  nestId: z.string().describe("Parent nest ID"),
922
+ search: z.string().optional().describe("Search scoped to this nest, full operator syntax. Ask for the subset you want rather than fetching every child and filtering: 'label:role' for a circle's roles, 'label:project fields.project.status:Waiting' for its waiting projects. depth:1 is applied when you set no depth, and the response says so in appliedDefaults."),
743
923
  sort: z.string().optional().describe(SORT_DESCRIPTION),
744
924
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
745
925
  page: z.number().optional().describe("Page number for pagination"),
746
- hints: z.boolean().optional().describe("Include contextual hints on each child nest (default: true). Set to false for large result sets or bulk operations where contextual signals aren't needed."),
926
+ hints: hintLevelSchema.describe("Contextual hints: 'summary' (default on a listing), 'full', or false. A listing at 'full' repeats the same teaching prose once per row."),
927
+ ...hintFilterSchemas,
928
+ linkedUsers: linkedUsersSchema,
747
929
  _listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Tasks for Website Redesign\"). Omit for default."),
748
930
  }),
931
+ hintsRollup: z.object({
932
+ nestId: z.string().describe("Nest to roll up. A circle or workspace root is the useful scope."),
933
+ hintTypes: coerceFromJson(z.array(z.string())).optional().describe("Count only these hint types."),
934
+ minSeverity: z.enum(["info", "suggestion", "warning", "alert"]).optional().describe("Drop rules below this severity."),
935
+ sampleSize: z.number().optional().describe("Example nests per type. Default 10, max 100, 0 for counts only."),
936
+ }),
937
+ apiSpec: z.object({
938
+ search: z.string().optional().describe("Filter operations by keyword against path and summary, e.g. 'meeting' or 'tension'."),
939
+ path: z.string().optional().describe("Return the full schema for one path, e.g. '/nests/{id}/children'."),
940
+ }),
749
941
  createNest: z.object({
750
942
  parentId: z.string().describe("Parent nest ID (workspace, circle, or project)"),
751
943
  title: z.string().describe("Title of the new nest (plain text, HTML stripped)"),
@@ -809,6 +1001,7 @@ export const schemas = {
809
1001
  sort: z.string().optional().describe(SORT_DESCRIPTION),
810
1002
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
811
1003
  page: z.number().optional().describe("Page number for pagination"),
1004
+ linkedUsers: linkedUsersSchema,
812
1005
  }),
813
1006
  listUserRoles: z.object({
814
1007
  userId: z.string().optional().describe("User ID to look up. Omit for yourself. Requires workspaceId when set."),
@@ -843,6 +1036,7 @@ export const schemas = {
843
1036
  sort: z.string().optional().describe(SORT_DESCRIPTION),
844
1037
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
845
1038
  page: z.number().optional().describe("Page number for pagination"),
1039
+ linkedUsers: linkedUsersSchema,
846
1040
  _listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Engineering projects\"). Omit for default."),
847
1041
  }),
848
1042
  getComments: z.object({
@@ -987,7 +1181,6 @@ export const schemas = {
987
1181
  sort: z.string().optional().describe(SORT_DESCRIPTION),
988
1182
  limit: z.number().optional().describe("Max results to return"),
989
1183
  page: z.number().optional().describe("Page number for pagination"),
990
- order: z.string().optional().describe("Deprecated alias of sort"),
991
1184
  }),
992
1185
  updateTension: z.object({
993
1186
  nestId: z.string().describe("ID of the circle or role the tension belongs to"),
@@ -1018,6 +1211,7 @@ export const schemas = {
1018
1211
  due: z.string().optional().describe("Due or re-election date, ISO. For an election, the term end; omit for no term."),
1019
1212
  accountabilities: coerceFromJson(z.array(z.string())).optional().describe("Accountability titles on a role (replaces all; children tools for individual edits)"),
1020
1213
  domains: coerceFromJson(z.array(z.string())).optional().describe("Domain titles on a role (replaces all; children tools for individual edits)"),
1214
+ role: z.string().optional().describe("The role this output belongs to, for an operational output (pathway 3/4). Pair with users: users is WHO does it, role is the role it is asked of, and the work request names both (\"Ada as Systems: ...\"). A role id, not a title. Distinct from roleId, which is election mode."),
1021
1215
  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."),
1022
1216
  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."),
1023
1217
  }).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." }),
@@ -1034,6 +1228,7 @@ export const schemas = {
1034
1228
  due: z.string().optional().describe("Updated due date (ISO format)"),
1035
1229
  accountabilities: coerceFromJson(z.array(z.string())).optional().describe("Updated accountabilities (replaces all; children tools for individual edits)"),
1036
1230
  domains: coerceFromJson(z.array(z.string())).optional().describe("Updated domains (replaces all; children tools for individual edits)"),
1231
+ role: z.string().optional().describe("Updated role for an operational output. A role id; pass an empty string to clear it."),
1037
1232
  }),
1038
1233
  removeTensionPart: z.object({
1039
1234
  nestId: z.string().describe("ID of the circle or role the tension belongs to"),
@@ -1206,7 +1401,7 @@ const destructive = { annotations: { readOnlyHint: false, destructiveHint: true
1206
1401
  export const toolDefinitions = [
1207
1402
  {
1208
1403
  name: "nestr_help",
1209
- description: "Nestr documentation, three modes. (1) Internal topic: `topic` with a curated key (search, labels, nest-model, inbox, daily-plan, notifications, insights, tension-processing, skills, mcp-apps, authentication, scrum, okr, ...); 'topics' lists them all. (2) Help article: `topic` with a slug from nestr.io/help/articles/<slug>; returns markdown plus a numbered list of its images. Images are never attached by default: includeImages:true takes the first maxImages screenshots, imageIndexes:[..] takes chosen ones. Attach when the user wants to see how something looks. (3) Search: `search` with free text; returns ranked matches, each a title and one-line summary, tolerant of typos and synonyms (kanban/sprint to scrum). A topic is tried internally first, then as an article. Every response opens with 'Resolved as:' naming the mode, and topics and articles cross-link. Call before unfamiliar operations. No auth.",
1404
+ description: "Nestr documentation, three modes. (1) Internal topic: `topic` with a curated key (search, labels, nest-model, inbox, daily-plan, notifications, insights, tension-processing, skills, mcp-apps, authentication, scrum, okr, ...); 'topics' lists them all. (2) Help article: `topic` with a slug from nestr.io/help/articles/<slug>; returns markdown plus a numbered list of its images. Images are never attached by default: includeImages:true takes the first maxImages screenshots, imageIndexes:[..] takes chosen ones. Attach when the user wants to see how something looks. (3) Search: `search` with free text, **in English whatever language the conversation is in** — the corpus is English and the index scores slugs and keywords, so a query in another language usually returns nothing; returns ranked matches, each a title and one-line summary, tolerant of typos and synonyms (kanban/sprint to scrum). A topic is tried internally first, then as an article. Every response opens with 'Resolved as:' naming the mode, and topics and articles cross-link. Call before unfamiliar operations. No auth.",
1210
1405
  inputSchema: {
1211
1406
  type: "object",
1212
1407
  properties: {
@@ -1296,12 +1491,14 @@ export const toolDefinitions = [
1296
1491
  },
1297
1492
  {
1298
1493
  name: "nestr_search",
1299
- description: "Search nests in a workspace. Supports operators like label:, assignee:, createdby:, completed:, in:, sort:. createdby: accepts me, an email address, or a user id. Always use completed:false for active work. See nestr_help('search') for full syntax. Results carry user ids; when presenting to a person, show names and/or emails, resolving ids via nestr_get_user or nestr_list_users.",
1494
+ description: "Search nests in a workspace. Supports operators like label:, assignee:, createdby:, completed:, in:, sort:. createdby: accepts me, an email address, or a user id. Always use completed:false for active work. See nestr_help('search') for full syntax. Results carry user ids; when presenting to a person, show names and/or emails. Pass `linkedUsers: true` to get them all resolved in the same request rather than calling nestr_get_user per id.",
1300
1495
  inputSchema: {
1301
1496
  type: "object",
1302
1497
  properties: {
1303
1498
  workspaceId: { type: "string", description: "Workspace ID to search in" },
1304
1499
  query: { type: "string", description: "Search query with optional operators (e.g., 'label:role', 'assignee:me completed:false')" },
1500
+ strict: { type: "boolean", description: "Reject the search when any operator, label or field filter in it was not recognised, rather than silently returning a broader result. Use it whenever you are counting rather than browsing: without it, a mistyped filter comes back as a real and larger answer with nothing to say the filter was dropped." },
1501
+ linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. Prefer this over calling nestr_get_user per id." },
1305
1502
  sort: { type: "string", description: `${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.` },
1306
1503
  limit: { type: "number", description: "Max results per page. Omit on the first call so meta.total shows the match count." },
1307
1504
  page: { type: "number", description: "Page number (1-indexed) for fetching additional pages" },
@@ -1322,7 +1519,9 @@ export const toolDefinitions = [
1322
1519
  properties: {
1323
1520
  nestId: { type: "string", description: "Nest ID, or comma-separated IDs for a batch (e.g. 'id1,id2'). Keep the URL under 2000 chars." },
1324
1521
  fieldsMetaData: { type: "boolean", description: "Set to true to include field schema metadata (available options, field types)" },
1325
- hints: { type: "boolean", description: "Contextual hints, default true. False for bulk lookups needing only structure." },
1522
+ hints: { type: ["string", "boolean"], enum: ["summary", "full", true, false], description: `${HINTS_DESC} Defaults to 'full' on a single read, which is what you want when you asked about one nest.` },
1523
+ hintTypes: { type: "array", items: { type: "string" }, description: "Keep only these hint types." },
1524
+ minSeverity: { type: "string", enum: ["info", "suggestion", "warning", "alert"], description: "Drop hints below this severity." },
1326
1525
  stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
1327
1526
  provenance: { type: "boolean", description: "Single nest. Which label and circle context defines each field and property, e.g. why a role has a given icon." },
1328
1527
  rights: { type: "boolean", description: "Single nest. The caller's composed rights (self read/update/delete) plus a deny trace naming what blocks each op, and why." },
@@ -1349,15 +1548,19 @@ export const toolDefinitions = [
1349
1548
  },
1350
1549
  {
1351
1550
  name: "nestr_get_nest_children",
1352
- description: "Get children of a nest. Paginated. Add hints=true for contextual signals.",
1551
+ description: "Get children of a nest, or just the ones you want. Pass `search` with the full operator syntax to ask for a subset rather than fetching everything and filtering: `search: 'label:role'` returns a circle's roles in one call. Paginated at 50 per page; read meta.total for the match count.",
1353
1552
  inputSchema: {
1354
1553
  type: "object",
1355
1554
  properties: {
1356
1555
  nestId: { type: "string", description: "Parent nest ID" },
1556
+ search: { type: "string", description: "Search scoped to this nest, full operator syntax. Ask for the subset you want instead of fetching every child and filtering: `label:role` for a circle's roles, `label:project fields.project.status:Waiting` for its waiting projects. `depth:1` is applied when you set no depth and the response says so in `appliedDefaults`; pass `depth:2` or higher to reach further down." },
1357
1557
  sort: { type: "string", description: SORT_DESCRIPTION },
1358
1558
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
1359
1559
  page: { type: "number", description: "Page number (1-indexed)" },
1360
- hints: { type: "boolean", description: "Include contextual hints (default: true). Set to false for large result sets or bulk operations." },
1560
+ hints: { type: ["string", "boolean"], enum: ["summary", "full", true, false], description: `${HINTS_DESC} Defaults to 'summary' here: a listing at 'full' repeats the same paragraph once per row.` },
1561
+ hintTypes: { type: "array", items: { type: "string" }, description: "Keep only these hint types." },
1562
+ minSeverity: { type: "string", enum: ["info", "suggestion", "warning", "alert"], description: "Drop hints below this severity." },
1563
+ linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. One request instead of one per id." },
1361
1564
  stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
1362
1565
  _listTitle: { type: "string", description: "Short descriptive title for the list UI header (e.g., \"Tasks for Website Redesign\", \"API project sub-tasks\"). Include the parent name for context." },
1363
1566
  },
@@ -1367,9 +1570,36 @@ export const toolDefinitions = [
1367
1570
  // The completable list app should only be used when results are confirmed to be completable items.
1368
1571
  ...readOnly,
1369
1572
  },
1573
+ {
1574
+ name: "nestr_hints_rollup",
1575
+ description: "Count hints across a whole circle or workspace in one call. A hint on a single nest says \"this one has this problem\"; this says how many have it, which is the question you actually ask of a circle. Answers \"how healthy is this circle\", \"how many projects are waiting with no reason\", \"how many roles have nobody in them\" without reading every nest. Each result carries type, severity, count and a small sample to open. Read `notComputed`: it names every hint type this cannot count in one query, so a type listed there is unknown rather than zero. Do not sum counts across types, one nest can carry several; ask for a single hintTypes when you want a number that adds up.",
1576
+ inputSchema: {
1577
+ type: "object",
1578
+ properties: {
1579
+ nestId: { type: "string", description: "Nest to roll up. A circle or the workspace root is the useful scope." },
1580
+ hintTypes: { type: "array", items: { type: "string" }, description: "Count only these hint types. Omit for every type this can compute." },
1581
+ minSeverity: { type: "string", enum: ["info", "suggestion", "warning", "alert"], description: "Drop rules below this severity. 'warning' is the useful floor for \"what needs attention\"." },
1582
+ sampleSize: { type: "number", description: "Example nests per type. Default 10, max 100, 0 for counts only." },
1583
+ },
1584
+ required: ["nestId"],
1585
+ },
1586
+ ...readOnly,
1587
+ },
1588
+ {
1589
+ name: "nestr_api_spec",
1590
+ description: "The deployment's own OpenAPI document, so you can check whether the API has something rather than concluding \"I did not find it\" and guessing. Call with no arguments for the operation index (method, path, one-line summary). `search` filters by keyword against path and summary. `path` returns the full schema for one operation, including its parameters. Use this to answer \"is there an endpoint for X\" with certainty, and note that a negative here is a real negative: if it is not in the spec, this deployment does not serve it. For the search query language rather than the HTTP surface, use nestr_help('search'); for a filter you are unsure of, `strict: true` on nestr_search tells you whether it applied.",
1591
+ inputSchema: {
1592
+ type: "object",
1593
+ properties: {
1594
+ search: { type: "string", description: "Filter operations by keyword against path and summary, e.g. 'meeting', 'tension', 'duration'." },
1595
+ path: { type: "string", description: "Return the full schema for one path, e.g. '/nests/{id}/children'." },
1596
+ },
1597
+ },
1598
+ ...readOnly,
1599
+ },
1370
1600
  {
1371
1601
  name: "nestr_create_nest",
1372
- description: `Create a nest under a parent. Labels define the type, e.g. ['project'], ['role']. ${PRIME_LABEL_RULE} Sprint/epic/milestone never pair; stories link to those via graph relations. In established workspaces prefer the tension flow for governance. See nestr_help('labels').`,
1602
+ description: `Create a nest under a parent. Labels define the type, e.g. ['project'], ['role']. ${PRIME_LABEL_RULE} Sprint/epic/milestone never pair; stories link to those via graph relations. In established workspaces prefer the tension flow for governance. Meetings attach to a circle that ALREADY exists — the workspace anchor circle counts, and is the right parent when no sub-circle fits. Never create a circle to hold a meeting. Use \`['meeting','circle-meeting']\` for a tactical meeting, \`['meeting','governance']\` for a governance meeting, and set \`due\` to the start time. See nestr_help('labels') and nestr_help('meetings').`,
1373
1603
  inputSchema: {
1374
1604
  type: "object",
1375
1605
  properties: {
@@ -1586,6 +1816,7 @@ export const toolDefinitions = [
1586
1816
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
1587
1817
  page: { type: "number", description: "Page number (1-indexed)" },
1588
1818
  stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
1819
+ linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. One request instead of one per id." },
1589
1820
  },
1590
1821
  required: ["workspaceId"],
1591
1822
  },
@@ -1801,6 +2032,7 @@ export const toolDefinitions = [
1801
2032
  page: { type: "number", description: "Page number (1-indexed)" },
1802
2033
  stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
1803
2034
  _listTitle: { type: "string", description: "Short descriptive title for the list UI header (e.g., \"Engineering projects\", \"All projects\"). Omit for default." },
2035
+ linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. One request instead of one per id." },
1804
2036
  },
1805
2037
  required: ["workspaceId"],
1806
2038
  },
@@ -1818,6 +2050,7 @@ export const toolDefinitions = [
1818
2050
  oneOf: [{ type: "number" }, { type: "string", enum: ["all"] }],
1819
2051
  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.",
1820
2052
  },
2053
+ unread: { type: "boolean", description: "true for comments you have not read, false for the ones you have. Omit for all. Combine with depth to sweep a circle for what you have missed." },
1821
2054
  },
1822
2055
  required: ["nestId"],
1823
2056
  },
@@ -1905,7 +2138,7 @@ export const toolDefinitions = [
1905
2138
  },
1906
2139
  {
1907
2140
  name: "nestr_get_workspace_apps",
1908
- description: "List enabled apps/features in a workspace. Check before using features that require specific apps (e.g., Insights).",
2141
+ description: "List a workspace's apps/features. Returns the FULL catalogue, each entry `{ _id, title, enabled }` — a disabled app is present with `enabled: false`, never absent, so test `enabled` and not presence. Note the field is `_id`, not `id`. Check before using features that require specific apps (e.g., Insights, Scrum, Meetings).",
1909
2142
  inputSchema: {
1910
2143
  type: "object",
1911
2144
  properties: {
@@ -2258,9 +2491,6 @@ export const toolDefinitions = [
2258
2491
  sort: { type: "string", description: SORT_DESCRIPTION },
2259
2492
  limit: { type: "number", description: "Max results to return" },
2260
2493
  page: { type: "number", description: "Page number (1-indexed)" },
2261
- // The legacy `order` alias is deliberately not advertised — the Zod
2262
- // schema still accepts it so existing callers keep working, but new
2263
- // clients should only learn the canonical `sort` param.
2264
2494
  },
2265
2495
  required: ["nestId"],
2266
2496
  },
@@ -2311,7 +2541,7 @@ export const toolDefinitions = [
2311
2541
  },
2312
2542
  {
2313
2543
  name: "nestr_add_tension_part",
2314
- description: "Add a governance proposal part to a tension. Modes: new item (omit _id, give title/labels); change one (_id plus the changed fields; editing a role copies its accountabilities/domains in, so it reads as a full role edit); delete one (_id plus removeNest:true); election (roleId plus users:[userId], optional due, which assigns or reconfirms the filler and leaves accountabilities/domains untouched). See nestr_help('tension-processing').",
2544
+ description: "Add a part to a tension. A part is either the operational work the tension asks for or a governance change it proposes. Modes: OPERATIONAL OUTPUT (title, description, users, role, and no governance label) is what a work request is made of, and is the common case; new governance item (omit _id, give title plus a governance label such as ['role'] or ['policy']); change one (_id plus the changed fields; editing a role copies its accountabilities/domains in, so it reads as a full role edit); delete one (_id plus removeNest:true); election (roleId plus users:[userId], optional due, which assigns or reconfirms the filler and leaves accountabilities/domains untouched). The part you get back has its own _id and a sourceId, the output nest underneath it; nestr_modify_tension_part takes the part _id, not the sourceId. See nestr_help('tension-processing').",
2315
2545
  inputSchema: {
2316
2546
  type: "object",
2317
2547
  properties: {
@@ -2327,6 +2557,7 @@ export const toolDefinitions = [
2327
2557
  due: { type: "string", description: "Due or re-election date, ISO. For an election, the term end; omit for no term." },
2328
2558
  accountabilities: { type: "array", items: { type: "string" }, description: "Accountability titles on a role (replaces all; children tools for individual edits)" },
2329
2559
  domains: { type: "array", items: { type: "string" }, description: "Domain titles on a role (replaces all; children tools for individual edits)" },
2560
+ role: { type: "string", description: "The role this output belongs to, for an operational output (pathway 3/4). Pair with users: users is WHO does it, role is the role it is asked of, and the work request names both (\"Ada as Systems: ...\"). A role id, not a title. Distinct from roleId, which is election mode." },
2330
2561
  roleId: { type: "string", description: "ELECTION mode: the electable role to fill (Facilitator, Secretary, Rep Link or any electable role). Pair with users:[oneUserId] and optional due. Never with _id." },
2331
2562
  removeNest: { type: "boolean", description: "With _id, propose deleting that governance item; it goes when the proposal is accepted. Not nestr_remove_tension_part, which undoes a part you already added." },
2332
2563
  },
@@ -2350,6 +2581,7 @@ export const toolDefinitions = [
2350
2581
  parentId: { type: "string", description: "Updated parent ID" },
2351
2582
  users: { type: "array", items: { type: "string" }, description: "Updated user assignments" },
2352
2583
  due: { type: "string", description: "Updated due date (ISO format)" },
2584
+ role: { type: "string", description: "Updated role for an operational output. A role id; pass an empty string to clear it." },
2353
2585
  accountabilities: { type: "array", items: { type: "string" }, description: "Updated accountabilities (replaces all; children tools for individual edits)" },
2354
2586
  domains: { type: "array", items: { type: "string" }, description: "Updated domains (replaces all; children tools for individual edits)" },
2355
2587
  },
@@ -2960,7 +3192,7 @@ async function _handleToolCall(client, name, args, context) {
2960
3192
  const entries = await loadArticleIndex();
2961
3193
  const hits = searchArticleIndex(entries, parsed.search, 8);
2962
3194
  if (hits.length === 0) {
2963
- return { content: [{ type: "text", text: `_Resolved as: help-article search._\n\nNo help articles matched "${parsed.search}". The index scores article slugs and curated keywords, not article bodies, so an exact feature, operator or field name often misses even when the docs cover it. Try broader terms or a synonym, or call nestr_help({ topic: "topics" }) for internal MCP topics. An empty result is not evidence the thing does not exist: say you could not find it documented, never that it is unsupported.` }] };
3195
+ return { content: [{ type: "text", text: `_Resolved as: help-article search._\n\nNo help articles matched "${parsed.search}". **The corpus and the index are English. If you searched in another language, translate the query and search again before concluding anything** — that is the single most common reason for an empty result, and one retry usually fixes it. Beyond that: the index scores article slugs and curated keywords, not article bodies, so an exact feature, operator or field name often misses even when the docs cover it. Try broader terms or a synonym, or call nestr_help({ topic: "topics" }) for internal MCP topics. An empty result is not evidence the thing does not exist: say you could not find it documented, never that it is unsupported. Never fill the gap from memory on anything a customer could check — prices, limits and plan names above all.` }] };
2964
3196
  }
2965
3197
  // Enrich the top hits with a title + one-line summary so the caller
2966
3198
  // can pick the right article without a blind fetch. Best-effort:
@@ -3144,11 +3376,12 @@ async function _handleToolCall(client, name, args, context) {
3144
3376
  case "nestr_search": {
3145
3377
  const parsed = schemas.search.parse(args);
3146
3378
  const directives = extractSearchDirectives(parsed.query);
3147
- const results = await client.searchWorkspace(parsed.workspaceId, parsed.query, {
3379
+ const results = await client.searchWorkspace(parsed.workspaceId, withStrict(parsed.query, parsed.strict), {
3148
3380
  sort: parsed.sort ?? directives.sort,
3149
3381
  limit: parsed.limit ?? directives.limit,
3150
3382
  page: parsed.page,
3151
3383
  cleanText: true,
3384
+ linkedUsers: parsed.linkedUsers,
3152
3385
  });
3153
3386
  return formatResult(completableResponse(compactResponse(results), "search", parsed._listTitle || `Search: ${parsed.query}`));
3154
3387
  }
@@ -3157,7 +3390,9 @@ async function _handleToolCall(client, name, args, context) {
3157
3390
  const nest = await client.getNest(parsed.nestId, {
3158
3391
  cleanText: true,
3159
3392
  fieldsMetaData: parsed.fieldsMetaData,
3160
- hints: parsed.hints !== false,
3393
+ hints: resolveHintLevel(parsed.hints, "full"),
3394
+ hintTypes: parsed.hintTypes,
3395
+ minSeverity: parsed.minSeverity,
3161
3396
  provenance: parsed.provenance,
3162
3397
  rights: parsed.rights,
3163
3398
  forUser: parsed.forUser,
@@ -3181,14 +3416,32 @@ async function _handleToolCall(client, name, args, context) {
3181
3416
  case "nestr_get_nest_children": {
3182
3417
  const parsed = schemas.getNestChildren.parse(args);
3183
3418
  const children = await client.getNestChildren(parsed.nestId, {
3419
+ search: parsed.search,
3184
3420
  sort: parsed.sort,
3185
3421
  limit: parsed.limit,
3186
3422
  page: parsed.page,
3187
3423
  cleanText: true,
3188
- hints: parsed.hints !== false,
3424
+ hints: resolveHintLevel(parsed.hints, "summary"),
3425
+ hintTypes: parsed.hintTypes,
3426
+ minSeverity: parsed.minSeverity,
3427
+ linkedUsers: parsed.linkedUsers,
3189
3428
  });
3190
3429
  return formatResult(completableResponse(compactResponse(enrichHints(children)), "children", parsed._listTitle || "Sub-items"));
3191
3430
  }
3431
+ case "nestr_hints_rollup": {
3432
+ const parsed = schemas.hintsRollup.parse(args);
3433
+ const rollup = await client.getHintsRollup(parsed.nestId, {
3434
+ hintTypes: parsed.hintTypes,
3435
+ minSeverity: parsed.minSeverity,
3436
+ sampleSize: parsed.sampleSize,
3437
+ });
3438
+ return formatResult(rollup);
3439
+ }
3440
+ case "nestr_api_spec": {
3441
+ const parsed = schemas.apiSpec.parse(args);
3442
+ const spec = await client.getApiSpec();
3443
+ return formatResult(summariseApiSpec(spec, parsed));
3444
+ }
3192
3445
  case "nestr_create_nest": {
3193
3446
  const parsed = schemas.createNest.parse(args);
3194
3447
  validatePrimeLabels(parsed.labels);
@@ -3325,6 +3578,7 @@ async function _handleToolCall(client, name, args, context) {
3325
3578
  limit: parsed.limit,
3326
3579
  page: parsed.page,
3327
3580
  cleanText: true,
3581
+ linkedUsers: parsed.linkedUsers,
3328
3582
  });
3329
3583
  return formatResult(compactResponse(roles, "role"));
3330
3584
  }
@@ -3381,6 +3635,7 @@ async function _handleToolCall(client, name, args, context) {
3381
3635
  limit: parsed.limit,
3382
3636
  page: parsed.page,
3383
3637
  cleanText: true,
3638
+ linkedUsers: parsed.linkedUsers,
3384
3639
  });
3385
3640
  return formatResult(completableResponse(compactResponse(projects), "projects", parsed._listTitle || "Projects"));
3386
3641
  }
@@ -3763,9 +4018,7 @@ async function _handleToolCall(client, name, args, context) {
3763
4018
  case "nestr_list_tensions": {
3764
4019
  const parsed = schemas.listTensions.parse(args);
3765
4020
  const tensions = await client.listTensions(parsed.nestId, parsed.search, {
3766
- // `order` is the legacy name for this option — it was never honored
3767
- // by the API (which reads `sort`), so route both through sort.
3768
- sort: parsed.sort ?? parsed.order,
4021
+ sort: parsed.sort,
3769
4022
  limit: parsed.limit,
3770
4023
  page: parsed.page,
3771
4024
  cleanText: true,