@nestr/mcp 0.1.89 → 0.1.91

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.
@@ -123,11 +123,31 @@ const HINT_URL_PATTERNS = [
123
123
  },
124
124
  // /nests/{id}/posts → nestr_get_comments
125
125
  { pattern: /^\/nests\/([^/]+)\/posts$/, tool: "nestr_get_comments", params: (m) => ({ nestId: m[1] }) },
126
+ // /nests/{id}/files → nestr_get_nest_files
127
+ { pattern: /^\/nests\/([^/]+)\/files$/, tool: "nestr_get_nest_files", params: (m) => ({ nestId: m[1] }) },
126
128
  // /nests/{id}/tensions → nestr_list_tensions
127
129
  { pattern: /^\/nests\/([^/]+)\/tensions$/, tool: "nestr_list_tensions", params: (m) => ({ nestId: m[1] }) },
128
130
  // /nests/{id} → nestr_get_nest (must be last — catches all /nests/{id} patterns)
129
131
  { pattern: /^\/nests\/([^/]+)$/, tool: "nestr_get_nest", params: (m) => ({ nestId: m[1] }) },
130
132
  ];
133
+ const HINT_TYPE_TOOL_CALLS = {
134
+ no_strategy(nest) {
135
+ const nestId = nest._id;
136
+ if (!nestId)
137
+ return null;
138
+ const labels = nest.labels || [];
139
+ const isAnchorCircle = labels.includes("circleplus-anchor-circle")
140
+ || labels.includes("anchor-circle");
141
+ const fieldKey = isAnchorCircle ? "anchor-circle.strategy" : "circle.strategy";
142
+ return {
143
+ tool: "nestr_update_nest",
144
+ params: {
145
+ nestId,
146
+ fields: { [fieldKey]: "<strategy statement — what this circle prioritises now vs. defers>" },
147
+ },
148
+ };
149
+ },
150
+ };
131
151
  const HINT_ENDPOINT_TOOL_MAPPINGS = [
132
152
  {
133
153
  method: "POST",
@@ -262,9 +282,17 @@ export function enrichHints(data) {
262
282
  const workspaceId = ancestors?.length ? ancestors[ancestors.length - 1] : undefined;
263
283
  record.hints = record.hints.map((hint) => {
264
284
  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) {
285
+ // Type-based overrides win over URL matching for hints whose URL is
286
+ // just the nest itself (`/nests/{id}` → nestr_get_nest) and doesn't
287
+ // point at the actionable follow-up. Registered in HINT_TYPE_TOOL_CALLS.
288
+ const typeOverride = HINT_TYPE_TOOL_CALLS[hint.type];
289
+ const overrideCall = typeOverride ? typeOverride(record) : null;
290
+ if (overrideCall) {
291
+ enriched.toolCall = overrideCall;
292
+ }
293
+ else if (hint.url) {
294
+ // Legacy: single URL → toolCall. Kept for backwards compatibility with
295
+ // hints that pre-date the endpoints[] payload.
268
296
  let rawUrl = hint.url;
269
297
  const apiPrefixMatch = rawUrl.match(/^https?:\/\/[^/]+\/api(\/.*)/);
270
298
  if (apiPrefixMatch)
@@ -385,10 +413,15 @@ const coerceIntArray = (schema) => z.preprocess((val) => {
385
413
  }
386
414
  return val;
387
415
  }, schema);
416
+ // Shared description for the sort parameter on list/fetch tools. All of these
417
+ // endpoints honor a `sort` query param server-side (field name, '-' prefix for
418
+ // descending) — the same fields the search `sort:` operator uses.
419
+ const SORT_DESCRIPTION = "Field to sort by, e.g. 'title', 'createdAt', 'updatedAt', 'due', 'order' (manual order). Prefix with '-' for descending, e.g. '-updatedAt'.";
388
420
  // Tool input schemas using Zod
389
421
  export const schemas = {
390
422
  listWorkspaces: z.object({
391
423
  search: z.string().optional().describe("Search query to filter workspaces"),
424
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
392
425
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
393
426
  page: z.number().optional().describe("Page number (1-indexed) for pagination"),
394
427
  }),
@@ -407,6 +440,7 @@ export const schemas = {
407
440
  search: z.object({
408
441
  workspaceId: z.string().describe("Workspace ID to search in"),
409
442
  query: z.string().describe("Search query"),
443
+ sort: z.string().optional().describe(`${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.`),
410
444
  limit: z.number().optional().describe("Max results per page. Omit on first call to see meta.total count."),
411
445
  page: z.number().optional().describe("Page number (1-indexed) for pagination"),
412
446
  _listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Marketing projects\", \"Overdue tasks\"). Omit for default."),
@@ -418,6 +452,7 @@ export const schemas = {
418
452
  }),
419
453
  getNestChildren: z.object({
420
454
  nestId: z.string().describe("Parent nest ID"),
455
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
421
456
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
422
457
  page: z.number().optional().describe("Page number for pagination"),
423
458
  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."),
@@ -469,17 +504,20 @@ export const schemas = {
469
504
  }),
470
505
  listCircles: z.object({
471
506
  workspaceId: z.string().describe("Workspace ID"),
507
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
472
508
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
473
509
  page: z.number().optional().describe("Page number for pagination"),
474
510
  }),
475
511
  getCircleRoles: z.object({
476
512
  workspaceId: z.string().describe("Workspace ID"),
477
513
  circleId: z.string().describe("Circle ID"),
514
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
478
515
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
479
516
  page: z.number().optional().describe("Page number for pagination"),
480
517
  }),
481
518
  listRoles: z.object({
482
519
  workspaceId: z.string().describe("Workspace ID"),
520
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
483
521
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
484
522
  page: z.number().optional().describe("Page number for pagination"),
485
523
  }),
@@ -508,6 +546,7 @@ export const schemas = {
508
546
  }),
509
547
  getProjects: z.object({
510
548
  workspaceId: z.string().describe("Workspace ID"),
549
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
511
550
  limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
512
551
  page: z.number().optional().describe("Page number for pagination"),
513
552
  _listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Engineering projects\"). Omit for default."),
@@ -609,6 +648,16 @@ export const schemas = {
609
648
  getMe: z.object({
610
649
  fullWorkspaces: z.boolean().optional().describe("Set true to include full workspace details (purpose, labels, governance type, user access roles). Recommended on first call to establish workspace context."),
611
650
  }),
651
+ // Current user's cross-workspace activity (requires OAuth token)
652
+ myActivity: z.object({
653
+ limit: z.number().optional().describe("Max activity items to return (default 50, max 200)."),
654
+ withUser: z.string().optional().describe("Scope DM considerations to the conversation with this user id. Agent internal considerations from direct-message runs are redacted to an anonymous marker unless you name that conversation's counterpart here."),
655
+ }),
656
+ // Another user's cross-workspace activity (requires OAuth token)
657
+ userActivity: z.object({
658
+ userId: z.string().describe("The user whose activity to fetch."),
659
+ limit: z.number().optional().describe("Max activity items to return (default 50, max 200)."),
660
+ }),
612
661
  // User tension tools (requires OAuth token)
613
662
  listMyTensions: z.object({
614
663
  context: z.string().optional().describe("Optional context filter (e.g., workspace ID or circle ID)"),
@@ -640,8 +689,10 @@ export const schemas = {
640
689
  listTensions: z.object({
641
690
  nestId: z.string().describe("ID of the circle or role to list tensions for"),
642
691
  search: z.string().optional().describe("Search query to filter tensions"),
692
+ sort: z.string().optional().describe(SORT_DESCRIPTION),
643
693
  limit: z.number().optional().describe("Max results to return"),
644
- order: z.string().optional().describe("Sort order (e.g., 'createdAt', '-createdAt')"),
694
+ page: z.number().optional().describe("Page number for pagination"),
695
+ order: z.string().optional().describe("Deprecated alias of sort"),
645
696
  }),
646
697
  updateTension: z.object({
647
698
  nestId: z.string().describe("ID of the circle or role the tension belongs to"),
@@ -759,6 +810,46 @@ export const schemas = {
759
810
  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
811
  }).refine((v) => Boolean(v.topic) || Boolean(v.search), { message: "Provide either `topic` or `search`." }),
761
812
  diagnose: z.object({}).describe("No arguments — diagnose reads session state from the server."),
813
+ // Connector tools (registration is workspace-admin only)
814
+ listConnectors: z.object({
815
+ workspaceId: z.string().describe("Workspace ID whose connector catalog to list"),
816
+ }),
817
+ registerConnector: z.object({
818
+ workspaceId: z.string().describe("Workspace ID to register the connector in"),
819
+ type: z.enum(["mcp", "cli", "api"]).describe("Transport: 'mcp' (MCP server over a url), 'api' (REST endpoint over a url), or 'cli' (a command)"),
820
+ name: z.string().describe("Unique connector name within the workspace catalog"),
821
+ config: coerceFromJson(z.record(z.unknown())).optional().describe("Per-type transport config, no secret. mcp/api need a url (e.g., { url: 'https://...' }); cli needs a command (e.g., { command: 'some-cli' }). Optional non-secret headers go under headers."),
822
+ capabilities: coerceFromJson(z.record(z.unknown())).optional().describe("Capability descriptor: { discover: boolean, tools: [{ name, description, inputSchema }] }. discover:true lets the connector self-describe its tools at runtime."),
823
+ exposure: coerceFromJson(z.record(z.unknown())).optional().describe("Exposure policy deciding which owners may bind: { userAgent: boolean, domainGated: boolean }. Set domainGated:true to allow binding to a role's domain."),
824
+ authStrategy: z.enum(["secret", "oauth2"]).optional().describe("How a principal connects: 'secret' (a one-time secret captured via the Connect button) or 'oauth2'. The agent never sees the secret."),
825
+ }),
826
+ bindConnector: z.object({
827
+ workspaceId: z.string().describe("Workspace ID the connector and owner belong to"),
828
+ connectorId: z.string().describe("ID of an enabled connector from nestr_list_connectors"),
829
+ ownerType: z.enum(["user", "agent", "workspace", "role-domain"]).describe("Owner type. 'role-domain' binds the connector to a role's domain so the role can use it and a credentials field is materialised on the domain."),
830
+ ownerId: z.string().describe("Owner ID. user/agent: the user ID. workspace: the workspace ID. role-domain: the domain nest ID."),
831
+ }),
832
+ // File attachments (a comment id works as the nestId — files are keyed by nestId)
833
+ getNestFiles: z.object({
834
+ nestId: z.string().describe("Nest or comment ID whose file attachments to list"),
835
+ }),
836
+ readFile: z.object({
837
+ nestId: z.string().describe("Nest or comment ID the file is attached to"),
838
+ fileId: z.string().describe("File ID from nestr_get_nest_files"),
839
+ }),
840
+ uploadFile: z.object({
841
+ nestId: z.string().describe("Nest or comment ID to attach the file to"),
842
+ name: z.string().describe('File name including extension (e.g. "notes.md", "data.csv")'),
843
+ contentType: z.string().describe('MIME type of the file (e.g. "text/markdown", "text/csv", "image/png")'),
844
+ content: z.string().optional().describe("File content as UTF-8 text. Use for text-native files you author directly (.md, .txt, .csv, .json, .html, .svg, code). Stored verbatim. Provide this OR dataBase64, not both."),
845
+ dataBase64: z.string().optional().describe("File bytes, base64-encoded. Use for binary content you already hold as base64. Provide this OR content, not both."),
846
+ }).refine((v) => (v.content !== undefined) !== (v.dataBase64 !== undefined), {
847
+ message: "Provide exactly one of content (UTF-8 text) or dataBase64 (base64 bytes).",
848
+ }),
849
+ deleteFile: z.object({
850
+ nestId: z.string().describe("Nest or comment ID the file is attached to"),
851
+ fileId: z.string().describe("File ID from nestr_get_nest_files"),
852
+ }),
762
853
  };
763
854
  // Tool annotations for MCP - hints for clients on tool behavior
764
855
  const readOnly = { annotations: { readOnlyHint: true, destructiveHint: false } };
@@ -797,6 +888,7 @@ export const toolDefinitions = [
797
888
  type: "object",
798
889
  properties: {
799
890
  search: { type: "string", description: "Search query to filter workspaces" },
891
+ sort: { type: "string", description: SORT_DESCRIPTION },
800
892
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
801
893
  page: { type: "number", description: "Page number (1-indexed) for pagination" },
802
894
  stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for bulk/index operations." },
@@ -863,6 +955,7 @@ export const toolDefinitions = [
863
955
  properties: {
864
956
  workspaceId: { type: "string", description: "Workspace ID to search in" },
865
957
  query: { type: "string", description: "Search query with optional operators (e.g., 'label:role', 'assignee:me completed:false')" },
958
+ sort: { type: "string", description: `${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.` },
866
959
  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." },
867
960
  page: { type: "number", description: "Page number (1-indexed) for fetching additional pages" },
868
961
  stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for bulk/index operations." },
@@ -896,6 +989,7 @@ export const toolDefinitions = [
896
989
  type: "object",
897
990
  properties: {
898
991
  nestId: { type: "string", description: "Parent nest ID" },
992
+ sort: { type: "string", description: SORT_DESCRIPTION },
899
993
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
900
994
  page: { type: "number", description: "Page number (1-indexed)" },
901
995
  hints: { type: "boolean", description: "Include contextual hints (default: true). Set to false for large result sets or bulk operations." },
@@ -1074,6 +1168,7 @@ export const toolDefinitions = [
1074
1168
  type: "object",
1075
1169
  properties: {
1076
1170
  workspaceId: { type: "string", description: "Workspace ID" },
1171
+ sort: { type: "string", description: SORT_DESCRIPTION },
1077
1172
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
1078
1173
  page: { type: "number", description: "Page number (1-indexed)" },
1079
1174
  stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large workspaces." },
@@ -1090,6 +1185,7 @@ export const toolDefinitions = [
1090
1185
  properties: {
1091
1186
  workspaceId: { type: "string", description: "Workspace ID" },
1092
1187
  circleId: { type: "string", description: "Circle ID" },
1188
+ sort: { type: "string", description: SORT_DESCRIPTION },
1093
1189
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
1094
1190
  page: { type: "number", description: "Page number (1-indexed)" },
1095
1191
  stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large circles." },
@@ -1105,6 +1201,7 @@ export const toolDefinitions = [
1105
1201
  type: "object",
1106
1202
  properties: {
1107
1203
  workspaceId: { type: "string", description: "Workspace ID" },
1204
+ sort: { type: "string", description: SORT_DESCRIPTION },
1108
1205
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
1109
1206
  page: { type: "number", description: "Page number (1-indexed)" },
1110
1207
  stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large workspaces." },
@@ -1179,6 +1276,7 @@ export const toolDefinitions = [
1179
1276
  type: "object",
1180
1277
  properties: {
1181
1278
  workspaceId: { type: "string", description: "Workspace ID" },
1279
+ sort: { type: "string", description: SORT_DESCRIPTION },
1182
1280
  limit: { type: "number", description: "Omit on first call to see meta.total count" },
1183
1281
  page: { type: "number", description: "Page number (1-indexed)" },
1184
1282
  stripDescription: { type: "boolean", description: "Set true to strip description fields from response, significantly reducing size. Ideal for large workspaces." },
@@ -1444,6 +1542,33 @@ export const toolDefinitions = [
1444
1542
  _meta: completableListUi,
1445
1543
  ...readOnly,
1446
1544
  },
1545
+ // Current user's cross-workspace activity (requires OAuth token)
1546
+ {
1547
+ name: "nestr_my_activity",
1548
+ description: "The caller's own activity across ALL their workspaces, newest first (gated by workspace membership and the token's scope). Not scoped to a project or a single nest — it's everything you've done. For an agent, this is how it sees what it has done: past considerations (with the tools used and the outcome), comments, governance changes, and other actions. Agent internal considerations from direct-message runs are redacted to an anonymous marker unless you pass withUser with that conversation's counterpart. Auth: OAuth only (user-scoped — workspace API keys lack user identity). On auth failure call nestr_diagnose.",
1549
+ inputSchema: {
1550
+ type: "object",
1551
+ properties: {
1552
+ limit: { type: "number", description: "Max activity items to return (default 50, max 200)." },
1553
+ withUser: { type: "string", description: "Scope DM considerations to the conversation with this user id. Agent internal considerations from direct-message runs are redacted to an anonymous marker unless you name that conversation's counterpart here." },
1554
+ },
1555
+ },
1556
+ ...readOnly,
1557
+ },
1558
+ // Another user's cross-workspace activity (requires OAuth token)
1559
+ {
1560
+ name: "nestr_user_activity",
1561
+ description: "Another user's activity across the workspaces you share with them, newest first. Lets an agent see what a colleague or another agent has done: their past considerations (with the tools used and the outcome), comments, governance changes, and other actions. Agent internal considerations from direct-message runs show their substance only when you are a participant of that conversation, otherwise an anonymous marker. Auth: OAuth only (user-scoped — workspace API keys lack user identity). On auth failure call nestr_diagnose.",
1562
+ inputSchema: {
1563
+ type: "object",
1564
+ properties: {
1565
+ userId: { type: "string", description: "The user whose activity to fetch." },
1566
+ limit: { type: "number", description: "Max activity items to return (default 50, max 200)." },
1567
+ },
1568
+ required: ["userId"],
1569
+ },
1570
+ ...readOnly,
1571
+ },
1447
1572
  // Label management
1448
1573
  {
1449
1574
  name: "nestr_add_label",
@@ -1601,8 +1726,12 @@ export const toolDefinitions = [
1601
1726
  properties: {
1602
1727
  nestId: { type: "string", description: "ID of the circle or role to list tensions for" },
1603
1728
  search: { type: "string", description: "Search query to filter tensions" },
1729
+ sort: { type: "string", description: SORT_DESCRIPTION },
1604
1730
  limit: { type: "number", description: "Max results to return" },
1605
- order: { type: "string", description: "Sort order (e.g., 'createdAt', '-createdAt')" },
1731
+ page: { type: "number", description: "Page number (1-indexed)" },
1732
+ // The legacy `order` alias is deliberately not advertised — the Zod
1733
+ // schema still accepts it so existing callers keep working, but new
1734
+ // clients should only learn the canonical `sort` param.
1606
1735
  },
1607
1736
  required: ["nestId"],
1608
1737
  },
@@ -1856,6 +1985,129 @@ export const toolDefinitions = [
1856
1985
  },
1857
1986
  ...destructive,
1858
1987
  },
1988
+ {
1989
+ name: "nestr_list_connectors",
1990
+ description: "List the workspace's connector catalog: the mcp / cli / api templates an admin has registered. Each entry holds no secret and shows its type, name, authStrategy, config, capabilities, exposure ({ userAgent, domainGated }) and whether it is enabled. Typical flow: register a connector, then bind it to a role's domain with nestr_bind_connector, then a human or agent connects the account via the credentials field's Connect button (the secret is captured out-of-band, never by the agent). Auth: any valid token can list.",
1991
+ inputSchema: {
1992
+ type: "object",
1993
+ properties: {
1994
+ workspaceId: { type: "string", description: "Workspace ID whose connector catalog to list" },
1995
+ },
1996
+ required: ["workspaceId"],
1997
+ },
1998
+ ...readOnly,
1999
+ },
2000
+ {
2001
+ name: "nestr_register_connector",
2002
+ description: "Register a connector in the workspace catalog: a reusable mcp / cli / api template that holds no secret. Workspace-admin only. A non-admin caller gets AUTH_SCOPE_INSUFFICIENT (call nestr_diagnose on any auth error). Provide type ('mcp' or 'api' need a url in config; 'cli' needs a command) and a unique name; optionally capabilities, exposure ({ userAgent, domainGated }), and authStrategy ('secret' or 'oauth2'). This only creates the template. Typical flow: register here, then bind it to a role's domain with nestr_bind_connector, then a human or agent connects the account via the credentials field's Connect button. The secret is captured out-of-band through that button, never by the agent.",
2003
+ inputSchema: {
2004
+ type: "object",
2005
+ properties: {
2006
+ workspaceId: { type: "string", description: "Workspace ID to register the connector in" },
2007
+ type: {
2008
+ type: "string",
2009
+ enum: ["mcp", "cli", "api"],
2010
+ description: "Transport: 'mcp' (MCP server over a url), 'api' (REST endpoint over a url), or 'cli' (a command)",
2011
+ },
2012
+ name: { type: "string", description: "Unique connector name within the workspace catalog" },
2013
+ config: {
2014
+ type: "object",
2015
+ description: "Per-type transport config, no secret. mcp/api need a url (e.g., { url: 'https://...' }); cli needs a command (e.g., { command: 'some-cli' }). Optional non-secret headers go under headers.",
2016
+ },
2017
+ capabilities: {
2018
+ type: "object",
2019
+ description: "Capability descriptor: { discover: boolean, tools: [{ name, description, inputSchema }] }. discover:true lets the connector self-describe its tools at runtime.",
2020
+ },
2021
+ exposure: {
2022
+ type: "object",
2023
+ description: "Exposure policy deciding which owners may bind: { userAgent: boolean, domainGated: boolean }. Set domainGated:true to allow binding to a role's domain.",
2024
+ },
2025
+ authStrategy: {
2026
+ type: "string",
2027
+ enum: ["secret", "oauth2"],
2028
+ description: "How a principal connects: 'secret' (a one-time secret captured via the Connect button) or 'oauth2'. The agent never sees the secret.",
2029
+ },
2030
+ },
2031
+ required: ["workspaceId", "type", "name"],
2032
+ },
2033
+ ...mutating,
2034
+ },
2035
+ {
2036
+ name: "nestr_bind_connector",
2037
+ description: "Bind a registered connector to an owner so that owner can use it. Owner types: 'user' or 'agent' (ownerId is the user ID), 'workspace' (ownerId is the workspace ID), or 'role-domain' (ownerId is the domain nest ID). A 'role-domain' owner also materialises a credentials field on the domain nest, so the role can use the connector and the Connect button renders there; the response then includes credentialsField { domainId, fieldId, fieldCode }. After binding, a human or agent connects the account via that Connect button. The secret is captured out-of-band and is never seen by the agent. Workspace-admin only: a non-admin caller gets AUTH_SCOPE_INSUFFICIENT. The connector must already be registered (nestr_register_connector) and enabled.",
2038
+ inputSchema: {
2039
+ type: "object",
2040
+ properties: {
2041
+ workspaceId: { type: "string", description: "Workspace ID the connector and owner belong to" },
2042
+ connectorId: { type: "string", description: "ID of an enabled connector from nestr_list_connectors" },
2043
+ ownerType: {
2044
+ type: "string",
2045
+ enum: ["user", "agent", "workspace", "role-domain"],
2046
+ description: "Owner type. 'role-domain' binds the connector to a role's domain so the role can use it and a credentials field is materialised on the domain.",
2047
+ },
2048
+ ownerId: {
2049
+ type: "string",
2050
+ description: "Owner ID. user/agent: the user ID. workspace: the workspace ID. role-domain: the domain nest ID.",
2051
+ },
2052
+ },
2053
+ required: ["workspaceId", "connectorId", "ownerType", "ownerId"],
2054
+ },
2055
+ ...mutating,
2056
+ },
2057
+ {
2058
+ name: "nestr_get_nest_files",
2059
+ description: "List a nest's file attachments. A comment ID works too — files are keyed by nestId, so pass a comment ID to see files attached to that comment. Returns each file's id, name, contentType and size. Use nestr_read_file with a returned id to read one (images come back as viewable image content). Auth: any valid token with access to the nest.",
2060
+ inputSchema: {
2061
+ type: "object",
2062
+ properties: {
2063
+ nestId: { type: "string", description: "Nest or comment ID whose file attachments to list" },
2064
+ },
2065
+ required: ["nestId"],
2066
+ },
2067
+ ...readOnly,
2068
+ },
2069
+ {
2070
+ name: "nestr_read_file",
2071
+ description: "Read a single file attachment on a nest (or comment). Branches on contentType: images (image/*) return as viewable image content so you can see them (very large images return metadata only); JSON and text (application/json, text/*) return as decoded UTF-8 text (large text is truncated); PDFs and other types return their metadata only (cannot be inlined yet). Get file ids from nestr_get_nest_files. A comment ID works as the nestId. Auth: any valid token with access to the nest.",
2072
+ inputSchema: {
2073
+ type: "object",
2074
+ properties: {
2075
+ nestId: { type: "string", description: "Nest or comment ID the file is attached to" },
2076
+ fileId: { type: "string", description: "File ID from nestr_get_nest_files" },
2077
+ },
2078
+ required: ["nestId", "fileId"],
2079
+ },
2080
+ ...readOnly,
2081
+ },
2082
+ {
2083
+ name: "nestr_upload_file",
2084
+ description: "Upload a file and attach it to a nest (or comment; files are keyed by nestId, so a comment ID attaches the file to that comment). Provide the file one of two ways: content (UTF-8 text, for text-native files you author directly such as .md, .csv, .json, .html, .svg, or code) or dataBase64 (base64-encoded bytes you already have). Any content type is accepted; the upload is rejected if it exceeds the server's maximum size (default 10MB). Returns the new file's descriptor (id, name, contentType, size). Auth: a token with write access to the nest.",
2085
+ inputSchema: {
2086
+ type: "object",
2087
+ properties: {
2088
+ nestId: { type: "string", description: "Nest or comment ID to attach the file to" },
2089
+ name: { type: "string", description: 'File name including extension (e.g. "notes.md", "data.csv")' },
2090
+ contentType: { type: "string", description: 'MIME type (e.g. "text/markdown", "text/csv", "image/png")' },
2091
+ content: { type: "string", description: "File content as UTF-8 text (for text-native files: .md, .txt, .csv, .json, .html, .svg, code). Stored verbatim. Provide this OR dataBase64." },
2092
+ dataBase64: { type: "string", description: "File bytes, base64-encoded. Provide this OR content." },
2093
+ },
2094
+ required: ["nestId", "name", "contentType"],
2095
+ },
2096
+ ...mutating,
2097
+ },
2098
+ {
2099
+ name: "nestr_delete_file",
2100
+ description: "Delete a file attachment from a nest (or comment). Get the file id from nestr_get_nest_files. A comment ID works as the nestId. This permanently removes the file. Auth: a token with delete access to the nest.",
2101
+ inputSchema: {
2102
+ type: "object",
2103
+ properties: {
2104
+ nestId: { type: "string", description: "Nest or comment ID the file is attached to" },
2105
+ fileId: { type: "string", description: "File ID from nestr_get_nest_files" },
2106
+ },
2107
+ required: ["nestId", "fileId"],
2108
+ },
2109
+ ...destructive,
2110
+ },
1859
2111
  ];
1860
2112
  // Strip description fields from nest objects in response data
1861
2113
  export function stripDescriptionFields(data) {
@@ -1907,6 +2159,42 @@ export function unescapeRichTextFields(args) {
1907
2159
  }
1908
2160
  return changed ? result : args;
1909
2161
  }
2162
+ /**
2163
+ * Extract sort:/sort-order:/limit: directives from a search query string.
2164
+ *
2165
+ * The REST API only honors sorting and limits via the `sort`/`limit` query
2166
+ * params — these operators inside the search string are dropped from free-text
2167
+ * matching and otherwise ignored. Translating them here keeps the documented
2168
+ * search syntax (see nestr_help('search')) working. The query string itself is
2169
+ * sent unmodified: the server strips operator terms from text matching anyway.
2170
+ *
2171
+ * First occurrence wins for each directive, matching the server-side parsing
2172
+ * of these operators elsewhere in Nestr.
2173
+ */
2174
+ export function extractSearchDirectives(query) {
2175
+ let sortField;
2176
+ let sortOrder;
2177
+ let limit;
2178
+ for (const term of query.split(/\s+/)) {
2179
+ const lower = term.toLowerCase();
2180
+ if (sortField === undefined && lower.startsWith("sort:")) {
2181
+ sortField = term.slice("sort:".length);
2182
+ }
2183
+ else if (sortOrder === undefined && lower.startsWith("sort-order:")) {
2184
+ sortOrder = lower.slice("sort-order:".length);
2185
+ }
2186
+ else if (limit === undefined && lower.startsWith("limit:")) {
2187
+ const parsed = Number.parseInt(lower.slice("limit:".length), 10);
2188
+ if (!Number.isNaN(parsed) && parsed > 0)
2189
+ limit = parsed;
2190
+ }
2191
+ }
2192
+ let sort;
2193
+ if (sortField) {
2194
+ sort = sortOrder === "desc" && !sortField.startsWith("-") ? `-${sortField}` : sortField;
2195
+ }
2196
+ return { sort, limit };
2197
+ }
1910
2198
  export async function handleToolCall(client, name, args, context) {
1911
2199
  const sanitizedArgs = unescapeRichTextFields(args);
1912
2200
  const shouldStripDescription = sanitizedArgs.stripDescription === true;
@@ -2114,6 +2402,7 @@ async function _handleToolCall(client, name, args, context) {
2114
2402
  const parsed = schemas.listWorkspaces.parse(args);
2115
2403
  const workspaces = await client.listWorkspaces({
2116
2404
  search: parsed.search,
2405
+ sort: parsed.sort,
2117
2406
  limit: parsed.limit,
2118
2407
  page: parsed.page,
2119
2408
  cleanText: true,
@@ -2142,7 +2431,13 @@ async function _handleToolCall(client, name, args, context) {
2142
2431
  }
2143
2432
  case "nestr_search": {
2144
2433
  const parsed = schemas.search.parse(args);
2145
- const results = await client.searchWorkspace(parsed.workspaceId, parsed.query, { limit: parsed.limit, page: parsed.page, cleanText: true });
2434
+ const directives = extractSearchDirectives(parsed.query);
2435
+ const results = await client.searchWorkspace(parsed.workspaceId, parsed.query, {
2436
+ sort: parsed.sort ?? directives.sort,
2437
+ limit: parsed.limit ?? directives.limit,
2438
+ page: parsed.page,
2439
+ cleanText: true,
2440
+ });
2146
2441
  return formatResult(completableResponse(compactResponse(results), "search", parsed._listTitle || `Search: ${parsed.query}`));
2147
2442
  }
2148
2443
  case "nestr_get_nest": {
@@ -2157,6 +2452,7 @@ async function _handleToolCall(client, name, args, context) {
2157
2452
  case "nestr_get_nest_children": {
2158
2453
  const parsed = schemas.getNestChildren.parse(args);
2159
2454
  const children = await client.getNestChildren(parsed.nestId, {
2455
+ sort: parsed.sort,
2160
2456
  limit: parsed.limit,
2161
2457
  page: parsed.page,
2162
2458
  cleanText: true,
@@ -2275,6 +2571,7 @@ async function _handleToolCall(client, name, args, context) {
2275
2571
  case "nestr_list_circles": {
2276
2572
  const parsed = schemas.listCircles.parse(args);
2277
2573
  const circles = await client.listCircles(parsed.workspaceId, {
2574
+ sort: parsed.sort,
2278
2575
  limit: parsed.limit,
2279
2576
  page: parsed.page,
2280
2577
  cleanText: true,
@@ -2283,12 +2580,13 @@ async function _handleToolCall(client, name, args, context) {
2283
2580
  }
2284
2581
  case "nestr_get_circle_roles": {
2285
2582
  const parsed = schemas.getCircleRoles.parse(args);
2286
- const roles = await client.getCircleRoles(parsed.workspaceId, parsed.circleId, { limit: parsed.limit, page: parsed.page, cleanText: true });
2583
+ const roles = await client.getCircleRoles(parsed.workspaceId, parsed.circleId, { sort: parsed.sort, limit: parsed.limit, page: parsed.page, cleanText: true });
2287
2584
  return formatResult(compactResponse(roles, "role"));
2288
2585
  }
2289
2586
  case "nestr_list_roles": {
2290
2587
  const parsed = schemas.listRoles.parse(args);
2291
2588
  const roles = await client.listRoles(parsed.workspaceId, {
2589
+ sort: parsed.sort,
2292
2590
  limit: parsed.limit,
2293
2591
  page: parsed.page,
2294
2592
  cleanText: true,
@@ -2331,6 +2629,7 @@ async function _handleToolCall(client, name, args, context) {
2331
2629
  case "nestr_get_projects": {
2332
2630
  const parsed = schemas.getProjects.parse(args);
2333
2631
  const projects = await client.getWorkspaceProjects(parsed.workspaceId, {
2632
+ sort: parsed.sort,
2334
2633
  limit: parsed.limit,
2335
2634
  page: parsed.page,
2336
2635
  cleanText: true,
@@ -2490,6 +2789,21 @@ async function _handleToolCall(client, name, args, context) {
2490
2789
  const items = await client.getDailyPlan();
2491
2790
  return formatResult(completableResponse(compactResponse(items), "daily-plan", "Daily Plan"));
2492
2791
  }
2792
+ case "nestr_my_activity": {
2793
+ const parsed = schemas.myActivity.parse(args);
2794
+ const activity = await client.getMyActivity({
2795
+ limit: parsed.limit,
2796
+ withUser: parsed.withUser,
2797
+ });
2798
+ return formatResult({ count: activity.length, activity });
2799
+ }
2800
+ case "nestr_user_activity": {
2801
+ const parsed = schemas.userActivity.parse(args);
2802
+ const activity = await client.getUserActivity(parsed.userId, {
2803
+ limit: parsed.limit,
2804
+ });
2805
+ return formatResult({ count: activity.length, activity });
2806
+ }
2493
2807
  // Current user identity and workspace context
2494
2808
  case "nestr_get_me": {
2495
2809
  const parsed = schemas.getMe.parse(args);
@@ -2610,7 +2924,14 @@ async function _handleToolCall(client, name, args, context) {
2610
2924
  }
2611
2925
  case "nestr_list_tensions": {
2612
2926
  const parsed = schemas.listTensions.parse(args);
2613
- const tensions = await client.listTensions(parsed.nestId, parsed.search, { limit: parsed.limit, order: parsed.order, cleanText: true });
2927
+ const tensions = await client.listTensions(parsed.nestId, parsed.search, {
2928
+ // `order` is the legacy name for this option — it was never honored
2929
+ // by the API (which reads `sort`), so route both through sort.
2930
+ sort: parsed.sort ?? parsed.order,
2931
+ limit: parsed.limit,
2932
+ page: parsed.page,
2933
+ cleanText: true,
2934
+ });
2614
2935
  return formatResult(compactResponse(enrichHints(tensions)));
2615
2936
  }
2616
2937
  case "nestr_update_tension": {
@@ -2733,6 +3054,127 @@ async function _handleToolCall(client, name, args, context) {
2733
3054
  const result = await client.removeGraphLink(parsed.nestId, parsed.relation, parsed.targetId);
2734
3055
  return formatResult({ message: "Graph link removed" });
2735
3056
  }
3057
+ // Connector tools
3058
+ case "nestr_list_connectors": {
3059
+ const parsed = schemas.listConnectors.parse(args);
3060
+ const connectors = await client.listConnectors(parsed.workspaceId);
3061
+ return formatResult(connectors);
3062
+ }
3063
+ case "nestr_register_connector": {
3064
+ const parsed = schemas.registerConnector.parse(args);
3065
+ const connector = await client.registerConnector(parsed.workspaceId, {
3066
+ type: parsed.type,
3067
+ name: parsed.name,
3068
+ config: parsed.config,
3069
+ capabilities: parsed.capabilities,
3070
+ exposure: parsed.exposure,
3071
+ authStrategy: parsed.authStrategy,
3072
+ });
3073
+ return formatResult({
3074
+ message: "Connector registered. Next, bind it to an owner with nestr_bind_connector (e.g. a role's domain), then a human or agent connects the account via the credentials field's Connect button.",
3075
+ connector,
3076
+ });
3077
+ }
3078
+ case "nestr_bind_connector": {
3079
+ const parsed = schemas.bindConnector.parse(args);
3080
+ const connection = await client.bindConnector(parsed.workspaceId, {
3081
+ connectorId: parsed.connectorId,
3082
+ owner: { type: parsed.ownerType, id: parsed.ownerId },
3083
+ });
3084
+ // For a role-domain binding the API materialises a credentials field on
3085
+ // the domain; surface that explicitly so the caller knows the Connect
3086
+ // button now renders there and the secret is captured out-of-band.
3087
+ const message = parsed.ownerType === "role-domain"
3088
+ ? "Connector bound to the role's domain. A credentials field was materialised on the domain (see credentialsField) so the role can use the connector and the Connect button renders. A human or agent now connects the account via that button; the secret is captured out-of-band and never by the agent."
3089
+ : "Connector bound to the owner. The owner now connects the account out-of-band; the secret is never seen by the agent.";
3090
+ return formatResult({ message, connection });
3091
+ }
3092
+ case "nestr_get_nest_files": {
3093
+ const parsed = schemas.getNestFiles.parse(args);
3094
+ const files = await client.getNestFiles(parsed.nestId);
3095
+ if (files.length === 0) {
3096
+ return { content: [{ type: "text", text: `No file attachments on ${parsed.nestId}.` }] };
3097
+ }
3098
+ const lines = files.map((f) => `- ${f.id} — ${f.name} (${f.contentType}, ${formatBytes(f.size)})`);
3099
+ const text = `${files.length} file${files.length === 1 ? "" : "s"} attached to ${parsed.nestId}. Read one with nestr_read_file({ nestId: "${parsed.nestId}", fileId: "<id>" }).\n\n${lines.join("\n")}`;
3100
+ return { content: [{ type: "text", text }] };
3101
+ }
3102
+ case "nestr_read_file": {
3103
+ const parsed = schemas.readFile.parse(args);
3104
+ const file = await client.getNestFile(parsed.nestId, parsed.fileId);
3105
+ const contentType = file.contentType || "application/octet-stream";
3106
+ // Images: return as a viewable image content item (mirrors nestr_help).
3107
+ // Guard the size first — an image over MAX_IMAGE_INLINE_BYTES exceeds the
3108
+ // per-image limit of the model API the agent forwards it to, so inlining
3109
+ // the base64 would just make that call fail. Degrade to a metadata note.
3110
+ if (contentType.startsWith("image/")) {
3111
+ // Not `??`: size can legitimately be 0 when a legacy file's metadata was
3112
+ // lost (the API descriptor falls back to 0), and there we want the real
3113
+ // byte length so the cap still applies. `> 0` recomputes on a zero/missing
3114
+ // size; trusting a 0 would skip the guard on a large legacy image.
3115
+ const imageBytes = file.size > 0 ? file.size : Buffer.byteLength(file.dataBase64, "base64");
3116
+ if (imageBytes > MAX_IMAGE_INLINE_BYTES) {
3117
+ return {
3118
+ content: [
3119
+ {
3120
+ type: "text",
3121
+ text: `File: ${file.name}\nContent type: ${contentType}\nSize: ${formatBytes(imageBytes)}\n\nThis image is too large to inline (limit ${formatBytes(MAX_IMAGE_INLINE_BYTES)}). Download it directly to view the content.`,
3122
+ },
3123
+ ],
3124
+ };
3125
+ }
3126
+ return {
3127
+ content: [
3128
+ { type: "text", text: `File: ${file.name} (${contentType}, ${formatBytes(file.size)})` },
3129
+ { type: "image", data: file.dataBase64, mimeType: contentType },
3130
+ ],
3131
+ };
3132
+ }
3133
+ // JSON / text / CSV: decode to UTF-8 and return as a text block.
3134
+ if (contentType.startsWith("text/") ||
3135
+ contentType.startsWith("application/json")) {
3136
+ const decoded = Buffer.from(file.dataBase64, "base64").toString("utf-8");
3137
+ const { text: body, truncated } = capText(decoded);
3138
+ const note = truncated
3139
+ ? `\n\n_(truncated — file is ${formatBytes(file.size)}; showing the first ${MAX_TEXT_FILE_CHARS} characters)_`
3140
+ : "";
3141
+ return {
3142
+ content: [
3143
+ { type: "text", text: `File: ${file.name} (${contentType}, ${formatBytes(file.size)})\n\n${body}${note}` },
3144
+ ],
3145
+ };
3146
+ }
3147
+ // PDFs and everything else: metadata only. The ToolResult content type
3148
+ // supports text + image blocks, so a non-image binary can't be inlined yet.
3149
+ return {
3150
+ content: [
3151
+ {
3152
+ type: "text",
3153
+ text: `File: ${file.name}\nContent type: ${contentType}\nSize: ${formatBytes(file.size)}\n\nThis file type can't be inlined yet — only images (returned as viewable image content) and text/JSON (returned as decoded text) are supported.`,
3154
+ },
3155
+ ],
3156
+ };
3157
+ }
3158
+ case "nestr_upload_file": {
3159
+ const parsed = schemas.uploadFile.parse(args);
3160
+ // The REST API takes base64 bytes; base64-encode the text convenience here
3161
+ // so the model can hand over content it authored without encoding it itself.
3162
+ const dataBase64 = parsed.dataBase64 ?? Buffer.from(parsed.content ?? "", "utf-8").toString("base64");
3163
+ const file = await client.createNestFile(parsed.nestId, {
3164
+ name: parsed.name,
3165
+ contentType: parsed.contentType,
3166
+ dataBase64,
3167
+ });
3168
+ return formatResult({
3169
+ message: `Uploaded ${file.name} (${formatBytes(file.size)}) to ${parsed.nestId}.`,
3170
+ file,
3171
+ });
3172
+ }
3173
+ case "nestr_delete_file": {
3174
+ const parsed = schemas.deleteFile.parse(args);
3175
+ await client.deleteNestFile(parsed.nestId, parsed.fileId);
3176
+ return formatResult({ message: `Deleted file ${parsed.fileId} from ${parsed.nestId}.` });
3177
+ }
2736
3178
  default:
2737
3179
  return formatError({
2738
3180
  error: true,
@@ -2801,6 +3243,34 @@ function buildDiagnoseHint(snapshot) {
2801
3243
  }
2802
3244
  return "Server-side state looks healthy. If a tool is failing, include sessionCorrelationId in the bug report.";
2803
3245
  }
3246
+ // Cap on decoded text-file content returned by nestr_read_file, to bound token
3247
+ // cost on large files. ~200 KB of characters.
3248
+ const MAX_TEXT_FILE_CHARS = 200_000;
3249
+ /** Truncate decoded text to MAX_TEXT_FILE_CHARS, flagging whether it was cut. */
3250
+ function capText(text) {
3251
+ if (text.length <= MAX_TEXT_FILE_CHARS)
3252
+ return { text, truncated: false };
3253
+ return { text: text.slice(0, MAX_TEXT_FILE_CHARS), truncated: true };
3254
+ }
3255
+ // Cap on an image returned inline by nestr_read_file. Above this the base64 blob
3256
+ // exceeds the ~5MB-per-image limit of the model API the agent forwards it to, so
3257
+ // we return metadata instead of an image block that call would reject.
3258
+ const MAX_IMAGE_INLINE_BYTES = 5 * 1024 * 1024;
3259
+ /** Human-readable byte size (e.g. "12.3 KB"). */
3260
+ function formatBytes(bytes) {
3261
+ if (!Number.isFinite(bytes) || bytes < 0)
3262
+ return "unknown size";
3263
+ if (bytes < 1024)
3264
+ return `${bytes} B`;
3265
+ const units = ["KB", "MB", "GB"];
3266
+ let size = bytes / 1024;
3267
+ let unit = 0;
3268
+ while (size >= 1024 && unit < units.length - 1) {
3269
+ size /= 1024;
3270
+ unit++;
3271
+ }
3272
+ return `${size.toFixed(1)} ${units[unit]}`;
3273
+ }
2804
3274
  function formatResult(data) {
2805
3275
  return {
2806
3276
  content: [