@pipeshub-ai/mcp 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +2 -0
  2. package/bin/mcp-server.js +648 -175
  3. package/bin/mcp-server.js.map +17 -11
  4. package/bin/pipeshub.js +946 -0
  5. package/bin/pipeshub.js.map +14 -0
  6. package/esm/cli/client.d.ts +54 -0
  7. package/esm/cli/client.d.ts.map +1 -0
  8. package/esm/cli/client.js +230 -0
  9. package/esm/cli/client.js.map +1 -0
  10. package/esm/cli/commands.d.ts +30 -0
  11. package/esm/cli/commands.d.ts.map +1 -0
  12. package/esm/cli/commands.js +321 -0
  13. package/esm/cli/commands.js.map +1 -0
  14. package/esm/cli/config.d.ts +73 -0
  15. package/esm/cli/config.d.ts.map +1 -0
  16. package/esm/cli/config.js +218 -0
  17. package/esm/cli/config.js.map +1 -0
  18. package/esm/cli/init-qm.d.ts +11 -0
  19. package/esm/cli/init-qm.d.ts.map +1 -0
  20. package/esm/cli/init-qm.js +170 -0
  21. package/esm/cli/init-qm.js.map +1 -0
  22. package/esm/cli/pipeshub.d.ts +2 -0
  23. package/esm/cli/pipeshub.d.ts.map +1 -0
  24. package/esm/cli/pipeshub.js +296 -0
  25. package/esm/cli/pipeshub.js.map +1 -0
  26. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts +68 -0
  27. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts.map +1 -0
  28. package/esm/funcs/connectorLookupRecordByIdentifier.js +138 -0
  29. package/esm/funcs/connectorLookupRecordByIdentifier.js.map +1 -0
  30. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts +75 -0
  31. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts.map +1 -0
  32. package/esm/funcs/connectorNavigateKnowledgeGraph.js +152 -0
  33. package/esm/funcs/connectorNavigateKnowledgeGraph.js.map +1 -0
  34. package/esm/lib/base64.d.ts +1 -1
  35. package/esm/lib/base64.d.ts.map +1 -1
  36. package/esm/lib/base64.js +6 -3
  37. package/esm/lib/base64.js.map +1 -1
  38. package/esm/lib/bytes-to-base64.d.ts +16 -0
  39. package/esm/lib/bytes-to-base64.d.ts.map +1 -0
  40. package/esm/lib/bytes-to-base64.js +23 -0
  41. package/esm/lib/bytes-to-base64.js.map +1 -0
  42. package/esm/mcp-server/instructions.d.ts +1 -1
  43. package/esm/mcp-server/instructions.d.ts.map +1 -1
  44. package/esm/mcp-server/instructions.js +30 -0
  45. package/esm/mcp-server/instructions.js.map +1 -1
  46. package/esm/mcp-server/shared.d.ts +2 -2
  47. package/esm/mcp-server/shared.d.ts.map +1 -1
  48. package/esm/mcp-server/shared.js +7 -3
  49. package/esm/mcp-server/shared.js.map +1 -1
  50. package/esm/mcp-server/tools/_agui.d.ts +34 -0
  51. package/esm/mcp-server/tools/_agui.d.ts.map +1 -0
  52. package/esm/mcp-server/tools/_agui.js +91 -0
  53. package/esm/mcp-server/tools/_agui.js.map +1 -0
  54. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  55. package/esm/mcp-server/tools/_helpers.js +13 -6
  56. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  57. package/esm/mcp-server/tools/pipeshubChat.d.ts +0 -3
  58. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  59. package/esm/mcp-server/tools/pipeshubChat.js +75 -98
  60. package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
  61. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +118 -1
  62. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -1
  63. package/esm/mcp-server/tools/pipeshubGetRecordContent.js +180 -29
  64. package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -1
  65. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  66. package/esm/mcp-server/tools/pipeshubSearch.js +14 -1
  67. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  68. package/esm/models/availablemodelsresponse.d.ts +1 -1
  69. package/esm/models/conversation.d.ts +1 -1
  70. package/esm/models/lookuprecordbyidentifierop.d.ts +7 -0
  71. package/esm/models/lookuprecordbyidentifierop.d.ts.map +1 -0
  72. package/esm/models/lookuprecordbyidentifierop.js +14 -0
  73. package/esm/models/lookuprecordbyidentifierop.js.map +1 -0
  74. package/esm/models/navigateknowledgegraphop.d.ts +14 -0
  75. package/esm/models/navigateknowledgegraphop.d.ts.map +1 -0
  76. package/esm/models/navigateknowledgegraphop.js +25 -0
  77. package/esm/models/navigateknowledgegraphop.js.map +1 -0
  78. package/esm/models/userteamsresponse.d.ts +1 -1
  79. package/esm/tool-names.js +3 -3
  80. package/esm/tool-names.js.map +1 -1
  81. package/package.json +3 -2
  82. package/qm/README.md +247 -0
  83. package/qm/SECURITY.md +134 -0
  84. package/qm/TROUBLESHOOTING.md +218 -0
  85. package/qm/qm.config.fragment.jsonc +42 -0
  86. package/qm/sandbox/Dockerfile +22 -0
  87. package/qm/sandbox/skills/pipeshub/SKILL.md +69 -0
  88. package/qm/sandbox/tools/pipeshub/tool.json +36 -0
  89. package/src/cli/client.ts +272 -0
  90. package/src/cli/commands.ts +403 -0
  91. package/src/cli/config.ts +229 -0
  92. package/src/cli/init-qm.ts +215 -0
  93. package/src/cli/pipeshub.ts +333 -0
  94. package/src/funcs/connectorLookupRecordByIdentifier.ts +200 -0
  95. package/src/funcs/connectorNavigateKnowledgeGraph.ts +215 -0
  96. package/src/lib/base64.ts +6 -3
  97. package/src/lib/bytes-to-base64.ts +24 -0
  98. package/src/mcp-server/instructions.ts +30 -0
  99. package/src/mcp-server/shared.ts +7 -3
  100. package/src/mcp-server/tools/_agui.ts +138 -0
  101. package/src/mcp-server/tools/_helpers.ts +13 -6
  102. package/src/mcp-server/tools/pipeshubChat.ts +78 -97
  103. package/src/mcp-server/tools/pipeshubGetRecordContent.ts +213 -29
  104. package/src/mcp-server/tools/pipeshubSearch.ts +14 -1
  105. package/src/models/lookuprecordbyidentifierop.ts +26 -0
  106. package/src/models/navigateknowledgegraphop.ts +56 -0
  107. package/src/tool-names.ts +3 -3
