@nestr/mcp 0.1.72 → 0.1.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/api/client.d.ts +84 -5
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +120 -21
- 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 +324 -12
- package/build/help/topics.js.map +1 -1
- package/build/http.d.ts +26 -13
- package/build/http.d.ts.map +1 -1
- package/build/http.js +572 -128
- 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/oauth/flow.d.ts +6 -0
- package/build/oauth/flow.d.ts.map +1 -1
- package/build/oauth/flow.js +30 -4
- package/build/oauth/flow.js.map +1 -1
- package/build/oauth/store.d.ts +14 -0
- package/build/oauth/store.d.ts.map +1 -1
- package/build/oauth/store.js.map +1 -1
- package/build/server.d.ts +11 -0
- package/build/server.d.ts.map +1 -1
- package/build/server.js +38 -5
- 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 +580 -90
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +598 -82
- 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/build/util/diagnose.d.ts +40 -0
- package/build/util/diagnose.d.ts.map +1 -0
- package/build/util/diagnose.js +26 -0
- package/build/util/diagnose.js.map +1 -0
- package/build/util/request-context.d.ts +11 -0
- package/build/util/request-context.d.ts.map +1 -0
- package/build/util/request-context.js +30 -0
- package/build/util/request-context.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
|
@@ -5,6 +5,27 @@
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { NestrApiError } from "../api/client.js";
|
|
7
7
|
import { appResources } from "../apps/index.js";
|
|
8
|
+
import { getCorrelationId } from "../util/request-context.js";
|
|
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
|
+
};
|
|
8
29
|
// MCP Apps UI metadata for tools that can render in the completable list app.
|
|
9
30
|
// IMPORTANT: Only use for completable items (tasks, projects, todos, inbox items).
|
|
10
31
|
// Do NOT use for structural nests like roles, circles, metrics, policies, etc.
|
|
@@ -82,6 +103,24 @@ const HINT_URL_PATTERNS = [
|
|
|
82
103
|
return result;
|
|
83
104
|
},
|
|
84
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
|
+
},
|
|
85
124
|
// /nests/{id}/posts → nestr_get_comments
|
|
86
125
|
{ pattern: /^\/nests\/([^/]+)\/posts$/, tool: "nestr_get_comments", params: (m) => ({ nestId: m[1] }) },
|
|
87
126
|
// /nests/{id}/tensions → nestr_list_tensions
|
|
@@ -89,6 +128,119 @@ const HINT_URL_PATTERNS = [
|
|
|
89
128
|
// /nests/{id} → nestr_get_nest (must be last — catches all /nests/{id} patterns)
|
|
90
129
|
{ pattern: /^\/nests\/([^/]+)$/, tool: "nestr_get_nest", params: (m) => ({ nestId: m[1] }) },
|
|
91
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
|
+
}
|
|
92
244
|
// Enrich hints with tool call parameters so models can act on hints directly.
|
|
93
245
|
// Extracts workspaceId from nest ancestors (last element) for search-based hints.
|
|
94
246
|
export function enrichHints(data) {
|
|
@@ -109,28 +261,91 @@ export function enrichHints(data) {
|
|
|
109
261
|
const ancestors = record.ancestors;
|
|
110
262
|
const workspaceId = ancestors?.length ? ancestors[ancestors.length - 1] : undefined;
|
|
111
263
|
record.hints = record.hints.map((hint) => {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
264
|
+
const enriched = { ...hint };
|
|
265
|
+
// Legacy: single URL → toolCall. Kept for backwards compatibility with
|
|
266
|
+
// hints that pre-date the endpoints[] payload.
|
|
267
|
+
if (hint.url) {
|
|
268
|
+
let rawUrl = hint.url;
|
|
269
|
+
const apiPrefixMatch = rawUrl.match(/^https?:\/\/[^/]+\/api(\/.*)/);
|
|
270
|
+
if (apiPrefixMatch)
|
|
271
|
+
rawUrl = apiPrefixMatch[1];
|
|
272
|
+
const [path, queryString] = rawUrl.split("?");
|
|
273
|
+
const searchParams = new URLSearchParams(queryString || "");
|
|
274
|
+
let matched = false;
|
|
275
|
+
for (const { pattern, tool, params } of HINT_URL_PATTERNS) {
|
|
276
|
+
const match = path.match(pattern);
|
|
277
|
+
if (match) {
|
|
278
|
+
enriched.toolCall = { tool, params: params(match, searchParams, workspaceId) };
|
|
279
|
+
matched = true;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!matched) {
|
|
284
|
+
console.error(`[nestr-mcp] Unrecognized hint URL pattern: "${hint.url}" (hint type: ${hint.type})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// New: endpoints[] → toolCalls[]. Each endpoint becomes one tool-call
|
|
288
|
+
// suggestion; unmapped routes are dropped silently (no invented tools).
|
|
289
|
+
if (Array.isArray(hint.endpoints) && hint.endpoints.length > 0) {
|
|
290
|
+
const toolCalls = hint.endpoints
|
|
291
|
+
.map((endpoint) => translateEndpoint(endpoint))
|
|
292
|
+
.filter((tc) => tc !== null);
|
|
293
|
+
if (toolCalls.length > 0) {
|
|
294
|
+
enriched.toolCalls = toolCalls;
|
|
125
295
|
}
|
|
126
296
|
}
|
|
127
|
-
|
|
128
|
-
console.error(`[nestr-mcp] Unrecognized hint URL pattern: "${hint.url}" (hint type: ${hint.type})`);
|
|
129
|
-
return hint;
|
|
297
|
+
return enriched;
|
|
130
298
|
});
|
|
131
299
|
}
|
|
132
300
|
return data;
|
|
133
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
|
+
}
|
|
134
349
|
// Coerce JSON-stringified arrays/objects before Zod validation.
|
|
135
350
|
// Some MCP clients send array/object params as JSON strings (e.g., "[\"project\"]" instead of ["project"]).
|
|
136
351
|
const coerceFromJson = (schema) => z.preprocess((val) => {
|
|
@@ -144,6 +359,32 @@ const coerceFromJson = (schema) => z.preprocess((val) => {
|
|
|
144
359
|
}
|
|
145
360
|
return val;
|
|
146
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);
|
|
147
388
|
// Tool input schemas using Zod
|
|
148
389
|
export const schemas = {
|
|
149
390
|
listWorkspaces: z.object({
|
|
@@ -215,11 +456,13 @@ export const schemas = {
|
|
|
215
456
|
}),
|
|
216
457
|
addComment: z.object({
|
|
217
458
|
nestId: z.string().describe("Nest ID to comment on"),
|
|
218
|
-
body: z.string().describe("Comment text
|
|
459
|
+
body: z.string().describe("Comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)."),
|
|
460
|
+
labels: z.array(z.string()).optional().describe("Optional label IDs to attach to the comment at creation time (e.g., 'decision', 'question', or a custom label ID). Personal labels are auto-scoped to the authenticated user. Use nestr_list_labels / nestr_list_personal_labels to discover IDs."),
|
|
219
461
|
}),
|
|
220
462
|
updateComment: z.object({
|
|
221
463
|
commentId: z.string().describe("Comment ID to update"),
|
|
222
|
-
body: z.string().describe("Updated comment text
|
|
464
|
+
body: z.string().describe("Updated comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)."),
|
|
465
|
+
labels: z.array(z.string()).optional().describe("Optional full set of label IDs for the comment. When provided, this REPLACES the comment's existing labels. To incrementally add or remove a single label without replacing the rest, use nestr_add_label / nestr_remove_label with the commentId as the nestId."),
|
|
223
466
|
}),
|
|
224
467
|
deleteComment: z.object({
|
|
225
468
|
commentId: z.string().describe("Comment ID to delete"),
|
|
@@ -270,8 +513,8 @@ export const schemas = {
|
|
|
270
513
|
_listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Engineering projects\"). Omit for default."),
|
|
271
514
|
}),
|
|
272
515
|
getComments: z.object({
|
|
273
|
-
nestId: z.string().describe("Nest ID to get comments from"),
|
|
274
|
-
depth: z.number().optional().describe("
|
|
516
|
+
nestId: z.string().describe("Nest ID to get comments from. Pass a workspace ID to gather communication across the whole workspace (combine with depth='all')."),
|
|
517
|
+
depth: z.union([z.number(), z.literal("all")]).optional().describe("How deep below the context nest to look for comments. 0 (default) returns only comments directly on this nest; N includes comments on descendants up to N levels deep; 'all' includes comments on this nest and every descendant. Use 'all' on a workspace or circle nest to analyse large sets of communication in one call."),
|
|
275
518
|
}),
|
|
276
519
|
getCircle: z.object({
|
|
277
520
|
workspaceId: z.string().describe("Workspace ID"),
|
|
@@ -425,11 +668,13 @@ export const schemas = {
|
|
|
425
668
|
description: z.string().optional().describe("The primary content field — detailed information about the item. Supports Markdown and HTML."),
|
|
426
669
|
purpose: z.string().optional().describe("ONLY for roles/circles — a short aspirational statement. Do NOT put detailed information here; use description instead. Supports HTML."),
|
|
427
670
|
parentId: z.string().optional().describe("Parent ID — use to move/restructure items (e.g., move role to different circle)"),
|
|
428
|
-
users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign
|
|
429
|
-
due: z.string().optional().describe("Due date / re-election date (ISO format)"),
|
|
671
|
+
users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign. For an election (with roleId), the single user being elected, e.g. [\"userId\"]."),
|
|
672
|
+
due: z.string().optional().describe("Due date / re-election date (ISO format). For an election (with roleId), the term end — omit to elect without a term."),
|
|
430
673
|
accountabilities: coerceFromJson(z.array(z.string())).optional().describe("Accountability titles to set on a role (replaces all — use children endpoint for individual management)"),
|
|
431
674
|
domains: coerceFromJson(z.array(z.string())).optional().describe("Domain titles to set on a role (replaces all — use children endpoint for individual management)"),
|
|
432
|
-
|
|
675
|
+
roleId: z.string().optional().describe("Hold an ELECTION: the electable role to fill (Facilitator/Secretary/Rep Link or any electable role). Assigns or reconfirms the role's filler for a term WITHOUT changing its accountabilities/domains — provide users:[userId] (one person) and optional due (term). Do not combine with _id."),
|
|
676
|
+
removeNest: z.boolean().optional().describe("Set true with _id to propose deletion of the referenced governance item (when the proposal is accepted, the item is removed). Distinct from nestr_remove_tension_part, which undoes a proposal part you already added. Requires _id; other body fields are ignored."),
|
|
677
|
+
}).refine((data) => !data.removeNest || !!data._id, { message: "removeNest:true requires _id to identify which item to propose for deletion" }).refine((data) => !data.roleId || (Array.isArray(data.users) && data.users.length === 1), { message: "An election (roleId) requires exactly one user to elect — users: [userId]." }).refine((data) => !(data.roleId && data._id), { message: "Provide either roleId (to hold an election) or _id (to change/delete an existing item), not both." }),
|
|
433
678
|
modifyTensionPart: z.object({
|
|
434
679
|
nestId: z.string().describe("ID of the circle or role the tension belongs to"),
|
|
435
680
|
tensionId: z.string().describe("Tension ID"),
|
|
@@ -507,8 +752,13 @@ export const schemas = {
|
|
|
507
752
|
targetId: z.string().describe("Target nest ID to unlink"),
|
|
508
753
|
}),
|
|
509
754
|
help: z.object({
|
|
510
|
-
topic: z.string().describe("Topic key (e.g., 'search', 'labels', 'tensions'). Use 'topics' for the full list."),
|
|
511
|
-
|
|
755
|
+
topic: z.string().optional().describe("Topic key (e.g., 'search', 'labels', 'tensions'). Use 'topics' for the full list. If the key isn't a known internal topic, it's tried as a help-article slug from nestr.io/help/articles/<slug>; the response's 'Resolved as:' line says which matched."),
|
|
756
|
+
search: z.string().optional().describe("Free-text query against the public help-article index (nestr.io/help/articles/*). Tolerates typos and common synonyms. Returns ranked matches, each with a title and one-line summary; fetch one with `topic: <slug>`."),
|
|
757
|
+
includeImages: z.boolean().optional().describe("Help-article mode only. Default false: a fetch returns markdown + a numbered image-URL list, with NO image blocks. Set true to also attach the first maxImages content screenshots as inline image content (base64, downscaled to bound token cost), in document order. Decorative images (uncaptioned, or the header/thumbnail before the first content heading) are never auto-attached — request them by index via imageIndexes. Use when the user wants to *see* how something looks. Ignored for internal topics and search."),
|
|
758
|
+
imageIndexes: coerceIntArray(z.array(z.number().int().nonnegative()).optional()).describe("Help-article mode only. Attach specific screenshots by their [index] from the numbered 'Images in this article' list shown in the response footer of a prior fetch. Pass an array of integers, e.g. [4,5,6]. Overrides the default selection AND the maxImages cap — exactly these indexes attach, in order (a [decorative] image attaches only when explicitly listed here). Ignored for internal topics and search."),
|
|
759
|
+
maxImages: coerceFromJson(z.number().int().positive().optional()).describe("Help-article mode only. Cap on how many screenshots the default selection attaches (the first N content images in document order). Default 3, max 6. Ignored when imageIndexes is provided."),
|
|
760
|
+
}).refine((v) => Boolean(v.topic) || Boolean(v.search), { message: "Provide either `topic` or `search`." }),
|
|
761
|
+
diagnose: z.object({}).describe("No arguments — diagnose reads session state from the server."),
|
|
512
762
|
};
|
|
513
763
|
// Tool annotations for MCP - hints for clients on tool behavior
|
|
514
764
|
const readOnly = { annotations: { readOnlyHint: true, destructiveHint: false } };
|
|
@@ -518,13 +768,25 @@ const destructive = { annotations: { readOnlyHint: false, destructiveHint: true
|
|
|
518
768
|
export const toolDefinitions = [
|
|
519
769
|
{
|
|
520
770
|
name: "nestr_help",
|
|
521
|
-
description: "Get
|
|
771
|
+
description: "Get Nestr documentation. Three modes: (1) internal MCP-flavoured topic — pass `topic` with one of the curated keys (search, labels, nest-model, inbox, daily-plan, notifications, insights, tension-processing, skills, mcp-apps, authentication, scrum, okr, ...); use topic 'topics' for the full list. (2) Help-article fetch — pass `topic` with a slug from nestr.io/help/articles/<slug>; returns the article as markdown plus a numbered list of its images (with URLs and captions). Images are NOT attached by default. Pass `includeImages: true` to also attach the first `maxImages` (default 3, max 6) content screenshots as renderable image blocks (downscaled; decorative header/thumbnail/uncaptioned images skipped), or `imageIndexes: [..]` to attach specific ones from the numbered list (e.g. a burndown chart further down, ignoring the cap). Use images when the user wants to *see* how something looks. The tool tries internal topics first, then falls back to article fetch. (3) Help-article search — pass `search` with a free-text query; returns ranked matches, each with a title and one-line summary. Search tolerates typos and common synonyms (e.g. kanban/sprint→scrum). Every response opens with a 'Resolved as:' line stating which mode answered, and internal topics and articles cross-link to each other. Call this before unfamiliar operations. Auth: none required.",
|
|
522
772
|
inputSchema: {
|
|
523
773
|
type: "object",
|
|
524
774
|
properties: {
|
|
525
|
-
topic: { type: "string", description: "
|
|
775
|
+
topic: { type: "string", description: "Internal topic key or help-article slug. Use 'topics' for the full list of internal topics." },
|
|
776
|
+
search: { type: "string", description: "Free-text query against the public help articles. Returns slugs to fetch via `topic`." },
|
|
777
|
+
includeImages: { type: "boolean", description: "Help-article mode only. Default false (markdown + numbered image-URL list, no image blocks). Set true to attach the first maxImages content screenshots as inline image content (base64, downscaled). Decorative header/thumbnail/uncaptioned images are never auto-attached — use imageIndexes for those. Ignored for internal topics and search." },
|
|
778
|
+
imageIndexes: { type: "array", items: { type: "integer", minimum: 0 }, description: "Help-article mode only. Attach specific screenshots by their [index] from the numbered 'Images in this article' list in a prior response's footer, e.g. [4,5,6]. Overrides the default selection and the maxImages cap; attaches exactly these indexes in order. Ignored for internal topics and search." },
|
|
779
|
+
maxImages: { type: "integer", minimum: 1, description: "Help-article mode only. Cap on screenshots in the default selection (first N content images). Default 3, max 6. Ignored when imageIndexes is provided." },
|
|
526
780
|
},
|
|
527
|
-
|
|
781
|
+
},
|
|
782
|
+
...readOnly,
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
name: "nestr_diagnose",
|
|
786
|
+
description: "Server-side auth and session diagnostics. Call this FIRST when any other tool returns an auth error (AUTH_TOKEN_REJECTED_BY_NESTR / AUTH_REFRESH_FAILED / AUTH_SCOPE_INSUFFICIENT / AUTH_PROXY_HEADER_DROPPED). Returns: flow (A=server-managed refresh, B=client-managed refresh, unknown=API key), tokenPresented, tokenFingerprint, lastUpstream401At, lastRefreshAttempt, sessionCorrelationId, serverVersion, mcpClient, mcpClientVersion. Auth: none required — works whether or not the bearer is valid.",
|
|
787
|
+
inputSchema: {
|
|
788
|
+
type: "object",
|
|
789
|
+
properties: {},
|
|
528
790
|
},
|
|
529
791
|
...readOnly,
|
|
530
792
|
},
|
|
@@ -557,7 +819,7 @@ export const toolDefinitions = [
|
|
|
557
819
|
},
|
|
558
820
|
{
|
|
559
821
|
name: "nestr_create_workspace",
|
|
560
|
-
description: "Create a new workspace. OAuth only. See nestr_help('workspace-setup') for guided setup.",
|
|
822
|
+
description: "Create a new workspace. Auth: OAuth only (user-scoped — workspace API keys cannot create new workspaces). On auth failure call nestr_diagnose. See nestr_help('workspace-setup') for guided setup.",
|
|
561
823
|
inputSchema: {
|
|
562
824
|
type: "object",
|
|
563
825
|
properties: {
|
|
@@ -648,7 +910,7 @@ export const toolDefinitions = [
|
|
|
648
910
|
},
|
|
649
911
|
{
|
|
650
912
|
name: "nestr_create_nest",
|
|
651
|
-
description: "Create a nest under a parent. Use labels to define type (e.g., ['project'], ['role']). For governance changes in established workspaces, prefer the tension flow. See nestr_help('labels') for available types.",
|
|
913
|
+
description: "Create a nest under a parent. Use labels to define type (e.g., ['project'], ['role']). Apply at most ONE prime label per nest (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback, userstory, sprint, epic, milestone) — they define the nest's core identity and cannot coexist. Sole exception: userstory may pair with project (userstory implies project); sprint/epic/milestone may not, and stories link to those containers via graph relations instead. For governance changes in established workspaces, prefer the tension flow. See nestr_help('labels') for available types.",
|
|
652
914
|
inputSchema: {
|
|
653
915
|
type: "object",
|
|
654
916
|
properties: {
|
|
@@ -691,7 +953,7 @@ export const toolDefinitions = [
|
|
|
691
953
|
},
|
|
692
954
|
{
|
|
693
955
|
name: "nestr_update_nest",
|
|
694
|
-
description: "Update nest properties. Set parentId to move. Only send fields you want to change. For governance changes, prefer tensions. See nestr_help('nest-model') for fields and data namespacing.",
|
|
956
|
+
description: "Update nest properties. Set parentId to move. Only send fields you want to change. When replacing `labels`, keep at most ONE prime label (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback, userstory, sprint, epic, milestone) — they define the nest's core identity. Sole exception: userstory may pair with project (userstory implies project). For governance changes, prefer tensions. See nestr_help('nest-model') for fields and data namespacing.",
|
|
695
957
|
inputSchema: {
|
|
696
958
|
type: "object",
|
|
697
959
|
properties: {
|
|
@@ -759,12 +1021,17 @@ export const toolDefinitions = [
|
|
|
759
1021
|
},
|
|
760
1022
|
{
|
|
761
1023
|
name: "nestr_add_comment",
|
|
762
|
-
description: "Add a comment to a nest. Supports HTML and @mentions (
|
|
1024
|
+
description: "Add a comment to a nest. Supports HTML and @mentions — **mentions MUST be wrapped in literal curly braces** (e.g. `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`); without the braces the user is not notified. Prefer `@{userId:roleId}` so the recipient knows which role they're being addressed in. Use for progress updates and discussion. Optionally attach labels at creation time via the `labels` parameter.",
|
|
763
1025
|
inputSchema: {
|
|
764
1026
|
type: "object",
|
|
765
1027
|
properties: {
|
|
766
1028
|
nestId: { type: "string", description: "Nest ID to comment on" },
|
|
767
|
-
body: { type: "string", description: "Comment text
|
|
1029
|
+
body: { type: "string", description: "Comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)." },
|
|
1030
|
+
labels: {
|
|
1031
|
+
type: "array",
|
|
1032
|
+
items: { type: "string" },
|
|
1033
|
+
description: "Optional label IDs to attach to the comment at creation time (e.g., 'decision', 'question', or a custom label ID). Personal labels are auto-scoped to the authenticated user. Use nestr_list_labels / nestr_list_personal_labels to discover IDs.",
|
|
1034
|
+
},
|
|
768
1035
|
},
|
|
769
1036
|
required: ["nestId", "body"],
|
|
770
1037
|
},
|
|
@@ -772,12 +1039,17 @@ export const toolDefinitions = [
|
|
|
772
1039
|
},
|
|
773
1040
|
{
|
|
774
1041
|
name: "nestr_update_comment",
|
|
775
|
-
description: "Update an existing comment's body. Supports HTML and @mentions.",
|
|
1042
|
+
description: "Update an existing comment's body and/or labels. Supports HTML and @mentions — **mentions MUST be wrapped in literal curly braces** (e.g. `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`); without the braces the user is not notified. When `labels` is provided it REPLACES the existing label set — use nestr_add_label / nestr_remove_label for incremental changes.",
|
|
776
1043
|
inputSchema: {
|
|
777
1044
|
type: "object",
|
|
778
1045
|
properties: {
|
|
779
1046
|
commentId: { type: "string", description: "Comment ID to update" },
|
|
780
|
-
body: { type: "string", description: "Updated comment text
|
|
1047
|
+
body: { type: "string", description: "Updated comment text. Supports HTML and @mentions. **Mentions MUST be wrapped in literal curly braces** — write `@{aBcD1234eFgH5678i:roleNestId}`, NOT `@aBcD1234eFgH5678i`. Without the braces the platform will not link the mention or notify the user. Forms: `@{userId:roleId}` (preferred — addresses the user in a specific role/circle), `@{userId}` (legacy — no role context), `@{email}`, `@{circle}` (all role fillers in nearest ancestor circle)." },
|
|
1048
|
+
labels: {
|
|
1049
|
+
type: "array",
|
|
1050
|
+
items: { type: "string" },
|
|
1051
|
+
description: "Optional full set of label IDs for the comment. When provided, this REPLACES the comment's existing labels. To incrementally add or remove a single label without replacing the rest, use nestr_add_label / nestr_remove_label with the commentId as the nestId.",
|
|
1052
|
+
},
|
|
781
1053
|
},
|
|
782
1054
|
required: ["commentId", "body"],
|
|
783
1055
|
},
|
|
@@ -919,12 +1191,15 @@ export const toolDefinitions = [
|
|
|
919
1191
|
},
|
|
920
1192
|
{
|
|
921
1193
|
name: "nestr_get_comments",
|
|
922
|
-
description: "Get comments and discussion history on a nest.",
|
|
1194
|
+
description: "Get comments and discussion history on a nest, including full nested reply threads. By default returns only comments posted directly on the given nest. Widen with depth to also include comments on descendant nests, or pass a workspace/circle nest ID with depth='all' to gather large sets of communication for analysis.",
|
|
923
1195
|
inputSchema: {
|
|
924
1196
|
type: "object",
|
|
925
1197
|
properties: {
|
|
926
|
-
nestId: { type: "string", description: "Nest ID to get comments from" },
|
|
927
|
-
depth: {
|
|
1198
|
+
nestId: { type: "string", description: "Nest ID to get comments from. Pass a workspace ID to gather communication across the whole workspace (combine with depth='all')." },
|
|
1199
|
+
depth: {
|
|
1200
|
+
oneOf: [{ type: "number" }, { type: "string", enum: ["all"] }],
|
|
1201
|
+
description: "How deep below the context nest to look for comments. 0 (default) returns only comments directly on this nest; N includes comments on descendants up to N levels deep; 'all' includes comments on this nest and every descendant.",
|
|
1202
|
+
},
|
|
928
1203
|
},
|
|
929
1204
|
required: ["nestId"],
|
|
930
1205
|
},
|
|
@@ -1016,7 +1291,7 @@ export const toolDefinitions = [
|
|
|
1016
1291
|
// Inbox tools (require OAuth token - won't work with workspace API keys)
|
|
1017
1292
|
{
|
|
1018
1293
|
name: "nestr_list_inbox",
|
|
1019
|
-
description: "List items in the user's personal inbox. Spans all workspaces. OAuth only.",
|
|
1294
|
+
description: "List items in the user's personal inbox. Spans all workspaces. Auth: OAuth only (user-scoped — workspace API keys lack user identity). On auth failure call nestr_diagnose.",
|
|
1020
1295
|
inputSchema: {
|
|
1021
1296
|
type: "object",
|
|
1022
1297
|
properties: {
|
|
@@ -1029,7 +1304,7 @@ export const toolDefinitions = [
|
|
|
1029
1304
|
},
|
|
1030
1305
|
{
|
|
1031
1306
|
name: "nestr_create_inbox_item",
|
|
1032
|
-
description: "Quick capture: add an item to the inbox for later processing. OAuth only.",
|
|
1307
|
+
description: "Quick capture: add an item to the inbox for later processing. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1033
1308
|
inputSchema: {
|
|
1034
1309
|
type: "object",
|
|
1035
1310
|
properties: {
|
|
@@ -1042,7 +1317,7 @@ export const toolDefinitions = [
|
|
|
1042
1317
|
},
|
|
1043
1318
|
{
|
|
1044
1319
|
name: "nestr_get_inbox_item",
|
|
1045
|
-
description: "Get details of a specific inbox item.
|
|
1320
|
+
description: "Get details of a specific inbox item. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1046
1321
|
inputSchema: {
|
|
1047
1322
|
type: "object",
|
|
1048
1323
|
properties: {
|
|
@@ -1055,7 +1330,7 @@ export const toolDefinitions = [
|
|
|
1055
1330
|
},
|
|
1056
1331
|
{
|
|
1057
1332
|
name: "nestr_update_inbox_item",
|
|
1058
|
-
description: "Update an inbox item. Set completed:true when processed. Use nestr_update_nest with parentId to move out of inbox. OAuth only.",
|
|
1333
|
+
description: "Update an inbox item. Set completed:true when processed. Use nestr_update_nest with parentId to move out of inbox. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1059
1334
|
inputSchema: {
|
|
1060
1335
|
type: "object",
|
|
1061
1336
|
properties: {
|
|
@@ -1071,7 +1346,7 @@ export const toolDefinitions = [
|
|
|
1071
1346
|
},
|
|
1072
1347
|
{
|
|
1073
1348
|
name: "nestr_reorder_inbox",
|
|
1074
|
-
description: "Reorder inbox items. Provide a subset of IDs — they go to the top in given order, rest unchanged. OAuth only.",
|
|
1349
|
+
description: "Reorder inbox items. Provide a subset of IDs — they go to the top in given order, rest unchanged. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1075
1350
|
inputSchema: {
|
|
1076
1351
|
type: "object",
|
|
1077
1352
|
properties: {
|
|
@@ -1087,7 +1362,7 @@ export const toolDefinitions = [
|
|
|
1087
1362
|
},
|
|
1088
1363
|
{
|
|
1089
1364
|
name: "nestr_reorder_inbox_item",
|
|
1090
|
-
description: "Reorder a single inbox item by positioning it before or after another inbox item.
|
|
1365
|
+
description: "Reorder a single inbox item by positioning it before or after another inbox item. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1091
1366
|
inputSchema: {
|
|
1092
1367
|
type: "object",
|
|
1093
1368
|
properties: {
|
|
@@ -1102,7 +1377,7 @@ export const toolDefinitions = [
|
|
|
1102
1377
|
// Personal labels (require OAuth token - user's own labels, not workspace labels)
|
|
1103
1378
|
{
|
|
1104
1379
|
name: "nestr_list_personal_labels",
|
|
1105
|
-
description: "List the current user's personal labels (not workspace labels). OAuth only.",
|
|
1380
|
+
description: "List the current user's personal labels (not workspace labels). Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1106
1381
|
inputSchema: {
|
|
1107
1382
|
type: "object",
|
|
1108
1383
|
properties: {},
|
|
@@ -1111,7 +1386,7 @@ export const toolDefinitions = [
|
|
|
1111
1386
|
},
|
|
1112
1387
|
{
|
|
1113
1388
|
name: "nestr_create_personal_label",
|
|
1114
|
-
description: "Create a personal label. Can be used across workspaces. OAuth only.",
|
|
1389
|
+
description: "Create a personal label. Can be used across workspaces. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1115
1390
|
inputSchema: {
|
|
1116
1391
|
type: "object",
|
|
1117
1392
|
properties: {
|
|
@@ -1159,7 +1434,7 @@ export const toolDefinitions = [
|
|
|
1159
1434
|
// Daily plan (requires OAuth token)
|
|
1160
1435
|
{
|
|
1161
1436
|
name: "nestr_get_daily_plan",
|
|
1162
|
-
description: "Get the user's daily plan — items marked for today. Spans all workspaces. OAuth only.",
|
|
1437
|
+
description: "Get the user's daily plan — items marked for today. Spans all workspaces. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1163
1438
|
inputSchema: {
|
|
1164
1439
|
type: "object",
|
|
1165
1440
|
properties: {
|
|
@@ -1172,7 +1447,7 @@ export const toolDefinitions = [
|
|
|
1172
1447
|
// Label management
|
|
1173
1448
|
{
|
|
1174
1449
|
name: "nestr_add_label",
|
|
1175
|
-
description: "Add a label to a nest. Personal labels (like 'now') are automatically scoped to the authenticated user by the API.",
|
|
1450
|
+
description: "Add a label to a nest. Personal labels (like 'now') are automatically scoped to the authenticated user by the API. Will reject any attempt to add a prime label (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback) to a nest that already has one — a nest can only have one core identity.",
|
|
1176
1451
|
inputSchema: {
|
|
1177
1452
|
type: "object",
|
|
1178
1453
|
properties: {
|
|
@@ -1198,7 +1473,7 @@ export const toolDefinitions = [
|
|
|
1198
1473
|
},
|
|
1199
1474
|
{
|
|
1200
1475
|
name: "nestr_add_to_daily_plan",
|
|
1201
|
-
description: "Add one or more items to the daily plan by applying the 'now' label.
|
|
1476
|
+
description: "Add one or more items to the daily plan by applying the 'now' label. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1202
1477
|
inputSchema: {
|
|
1203
1478
|
type: "object",
|
|
1204
1479
|
properties: {
|
|
@@ -1214,7 +1489,7 @@ export const toolDefinitions = [
|
|
|
1214
1489
|
},
|
|
1215
1490
|
{
|
|
1216
1491
|
name: "nestr_remove_from_daily_plan",
|
|
1217
|
-
description: "Remove one or more items from the daily plan by removing the 'now' label.
|
|
1492
|
+
description: "Remove one or more items from the daily plan by removing the 'now' label. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1218
1493
|
inputSchema: {
|
|
1219
1494
|
type: "object",
|
|
1220
1495
|
properties: {
|
|
@@ -1243,7 +1518,7 @@ export const toolDefinitions = [
|
|
|
1243
1518
|
// User tension tools (requires OAuth token)
|
|
1244
1519
|
{
|
|
1245
1520
|
name: "nestr_list_my_tensions",
|
|
1246
|
-
description: "List tensions created by or assigned to the current user. Check at session start and natural breakpoints. OAuth only.",
|
|
1521
|
+
description: "List tensions created by or assigned to the current user. Check at session start and natural breakpoints. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1247
1522
|
inputSchema: {
|
|
1248
1523
|
type: "object",
|
|
1249
1524
|
properties: {
|
|
@@ -1254,7 +1529,7 @@ export const toolDefinitions = [
|
|
|
1254
1529
|
},
|
|
1255
1530
|
{
|
|
1256
1531
|
name: "nestr_list_tensions_awaiting_consent",
|
|
1257
|
-
description: "List tensions awaiting the current user's consent vote. Check proactively. OAuth only.",
|
|
1532
|
+
description: "List tensions awaiting the current user's consent vote. Check proactively. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1258
1533
|
inputSchema: {
|
|
1259
1534
|
type: "object",
|
|
1260
1535
|
properties: {
|
|
@@ -1266,7 +1541,7 @@ export const toolDefinitions = [
|
|
|
1266
1541
|
// Notification tools (requires OAuth token)
|
|
1267
1542
|
{
|
|
1268
1543
|
name: "nestr_list_notifications",
|
|
1269
|
-
description: "List notifications. Use type 'me' for direct (mentions, replies) or 'relevant' for organizational changes. OAuth only.",
|
|
1544
|
+
description: "List notifications. Use type 'me' for direct (mentions, replies) or 'relevant' for organizational changes. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1270
1545
|
inputSchema: {
|
|
1271
1546
|
type: "object",
|
|
1272
1547
|
properties: {
|
|
@@ -1281,7 +1556,7 @@ export const toolDefinitions = [
|
|
|
1281
1556
|
},
|
|
1282
1557
|
{
|
|
1283
1558
|
name: "nestr_mark_notifications_read",
|
|
1284
|
-
description: "Mark all unread in-app notifications as read for the current user. Returns { status, data: { markedCount } }.
|
|
1559
|
+
description: "Mark all unread in-app notifications as read for the current user. Returns { status, data: { markedCount } }. Auth: OAuth only (user-scoped). On auth failure call nestr_diagnose.",
|
|
1285
1560
|
inputSchema: {
|
|
1286
1561
|
type: "object",
|
|
1287
1562
|
properties: {},
|
|
@@ -1378,7 +1653,7 @@ export const toolDefinitions = [
|
|
|
1378
1653
|
},
|
|
1379
1654
|
{
|
|
1380
1655
|
name: "nestr_add_tension_part",
|
|
1381
|
-
description: "Add
|
|
1656
|
+
description: "Add a governance proposal part to a tension. Four modes: (1) propose a new item — omit _id, provide title/labels/etc.; (2) propose changes to an existing item — provide _id plus the fields to change (note: editing a role this way copies its existing accountabilities/domains into the proposal, so it reads as a full role edit); (3) propose deletion of an existing item — provide _id and removeNest:true; (4) hold an election — provide roleId (the electable role to fill) plus users:[userId] and optional due (term), which assigns/reconfirms the role's filler WITHOUT changing its accountabilities/domains. See nestr_help('tension-processing').",
|
|
1382
1657
|
inputSchema: {
|
|
1383
1658
|
type: "object",
|
|
1384
1659
|
properties: {
|
|
@@ -1390,10 +1665,12 @@ export const toolDefinitions = [
|
|
|
1390
1665
|
description: { type: "string", description: "The primary content field — detailed information about the item. Supports Markdown and HTML." },
|
|
1391
1666
|
purpose: { type: "string", description: "ONLY for roles/circles — a short aspirational statement. Do NOT put detailed information here; use description instead. Supports HTML." },
|
|
1392
1667
|
parentId: { type: "string", description: "Parent ID — use to move/restructure items (e.g., move role to different circle)" },
|
|
1393
|
-
users: { type: "array", items: { type: "string" }, description: "User IDs to assign (
|
|
1394
|
-
due: { type: "string", description: "Due date / re-election date (ISO format)" },
|
|
1668
|
+
users: { type: "array", items: { type: "string" }, description: "User IDs to assign. For an election (with roleId), the single user being elected, e.g. [\"userId\"]." },
|
|
1669
|
+
due: { type: "string", description: "Due date / re-election date (ISO format). For an election (with roleId), the term end — omit to elect without a term." },
|
|
1395
1670
|
accountabilities: { type: "array", items: { type: "string" }, description: "Accountability titles to set on a role (replaces all — use children endpoint for individual management)" },
|
|
1396
1671
|
domains: { type: "array", items: { type: "string" }, description: "Domain titles to set on a role (replaces all — use children endpoint for individual management)" },
|
|
1672
|
+
roleId: { type: "string", description: "Hold an ELECTION: the electable role to fill (Facilitator/Secretary/Rep Link or any electable role). Assigns/reconfirms the role's filler for a term WITHOUT changing its accountabilities/domains — provide users:[userId] (one person) and optional due (term). Do not combine with _id." },
|
|
1673
|
+
removeNest: { type: "boolean", description: "Set true with _id to propose deletion of the referenced governance item (when the proposal is accepted, the item is removed). Distinct from nestr_remove_tension_part, which undoes a proposal part you already added." },
|
|
1397
1674
|
},
|
|
1398
1675
|
required: ["nestId", "tensionId"],
|
|
1399
1676
|
},
|
|
@@ -1630,32 +1907,208 @@ export function unescapeRichTextFields(args) {
|
|
|
1630
1907
|
}
|
|
1631
1908
|
return changed ? result : args;
|
|
1632
1909
|
}
|
|
1633
|
-
export async function handleToolCall(client, name, args) {
|
|
1910
|
+
export async function handleToolCall(client, name, args, context) {
|
|
1634
1911
|
const sanitizedArgs = unescapeRichTextFields(args);
|
|
1635
1912
|
const shouldStripDescription = sanitizedArgs.stripDescription === true;
|
|
1636
|
-
const result = await _handleToolCall(client, name, sanitizedArgs);
|
|
1913
|
+
const result = await _handleToolCall(client, name, sanitizedArgs, context);
|
|
1637
1914
|
if (shouldStripDescription && !result.isError) {
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1915
|
+
const first = result.content[0];
|
|
1916
|
+
if (first && first.type === "text") {
|
|
1917
|
+
try {
|
|
1918
|
+
const parsed = JSON.parse(first.text);
|
|
1919
|
+
first.text = JSON.stringify(stripDescriptionFields(parsed), null, 2);
|
|
1920
|
+
}
|
|
1921
|
+
catch {
|
|
1922
|
+
// If parsing fails, return as-is
|
|
1923
|
+
}
|
|
1644
1924
|
}
|
|
1645
1925
|
}
|
|
1646
1926
|
return result;
|
|
1647
1927
|
}
|
|
1648
|
-
async function _handleToolCall(client, name, args) {
|
|
1928
|
+
async function _handleToolCall(client, name, args, context) {
|
|
1649
1929
|
try {
|
|
1930
|
+
// PUBLIC surface gate (defense in depth — the public route also filters the
|
|
1931
|
+
// advertised tool list). Refuse anything outside the public allow-list so a
|
|
1932
|
+
// hand-crafted tools/call can never reach an authenticated Nestr path, and
|
|
1933
|
+
// serve nestr_get_me from a fixed guest payload without an API call.
|
|
1934
|
+
if (context?.isPublic) {
|
|
1935
|
+
if (!PUBLIC_TOOL_NAMES.has(name)) {
|
|
1936
|
+
return formatError({
|
|
1937
|
+
error: true,
|
|
1938
|
+
code: "AUTH_SCOPE_INSUFFICIENT",
|
|
1939
|
+
message: `Tool '${name}' is not available on the public Nestr MCP. Guest mode exposes product help only (${[...PUBLIC_TOOL_NAMES].join(", ")}).`,
|
|
1940
|
+
retryable: false,
|
|
1941
|
+
hint: "Add AI credit / sign in and connect to the authenticated MCP endpoint to use workspace tools.",
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
if (name === "nestr_get_me") {
|
|
1945
|
+
schemas.getMe.parse(args);
|
|
1946
|
+
return formatResult(PUBLIC_GUEST_ME);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1650
1949
|
switch (name) {
|
|
1651
1950
|
case "nestr_help": {
|
|
1652
1951
|
const parsed = schemas.help.parse(args);
|
|
1653
1952
|
const { HELP_TOPICS } = await import("../help/topics.js");
|
|
1654
|
-
const
|
|
1655
|
-
|
|
1656
|
-
|
|
1953
|
+
const { relatedArticlesForTopic, relatedTopicForArticle } = await import("../help/cross-links.js");
|
|
1954
|
+
// Search mode: query the public help-article index. Returns a ranked
|
|
1955
|
+
// list of slugs; the caller pulls a specific article with a second
|
|
1956
|
+
// call passing `topic: <slug>`.
|
|
1957
|
+
if (parsed.search) {
|
|
1958
|
+
const { loadArticleIndex, searchArticleIndex, fetchArticleMeta } = await import("../help/articles.js");
|
|
1959
|
+
try {
|
|
1960
|
+
const entries = await loadArticleIndex();
|
|
1961
|
+
const hits = searchArticleIndex(entries, parsed.search, 8);
|
|
1962
|
+
if (hits.length === 0) {
|
|
1963
|
+
return { content: [{ type: "text", text: `_Resolved as: help-article search._\n\nNo help articles matched "${parsed.search}". Try broader terms or a synonym, or call nestr_help({ topic: "topics" }) for internal MCP topics.` }] };
|
|
1964
|
+
}
|
|
1965
|
+
// Enrich the top hits with a title + one-line summary so the caller
|
|
1966
|
+
// can pick the right article without a blind fetch. Best-effort:
|
|
1967
|
+
// a meta-fetch failure just falls back to the bare slug for that row.
|
|
1968
|
+
const ENRICH = 5;
|
|
1969
|
+
const metas = await Promise.allSettled(hits.slice(0, ENRICH).map(h => fetchArticleMeta(h.slug)));
|
|
1970
|
+
const lines = hits.map((h, i) => {
|
|
1971
|
+
const settled = i < ENRICH ? metas[i] : undefined;
|
|
1972
|
+
const meta = settled?.status === "fulfilled" ? settled.value : undefined;
|
|
1973
|
+
const topic = relatedTopicForArticle(h.slug);
|
|
1974
|
+
const seeAlso = topic ? ` _(see also internal topic \`${topic}\`)_` : "";
|
|
1975
|
+
if (meta?.title) {
|
|
1976
|
+
const summary = meta.description ? ` — ${meta.description}` : "";
|
|
1977
|
+
return `- \`${h.slug}\` — **${meta.title}**${summary}${seeAlso}`;
|
|
1978
|
+
}
|
|
1979
|
+
return `- \`${h.slug}\` — ${h.url}${seeAlso}`;
|
|
1980
|
+
});
|
|
1981
|
+
const body = [
|
|
1982
|
+
`_Resolved as: help-article search._`,
|
|
1983
|
+
``,
|
|
1984
|
+
`Found ${hits.length} help article${hits.length === 1 ? "" : "s"} for "${parsed.search}". Fetch one with nestr_help({ topic: "<slug>" }).`,
|
|
1985
|
+
``,
|
|
1986
|
+
...lines,
|
|
1987
|
+
].join("\n");
|
|
1988
|
+
return { content: [{ type: "text", text: body }] };
|
|
1989
|
+
}
|
|
1990
|
+
catch (err) {
|
|
1991
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1992
|
+
return { content: [{ type: "text", text: `Help-article search failed: ${message}. Internal topics still available via nestr_help({ topic: "topics" }).` }], isError: true };
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
// Topic mode: prefer the curated internal topic; if there's no match,
|
|
1996
|
+
// try the slug as a help article. Network failures on the fallback are
|
|
1997
|
+
// surfaced rather than masked — a stale link is more useful than a
|
|
1998
|
+
// generic "not found". Every branch opens with a "Resolved as:" line so
|
|
1999
|
+
// the caller knows which source answered, even if a slug ever shadows
|
|
2000
|
+
// an internal key.
|
|
2001
|
+
const topic = parsed.topic;
|
|
2002
|
+
const content = HELP_TOPICS[topic];
|
|
2003
|
+
if (content) {
|
|
2004
|
+
const related = relatedArticlesForTopic(topic);
|
|
2005
|
+
const footer = related.length
|
|
2006
|
+
? `\n\n---\nRelated public help article${related.length === 1 ? "" : "s"} (fetch with nestr_help({ topic: "<slug>" })): ${related.map(s => `\`${s}\``).join(", ")}`
|
|
2007
|
+
: "";
|
|
2008
|
+
return { content: [{ type: "text", text: `_Resolved as: internal MCP topic "${topic}"._\n\n${content}${footer}` }] };
|
|
2009
|
+
}
|
|
2010
|
+
const { fetchArticleMarkdown, extractImages, collectArticleImages, selectImageIndexes, clampMaxImages } = await import("../help/articles.js");
|
|
2011
|
+
try {
|
|
2012
|
+
const article = await fetchArticleMarkdown(topic);
|
|
2013
|
+
const images = extractImages(article.markdown);
|
|
2014
|
+
const relatedTopic = relatedTopicForArticle(article.slug);
|
|
2015
|
+
// Image attachment is opt-in: only when the caller explicitly asks via
|
|
2016
|
+
// includeImages:true (default selection) or imageIndexes (exact
|
|
2017
|
+
// entries). A plain fetch returns markdown + the numbered URL list, so
|
|
2018
|
+
// the agent/user can decide whether the screenshots are worth the
|
|
2019
|
+
// tokens, then re-call to pull them. Fetch first so the text list can
|
|
2020
|
+
// mark which entries were attached; best-effort — failures just leave
|
|
2021
|
+
// the text list, which always carries every image's URL.
|
|
2022
|
+
const imageOpts = { indexes: parsed.imageIndexes, max: parsed.maxImages };
|
|
2023
|
+
const hasExplicitIndexes = (parsed.imageIndexes?.length ?? 0) > 0;
|
|
2024
|
+
const wantImages = parsed.includeImages === true || hasExplicitIndexes;
|
|
2025
|
+
const selectedCount = wantImages && images.length ? selectImageIndexes(images, imageOpts).length : 0;
|
|
2026
|
+
const inlined = wantImages && images.length ? await collectArticleImages(images, imageOpts) : [];
|
|
2027
|
+
const attached = new Set(inlined.map(img => img.index));
|
|
2028
|
+
// Surface a clear, prominent hint whenever the article has screenshots
|
|
2029
|
+
// so the caller knows they exist and how to pull them in (images are
|
|
2030
|
+
// opt-in). Adapts to whether any are already attached.
|
|
2031
|
+
const cap = clampMaxImages(parsed.maxImages);
|
|
2032
|
+
const contentCount = images.filter(img => !img.decorative).length;
|
|
2033
|
+
let imageHint = "";
|
|
2034
|
+
if (inlined.length > 0) {
|
|
2035
|
+
const more = images.length - inlined.length;
|
|
2036
|
+
imageHint = more > 0
|
|
2037
|
+
? `_${inlined.length} screenshot${inlined.length === 1 ? "" : "s"} attached below as viewable image${inlined.length === 1 ? "" : "s"}. ${more} more are listed under the article — request any by [index] with imageIndexes:[..]._`
|
|
2038
|
+
: `_${inlined.length} screenshot${inlined.length === 1 ? "" : "s"} attached below as viewable image${inlined.length === 1 ? "" : "s"}._`;
|
|
2039
|
+
}
|
|
2040
|
+
else if (contentCount > 0) {
|
|
2041
|
+
imageHint = `_This article has ${contentCount} screenshot${contentCount === 1 ? "" : "s"} you can view — not attached by default. Re-call nestr_help with includeImages:true to attach the first ${cap}, or imageIndexes:[..] for specific ones (see the numbered list below)._`;
|
|
2042
|
+
}
|
|
2043
|
+
const parts = [
|
|
2044
|
+
`_Resolved as: help article "${article.slug}" (fetched from ${article.url})._`,
|
|
2045
|
+
``,
|
|
2046
|
+
`# ${article.title || article.slug}`,
|
|
2047
|
+
];
|
|
2048
|
+
if (article.description)
|
|
2049
|
+
parts.push(``, `> ${article.description}`);
|
|
2050
|
+
if (imageHint)
|
|
2051
|
+
parts.push(``, imageHint);
|
|
2052
|
+
parts.push(``, article.markdown);
|
|
2053
|
+
if (images.length) {
|
|
2054
|
+
parts.push(``, `---`, `Images in this article (${images.length}) — [index] is stable; [decorative] = header/thumbnail (not auto-attached). Attach with includeImages:true (first ${cap} content images) or imageIndexes:[..]:`, ...images.map((img, i) => {
|
|
2055
|
+
const tag = img.decorative ? " [decorative]" : "";
|
|
2056
|
+
const captionText = img.caption ? `"${img.caption}"` : "(no caption)";
|
|
2057
|
+
const mark = attached.has(i) ? " — attached inline below" : "";
|
|
2058
|
+
return `- [${i}]${tag} ${captionText} — ${img.url}${mark}`;
|
|
2059
|
+
}));
|
|
2060
|
+
if (wantImages && inlined.length === 0) {
|
|
2061
|
+
const why = selectedCount === 0
|
|
2062
|
+
? (parsed.imageIndexes?.length ? "the requested imageIndexes were out of range" : "this article has no non-decorative content screenshots")
|
|
2063
|
+
: "the selected images could not be fetched";
|
|
2064
|
+
parts.push(``, `_(No images attached — ${why}.)_`);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
if (relatedTopic) {
|
|
2068
|
+
parts.push(``, `---`, `Related internal MCP topic (agent-flavoured tool-call guidance): \`${relatedTopic}\` — fetch with nestr_help({ topic: "${relatedTopic}" }).`);
|
|
2069
|
+
}
|
|
2070
|
+
parts.push(``, `---`, `Source: ${article.url}`);
|
|
2071
|
+
const content = [{ type: "text", text: parts.join("\n") }];
|
|
2072
|
+
for (const img of inlined) {
|
|
2073
|
+
content.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
2074
|
+
}
|
|
2075
|
+
return { content };
|
|
2076
|
+
}
|
|
2077
|
+
catch (err) {
|
|
2078
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2079
|
+
return { content: [{ type: "text", text: `_Resolved as: not found._\n\nUnknown topic: "${topic}". Tried internal topics (call nestr_help({ topic: "topics" }) for the full list) and the help-article fetch (failed: ${message}). To search the help site instead, call nestr_help({ search: "<query>" }).` }] };
|
|
1657
2080
|
}
|
|
1658
|
-
|
|
2081
|
+
}
|
|
2082
|
+
case "nestr_diagnose": {
|
|
2083
|
+
schemas.diagnose.parse(args);
|
|
2084
|
+
const snapshot = context?.getDiagnose?.();
|
|
2085
|
+
const correlationId = getCorrelationId();
|
|
2086
|
+
const result = {
|
|
2087
|
+
serverVersion: VERSION,
|
|
2088
|
+
correlationId,
|
|
2089
|
+
// No request context (e.g. stdio mode): we still report the basic facts.
|
|
2090
|
+
flow: snapshot?.flow ?? "unknown",
|
|
2091
|
+
tokenPresented: snapshot?.tokenPresented ?? false,
|
|
2092
|
+
tokenFingerprint: snapshot?.tokenFingerprint ?? "none",
|
|
2093
|
+
tokenAge: snapshot?.tokenAge ?? null,
|
|
2094
|
+
lastUpstream401At: snapshot?.lastUpstream401At
|
|
2095
|
+
? new Date(snapshot.lastUpstream401At).toISOString()
|
|
2096
|
+
: null,
|
|
2097
|
+
lastRefreshAttempt: snapshot?.lastRefreshAttempt
|
|
2098
|
+
? {
|
|
2099
|
+
at: new Date(snapshot.lastRefreshAttempt.at).toISOString(),
|
|
2100
|
+
success: snapshot.lastRefreshAttempt.success,
|
|
2101
|
+
error: snapshot.lastRefreshAttempt.error,
|
|
2102
|
+
}
|
|
2103
|
+
: null,
|
|
2104
|
+
sessionCorrelationId: snapshot?.sessionCorrelationId,
|
|
2105
|
+
authMode: snapshot?.isApiKey ? "api-key" : snapshot?.tokenPresented ? "oauth" : "none",
|
|
2106
|
+
mcpClient: snapshot?.mcpClient,
|
|
2107
|
+
mcpClientVersion: snapshot?.mcpClientVersion,
|
|
2108
|
+
userId: snapshot?.userId,
|
|
2109
|
+
hint: buildDiagnoseHint(snapshot),
|
|
2110
|
+
};
|
|
2111
|
+
return formatResult(result);
|
|
1659
2112
|
}
|
|
1660
2113
|
case "nestr_list_workspaces": {
|
|
1661
2114
|
const parsed = schemas.listWorkspaces.parse(args);
|
|
@@ -1713,6 +2166,8 @@ async function _handleToolCall(client, name, args) {
|
|
|
1713
2166
|
}
|
|
1714
2167
|
case "nestr_create_nest": {
|
|
1715
2168
|
const parsed = schemas.createNest.parse(args);
|
|
2169
|
+
validatePrimeLabels(parsed.labels);
|
|
2170
|
+
parsed.labels = ensureMeetingModifier(parsed.labels);
|
|
1716
2171
|
const hasGovernanceLabels = parsed.labels?.some(l => ["role", "circle"].includes(l));
|
|
1717
2172
|
const hasInlineGovernance = parsed.accountabilities?.length || parsed.domains?.length;
|
|
1718
2173
|
// Route to self-organization API when creating roles/circles with accountabilities/domains
|
|
@@ -1751,6 +2206,8 @@ async function _handleToolCall(client, name, args) {
|
|
|
1751
2206
|
}
|
|
1752
2207
|
case "nestr_update_nest": {
|
|
1753
2208
|
const parsed = schemas.updateNest.parse(args);
|
|
2209
|
+
validatePrimeLabels(parsed.labels);
|
|
2210
|
+
parsed.labels = ensureMeetingModifier(parsed.labels);
|
|
1754
2211
|
const hasInlineGovernance = parsed.accountabilities?.length || parsed.domains?.length;
|
|
1755
2212
|
// Route to self-organization API when updating roles/circles with accountabilities/domains
|
|
1756
2213
|
if (hasInlineGovernance && parsed.workspaceId) {
|
|
@@ -1797,13 +2254,16 @@ async function _handleToolCall(client, name, args) {
|
|
|
1797
2254
|
}
|
|
1798
2255
|
case "nestr_add_comment": {
|
|
1799
2256
|
const parsed = schemas.addComment.parse(args);
|
|
1800
|
-
const post = await client.createPost(parsed.nestId, parsed.body
|
|
2257
|
+
const post = await client.createPost(parsed.nestId, parsed.body, {
|
|
2258
|
+
labels: parsed.labels,
|
|
2259
|
+
});
|
|
1801
2260
|
return formatResult({ message: "Comment added successfully", post });
|
|
1802
2261
|
}
|
|
1803
2262
|
case "nestr_update_comment": {
|
|
1804
2263
|
const parsed = schemas.updateComment.parse(args);
|
|
1805
2264
|
const updated = await client.updateNest(parsed.commentId, {
|
|
1806
2265
|
title: parsed.body,
|
|
2266
|
+
...(parsed.labels !== undefined ? { labels: parsed.labels } : {}),
|
|
1807
2267
|
});
|
|
1808
2268
|
return formatResult({ message: "Comment updated successfully", comment: updated });
|
|
1809
2269
|
}
|
|
@@ -1993,6 +2453,13 @@ async function _handleToolCall(client, name, args) {
|
|
|
1993
2453
|
// Label management
|
|
1994
2454
|
case "nestr_add_label": {
|
|
1995
2455
|
const parsed = schemas.addLabel.parse(args);
|
|
2456
|
+
// Only fetch existing labels when applying a prime label — for all
|
|
2457
|
+
// other labels there's no possible conflict, so skip the extra call.
|
|
2458
|
+
if (PRIME_LABELS.has(parsed.labelId)) {
|
|
2459
|
+
const existing = await client.getNest(parsed.nestId);
|
|
2460
|
+
const existingNest = Array.isArray(existing) ? existing[0] : existing;
|
|
2461
|
+
validatePrimeLabels([...(existingNest?.labels ?? []), parsed.labelId]);
|
|
2462
|
+
}
|
|
1996
2463
|
const nest = await client.addLabel(parsed.nestId, parsed.labelId);
|
|
1997
2464
|
return formatResult({ message: `Label '${parsed.labelId}' added successfully`, nest: compactResponse(nest) });
|
|
1998
2465
|
}
|
|
@@ -2055,8 +2522,8 @@ async function _handleToolCall(client, name, args) {
|
|
|
2055
2522
|
catch (err) {
|
|
2056
2523
|
// 401 means the token itself is invalid — propagate so callers don't
|
|
2057
2524
|
// see a false "success" here while every other tool fails on the
|
|
2058
|
-
// same auth state.
|
|
2059
|
-
if (err instanceof NestrApiError && err.
|
|
2525
|
+
// same auth state. Match by status (any AUTH_* 401 code).
|
|
2526
|
+
if (err instanceof NestrApiError && err.status === 401) {
|
|
2060
2527
|
throw err;
|
|
2061
2528
|
}
|
|
2062
2529
|
// 403 likely means the token is valid but has no user scope (workspace
|
|
@@ -2064,7 +2531,7 @@ async function _handleToolCall(client, name, args) {
|
|
|
2064
2531
|
// endpoint that accepts both OAuth and workspace keys before reporting
|
|
2065
2532
|
// "workspace mode" — that way a forbidden coming from anywhere else
|
|
2066
2533
|
// (e.g. an unauthorized token that happens to 403) doesn't get masked.
|
|
2067
|
-
if (err instanceof NestrApiError && err.code === "
|
|
2534
|
+
if (err instanceof NestrApiError && err.code === "AUTH_SCOPE_INSUFFICIENT") {
|
|
2068
2535
|
const workspaceModeResponse = formatResult({
|
|
2069
2536
|
authMode: "api-key",
|
|
2070
2537
|
user: null,
|
|
@@ -2078,7 +2545,7 @@ async function _handleToolCall(client, name, args) {
|
|
|
2078
2545
|
catch (verifyErr) {
|
|
2079
2546
|
// The verification's job is to rule out "this token is bad". An
|
|
2080
2547
|
// explicit auth failure on listWorkspaces does that — propagate.
|
|
2081
|
-
if (verifyErr instanceof NestrApiError && verifyErr.
|
|
2548
|
+
if (verifyErr instanceof NestrApiError && verifyErr.status === 401) {
|
|
2082
2549
|
throw verifyErr;
|
|
2083
2550
|
}
|
|
2084
2551
|
// Anything else (5xx, network, rate limit) is a transient hiccup
|
|
@@ -2134,17 +2601,17 @@ async function _handleToolCall(client, name, args) {
|
|
|
2134
2601
|
description: parsed.description,
|
|
2135
2602
|
...(Object.keys(fields).length > 0 ? { fields } : {}),
|
|
2136
2603
|
});
|
|
2137
|
-
return formatResult({ message: "Tension created successfully", tension });
|
|
2604
|
+
return formatResult({ message: "Tension created successfully", tension: enrichHints(tension) });
|
|
2138
2605
|
}
|
|
2139
2606
|
case "nestr_get_tension": {
|
|
2140
2607
|
const parsed = schemas.getTension.parse(args);
|
|
2141
2608
|
const tension = await client.getTension(parsed.nestId, parsed.tensionId, { cleanText: true });
|
|
2142
|
-
return formatResult(tension);
|
|
2609
|
+
return formatResult(enrichHints(tension));
|
|
2143
2610
|
}
|
|
2144
2611
|
case "nestr_list_tensions": {
|
|
2145
2612
|
const parsed = schemas.listTensions.parse(args);
|
|
2146
2613
|
const tensions = await client.listTensions(parsed.nestId, parsed.search, { limit: parsed.limit, order: parsed.order, cleanText: true });
|
|
2147
|
-
return formatResult(compactResponse(tensions));
|
|
2614
|
+
return formatResult(compactResponse(enrichHints(tensions)));
|
|
2148
2615
|
}
|
|
2149
2616
|
case "nestr_update_tension": {
|
|
2150
2617
|
const parsed = schemas.updateTension.parse(args);
|
|
@@ -2172,8 +2639,24 @@ async function _handleToolCall(client, name, args) {
|
|
|
2172
2639
|
}
|
|
2173
2640
|
case "nestr_add_tension_part": {
|
|
2174
2641
|
const parsed = schemas.addTensionPart.parse(args);
|
|
2175
|
-
const { nestId, tensionId, ...body } = parsed;
|
|
2176
|
-
if (
|
|
2642
|
+
const { nestId, tensionId, removeNest, roleId, ...body } = parsed;
|
|
2643
|
+
if (roleId) {
|
|
2644
|
+
// Hold an election: assign/reconfirm the role's filler for a term without
|
|
2645
|
+
// changing its accountabilities/domains. Reuses `users` (the elected person)
|
|
2646
|
+
// and `due` (the term). The schema guarantees exactly one user here.
|
|
2647
|
+
const part = await client.createElection(nestId, tensionId, {
|
|
2648
|
+
roleId,
|
|
2649
|
+
users: body.users ?? [],
|
|
2650
|
+
...(body.due !== undefined ? { due: body.due } : {}),
|
|
2651
|
+
});
|
|
2652
|
+
return formatResult({ message: "Election added to the tension successfully", part });
|
|
2653
|
+
}
|
|
2654
|
+
else if (body._id && removeNest === true) {
|
|
2655
|
+
// Propose deletion of an existing structural item.
|
|
2656
|
+
const part = await client.proposeTensionDeletion(nestId, tensionId, body._id);
|
|
2657
|
+
return formatResult({ message: "Deletion proposal added successfully", part });
|
|
2658
|
+
}
|
|
2659
|
+
else if (body._id) {
|
|
2177
2660
|
// Propose change to existing item (existing children auto-copied if accountabilities/domains not provided)
|
|
2178
2661
|
const part = await client.proposeTensionChange(nestId, tensionId, body);
|
|
2179
2662
|
return formatResult({ message: "Change proposal added successfully", part });
|
|
@@ -2261,7 +2744,9 @@ async function _handleToolCall(client, name, args) {
|
|
|
2261
2744
|
}
|
|
2262
2745
|
}
|
|
2263
2746
|
catch (error) {
|
|
2264
|
-
// Handle Nestr API errors with structured response
|
|
2747
|
+
// Handle Nestr API errors with structured response. NestrApiError.toToolError
|
|
2748
|
+
// already attaches the correlationId from the active request context, so no
|
|
2749
|
+
// extra wiring is needed here.
|
|
2265
2750
|
if (error instanceof NestrApiError) {
|
|
2266
2751
|
return formatError(error.toToolError());
|
|
2267
2752
|
}
|
|
@@ -2273,6 +2758,18 @@ async function _handleToolCall(client, name, args) {
|
|
|
2273
2758
|
message: error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join("; "),
|
|
2274
2759
|
retryable: false,
|
|
2275
2760
|
hint: "Check the tool parameters match the expected schema.",
|
|
2761
|
+
correlationId: getCorrelationId(),
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
// Prime-label conflicts (e.g. ['project', 'tension'] on one nest)
|
|
2765
|
+
if (error instanceof PrimeLabelConflictError) {
|
|
2766
|
+
return formatError({
|
|
2767
|
+
error: true,
|
|
2768
|
+
code: "VALIDATION",
|
|
2769
|
+
message: error.message,
|
|
2770
|
+
retryable: false,
|
|
2771
|
+
hint: `Prime labels (one per nest): ${[...PRIME_LABELS].join(", ")}. Sole allowed pair: userstory+project (userstory implies project). Drop one label and retry, or create separate nests linked via nestr_add_graph_link (stories link to containers via userstory_sprint / userstory_epic / userstory_milestone).`,
|
|
2772
|
+
correlationId: getCorrelationId(),
|
|
2276
2773
|
});
|
|
2277
2774
|
}
|
|
2278
2775
|
// Handle other errors
|
|
@@ -2282,15 +2779,34 @@ async function _handleToolCall(client, name, args) {
|
|
|
2282
2779
|
code: "UNKNOWN",
|
|
2283
2780
|
message,
|
|
2284
2781
|
retryable: false,
|
|
2782
|
+
correlationId: getCorrelationId(),
|
|
2285
2783
|
});
|
|
2286
2784
|
}
|
|
2287
2785
|
}
|
|
2786
|
+
function buildDiagnoseHint(snapshot) {
|
|
2787
|
+
if (!snapshot || !snapshot.tokenPresented) {
|
|
2788
|
+
return "No bearer was presented to the server. The MCP client should run the OAuth flow (or pass an X-Nestr-API-Key header) and retry.";
|
|
2789
|
+
}
|
|
2790
|
+
if (snapshot.lastRefreshAttempt && snapshot.lastRefreshAttempt.success === false) {
|
|
2791
|
+
return "Most recent refresh failed. Tell the user to reconnect Nestr.";
|
|
2792
|
+
}
|
|
2793
|
+
if (snapshot.lastUpstream401At) {
|
|
2794
|
+
if (snapshot.flow === "B") {
|
|
2795
|
+
return "Nestr recently rejected the bearer. For Flow B, refresh is the client's responsibility — the MCP client should call /oauth/token with grant_type=refresh_token, then retry. If refresh also fails, tell the user to reconnect Nestr.";
|
|
2796
|
+
}
|
|
2797
|
+
return "Nestr recently rejected the bearer. Tell the user to reconnect Nestr.";
|
|
2798
|
+
}
|
|
2799
|
+
if (snapshot.tokenAge?.exp && snapshot.tokenAge.exp < snapshot.tokenAge.now) {
|
|
2800
|
+
return "Bearer is expired (exp < now). The client should refresh.";
|
|
2801
|
+
}
|
|
2802
|
+
return "Server-side state looks healthy. If a tool is failing, include sessionCorrelationId in the bug report.";
|
|
2803
|
+
}
|
|
2288
2804
|
function formatResult(data) {
|
|
2289
2805
|
return {
|
|
2290
2806
|
content: [
|
|
2291
2807
|
{
|
|
2292
2808
|
type: "text",
|
|
2293
|
-
text: JSON.stringify(data, null, 2),
|
|
2809
|
+
text: JSON.stringify(addNestUrls(data), null, 2),
|
|
2294
2810
|
},
|
|
2295
2811
|
],
|
|
2296
2812
|
};
|