@nestr/mcp 0.1.73 → 0.1.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/api/client.d.ts +39 -3
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +57 -5
- package/build/api/client.js.map +1 -1
- package/build/help/articles.d.ts +146 -0
- package/build/help/articles.d.ts.map +1 -0
- package/build/help/articles.js +574 -0
- package/build/help/articles.js.map +1 -0
- package/build/help/cross-links.d.ts +21 -0
- package/build/help/cross-links.d.ts.map +1 -0
- package/build/help/cross-links.js +61 -0
- package/build/help/cross-links.js.map +1 -0
- package/build/help/topics.d.ts.map +1 -1
- package/build/help/topics.js +315 -15
- package/build/help/topics.js.map +1 -1
- package/build/http.d.ts +4 -13
- package/build/http.d.ts.map +1 -1
- package/build/http.js +341 -89
- package/build/http.js.map +1 -1
- package/build/oauth/client-info.d.ts +58 -0
- package/build/oauth/client-info.d.ts.map +1 -0
- package/build/oauth/client-info.js +68 -0
- package/build/oauth/client-info.js.map +1 -0
- package/build/oauth/config.d.ts +19 -0
- package/build/oauth/config.d.ts.map +1 -1
- package/build/oauth/config.js +12 -0
- package/build/oauth/config.js.map +1 -1
- package/build/server.d.ts +7 -0
- package/build/server.d.ts.map +1 -1
- package/build/server.js +23 -4
- package/build/server.js.map +1 -1
- package/build/skills/tension-processing.d.ts.map +1 -1
- package/build/skills/tension-processing.js +11 -1
- package/build/skills/tension-processing.js.map +1 -1
- package/build/tools/index.d.ts +612 -68
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +592 -63
- package/build/tools/index.js.map +1 -1
- package/build/tools/validation.d.ts +42 -0
- package/build/tools/validation.d.ts.map +1 -0
- package/build/tools/validation.js +97 -0
- package/build/tools/validation.js.map +1 -0
- package/package.json +2 -1
- package/web/index.html +25 -0
- package/web/styles.css +62 -0
package/build/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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})`);
|
|
127
285
|
}
|
|
128
286
|
}
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
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,10 +359,41 @@ 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);
|
|
388
|
+
// Shared description for the sort parameter on list/fetch tools. All of these
|
|
389
|
+
// endpoints honor a `sort` query param server-side (field name, '-' prefix for
|
|
390
|
+
// descending) — the same fields the search `sort:` operator uses.
|
|
391
|
+
const SORT_DESCRIPTION = "Field to sort by, e.g. 'title', 'createdAt', 'updatedAt', 'due', 'order' (manual order). Prefix with '-' for descending, e.g. '-updatedAt'.";
|
|
149
392
|
// Tool input schemas using Zod
|
|
150
393
|
export const schemas = {
|
|
151
394
|
listWorkspaces: z.object({
|
|
152
395
|
search: z.string().optional().describe("Search query to filter workspaces"),
|
|
396
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
153
397
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
154
398
|
page: z.number().optional().describe("Page number (1-indexed) for pagination"),
|
|
155
399
|
}),
|
|
@@ -168,6 +412,7 @@ export const schemas = {
|
|
|
168
412
|
search: z.object({
|
|
169
413
|
workspaceId: z.string().describe("Workspace ID to search in"),
|
|
170
414
|
query: z.string().describe("Search query"),
|
|
415
|
+
sort: z.string().optional().describe(`${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.`),
|
|
171
416
|
limit: z.number().optional().describe("Max results per page. Omit on first call to see meta.total count."),
|
|
172
417
|
page: z.number().optional().describe("Page number (1-indexed) for pagination"),
|
|
173
418
|
_listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Marketing projects\", \"Overdue tasks\"). Omit for default."),
|
|
@@ -179,6 +424,7 @@ export const schemas = {
|
|
|
179
424
|
}),
|
|
180
425
|
getNestChildren: z.object({
|
|
181
426
|
nestId: z.string().describe("Parent nest ID"),
|
|
427
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
182
428
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
183
429
|
page: z.number().optional().describe("Page number for pagination"),
|
|
184
430
|
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."),
|
|
@@ -217,28 +463,33 @@ export const schemas = {
|
|
|
217
463
|
}),
|
|
218
464
|
addComment: z.object({
|
|
219
465
|
nestId: z.string().describe("Nest ID to comment on"),
|
|
220
|
-
body: z.string().describe("Comment text
|
|
466
|
+
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)."),
|
|
467
|
+
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
468
|
}),
|
|
222
469
|
updateComment: z.object({
|
|
223
470
|
commentId: z.string().describe("Comment ID to update"),
|
|
224
|
-
body: z.string().describe("Updated comment text
|
|
471
|
+
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)."),
|
|
472
|
+
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
473
|
}),
|
|
226
474
|
deleteComment: z.object({
|
|
227
475
|
commentId: z.string().describe("Comment ID to delete"),
|
|
228
476
|
}),
|
|
229
477
|
listCircles: z.object({
|
|
230
478
|
workspaceId: z.string().describe("Workspace ID"),
|
|
479
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
231
480
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
232
481
|
page: z.number().optional().describe("Page number for pagination"),
|
|
233
482
|
}),
|
|
234
483
|
getCircleRoles: z.object({
|
|
235
484
|
workspaceId: z.string().describe("Workspace ID"),
|
|
236
485
|
circleId: z.string().describe("Circle ID"),
|
|
486
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
237
487
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
238
488
|
page: z.number().optional().describe("Page number for pagination"),
|
|
239
489
|
}),
|
|
240
490
|
listRoles: z.object({
|
|
241
491
|
workspaceId: z.string().describe("Workspace ID"),
|
|
492
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
242
493
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
243
494
|
page: z.number().optional().describe("Page number for pagination"),
|
|
244
495
|
}),
|
|
@@ -267,13 +518,14 @@ export const schemas = {
|
|
|
267
518
|
}),
|
|
268
519
|
getProjects: z.object({
|
|
269
520
|
workspaceId: z.string().describe("Workspace ID"),
|
|
521
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
270
522
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
271
523
|
page: z.number().optional().describe("Page number for pagination"),
|
|
272
524
|
_listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Engineering projects\"). Omit for default."),
|
|
273
525
|
}),
|
|
274
526
|
getComments: z.object({
|
|
275
|
-
nestId: z.string().describe("Nest ID to get comments from"),
|
|
276
|
-
depth: z.number().optional().describe("
|
|
527
|
+
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')."),
|
|
528
|
+
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
529
|
}),
|
|
278
530
|
getCircle: z.object({
|
|
279
531
|
workspaceId: z.string().describe("Workspace ID"),
|
|
@@ -399,8 +651,10 @@ export const schemas = {
|
|
|
399
651
|
listTensions: z.object({
|
|
400
652
|
nestId: z.string().describe("ID of the circle or role to list tensions for"),
|
|
401
653
|
search: z.string().optional().describe("Search query to filter tensions"),
|
|
654
|
+
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
402
655
|
limit: z.number().optional().describe("Max results to return"),
|
|
403
|
-
|
|
656
|
+
page: z.number().optional().describe("Page number for pagination"),
|
|
657
|
+
order: z.string().optional().describe("Deprecated alias of sort"),
|
|
404
658
|
}),
|
|
405
659
|
updateTension: z.object({
|
|
406
660
|
nestId: z.string().describe("ID of the circle or role the tension belongs to"),
|
|
@@ -427,11 +681,13 @@ export const schemas = {
|
|
|
427
681
|
description: z.string().optional().describe("The primary content field — detailed information about the item. Supports Markdown and HTML."),
|
|
428
682
|
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
683
|
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
|
|
431
|
-
due: z.string().optional().describe("Due date / re-election date (ISO format)"),
|
|
684
|
+
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\"]."),
|
|
685
|
+
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
686
|
accountabilities: coerceFromJson(z.array(z.string())).optional().describe("Accountability titles to set on a role (replaces all — use children endpoint for individual management)"),
|
|
433
687
|
domains: coerceFromJson(z.array(z.string())).optional().describe("Domain titles to set on a role (replaces all — use children endpoint for individual management)"),
|
|
434
|
-
|
|
688
|
+
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."),
|
|
689
|
+
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."),
|
|
690
|
+
}).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
691
|
modifyTensionPart: z.object({
|
|
436
692
|
nestId: z.string().describe("ID of the circle or role the tension belongs to"),
|
|
437
693
|
tensionId: z.string().describe("Tension ID"),
|
|
@@ -509,8 +765,12 @@ export const schemas = {
|
|
|
509
765
|
targetId: z.string().describe("Target nest ID to unlink"),
|
|
510
766
|
}),
|
|
511
767
|
help: z.object({
|
|
512
|
-
topic: z.string().describe("Topic key (e.g., 'search', 'labels', 'tensions'). Use 'topics' for the full list."),
|
|
513
|
-
|
|
768
|
+
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."),
|
|
769
|
+
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>`."),
|
|
770
|
+
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."),
|
|
771
|
+
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."),
|
|
772
|
+
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."),
|
|
773
|
+
}).refine((v) => Boolean(v.topic) || Boolean(v.search), { message: "Provide either `topic` or `search`." }),
|
|
514
774
|
diagnose: z.object({}).describe("No arguments — diagnose reads session state from the server."),
|
|
515
775
|
};
|
|
516
776
|
// Tool annotations for MCP - hints for clients on tool behavior
|
|
@@ -521,13 +781,16 @@ const destructive = { annotations: { readOnlyHint: false, destructiveHint: true
|
|
|
521
781
|
export const toolDefinitions = [
|
|
522
782
|
{
|
|
523
783
|
name: "nestr_help",
|
|
524
|
-
description: "Get
|
|
784
|
+
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
785
|
inputSchema: {
|
|
526
786
|
type: "object",
|
|
527
787
|
properties: {
|
|
528
|
-
topic: { type: "string", description: "
|
|
788
|
+
topic: { type: "string", description: "Internal topic key or help-article slug. Use 'topics' for the full list of internal topics." },
|
|
789
|
+
search: { type: "string", description: "Free-text query against the public help articles. Returns slugs to fetch via `topic`." },
|
|
790
|
+
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." },
|
|
791
|
+
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." },
|
|
792
|
+
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
793
|
},
|
|
530
|
-
required: ["topic"],
|
|
531
794
|
},
|
|
532
795
|
...readOnly,
|
|
533
796
|
},
|
|
@@ -547,6 +810,7 @@ export const toolDefinitions = [
|
|
|
547
810
|
type: "object",
|
|
548
811
|
properties: {
|
|
549
812
|
search: { type: "string", description: "Search query to filter workspaces" },
|
|
813
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
550
814
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
551
815
|
page: { type: "number", description: "Page number (1-indexed) for pagination" },
|
|
552
816
|
stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for bulk/index operations." },
|
|
@@ -613,6 +877,7 @@ export const toolDefinitions = [
|
|
|
613
877
|
properties: {
|
|
614
878
|
workspaceId: { type: "string", description: "Workspace ID to search in" },
|
|
615
879
|
query: { type: "string", description: "Search query with optional operators (e.g., 'label:role', 'assignee:me completed:false')" },
|
|
880
|
+
sort: { type: "string", description: `${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.` },
|
|
616
881
|
limit: { type: "number", description: "Max results per page. Do NOT set on first call - let API return default with meta.total count showing total matches." },
|
|
617
882
|
page: { type: "number", description: "Page number (1-indexed) for fetching additional pages" },
|
|
618
883
|
stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for bulk/index operations." },
|
|
@@ -646,6 +911,7 @@ export const toolDefinitions = [
|
|
|
646
911
|
type: "object",
|
|
647
912
|
properties: {
|
|
648
913
|
nestId: { type: "string", description: "Parent nest ID" },
|
|
914
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
649
915
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
650
916
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
651
917
|
hints: { type: "boolean", description: "Include contextual hints (default: true). Set to false for large result sets or bulk operations." },
|
|
@@ -660,7 +926,7 @@ export const toolDefinitions = [
|
|
|
660
926
|
},
|
|
661
927
|
{
|
|
662
928
|
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.",
|
|
929
|
+
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
930
|
inputSchema: {
|
|
665
931
|
type: "object",
|
|
666
932
|
properties: {
|
|
@@ -703,7 +969,7 @@ export const toolDefinitions = [
|
|
|
703
969
|
},
|
|
704
970
|
{
|
|
705
971
|
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.",
|
|
972
|
+
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
973
|
inputSchema: {
|
|
708
974
|
type: "object",
|
|
709
975
|
properties: {
|
|
@@ -771,12 +1037,17 @@ export const toolDefinitions = [
|
|
|
771
1037
|
},
|
|
772
1038
|
{
|
|
773
1039
|
name: "nestr_add_comment",
|
|
774
|
-
description: "Add a comment to a nest. Supports HTML and @mentions (
|
|
1040
|
+
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
1041
|
inputSchema: {
|
|
776
1042
|
type: "object",
|
|
777
1043
|
properties: {
|
|
778
1044
|
nestId: { type: "string", description: "Nest ID to comment on" },
|
|
779
|
-
body: { type: "string", description: "Comment text
|
|
1045
|
+
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)." },
|
|
1046
|
+
labels: {
|
|
1047
|
+
type: "array",
|
|
1048
|
+
items: { type: "string" },
|
|
1049
|
+
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.",
|
|
1050
|
+
},
|
|
780
1051
|
},
|
|
781
1052
|
required: ["nestId", "body"],
|
|
782
1053
|
},
|
|
@@ -784,12 +1055,17 @@ export const toolDefinitions = [
|
|
|
784
1055
|
},
|
|
785
1056
|
{
|
|
786
1057
|
name: "nestr_update_comment",
|
|
787
|
-
description: "Update an existing comment's body. Supports HTML and @mentions.",
|
|
1058
|
+
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
1059
|
inputSchema: {
|
|
789
1060
|
type: "object",
|
|
790
1061
|
properties: {
|
|
791
1062
|
commentId: { type: "string", description: "Comment ID to update" },
|
|
792
|
-
body: { type: "string", description: "Updated comment text
|
|
1063
|
+
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)." },
|
|
1064
|
+
labels: {
|
|
1065
|
+
type: "array",
|
|
1066
|
+
items: { type: "string" },
|
|
1067
|
+
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.",
|
|
1068
|
+
},
|
|
793
1069
|
},
|
|
794
1070
|
required: ["commentId", "body"],
|
|
795
1071
|
},
|
|
@@ -814,6 +1090,7 @@ export const toolDefinitions = [
|
|
|
814
1090
|
type: "object",
|
|
815
1091
|
properties: {
|
|
816
1092
|
workspaceId: { type: "string", description: "Workspace ID" },
|
|
1093
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
817
1094
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
818
1095
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
819
1096
|
stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large workspaces." },
|
|
@@ -830,6 +1107,7 @@ export const toolDefinitions = [
|
|
|
830
1107
|
properties: {
|
|
831
1108
|
workspaceId: { type: "string", description: "Workspace ID" },
|
|
832
1109
|
circleId: { type: "string", description: "Circle ID" },
|
|
1110
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
833
1111
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
834
1112
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
835
1113
|
stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large circles." },
|
|
@@ -845,6 +1123,7 @@ export const toolDefinitions = [
|
|
|
845
1123
|
type: "object",
|
|
846
1124
|
properties: {
|
|
847
1125
|
workspaceId: { type: "string", description: "Workspace ID" },
|
|
1126
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
848
1127
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
849
1128
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
850
1129
|
stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large workspaces." },
|
|
@@ -919,6 +1198,7 @@ export const toolDefinitions = [
|
|
|
919
1198
|
type: "object",
|
|
920
1199
|
properties: {
|
|
921
1200
|
workspaceId: { type: "string", description: "Workspace ID" },
|
|
1201
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
922
1202
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
923
1203
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
924
1204
|
stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large workspaces." },
|
|
@@ -931,12 +1211,15 @@ export const toolDefinitions = [
|
|
|
931
1211
|
},
|
|
932
1212
|
{
|
|
933
1213
|
name: "nestr_get_comments",
|
|
934
|
-
description: "Get comments and discussion history on a nest.",
|
|
1214
|
+
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
1215
|
inputSchema: {
|
|
936
1216
|
type: "object",
|
|
937
1217
|
properties: {
|
|
938
|
-
nestId: { type: "string", description: "Nest ID to get comments from" },
|
|
939
|
-
depth: {
|
|
1218
|
+
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')." },
|
|
1219
|
+
depth: {
|
|
1220
|
+
oneOf: [{ type: "number" }, { type: "string", enum: ["all"] }],
|
|
1221
|
+
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.",
|
|
1222
|
+
},
|
|
940
1223
|
},
|
|
941
1224
|
required: ["nestId"],
|
|
942
1225
|
},
|
|
@@ -1184,7 +1467,7 @@ export const toolDefinitions = [
|
|
|
1184
1467
|
// Label management
|
|
1185
1468
|
{
|
|
1186
1469
|
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.",
|
|
1470
|
+
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
1471
|
inputSchema: {
|
|
1189
1472
|
type: "object",
|
|
1190
1473
|
properties: {
|
|
@@ -1338,8 +1621,12 @@ export const toolDefinitions = [
|
|
|
1338
1621
|
properties: {
|
|
1339
1622
|
nestId: { type: "string", description: "ID of the circle or role to list tensions for" },
|
|
1340
1623
|
search: { type: "string", description: "Search query to filter tensions" },
|
|
1624
|
+
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
1341
1625
|
limit: { type: "number", description: "Max results to return" },
|
|
1342
|
-
|
|
1626
|
+
page: { type: "number", description: "Page number (1-indexed)" },
|
|
1627
|
+
// The legacy `order` alias is deliberately not advertised — the Zod
|
|
1628
|
+
// schema still accepts it so existing callers keep working, but new
|
|
1629
|
+
// clients should only learn the canonical `sort` param.
|
|
1343
1630
|
},
|
|
1344
1631
|
required: ["nestId"],
|
|
1345
1632
|
},
|
|
@@ -1390,7 +1677,7 @@ export const toolDefinitions = [
|
|
|
1390
1677
|
},
|
|
1391
1678
|
{
|
|
1392
1679
|
name: "nestr_add_tension_part",
|
|
1393
|
-
description: "Add
|
|
1680
|
+
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
1681
|
inputSchema: {
|
|
1395
1682
|
type: "object",
|
|
1396
1683
|
properties: {
|
|
@@ -1402,10 +1689,12 @@ export const toolDefinitions = [
|
|
|
1402
1689
|
description: { type: "string", description: "The primary content field — detailed information about the item. Supports Markdown and HTML." },
|
|
1403
1690
|
purpose: { type: "string", description: "ONLY for roles/circles — a short aspirational statement. Do NOT put detailed information here; use description instead. Supports HTML." },
|
|
1404
1691
|
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 (
|
|
1406
|
-
due: { type: "string", description: "Due date / re-election date (ISO format)" },
|
|
1692
|
+
users: { type: "array", items: { type: "string" }, description: "User IDs to assign. For an election (with roleId), the single user being elected, e.g. [\"userId\"]." },
|
|
1693
|
+
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
1694
|
accountabilities: { type: "array", items: { type: "string" }, description: "Accountability titles to set on a role (replaces all — use children endpoint for individual management)" },
|
|
1408
1695
|
domains: { type: "array", items: { type: "string" }, description: "Domain titles to set on a role (replaces all — use children endpoint for individual management)" },
|
|
1696
|
+
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." },
|
|
1697
|
+
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
1698
|
},
|
|
1410
1699
|
required: ["nestId", "tensionId"],
|
|
1411
1700
|
},
|
|
@@ -1642,32 +1931,213 @@ export function unescapeRichTextFields(args) {
|
|
|
1642
1931
|
}
|
|
1643
1932
|
return changed ? result : args;
|
|
1644
1933
|
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Extract sort:/sort-order:/limit: directives from a search query string.
|
|
1936
|
+
*
|
|
1937
|
+
* The REST API only honors sorting and limits via the `sort`/`limit` query
|
|
1938
|
+
* params — these operators inside the search string are dropped from free-text
|
|
1939
|
+
* matching and otherwise ignored. Translating them here keeps the documented
|
|
1940
|
+
* search syntax (see nestr_help('search')) working. The query string itself is
|
|
1941
|
+
* sent unmodified: the server strips operator terms from text matching anyway.
|
|
1942
|
+
*
|
|
1943
|
+
* First occurrence wins for each directive, matching the server-side parsing
|
|
1944
|
+
* of these operators elsewhere in Nestr.
|
|
1945
|
+
*/
|
|
1946
|
+
export function extractSearchDirectives(query) {
|
|
1947
|
+
let sortField;
|
|
1948
|
+
let sortOrder;
|
|
1949
|
+
let limit;
|
|
1950
|
+
for (const term of query.split(/\s+/)) {
|
|
1951
|
+
const lower = term.toLowerCase();
|
|
1952
|
+
if (sortField === undefined && lower.startsWith("sort:")) {
|
|
1953
|
+
sortField = term.slice("sort:".length);
|
|
1954
|
+
}
|
|
1955
|
+
else if (sortOrder === undefined && lower.startsWith("sort-order:")) {
|
|
1956
|
+
sortOrder = lower.slice("sort-order:".length);
|
|
1957
|
+
}
|
|
1958
|
+
else if (limit === undefined && lower.startsWith("limit:")) {
|
|
1959
|
+
const parsed = Number.parseInt(lower.slice("limit:".length), 10);
|
|
1960
|
+
if (!Number.isNaN(parsed) && parsed > 0)
|
|
1961
|
+
limit = parsed;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
let sort;
|
|
1965
|
+
if (sortField) {
|
|
1966
|
+
sort = sortOrder === "desc" && !sortField.startsWith("-") ? `-${sortField}` : sortField;
|
|
1967
|
+
}
|
|
1968
|
+
return { sort, limit };
|
|
1969
|
+
}
|
|
1645
1970
|
export async function handleToolCall(client, name, args, context) {
|
|
1646
1971
|
const sanitizedArgs = unescapeRichTextFields(args);
|
|
1647
1972
|
const shouldStripDescription = sanitizedArgs.stripDescription === true;
|
|
1648
1973
|
const result = await _handleToolCall(client, name, sanitizedArgs, context);
|
|
1649
1974
|
if (shouldStripDescription && !result.isError) {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1975
|
+
const first = result.content[0];
|
|
1976
|
+
if (first && first.type === "text") {
|
|
1977
|
+
try {
|
|
1978
|
+
const parsed = JSON.parse(first.text);
|
|
1979
|
+
first.text = JSON.stringify(stripDescriptionFields(parsed), null, 2);
|
|
1980
|
+
}
|
|
1981
|
+
catch {
|
|
1982
|
+
// If parsing fails, return as-is
|
|
1983
|
+
}
|
|
1656
1984
|
}
|
|
1657
1985
|
}
|
|
1658
1986
|
return result;
|
|
1659
1987
|
}
|
|
1660
1988
|
async function _handleToolCall(client, name, args, context) {
|
|
1661
1989
|
try {
|
|
1990
|
+
// PUBLIC surface gate (defense in depth — the public route also filters the
|
|
1991
|
+
// advertised tool list). Refuse anything outside the public allow-list so a
|
|
1992
|
+
// hand-crafted tools/call can never reach an authenticated Nestr path, and
|
|
1993
|
+
// serve nestr_get_me from a fixed guest payload without an API call.
|
|
1994
|
+
if (context?.isPublic) {
|
|
1995
|
+
if (!PUBLIC_TOOL_NAMES.has(name)) {
|
|
1996
|
+
return formatError({
|
|
1997
|
+
error: true,
|
|
1998
|
+
code: "AUTH_SCOPE_INSUFFICIENT",
|
|
1999
|
+
message: `Tool '${name}' is not available on the public Nestr MCP. Guest mode exposes product help only (${[...PUBLIC_TOOL_NAMES].join(", ")}).`,
|
|
2000
|
+
retryable: false,
|
|
2001
|
+
hint: "Add AI credit / sign in and connect to the authenticated MCP endpoint to use workspace tools.",
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
if (name === "nestr_get_me") {
|
|
2005
|
+
schemas.getMe.parse(args);
|
|
2006
|
+
return formatResult(PUBLIC_GUEST_ME);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
1662
2009
|
switch (name) {
|
|
1663
2010
|
case "nestr_help": {
|
|
1664
2011
|
const parsed = schemas.help.parse(args);
|
|
1665
2012
|
const { HELP_TOPICS } = await import("../help/topics.js");
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
2013
|
+
const { relatedArticlesForTopic, relatedTopicForArticle } = await import("../help/cross-links.js");
|
|
2014
|
+
// Search mode: query the public help-article index. Returns a ranked
|
|
2015
|
+
// list of slugs; the caller pulls a specific article with a second
|
|
2016
|
+
// call passing `topic: <slug>`.
|
|
2017
|
+
if (parsed.search) {
|
|
2018
|
+
const { loadArticleIndex, searchArticleIndex, fetchArticleMeta } = await import("../help/articles.js");
|
|
2019
|
+
try {
|
|
2020
|
+
const entries = await loadArticleIndex();
|
|
2021
|
+
const hits = searchArticleIndex(entries, parsed.search, 8);
|
|
2022
|
+
if (hits.length === 0) {
|
|
2023
|
+
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.` }] };
|
|
2024
|
+
}
|
|
2025
|
+
// Enrich the top hits with a title + one-line summary so the caller
|
|
2026
|
+
// can pick the right article without a blind fetch. Best-effort:
|
|
2027
|
+
// a meta-fetch failure just falls back to the bare slug for that row.
|
|
2028
|
+
const ENRICH = 5;
|
|
2029
|
+
const metas = await Promise.allSettled(hits.slice(0, ENRICH).map(h => fetchArticleMeta(h.slug)));
|
|
2030
|
+
const lines = hits.map((h, i) => {
|
|
2031
|
+
const settled = i < ENRICH ? metas[i] : undefined;
|
|
2032
|
+
const meta = settled?.status === "fulfilled" ? settled.value : undefined;
|
|
2033
|
+
const topic = relatedTopicForArticle(h.slug);
|
|
2034
|
+
const seeAlso = topic ? ` _(see also internal topic \`${topic}\`)_` : "";
|
|
2035
|
+
if (meta?.title) {
|
|
2036
|
+
const summary = meta.description ? ` — ${meta.description}` : "";
|
|
2037
|
+
return `- \`${h.slug}\` — **${meta.title}**${summary}${seeAlso}`;
|
|
2038
|
+
}
|
|
2039
|
+
return `- \`${h.slug}\` — ${h.url}${seeAlso}`;
|
|
2040
|
+
});
|
|
2041
|
+
const body = [
|
|
2042
|
+
`_Resolved as: help-article search._`,
|
|
2043
|
+
``,
|
|
2044
|
+
`Found ${hits.length} help article${hits.length === 1 ? "" : "s"} for "${parsed.search}". Fetch one with nestr_help({ topic: "<slug>" }).`,
|
|
2045
|
+
``,
|
|
2046
|
+
...lines,
|
|
2047
|
+
].join("\n");
|
|
2048
|
+
return { content: [{ type: "text", text: body }] };
|
|
2049
|
+
}
|
|
2050
|
+
catch (err) {
|
|
2051
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2052
|
+
return { content: [{ type: "text", text: `Help-article search failed: ${message}. Internal topics still available via nestr_help({ topic: "topics" }).` }], isError: true };
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
// Topic mode: prefer the curated internal topic; if there's no match,
|
|
2056
|
+
// try the slug as a help article. Network failures on the fallback are
|
|
2057
|
+
// surfaced rather than masked — a stale link is more useful than a
|
|
2058
|
+
// generic "not found". Every branch opens with a "Resolved as:" line so
|
|
2059
|
+
// the caller knows which source answered, even if a slug ever shadows
|
|
2060
|
+
// an internal key.
|
|
2061
|
+
const topic = parsed.topic;
|
|
2062
|
+
const content = HELP_TOPICS[topic];
|
|
2063
|
+
if (content) {
|
|
2064
|
+
const related = relatedArticlesForTopic(topic);
|
|
2065
|
+
const footer = related.length
|
|
2066
|
+
? `\n\n---\nRelated public help article${related.length === 1 ? "" : "s"} (fetch with nestr_help({ topic: "<slug>" })): ${related.map(s => `\`${s}\``).join(", ")}`
|
|
2067
|
+
: "";
|
|
2068
|
+
return { content: [{ type: "text", text: `_Resolved as: internal MCP topic "${topic}"._\n\n${content}${footer}` }] };
|
|
2069
|
+
}
|
|
2070
|
+
const { fetchArticleMarkdown, extractImages, collectArticleImages, selectImageIndexes, clampMaxImages } = await import("../help/articles.js");
|
|
2071
|
+
try {
|
|
2072
|
+
const article = await fetchArticleMarkdown(topic);
|
|
2073
|
+
const images = extractImages(article.markdown);
|
|
2074
|
+
const relatedTopic = relatedTopicForArticle(article.slug);
|
|
2075
|
+
// Image attachment is opt-in: only when the caller explicitly asks via
|
|
2076
|
+
// includeImages:true (default selection) or imageIndexes (exact
|
|
2077
|
+
// entries). A plain fetch returns markdown + the numbered URL list, so
|
|
2078
|
+
// the agent/user can decide whether the screenshots are worth the
|
|
2079
|
+
// tokens, then re-call to pull them. Fetch first so the text list can
|
|
2080
|
+
// mark which entries were attached; best-effort — failures just leave
|
|
2081
|
+
// the text list, which always carries every image's URL.
|
|
2082
|
+
const imageOpts = { indexes: parsed.imageIndexes, max: parsed.maxImages };
|
|
2083
|
+
const hasExplicitIndexes = (parsed.imageIndexes?.length ?? 0) > 0;
|
|
2084
|
+
const wantImages = parsed.includeImages === true || hasExplicitIndexes;
|
|
2085
|
+
const selectedCount = wantImages && images.length ? selectImageIndexes(images, imageOpts).length : 0;
|
|
2086
|
+
const inlined = wantImages && images.length ? await collectArticleImages(images, imageOpts) : [];
|
|
2087
|
+
const attached = new Set(inlined.map(img => img.index));
|
|
2088
|
+
// Surface a clear, prominent hint whenever the article has screenshots
|
|
2089
|
+
// so the caller knows they exist and how to pull them in (images are
|
|
2090
|
+
// opt-in). Adapts to whether any are already attached.
|
|
2091
|
+
const cap = clampMaxImages(parsed.maxImages);
|
|
2092
|
+
const contentCount = images.filter(img => !img.decorative).length;
|
|
2093
|
+
let imageHint = "";
|
|
2094
|
+
if (inlined.length > 0) {
|
|
2095
|
+
const more = images.length - inlined.length;
|
|
2096
|
+
imageHint = more > 0
|
|
2097
|
+
? `_${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:[..]._`
|
|
2098
|
+
: `_${inlined.length} screenshot${inlined.length === 1 ? "" : "s"} attached below as viewable image${inlined.length === 1 ? "" : "s"}._`;
|
|
2099
|
+
}
|
|
2100
|
+
else if (contentCount > 0) {
|
|
2101
|
+
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)._`;
|
|
2102
|
+
}
|
|
2103
|
+
const parts = [
|
|
2104
|
+
`_Resolved as: help article "${article.slug}" (fetched from ${article.url})._`,
|
|
2105
|
+
``,
|
|
2106
|
+
`# ${article.title || article.slug}`,
|
|
2107
|
+
];
|
|
2108
|
+
if (article.description)
|
|
2109
|
+
parts.push(``, `> ${article.description}`);
|
|
2110
|
+
if (imageHint)
|
|
2111
|
+
parts.push(``, imageHint);
|
|
2112
|
+
parts.push(``, article.markdown);
|
|
2113
|
+
if (images.length) {
|
|
2114
|
+
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) => {
|
|
2115
|
+
const tag = img.decorative ? " [decorative]" : "";
|
|
2116
|
+
const captionText = img.caption ? `"${img.caption}"` : "(no caption)";
|
|
2117
|
+
const mark = attached.has(i) ? " — attached inline below" : "";
|
|
2118
|
+
return `- [${i}]${tag} ${captionText} — ${img.url}${mark}`;
|
|
2119
|
+
}));
|
|
2120
|
+
if (wantImages && inlined.length === 0) {
|
|
2121
|
+
const why = selectedCount === 0
|
|
2122
|
+
? (parsed.imageIndexes?.length ? "the requested imageIndexes were out of range" : "this article has no non-decorative content screenshots")
|
|
2123
|
+
: "the selected images could not be fetched";
|
|
2124
|
+
parts.push(``, `_(No images attached — ${why}.)_`);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
if (relatedTopic) {
|
|
2128
|
+
parts.push(``, `---`, `Related internal MCP topic (agent-flavoured tool-call guidance): \`${relatedTopic}\` — fetch with nestr_help({ topic: "${relatedTopic}" }).`);
|
|
2129
|
+
}
|
|
2130
|
+
parts.push(``, `---`, `Source: ${article.url}`);
|
|
2131
|
+
const content = [{ type: "text", text: parts.join("\n") }];
|
|
2132
|
+
for (const img of inlined) {
|
|
2133
|
+
content.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
2134
|
+
}
|
|
2135
|
+
return { content };
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2139
|
+
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
2140
|
}
|
|
1670
|
-
return { content: [{ type: "text", text: content }] };
|
|
1671
2141
|
}
|
|
1672
2142
|
case "nestr_diagnose": {
|
|
1673
2143
|
schemas.diagnose.parse(args);
|
|
@@ -1704,6 +2174,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1704
2174
|
const parsed = schemas.listWorkspaces.parse(args);
|
|
1705
2175
|
const workspaces = await client.listWorkspaces({
|
|
1706
2176
|
search: parsed.search,
|
|
2177
|
+
sort: parsed.sort,
|
|
1707
2178
|
limit: parsed.limit,
|
|
1708
2179
|
page: parsed.page,
|
|
1709
2180
|
cleanText: true,
|
|
@@ -1732,7 +2203,13 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1732
2203
|
}
|
|
1733
2204
|
case "nestr_search": {
|
|
1734
2205
|
const parsed = schemas.search.parse(args);
|
|
1735
|
-
const
|
|
2206
|
+
const directives = extractSearchDirectives(parsed.query);
|
|
2207
|
+
const results = await client.searchWorkspace(parsed.workspaceId, parsed.query, {
|
|
2208
|
+
sort: parsed.sort ?? directives.sort,
|
|
2209
|
+
limit: parsed.limit ?? directives.limit,
|
|
2210
|
+
page: parsed.page,
|
|
2211
|
+
cleanText: true,
|
|
2212
|
+
});
|
|
1736
2213
|
return formatResult(completableResponse(compactResponse(results), "search", parsed._listTitle || `Search: ${parsed.query}`));
|
|
1737
2214
|
}
|
|
1738
2215
|
case "nestr_get_nest": {
|
|
@@ -1747,6 +2224,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1747
2224
|
case "nestr_get_nest_children": {
|
|
1748
2225
|
const parsed = schemas.getNestChildren.parse(args);
|
|
1749
2226
|
const children = await client.getNestChildren(parsed.nestId, {
|
|
2227
|
+
sort: parsed.sort,
|
|
1750
2228
|
limit: parsed.limit,
|
|
1751
2229
|
page: parsed.page,
|
|
1752
2230
|
cleanText: true,
|
|
@@ -1756,6 +2234,8 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1756
2234
|
}
|
|
1757
2235
|
case "nestr_create_nest": {
|
|
1758
2236
|
const parsed = schemas.createNest.parse(args);
|
|
2237
|
+
validatePrimeLabels(parsed.labels);
|
|
2238
|
+
parsed.labels = ensureMeetingModifier(parsed.labels);
|
|
1759
2239
|
const hasGovernanceLabels = parsed.labels?.some(l => ["role", "circle"].includes(l));
|
|
1760
2240
|
const hasInlineGovernance = parsed.accountabilities?.length || parsed.domains?.length;
|
|
1761
2241
|
// Route to self-organization API when creating roles/circles with accountabilities/domains
|
|
@@ -1794,6 +2274,8 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1794
2274
|
}
|
|
1795
2275
|
case "nestr_update_nest": {
|
|
1796
2276
|
const parsed = schemas.updateNest.parse(args);
|
|
2277
|
+
validatePrimeLabels(parsed.labels);
|
|
2278
|
+
parsed.labels = ensureMeetingModifier(parsed.labels);
|
|
1797
2279
|
const hasInlineGovernance = parsed.accountabilities?.length || parsed.domains?.length;
|
|
1798
2280
|
// Route to self-organization API when updating roles/circles with accountabilities/domains
|
|
1799
2281
|
if (hasInlineGovernance && parsed.workspaceId) {
|
|
@@ -1840,13 +2322,16 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1840
2322
|
}
|
|
1841
2323
|
case "nestr_add_comment": {
|
|
1842
2324
|
const parsed = schemas.addComment.parse(args);
|
|
1843
|
-
const post = await client.createPost(parsed.nestId, parsed.body
|
|
2325
|
+
const post = await client.createPost(parsed.nestId, parsed.body, {
|
|
2326
|
+
labels: parsed.labels,
|
|
2327
|
+
});
|
|
1844
2328
|
return formatResult({ message: "Comment added successfully", post });
|
|
1845
2329
|
}
|
|
1846
2330
|
case "nestr_update_comment": {
|
|
1847
2331
|
const parsed = schemas.updateComment.parse(args);
|
|
1848
2332
|
const updated = await client.updateNest(parsed.commentId, {
|
|
1849
2333
|
title: parsed.body,
|
|
2334
|
+
...(parsed.labels !== undefined ? { labels: parsed.labels } : {}),
|
|
1850
2335
|
});
|
|
1851
2336
|
return formatResult({ message: "Comment updated successfully", comment: updated });
|
|
1852
2337
|
}
|
|
@@ -1858,6 +2343,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1858
2343
|
case "nestr_list_circles": {
|
|
1859
2344
|
const parsed = schemas.listCircles.parse(args);
|
|
1860
2345
|
const circles = await client.listCircles(parsed.workspaceId, {
|
|
2346
|
+
sort: parsed.sort,
|
|
1861
2347
|
limit: parsed.limit,
|
|
1862
2348
|
page: parsed.page,
|
|
1863
2349
|
cleanText: true,
|
|
@@ -1866,12 +2352,13 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1866
2352
|
}
|
|
1867
2353
|
case "nestr_get_circle_roles": {
|
|
1868
2354
|
const parsed = schemas.getCircleRoles.parse(args);
|
|
1869
|
-
const roles = await client.getCircleRoles(parsed.workspaceId, parsed.circleId, { limit: parsed.limit, page: parsed.page, cleanText: true });
|
|
2355
|
+
const roles = await client.getCircleRoles(parsed.workspaceId, parsed.circleId, { sort: parsed.sort, limit: parsed.limit, page: parsed.page, cleanText: true });
|
|
1870
2356
|
return formatResult(compactResponse(roles, "role"));
|
|
1871
2357
|
}
|
|
1872
2358
|
case "nestr_list_roles": {
|
|
1873
2359
|
const parsed = schemas.listRoles.parse(args);
|
|
1874
2360
|
const roles = await client.listRoles(parsed.workspaceId, {
|
|
2361
|
+
sort: parsed.sort,
|
|
1875
2362
|
limit: parsed.limit,
|
|
1876
2363
|
page: parsed.page,
|
|
1877
2364
|
cleanText: true,
|
|
@@ -1914,6 +2401,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
1914
2401
|
case "nestr_get_projects": {
|
|
1915
2402
|
const parsed = schemas.getProjects.parse(args);
|
|
1916
2403
|
const projects = await client.getWorkspaceProjects(parsed.workspaceId, {
|
|
2404
|
+
sort: parsed.sort,
|
|
1917
2405
|
limit: parsed.limit,
|
|
1918
2406
|
page: parsed.page,
|
|
1919
2407
|
cleanText: true,
|
|
@@ -2036,6 +2524,13 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
2036
2524
|
// Label management
|
|
2037
2525
|
case "nestr_add_label": {
|
|
2038
2526
|
const parsed = schemas.addLabel.parse(args);
|
|
2527
|
+
// Only fetch existing labels when applying a prime label — for all
|
|
2528
|
+
// other labels there's no possible conflict, so skip the extra call.
|
|
2529
|
+
if (PRIME_LABELS.has(parsed.labelId)) {
|
|
2530
|
+
const existing = await client.getNest(parsed.nestId);
|
|
2531
|
+
const existingNest = Array.isArray(existing) ? existing[0] : existing;
|
|
2532
|
+
validatePrimeLabels([...(existingNest?.labels ?? []), parsed.labelId]);
|
|
2533
|
+
}
|
|
2039
2534
|
const nest = await client.addLabel(parsed.nestId, parsed.labelId);
|
|
2040
2535
|
return formatResult({ message: `Label '${parsed.labelId}' added successfully`, nest: compactResponse(nest) });
|
|
2041
2536
|
}
|
|
@@ -2177,17 +2672,24 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
2177
2672
|
description: parsed.description,
|
|
2178
2673
|
...(Object.keys(fields).length > 0 ? { fields } : {}),
|
|
2179
2674
|
});
|
|
2180
|
-
return formatResult({ message: "Tension created successfully", tension });
|
|
2675
|
+
return formatResult({ message: "Tension created successfully", tension: enrichHints(tension) });
|
|
2181
2676
|
}
|
|
2182
2677
|
case "nestr_get_tension": {
|
|
2183
2678
|
const parsed = schemas.getTension.parse(args);
|
|
2184
2679
|
const tension = await client.getTension(parsed.nestId, parsed.tensionId, { cleanText: true });
|
|
2185
|
-
return formatResult(tension);
|
|
2680
|
+
return formatResult(enrichHints(tension));
|
|
2186
2681
|
}
|
|
2187
2682
|
case "nestr_list_tensions": {
|
|
2188
2683
|
const parsed = schemas.listTensions.parse(args);
|
|
2189
|
-
const tensions = await client.listTensions(parsed.nestId, parsed.search, {
|
|
2190
|
-
|
|
2684
|
+
const tensions = await client.listTensions(parsed.nestId, parsed.search, {
|
|
2685
|
+
// `order` is the legacy name for this option — it was never honored
|
|
2686
|
+
// by the API (which reads `sort`), so route both through sort.
|
|
2687
|
+
sort: parsed.sort ?? parsed.order,
|
|
2688
|
+
limit: parsed.limit,
|
|
2689
|
+
page: parsed.page,
|
|
2690
|
+
cleanText: true,
|
|
2691
|
+
});
|
|
2692
|
+
return formatResult(compactResponse(enrichHints(tensions)));
|
|
2191
2693
|
}
|
|
2192
2694
|
case "nestr_update_tension": {
|
|
2193
2695
|
const parsed = schemas.updateTension.parse(args);
|
|
@@ -2215,8 +2717,24 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
2215
2717
|
}
|
|
2216
2718
|
case "nestr_add_tension_part": {
|
|
2217
2719
|
const parsed = schemas.addTensionPart.parse(args);
|
|
2218
|
-
const { nestId, tensionId, ...body } = parsed;
|
|
2219
|
-
if (
|
|
2720
|
+
const { nestId, tensionId, removeNest, roleId, ...body } = parsed;
|
|
2721
|
+
if (roleId) {
|
|
2722
|
+
// Hold an election: assign/reconfirm the role's filler for a term without
|
|
2723
|
+
// changing its accountabilities/domains. Reuses `users` (the elected person)
|
|
2724
|
+
// and `due` (the term). The schema guarantees exactly one user here.
|
|
2725
|
+
const part = await client.createElection(nestId, tensionId, {
|
|
2726
|
+
roleId,
|
|
2727
|
+
users: body.users ?? [],
|
|
2728
|
+
...(body.due !== undefined ? { due: body.due } : {}),
|
|
2729
|
+
});
|
|
2730
|
+
return formatResult({ message: "Election added to the tension successfully", part });
|
|
2731
|
+
}
|
|
2732
|
+
else if (body._id && removeNest === true) {
|
|
2733
|
+
// Propose deletion of an existing structural item.
|
|
2734
|
+
const part = await client.proposeTensionDeletion(nestId, tensionId, body._id);
|
|
2735
|
+
return formatResult({ message: "Deletion proposal added successfully", part });
|
|
2736
|
+
}
|
|
2737
|
+
else if (body._id) {
|
|
2220
2738
|
// Propose change to existing item (existing children auto-copied if accountabilities/domains not provided)
|
|
2221
2739
|
const part = await client.proposeTensionChange(nestId, tensionId, body);
|
|
2222
2740
|
return formatResult({ message: "Change proposal added successfully", part });
|
|
@@ -2321,6 +2839,17 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
2321
2839
|
correlationId: getCorrelationId(),
|
|
2322
2840
|
});
|
|
2323
2841
|
}
|
|
2842
|
+
// Prime-label conflicts (e.g. ['project', 'tension'] on one nest)
|
|
2843
|
+
if (error instanceof PrimeLabelConflictError) {
|
|
2844
|
+
return formatError({
|
|
2845
|
+
error: true,
|
|
2846
|
+
code: "VALIDATION",
|
|
2847
|
+
message: error.message,
|
|
2848
|
+
retryable: false,
|
|
2849
|
+
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).`,
|
|
2850
|
+
correlationId: getCorrelationId(),
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2324
2853
|
// Handle other errors
|
|
2325
2854
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2326
2855
|
return formatError({
|
|
@@ -2355,7 +2884,7 @@ function formatResult(data) {
|
|
|
2355
2884
|
content: [
|
|
2356
2885
|
{
|
|
2357
2886
|
type: "text",
|
|
2358
|
-
text: JSON.stringify(data, null, 2),
|
|
2887
|
+
text: JSON.stringify(addNestUrls(data), null, 2),
|
|
2359
2888
|
},
|
|
2360
2889
|
],
|
|
2361
2890
|
};
|