@@ -1,24 +1,13 @@
1
1
  // `POST /conversations/stream` (and
2
2
  // `POST /conversations/{conversationId}/messages/stream` for follow-ups),
3
3
  // accumulate the frames server-side, and hand the LLM a single trimmed
4
- // `CallToolResult` once the stream emits its terminal `complete` (or
5
- // `error`) frame.
4
+ // `CallToolResult` once the stream reaches a terminal frame.
6
5
  //
7
- // Frame types we know about (from live wire traces):
8
- // - connected — handshake; ignored
9
- // - status — { status: "started" | "searching" | "processing"
10
- // | "checking_tools" | ... }; ignored (UI-only)
11
- // - answer_chunk — { content, accumulated, ... }; we keep the latest
12
- // `accumulated` as a fallback in case the stream
13
- // ends without a `complete` frame
14
- // - tool_call — model invoked an internal tool; we collect these
15
- // as observability breadcrumbs but the LLM doesn't
16
- // need them to answer
17
- // - tool_success — paired with tool_call; same treatment
18
- // - complete — terminal; payload is `{ conversation, meta }`,
19
- // matching the non-stream response shape, so we
20
- // feed it through `trimConversation` like before
21
- // - error — terminal; surface to the LLM as a tool error
6
+ // The wire protocol is AG-UI. The legacy `connected` / `answer_chunk` /
7
+ // `complete` / `error` vocabulary was removed with the new agent loop and is
8
+ // never emitted. The frames that matter CUSTOM{conversation_created},
9
+ // TEXT_MESSAGE_CONTENT, RUN_FINISHED, RUN_ERROR are folded by `./_agui.js`,
10
+ // which is kept separate so the fold stays a pure function over frames.
22
11
 
23
12
  import * as z from "zod";
24
13
  import { conversationsStreamConversation } from "../../funcs/conversationsStreamConversation.js";
@@ -26,6 +15,11 @@ import { conversationsStreamMessage } from "../../funcs/conversationsStreamMessa
26
15
  import { agentsStreamConversation } from "../../funcs/agentsStreamConversation.js";
27
16
  import { agentsStreamMessage } from "../../funcs/agentsStreamMessage.js";
28
17
  import { ToolDefinition } from "../tools.js";
18
+ import {
19
+ applyAGUIFrame,
20
+ newChatStreamState,
21
+ salvagedText,
22
+ } from "./_agui.js";
29
23
  import {
30
24
  errorResult,
31
25
  httpErrorResult,
@@ -36,9 +30,11 @@ import {
36
30
 
37
31
  const FiltersShape = z.object({
38
32
  apps: z.array(z.string()).optional().describe(
39
- "Source-scoping ids. Mix connector instance UUIDs with the synthetic "
40
- + "`knowledgeBase_<orgId>` id (use pipeshub_sources to discover them). "
41
- + "Empty / omitted means no app-side restriction.",
33
+ "Source-scoping ids from `pipeshub_sources` — connector instance and / "
34
+ + "or knowledge base ids, mixed freely. The legacy org-wide "
35
+ + "`knowledgeBase_<orgId>` id is still accepted on deployments that "
36
+ + "predate per-KB sources. Empty / omitted means no app-side "
37
+ + "restriction.",
42
38
  ),
43
39
  kb: z.array(z.string()).optional().describe(
44
40
  "Legacy / unused. Leave empty.",
@@ -76,48 +72,53 @@ const args = {
76
72
  chatMode: z.enum([
77
73
  "internal_search",
78
74
  "web_search",
79
- "auto",
80
75
  "quick",
81
- "verification",
82
- "deep",
83
76
  ]).optional().describe(
84
77
  "Response strategy. The valid values depend on whether `agentId` is set:\n"
85
78
  + "- WITHOUT `agentId` (plain chat): `internal_search` — answer from the "
86
79
  + "org's indexed knowledge (default) — or `web_search` — answer from the "
87
80
  + "live web.\n"
88
- + "- WITH `agentId` (agent chat): `auto` (let the agent decide; default), "
89
- + "`quick`, `verification`, or `deep`.",
81
+ + "- WITH `agentId` (agent chat): `quick` is the only supported mode and "
82
+ + "is sent automatically, so this argument can be omitted.",
90
83
  ),
91
84
  };
92
85
 
93
86
  export const tool$pipeshubChat: ToolDefinition<typeof args> = {
94
87
  name: "pipeshub_chat",
95
88
  description:
96
- `**Primary chat tool handles both internal knowledge queries and web search.**
89
+ `Ask a question, get an answer grounded in the org's indexed data with
90
+ citations. It reads a few retrieved passages — never a whole document,
91
+ never a complete list.
92
+
93
+ **Three questions this tool gets WRONG. Check them first:**
94
+ - **Structure** — "what's under this epic?", "which pages are in this
95
+ space?", "what links to this ticket?", "what's in this folder?" →
96
+ \`pipeshub_get_record_content\` \`mode:"navigate"\`. Ranking cannot see how
97
+ records relate.
98
+ - **Exhaustive** — "how many X?", "list ALL the Y", "every Z" →
99
+ \`mode:"navigate"\`, which reports the group's real total. This tool
100
+ undercounts and will not say so.
101
+ - **One named document** — summarize it, extract from it, what does it say
102
+ about X → \`pipeshub_search\` for the \`recordId\`, then \`mode:"content"\`.
103
+
104
+ Everything else about the org's knowledge belongs here: policies,
105
+ processes, decisions, history, "what do we know about X", and any question
106
+ spanning several documents.
97
107
 
98
- **Internal search** (default, \`chatMode: "internal_search"\`): Use whenever
99
- the user asks about their documents, files, knowledge base, company policies,
100
- or anything that could plausibly be answered by content in their PipesHub-indexed
101
- sources (Drive, Box, Confluence, Slack, Gmail, Jira, the org's KB, ...).
102
- Grounds the answer in the user's actual data and returns citations.
103
- Answers come from a few retrieved passages, not whole documents — for
104
- any task needing a document's full content, use
105
- \`pipeshub_get_record_content\` instead.
108
+ **Internal search** (default, \`chatMode: "internal_search"\`): the user's
109
+ documents, files, knowledge base, company policies — anything in their
110
+ PipesHub-indexed sources (Drive, Box, Confluence, Slack, Gmail, Jira, the
111
+ org's KB, ...).
106
112
 
107
- **Web search** (\`chatMode: "web_search"\`): Use when the user asks about
108
- current events, public information, or anything unlikely to be in the org's
109
- internal knowledge base. Pass \`chatMode: "web_search"\` and this tool will
110
- search the public web instead.
113
+ **Web search** (\`chatMode: "web_search"\`): current events or public
114
+ information unlikely to be in the org's knowledge base.
115
+
116
+ Both are plain-chat modes. **Agent chat** — pass an \`agentId\` from
117
+ \`pipeshub_agents\` — runs against that agent's own prompt, tools and knowledge;
118
+ \`quick\` is its only mode, requires the \`agentId\`, and is sent automatically.
111
119
 
112
- **When to pick this over other tools:**
113
- - "Summarize <doc>" / "key points of <doc>" / "what does <document> say
114
- about X?" → NOT this tool. Use \`pipeshub_search\` →
115
- \`pipeshub_get_record_content\`: answering for a specific document
116
- requires its full content, and chat only sees a few retrieved
117
- passages, never the whole document.
118
120
  - "What's our policy on Y?" → \`pipeshub_chat\` (internal_search)
119
121
  - "What's in the news about Z?" → \`pipeshub_chat\` (web_search)
120
- - "What is the latest version of <library>?" → \`pipeshub_chat\` (web_search)
121
122
  - "Find / locate the file named X" → \`pipeshub_search\` (then
122
123
  \`pipeshub_download_record\` if the user wants the bytes).
123
124
 
@@ -147,11 +148,19 @@ cited document, take \`citations[*].recordId\` and call
147
148
  args,
148
149
  tool: async (client, args, ctx) => {
149
150
  const fetchOptions = { signal: ctx.signal };
151
+ // `quick` is agent-only. The plain stream schemas accept internal_search /
152
+ // web_search / agent, so collapse anything else to the default rather than
153
+ // forwarding a value that path would reject.
154
+ const plainChatMode = args.chatMode === "web_search"
155
+ ? "web_search"
156
+ : "internal_search";
150
157
  let response: Response;
151
158
 
152
159
  if (args.agentId) {
153
- // Agent conversation. Agent chatMode vocabulary defaults to `auto`.
154
- const agentChatMode = args.chatMode ?? "auto";
160
+ // `quick` is the only value the agent stream schemas accept, and it is
161
+ // required so ignore whatever the caller passed rather than forwarding
162
+ // a value the gateway would reject.
163
+ const agentChatMode = "quick" as const;
155
164
  if (args.conversationId) {
156
165
  // Continue an existing agent conversation.
157
166
  const [result] = await agentsStreamMessage(client, {
@@ -192,7 +201,7 @@ cited document, take \`citations[*].recordId\` and call
192
201
  modelKey: args.modelKey,
193
202
  modelName: args.modelName,
194
203
  modelFriendlyName: args.modelFriendlyName,
195
- chatMode: args.chatMode ?? "internal_search",
204
+ chatMode: plainChatMode,
196
205
  },
197
206
  }, { fetchOptions }).$inspect();
198
207
  if (!result.ok) return errorResult(result.error.message);
@@ -205,7 +214,7 @@ cited document, take \`citations[*].recordId\` and call
205
214
  modelKey: args.modelKey,
206
215
  modelName: args.modelName,
207
216
  modelFriendlyName: args.modelFriendlyName,
208
- chatMode: args.chatMode ?? "internal_search",
217
+ chatMode: plainChatMode,
209
218
  }, { fetchOptions }).$inspect();
210
219
  if (!result.ok) return errorResult(result.error.message);
211
220
  response = result.value;
@@ -217,72 +226,44 @@ cited document, take \`citations[*].recordId\` and call
217
226
  const httpErr = await httpErrorResult(response, "PipesHub chat request");
218
227
  if (httpErr) return httpErr;
219
228
 
220
- // Drain the SSE stream. We only need the terminal `complete` (or
221
- // `error`) frame; everything else is observability and ignored.
222
- let finalConversation: any = null;
223
- let recordsUsed: number | undefined;
224
- let lastAccumulated: string | null = null;
225
- let errorMessage: string | null = null;
226
-
229
+ // Drain the AG-UI stream. `applyAGUIFrame` returns true only on a
230
+ // genuinely terminal frame a sub-agent's RUN_FINISHED carries no
231
+ // `result` and must not end the loop.
232
+ const state = newChatStreamState();
227
233
  try {
228
234
  for await (const frame of iterateSSE(response)) {
229
- switch (frame.event) {
230
- case "complete": {
231
- const d = frame.data ?? {};
232
- finalConversation = d.conversation ?? null;
233
- recordsUsed = d.recordsUsed ?? d.meta?.recordsUsed;
234
- break;
235
- }
236
- case "error": {
237
- const d = frame.data ?? {};
238
- errorMessage = typeof d === "string"
239
- ? d
240
- : (d.error ?? d.message ?? frame.raw ?? "Stream error");
241
- break;
242
- }
243
- case "answer_chunk": {
244
- const d = frame.data ?? {};
245
- if (typeof d.accumulated === "string") {
246
- lastAccumulated = d.accumulated;
247
- } else if (typeof d.content === "string") {
248
- lastAccumulated = (lastAccumulated ?? "") + d.content;
249
- }
250
- break;
251
- }
252
- // status / tool_call / tool_success / connected — ignored.
253
- default:
254
- break;
255
- }
256
- if (finalConversation || errorMessage) break;
235
+ if (applyAGUIFrame(state, frame)) break;
257
236
  }
258
237
  } catch (e: unknown) {
259
238
  return errorResult(`SSE stream failed: ${(e as Error).message}`);
260
239
  }
261
240
 
262
- if (errorMessage) return errorResult(errorMessage);
241
+ if (state.error) return errorResult(state.error);
263
242
 
264
- if (finalConversation) {
243
+ if (state.conversation) {
265
244
  return jsonResult({
266
- ...trimConversation(finalConversation),
267
- recordsUsed,
245
+ ...trimConversation(state.conversation),
246
+ recordsUsed: state.recordsUsed,
268
247
  });
269
248
  }
270
249
 
271
- // Stream ended without a terminal frame — unusual, but salvage what
272
- // we accumulated so the LLM has something to work with.
273
- if (lastAccumulated) {
250
+ // Stream ended without a terminal frame — unusual, but salvage what we
251
+ // accumulated so the LLM has something to work with. `conversation_created`
252
+ // arrives before any answer text, so a follow-up turn can still resume.
253
+ const salvaged = salvagedText(state);
254
+ if (salvaged) {
274
255
  return jsonResult({
275
- conversationId: null,
276
- title: null,
256
+ conversationId: state.conversationId,
257
+ title: state.title,
277
258
  status: "Inprogress",
278
- answer: lastAccumulated,
259
+ answer: salvaged,
279
260
  confidence: null,
280
261
  citations: [],
281
262
  followUpQuestions: [],
282
263
  messageCount: 0,
283
- recordsUsed,
284
- warning: "Stream ended without a `complete` frame; answer is "
285
- + "the last accumulated chunk and citations are unavailable.",
264
+ recordsUsed: state.recordsUsed,
265
+ warning: "Stream ended without a terminal RUN_FINISHED; answer is the "
266
+ + "accumulated TEXT_MESSAGE_CONTENT and citations are unavailable.",
286
267
  });
287
268
  }
288
269
 
@@ -1,46 +1,184 @@
1
1
  import * as z from "zod";
2
2
  import { connectorGetRecordContent } from "../../funcs/connectorGetRecordContent.js";
3
+ import { connectorLookupRecordByIdentifier } from "../../funcs/connectorLookupRecordByIdentifier.js";
4
+ import { connectorNavigateKnowledgeGraph } from "../../funcs/connectorNavigateKnowledgeGraph.js";
3
5
  import { ToolDefinition } from "../tools.js";
4
- import { errorResult, httpErrorResult, readJson } from "./_helpers.js";
6
+ import {
7
+ errorResult,
8
+ httpErrorResult,
9
+ jsonResult,
10
+ readJson,
11
+ } from "./_helpers.js";
12
+
13
+ /**
14
+ * Navigate and lookup return a server-rendered `text` view alongside the
15
+ * structured fields. It ends in a `Next:` line naming the exact follow-up call,
16
+ * so hand the LLM that string; fall back to JSON if the field is missing.
17
+ */
18
+ export async function renderedResult(response: Response) {
19
+ const parsed = await readJson<{ text?: string }>(response);
20
+ if (!parsed.ok) return parsed.result;
21
+ const text = parsed.value?.text;
22
+ return typeof text === "string" && text.trim().length > 0
23
+ ? { content: [{ type: "text" as const, text }] }
24
+ : jsonResult(parsed.value);
25
+ }
5
26
 
6
27
  const args = {
7
- recordId: z.string().min(1).describe(
28
+ mode: z.enum(["content", "navigate", "lookup"]).default("content").describe(
29
+ "`content` (default) reads a record's full text by `recordId`. "
30
+ + "`lookup` resolves a URL / issue key / external ID to a recordId. "
31
+ + "`navigate` browses the knowledge graph tree.",
32
+ ),
33
+
34
+ // mode: "content"
35
+ recordId: z.string().min(1).optional().describe(
8
36
  "Record identifier — usually a UUID for connector-sourced records or "
9
37
  + "a 24-character ObjectId for uploaded records. Get it from a chat "
10
- + "citation (`citations[*].recordId`) or from a `pipeshub_search` hit.",
38
+ + "citation (`citations[*].recordId`) or from a `pipeshub_search` hit. "
39
+ + "Required when `mode` is `content`.",
40
+ ),
41
+
42
+ // mode: "navigate"
43
+ nodeId: z.string().min(1).max(2048).optional().describe(
44
+ "The node to open. Take it from a `record_id=` or `node_id=` shown in a "
45
+ + "previous navigate or lookup response, from a search hit's `recordId`, "
46
+ + "or from a `pipeshub_sources` id — a KB or connector id opens that "
47
+ + "source directly. Omit it entirely for the flat listing of everything "
48
+ + "reachable, newest first — the usual starting point. A URL or an issue "
49
+ + "key such as `PA-1787` also works: it is resolved to its record "
50
+ + "automatically, so no separate lookup is needed.",
51
+ ),
52
+ page: z.int().min(1).default(1).describe("Page number, 1-indexed."),
53
+ limit: z.int().min(50).max(200).default(50).describe(
54
+ "Children per page. The minimum is 50 — smaller values are rejected "
55
+ + "rather than silently raised.",
56
+ ),
57
+ depth: z.int().min(1).max(3).default(1).describe(
58
+ "Levels of descendants to return in one call. Above 1, the listing is a "
59
+ + "flat list of all descendants down to that level rather than only "
60
+ + "direct children, and each row carries its own `level`.",
61
+ ),
62
+ nodeTypes: z.array(z.string()).optional().describe(
63
+ "Restrict children to these node types, e.g. `[\"record\", \"folder\"]`.",
64
+ ),
65
+ createdAfter: z.string().optional().describe(
66
+ "Filter children by source creation time. ISO 8601 `YYYY-MM-DD`, or a "
67
+ + "full datetime that MUST carry a timezone offset — a naive datetime is "
68
+ + "rejected rather than assumed to be UTC.",
69
+ ),
70
+ createdBefore: z.string().optional().describe(
71
+ "Filter children by source creation time. `YYYY-MM-DD` is inclusive of "
72
+ + "the whole day.",
73
+ ),
74
+ modifiedAfter: z.string().optional().describe(
75
+ "Filter children by source modification time. Same formats as "
76
+ + "`createdAfter`.",
77
+ ),
78
+ modifiedBefore: z.string().optional().describe(
79
+ "Filter children by source modification time. Same formats as "
80
+ + "`createdBefore`.",
81
+ ),
82
+
83
+ // mode: "lookup"
84
+ identifiers: z.union([
85
+ z.string().min(1).max(2048),
86
+ z.array(z.string().min(1).max(2048)).min(1).max(10),
87
+ ]).optional().describe(
88
+ "The reference(s) to resolve: a URL, an issue key such as `PA-1787`, or "
89
+ + "a bare external system ID. Paste each exactly as you found it — "
90
+ + "tracking parameters and fragments are handled. Pass a single string, "
91
+ + "or an array of up to 10 to resolve them in one call. Required when "
92
+ + "`mode` is `lookup`.",
93
+ ),
94
+ connectorName: z.string().optional().describe(
95
+ "Optional hint that prioritises resolution order, e.g. `JIRA`, "
96
+ + "`CONFLUENCE`, `GOOGLE_DRIVE`, `SLACK`. It cannot widen the search beyond the "
97
+ + "connectors you can already access. Useful on a retry when a lookup "
98
+ + "came back empty.",
11
99
  ),
12
100
  };
13
101
 
14
102
  export const tool$pipeshubGetRecordContent: ToolDefinition<typeof args> = {
15
103
  name: "pipeshub_get_record_content",
16
104
  description:
17
- `Read a record's full parsed content by \`recordId\` the only way to
18
- see a document's COMPLETE text.
19
-
20
- Use it whenever the answer depends on a document's full content — any
21
- task where missing a part could make the answer wrong: summarize / key
22
- points / action items; extract or list ALL of something; check whether
23
- or where the doc mentions X; translate, rewrite, outline, or review the
24
- doc; compare named docs (fetch each); any question scoped to one named
25
- document. \`pipeshub_chat\` cannot do these it only sees a few
26
- retrieved passages, never the whole document. Get the \`recordId\` from
27
- a \`pipeshub_search\` top hit or a chat citation.
28
-
29
- Judge by the user's INTENT, not their keywords: they need not say
30
- "summarize", "key points", or "extract". Reason about what a good
31
- answer requires if it would need the whole document (e.g. "what's
32
- this doc about?", "walk me through the report", "anything in here
33
- about Y?"), that is a full-content task, so call this tool.
34
-
35
- Returns a single \`content\` string: a short metadata header (title,
36
- source, key fields, and a pre-generated summary) followed by the
37
- record's full parsed text paragraphs, tables, and lists in reading
38
- order. For a record with no extractable content, \`content\` is the
39
- literal \`No record found\`. Use \`pipeshub_download_record\` only when
40
- you need the original file bytes.`,
105
+ `Three operations on the org's records. Pick by what you hold:
106
+
107
+ \`mode:"lookup"\` — a URL, issue key (PA-1787), or external ID
108
+ its recordId plus the record's metadata
109
+ \`mode:"navigate"\` a question about structure: what is under X,
110
+ what links to Y browses the hierarchy
111
+ \`mode:"content"\` — a recordId, and you need the document's COMPLETE text
112
+
113
+ **\`mode:"content"\` (default)**the only way to see a document's complete
114
+ text. Use it whenever missing part of the document could make the answer
115
+ wrong: summarize, extract or list ALL of something, check whether or where
116
+ a doc mentions X, review, or compare named docs. \`pipeshub_chat\` cannot do
117
+ these it never sees a whole document.
118
+
119
+ Judge by the user's INTENT, not their keywords: "what's this doc about?",
120
+ "walk me through the report", "anything in here about Y?" are all
121
+ full-content tasks. Get the \`recordId\` from a \`pipeshub_search\` top hit, a
122
+ chat citation, or \`mode:"lookup"\`.
123
+
124
+ Returns one \`content\` string: a metadata header (title, source, key fields,
125
+ pre-generated summary) then the full parsed text. A record with no
126
+ extractable content returns the literal \`No record found\`. Use
127
+ \`pipeshub_download_record\` only for the original file bytes.
128
+
129
+ **\`mode:"navigate"\`** — browse the hierarchy: RecordGroup (project /
130
+ space / drive / folder) → Record (epic / story / page / file) → children,
131
+ with breadcrumbs, related links and record IDs.
132
+
133
+ Use it when the question depends on structure rather than wording: what is
134
+ under this epic, which pages sit in this space, what is linked to this
135
+ ticket, what is in this folder — and every "how many" / "all of" / "every"
136
+ question. Search ranks by content; only this shows how records relate, and
137
+ only this gives a count you can trust.
138
+
139
+ Omit \`nodeId\` for a flat listing of everything reachable, most recently
140
+ updated first — the usual starting point. A URL, an issue key, or a
141
+ \`pipeshub_sources\` id also works and resolves automatically.
142
+
143
+ Pass \`depth:2\` or \`depth:3\` to see several levels in ONE call — an epic's
144
+ stories AND their subtasks, a space's pages AND their children — instead of
145
+ one call per level. Use it whenever the question needs an overview of a
146
+ hierarchy rather than a single node.
147
+
148
+ Opening a record also prints that record's own metadata — for a ticket,
149
+ status, assignee, priority and dates — so a question about one record is
150
+ often answered by this call alone. It returns no document text; for that,
151
+ re-call with \`mode:"content"\`.
152
+
153
+ Returns \`Path\` breadcrumbs, the current node's metadata, a children listing
154
+ carrying \`record_id=\` or \`node_id=\` per row plus the group's total
155
+ (\`Children 1-50 of 61\`), \`Related\` cross-references, and a \`Next:\` line.
156
+ One page is usually every child, so only pass \`page:2\` when that \`Next:\`
157
+ line says more exist.
158
+
159
+ **\`mode:"lookup"\`** — turn an external reference into a recordId, the first
160
+ step whenever the question names one. Returns that record's metadata (for a
161
+ ticket: status, assignee, priority, dates) plus its recordId, which
162
+ \`mode:"navigate"\` takes to list what is under it and \`mode:"content"\` takes
163
+ to read it.
164
+
165
+ Handles Jira keys and URLs, Confluence, Drive, Slack permalinks, Linear,
166
+ Notion, ServiceNow sys_id, SharePoint, Gmail/Outlook, and any connector
167
+ whose records index a web URL. Resolution searches ALL connectors you can
168
+ access, regardless of any source filter you used elsewhere.
169
+
170
+ A miss is a 200 with empty \`matches\` and the input echoed in
171
+ \`not_found_identifiers\` — that may mean no-access, not non-existence. Use
172
+ \`mode:"navigate"\` to confirm the record exists before telling the user it
173
+ does not. If \`ambiguous\` is true, pick from \`matches\` rather than taking
174
+ the first.
175
+
176
+ Navigate and lookup return a rendered text view whose closing \`Next:\` line
177
+ names the exact follow-up call — follow it. When presenting a record, link
178
+ it using the \`Web URL\` from its metadata header (when present).`,
41
179
  scopes: ["read"],
42
180
  annotations: {
43
- title: "Get a record's full parsed content",
181
+ title: "Read, browse, or resolve records",
44
182
  destructiveHint: false,
45
183
  idempotentHint: true,
46
184
  openWorldHint: false,
@@ -48,9 +186,55 @@ you need the original file bytes.`,
48
186
  },
49
187
  args,
50
188
  tool: async (client, args, ctx) => {
189
+ const fetchOptions = { fetchOptions: { signal: ctx.signal } };
190
+
191
+ // Navigate / lookup return the rendered `text` view rather than the
192
+ // `{ content: <string> }` envelope that content mode unwraps.
193
+ if (args.mode === "navigate") {
194
+ const [r] = await connectorNavigateKnowledgeGraph(client, {
195
+ nodeId: args.nodeId,
196
+ page: args.page,
197
+ limit: args.limit,
198
+ depth: args.depth,
199
+ nodeTypes: args.nodeTypes,
200
+ createdAfter: args.createdAfter,
201
+ createdBefore: args.createdBefore,
202
+ modifiedAfter: args.modifiedAfter,
203
+ modifiedBefore: args.modifiedBefore,
204
+ }, fetchOptions).$inspect();
205
+ if (!r.ok) return errorResult(r.error.message);
206
+
207
+ const httpErr = await httpErrorResult(r.value, "Navigate knowledge graph");
208
+ if (httpErr) return httpErr;
209
+
210
+ return renderedResult(r.value);
211
+ }
212
+
213
+ if (args.mode === "lookup") {
214
+ const identifiers = typeof args.identifiers === "string"
215
+ ? [args.identifiers]
216
+ : args.identifiers ?? [];
217
+ if (identifiers.length === 0) {
218
+ return errorResult('`identifiers` is required when mode is "lookup".');
219
+ }
220
+ const [r] = await connectorLookupRecordByIdentifier(client, {
221
+ identifiers,
222
+ connectorName: args.connectorName,
223
+ }, fetchOptions).$inspect();
224
+ if (!r.ok) return errorResult(r.error.message);
225
+
226
+ const httpErr = await httpErrorResult(r.value, "Record lookup");
227
+ if (httpErr) return httpErr;
228
+
229
+ return renderedResult(r.value);
230
+ }
231
+
232
+ if (!args.recordId) {
233
+ return errorResult('`recordId` is required when mode is "content".');
234
+ }
51
235
  const [result] = await connectorGetRecordContent(client, {
52
236
  recordId: args.recordId,
53
- }, { fetchOptions: { signal: ctx.signal } }).$inspect();
237
+ }, fetchOptions).$inspect();
54
238
  if (!result.ok) return errorResult(result.error.message);
55
239
 
56
240
  // The SDK func uses errorCodes:[], so any non-2xx comes back as an
@@ -38,10 +38,23 @@ Typical uses:
38
38
  - Show the user a ranked list of matching files when they ask "find /
39
39
  search for X".
40
40
 
41
+ Not for structural questions — what is under this epic, which pages are
42
+ in this space, what links to this ticket. Ranking by content cannot show
43
+ how records relate; use \`pipeshub_get_record_content\` \`mode:"navigate"\`.
44
+
45
+ **A ranked sample, never a complete list.** Hits are the top-scoring
46
+ blocks from the best-matching records — not all blocks of any record, and
47
+ not every record that matches. Never count them to answer "how many" /
48
+ "all" / "every"; navigate the record group instead, which reports its
49
+ real total.
50
+
41
51
  The response is trimmed to one row per hit:
42
52
  \`{ recordId, recordName, score, snippet, mimeType, webUrl, ... }\`.
43
53
  Highest \`score\` first; multiple hits may share the same \`recordId\`
44
- (different chunks of the same record).`,
54
+ (different blocks of the same record).
55
+
56
+ When presenting results to the user, link each record using its
57
+ \`webUrl\` (when present).`,
45
58
  scopes: ["read"],
46
59
  annotations: {
47
60
  title: "Semantic search",
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Adapted from Speakeasy-generated lookuprecordbyidentifierop.
3
+ *
4
+ * Request only. The generated file also declared a `LookupRecordByIdentifierResponse`
5
+ * union, which pulls in further response models; the func returns a raw `Response`
6
+ * (errorCodes: []) and the tool parses it with `readJson`, so none of that is used
7
+ * here.
8
+ */
9
+
10
+ import * as z from "zod";
11
+
12
+ export type LookupRecordByIdentifierRequest = {
13
+ identifiers: Array<string>;
14
+ connectorName?: string | undefined;
15
+ };
16
+
17
+ export const LookupRecordByIdentifierRequest$zodSchema: z.ZodType<
18
+ LookupRecordByIdentifierRequest
19
+ > = z.object({
20
+ connectorName: z.string().describe(
21
+ "Connector hint to prioritise resolution order, e.g. `JIRA`, `CONFLUENCE`, `GOOGLE_DRIVE`, `SLACK`. Cannot widen beyond the connectors the caller can access.",
22
+ ).optional(),
23
+ identifiers: z.array(z.string().min(1).max(2048)).min(1).max(10).describe(
24
+ "The references to resolve: URLs, issue keys such as `PA-1787`, or bare external system IDs. Paste each exactly as you found it — tracking parameters and fragments are handled. Up to 10 per call.",
25
+ ),
26
+ });
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Adapted from Speakeasy-generated navigateknowledgegraphop.
3
+ *
4
+ * Request only. The generated file also declared a `NavigateKnowledgeGraphResponse`
5
+ * union, which pulls in ~10 further response models; the func returns a raw
6
+ * `Response` (errorCodes: []) and the tool parses it with `readJson`, so none of
7
+ * that is used here.
8
+ */
9
+
10
+ import * as z from "zod";
11
+
12
+ export type NavigateKnowledgeGraphRequest = {
13
+ nodeId?: string | undefined;
14
+ page?: number | undefined;
15
+ limit?: number | undefined;
16
+ depth?: number | undefined;
17
+ nodeTypes?: Array<string> | undefined;
18
+ createdAfter?: string | undefined;
19
+ createdBefore?: string | undefined;
20
+ modifiedAfter?: string | undefined;
21
+ modifiedBefore?: string | undefined;
22
+ };
23
+
24
+ // Bounds come from the OpenAPI spec rather than the generated schema, which
25
+ // dropped them: the endpoint 400s on `limit` outside 50-200, `depth` outside
26
+ // 1-3, `page` below 1, or an empty `nodeId`. Rejecting client-side turns each
27
+ // of those into a validation message instead of a round-trip.
28
+ export const NavigateKnowledgeGraphRequest$zodSchema: z.ZodType<
29
+ NavigateKnowledgeGraphRequest
30
+ > = z.object({
31
+ createdAfter: z.string().describe(
32
+ "Filter children by source creation time. ISO 8601 `YYYY-MM-DD`, or a full datetime that MUST carry a timezone offset — a naive datetime is rejected rather than assumed to be UTC.",
33
+ ).optional(),
34
+ createdBefore: z.string().describe(
35
+ "Filter children by source creation time. `YYYY-MM-DD` is inclusive of the whole day.",
36
+ ).optional(),
37
+ depth: z.int().min(1).max(3).describe(
38
+ "Levels of descendants to return in one call. Above 1, `rows` is a flat list of all descendants down to that level rather than only direct children, and each row carries its own `level`.",
39
+ ).optional(),
40
+ limit: z.int().min(50).max(200).describe(
41
+ "Children per page. The minimum is 50 — smaller values are rejected rather than silently raised.",
42
+ ).optional(),
43
+ modifiedAfter: z.string().describe(
44
+ "Filter children by source modification time.",
45
+ ).optional(),
46
+ modifiedBefore: z.string().describe(
47
+ "Filter children by source modification time.",
48
+ ).optional(),
49
+ nodeId: z.string().min(1).max(2048).describe(
50
+ "The node to open. Take it from an `id` in a previous navigate or lookup response. Omit it entirely for the flat listing of everything reachable — the usual starting point. A URL or an issue key such as `PA-1787` also works: it is resolved to its record automatically.",
51
+ ).optional(),
52
+ nodeTypes: z.array(z.string()).describe(
53
+ "Restrict children to these node types. Repeat the parameter for multiple types: `?nodeTypes=record&nodeTypes=folder`.",
54
+ ).optional(),
55
+ page: z.int().min(1).describe("Page number, 1-indexed.").optional(),
56
+ });