@pipeshub-ai/mcp 2.1.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 (146) hide show
  1. package/README.md +92 -2
  2. package/bin/mcp-server.js +976 -169
  3. package/bin/mcp-server.js.map +28 -15
  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/connectorGetRecordContent.d.ts +43 -0
  27. package/esm/funcs/connectorGetRecordContent.d.ts.map +1 -0
  28. package/esm/funcs/connectorGetRecordContent.js +114 -0
  29. package/esm/funcs/connectorGetRecordContent.js.map +1 -0
  30. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts +68 -0
  31. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts.map +1 -0
  32. package/esm/funcs/connectorLookupRecordByIdentifier.js +138 -0
  33. package/esm/funcs/connectorLookupRecordByIdentifier.js.map +1 -0
  34. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts +75 -0
  35. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts.map +1 -0
  36. package/esm/funcs/connectorNavigateKnowledgeGraph.js +152 -0
  37. package/esm/funcs/connectorNavigateKnowledgeGraph.js.map +1 -0
  38. package/esm/funcs/conversationsStreamConversation.js +1 -1
  39. package/esm/funcs/conversationsStreamConversation.js.map +1 -1
  40. package/esm/funcs/userGroupsGetAllUserGroups.js +1 -1
  41. package/esm/funcs/userGroupsGetAllUserGroups.js.map +1 -1
  42. package/esm/hooks/registration.d.ts.map +1 -1
  43. package/esm/hooks/registration.js +2 -1
  44. package/esm/hooks/registration.js.map +1 -1
  45. package/esm/hooks/request-context.d.ts +10 -0
  46. package/esm/hooks/request-context.d.ts.map +1 -0
  47. package/esm/hooks/request-context.js +38 -0
  48. package/esm/hooks/request-context.js.map +1 -0
  49. package/esm/hooks/requestid.d.ts +5 -0
  50. package/esm/hooks/requestid.d.ts.map +1 -0
  51. package/esm/hooks/requestid.js +58 -0
  52. package/esm/hooks/requestid.js.map +1 -0
  53. package/esm/lib/base64.d.ts +1 -1
  54. package/esm/lib/base64.d.ts.map +1 -1
  55. package/esm/lib/base64.js +6 -3
  56. package/esm/lib/base64.js.map +1 -1
  57. package/esm/lib/bytes-to-base64.d.ts +16 -0
  58. package/esm/lib/bytes-to-base64.d.ts.map +1 -0
  59. package/esm/lib/bytes-to-base64.js +23 -0
  60. package/esm/lib/bytes-to-base64.js.map +1 -0
  61. package/esm/mcp-server/instructions.d.ts +1 -1
  62. package/esm/mcp-server/instructions.d.ts.map +1 -1
  63. package/esm/mcp-server/instructions.js +62 -9
  64. package/esm/mcp-server/instructions.js.map +1 -1
  65. package/esm/mcp-server/server.d.ts.map +1 -1
  66. package/esm/mcp-server/server.js +10 -3
  67. package/esm/mcp-server/server.js.map +1 -1
  68. package/esm/mcp-server/shared.d.ts +2 -2
  69. package/esm/mcp-server/shared.d.ts.map +1 -1
  70. package/esm/mcp-server/shared.js +7 -3
  71. package/esm/mcp-server/shared.js.map +1 -1
  72. package/esm/mcp-server/tools/_agui.d.ts +34 -0
  73. package/esm/mcp-server/tools/_agui.d.ts.map +1 -0
  74. package/esm/mcp-server/tools/_agui.js +91 -0
  75. package/esm/mcp-server/tools/_agui.js.map +1 -0
  76. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  77. package/esm/mcp-server/tools/_helpers.js +13 -6
  78. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  79. package/esm/mcp-server/tools/pipeshubChat.d.ts +0 -3
  80. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  81. package/esm/mcp-server/tools/pipeshubChat.js +75 -92
  82. package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
  83. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +125 -0
  84. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -0
  85. package/esm/mcp-server/tools/pipeshubGetRecordContent.js +214 -0
  86. package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -0
  87. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  88. package/esm/mcp-server/tools/pipeshubSearch.js +22 -5
  89. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  90. package/esm/mcp-server/tools.d.ts.map +1 -1
  91. package/esm/mcp-server/tools.js +13 -1
  92. package/esm/mcp-server/tools.js.map +1 -1
  93. package/esm/models/availablemodelsresponse.d.ts +1 -1
  94. package/esm/models/conversation.d.ts +1 -1
  95. package/esm/models/getrecordcontentop.d.ts +6 -0
  96. package/esm/models/getrecordcontentop.d.ts.map +1 -0
  97. package/esm/models/getrecordcontentop.js +5 -0
  98. package/esm/models/getrecordcontentop.js.map +1 -0
  99. package/esm/models/lookuprecordbyidentifierop.d.ts +7 -0
  100. package/esm/models/lookuprecordbyidentifierop.d.ts.map +1 -0
  101. package/esm/models/lookuprecordbyidentifierop.js +14 -0
  102. package/esm/models/lookuprecordbyidentifierop.js.map +1 -0
  103. package/esm/models/navigateknowledgegraphop.d.ts +14 -0
  104. package/esm/models/navigateknowledgegraphop.d.ts.map +1 -0
  105. package/esm/models/navigateknowledgegraphop.js +25 -0
  106. package/esm/models/navigateknowledgegraphop.js.map +1 -0
  107. package/esm/models/userteamsresponse.d.ts +1 -1
  108. package/esm/tool-names.d.ts.map +1 -1
  109. package/esm/tool-names.js +6 -2
  110. package/esm/tool-names.js.map +1 -1
  111. package/package.json +4 -2
  112. package/qm/README.md +247 -0
  113. package/qm/SECURITY.md +134 -0
  114. package/qm/TROUBLESHOOTING.md +218 -0
  115. package/qm/qm.config.fragment.jsonc +42 -0
  116. package/qm/sandbox/Dockerfile +22 -0
  117. package/qm/sandbox/skills/pipeshub/SKILL.md +69 -0
  118. package/qm/sandbox/tools/pipeshub/tool.json +36 -0
  119. package/src/cli/client.ts +272 -0
  120. package/src/cli/commands.ts +403 -0
  121. package/src/cli/config.ts +229 -0
  122. package/src/cli/init-qm.ts +215 -0
  123. package/src/cli/pipeshub.ts +333 -0
  124. package/src/funcs/connectorGetRecordContent.ts +179 -0
  125. package/src/funcs/connectorLookupRecordByIdentifier.ts +200 -0
  126. package/src/funcs/connectorNavigateKnowledgeGraph.ts +215 -0
  127. package/src/funcs/conversationsStreamConversation.ts +1 -1
  128. package/src/funcs/userGroupsGetAllUserGroups.ts +1 -1
  129. package/src/hooks/registration.ts +2 -1
  130. package/src/hooks/request-context.ts +47 -0
  131. package/src/hooks/requestid.ts +62 -0
  132. package/src/lib/base64.ts +6 -3
  133. package/src/lib/bytes-to-base64.ts +24 -0
  134. package/src/mcp-server/instructions.ts +62 -9
  135. package/src/mcp-server/server.ts +11 -3
  136. package/src/mcp-server/shared.ts +7 -3
  137. package/src/mcp-server/tools/_agui.ts +138 -0
  138. package/src/mcp-server/tools/_helpers.ts +13 -6
  139. package/src/mcp-server/tools/pipeshubChat.ts +78 -91
  140. package/src/mcp-server/tools/pipeshubGetRecordContent.ts +251 -0
  141. package/src/mcp-server/tools/pipeshubSearch.ts +22 -5
  142. package/src/mcp-server/tools.ts +14 -1
  143. package/src/models/getrecordcontentop.ts +11 -0
  144. package/src/models/lookuprecordbyidentifierop.ts +26 -0
  145. package/src/models/navigateknowledgegraphop.ts +56 -0
  146. package/src/tool-names.ts +6 -2
@@ -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,42 +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.
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, ...).
103
112
 
104
- **Web search** (\`chatMode: "web_search"\`): Use when the user asks about
105
- current events, public information, or anything unlikely to be in the org's
106
- internal knowledge base. Pass \`chatMode: "web_search"\` and this tool will
107
- 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.
108
119
 
109
- **When to pick this over other tools:**
110
- - "What does <document> say about X?" → \`pipeshub_chat\` (internal_search)
111
- - "Summarize <topic / doc>." → \`pipeshub_chat\` (internal_search)
112
120
  - "What's our policy on Y?" → \`pipeshub_chat\` (internal_search)
113
121
  - "What's in the news about Z?" → \`pipeshub_chat\` (web_search)
114
- - "What is the latest version of <library>?" → \`pipeshub_chat\` (web_search)
115
122
  - "Find / locate the file named X" → \`pipeshub_search\` (then
116
123
  \`pipeshub_download_record\` if the user wants the bytes).
117
124
 
@@ -141,11 +148,19 @@ cited document, take \`citations[*].recordId\` and call
141
148
  args,
142
149
  tool: async (client, args, ctx) => {
143
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";
144
157
  let response: Response;
145
158
 
146
159
  if (args.agentId) {
147
- // Agent conversation. Agent chatMode vocabulary defaults to `auto`.
148
- 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;
149
164
  if (args.conversationId) {
150
165
  // Continue an existing agent conversation.
151
166
  const [result] = await agentsStreamMessage(client, {
@@ -186,7 +201,7 @@ cited document, take \`citations[*].recordId\` and call
186
201
  modelKey: args.modelKey,
187
202
  modelName: args.modelName,
188
203
  modelFriendlyName: args.modelFriendlyName,
189
- chatMode: args.chatMode ?? "internal_search",
204
+ chatMode: plainChatMode,
190
205
  },
191
206
  }, { fetchOptions }).$inspect();
192
207
  if (!result.ok) return errorResult(result.error.message);
@@ -199,7 +214,7 @@ cited document, take \`citations[*].recordId\` and call
199
214
  modelKey: args.modelKey,
200
215
  modelName: args.modelName,
201
216
  modelFriendlyName: args.modelFriendlyName,
202
- chatMode: args.chatMode ?? "internal_search",
217
+ chatMode: plainChatMode,
203
218
  }, { fetchOptions }).$inspect();
204
219
  if (!result.ok) return errorResult(result.error.message);
205
220
  response = result.value;
@@ -211,72 +226,44 @@ cited document, take \`citations[*].recordId\` and call
211
226
  const httpErr = await httpErrorResult(response, "PipesHub chat request");
212
227
  if (httpErr) return httpErr;
213
228
 
214
- // Drain the SSE stream. We only need the terminal `complete` (or
215
- // `error`) frame; everything else is observability and ignored.
216
- let finalConversation: any = null;
217
- let recordsUsed: number | undefined;
218
- let lastAccumulated: string | null = null;
219
- let errorMessage: string | null = null;
220
-
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();
221
233
  try {
222
234
  for await (const frame of iterateSSE(response)) {
223
- switch (frame.event) {
224
- case "complete": {
225
- const d = frame.data ?? {};
226
- finalConversation = d.conversation ?? null;
227
- recordsUsed = d.recordsUsed ?? d.meta?.recordsUsed;
228
- break;
229
- }
230
- case "error": {
231
- const d = frame.data ?? {};
232
- errorMessage = typeof d === "string"
233
- ? d
234
- : (d.error ?? d.message ?? frame.raw ?? "Stream error");
235
- break;
236
- }
237
- case "answer_chunk": {
238
- const d = frame.data ?? {};
239
- if (typeof d.accumulated === "string") {
240
- lastAccumulated = d.accumulated;
241
- } else if (typeof d.content === "string") {
242
- lastAccumulated = (lastAccumulated ?? "") + d.content;
243
- }
244
- break;
245
- }
246
- // status / tool_call / tool_success / connected — ignored.
247
- default:
248
- break;
249
- }
250
- if (finalConversation || errorMessage) break;
235
+ if (applyAGUIFrame(state, frame)) break;
251
236
  }
252
237
  } catch (e: unknown) {
253
238
  return errorResult(`SSE stream failed: ${(e as Error).message}`);
254
239
  }
255
240
 
256
- if (errorMessage) return errorResult(errorMessage);
241
+ if (state.error) return errorResult(state.error);
257
242
 
258
- if (finalConversation) {
243
+ if (state.conversation) {
259
244
  return jsonResult({
260
- ...trimConversation(finalConversation),
261
- recordsUsed,
245
+ ...trimConversation(state.conversation),
246
+ recordsUsed: state.recordsUsed,
262
247
  });
263
248
  }
264
249
 
265
- // Stream ended without a terminal frame — unusual, but salvage what
266
- // we accumulated so the LLM has something to work with.
267
- 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) {
268
255
  return jsonResult({
269
- conversationId: null,
270
- title: null,
256
+ conversationId: state.conversationId,
257
+ title: state.title,
271
258
  status: "Inprogress",
272
- answer: lastAccumulated,
259
+ answer: salvaged,
273
260
  confidence: null,
274
261
  citations: [],
275
262
  followUpQuestions: [],
276
263
  messageCount: 0,
277
- recordsUsed,
278
- warning: "Stream ended without a `complete` frame; answer is "
279
- + "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.",
280
267
  });
281
268
  }
282
269
 
@@ -0,0 +1,251 @@
1
+ import * as z from "zod";
2
+ import { connectorGetRecordContent } from "../../funcs/connectorGetRecordContent.js";
3
+ import { connectorLookupRecordByIdentifier } from "../../funcs/connectorLookupRecordByIdentifier.js";
4
+ import { connectorNavigateKnowledgeGraph } from "../../funcs/connectorNavigateKnowledgeGraph.js";
5
+ import { ToolDefinition } from "../tools.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
+ }
26
+
27
+ const args = {
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(
36
+ "Record identifier — usually a UUID for connector-sourced records or "
37
+ + "a 24-character ObjectId for uploaded records. Get it from a chat "
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.",
99
+ ),
100
+ };
101
+
102
+ export const tool$pipeshubGetRecordContent: ToolDefinition<typeof args> = {
103
+ name: "pipeshub_get_record_content",
104
+ description:
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).`,
179
+ scopes: ["read"],
180
+ annotations: {
181
+ title: "Read, browse, or resolve records",
182
+ destructiveHint: false,
183
+ idempotentHint: true,
184
+ openWorldHint: false,
185
+ readOnlyHint: true,
186
+ },
187
+ args,
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
+ }
235
+ const [result] = await connectorGetRecordContent(client, {
236
+ recordId: args.recordId,
237
+ }, fetchOptions).$inspect();
238
+ if (!result.ok) return errorResult(result.error.message);
239
+
240
+ // The SDK func uses errorCodes:[], so any non-2xx comes back as an
241
+ // ok=false Response — surface it as an error rather than parsing it.
242
+ const httpErr = await httpErrorResult(result.value, "Get record content");
243
+ if (httpErr) return httpErr;
244
+
245
+ // Success: the endpoint returns { content: <string> }. Hand the LLM the
246
+ // plain text (real newlines), not the JSON wrapper.
247
+ const parsed = await readJson<{ content?: string }>(result.value);
248
+ if (!parsed.ok) return parsed.result;
249
+ return { content: [{ type: "text", text: parsed.value.content ?? "" }] };
250
+ },
251
+ };
@@ -23,21 +23,38 @@ export const tool$pipeshubSearch: ToolDefinition<typeof args> = {
23
23
  description:
24
24
  `Vector / semantic search across the org's indexed documents.
25
25
 
26
- **Use this only when the user wants to LOCATE a document** — by name,
27
- topic, or a phrase to grep for. For "what does the document say about
28
- X?" or any open-ended question, use \`pipeshub_chat\` instead, which
29
- does the retrieval internally and grounds the answer in citations.
26
+ **Use this when the user wants to LOCATE a document** — by name, topic,
27
+ or a phrase to grep for and to resolve it to a \`recordId\`. For
28
+ open-ended questions across many documents, use \`pipeshub_chat\`
29
+ instead, which does the retrieval internally and grounds the answer in
30
+ citations.
30
31
 
31
32
  Typical uses:
33
+ - Resolve a doc name / topic into a \`recordId\` for
34
+ \`pipeshub_get_record_content\` — step 1 of any full-document task
35
+ (summarize, extract, review, "what does the doc say?").
32
36
  - Resolve a filename / phrase into a \`recordId\` for
33
37
  \`pipeshub_download_record\`.
34
38
  - Show the user a ranked list of matching files when they ask "find /
35
39
  search for X".
36
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
+
37
51
  The response is trimmed to one row per hit:
38
52
  \`{ recordId, recordName, score, snippet, mimeType, webUrl, ... }\`.
39
53
  Highest \`score\` first; multiple hits may share the same \`recordId\`
40
- (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).`,
41
58
  scopes: ["read"],
42
59
  annotations: {
43
60
  title: "Semantic search",
@@ -75,9 +75,22 @@ export async function formatResult(
75
75
  content = data == null
76
76
  ? []
77
77
  : [{ type: "audio", data, mimeType: contentType }];
78
- } else {
78
+ } else if (
79
+ contentType.startsWith("text/")
80
+ || contentType.includes("json")
81
+ || contentType.includes("xml")
82
+ || contentType.includes("yaml")
83
+ ) {
79
84
  const text = await response.text();
80
85
  content = [{ type: "text", text }];
86
+ } else {
87
+ const blob = await valueToBase64(await response.arrayBuffer());
88
+ content = blob == null
89
+ ? []
90
+ : [{
91
+ type: "resource",
92
+ resource: { uri: response.url, mimeType: contentType, blob },
93
+ }];
81
94
  }
82
95
 
83
96
  return response.ok ? { content } : { content, isError: true };
@@ -0,0 +1,11 @@
1
+ import * as z from "zod";
2
+
3
+ export type GetRecordContentRequest = { recordId: string };
4
+
5
+ export const GetRecordContentRequest$zodSchema: z.ZodType<
6
+ GetRecordContentRequest
7
+ > = z.object({
8
+ recordId: z.string().describe(
9
+ "Record ID to fetch. Obtain it from a `pipeshub_search` result (`hits[*].recordId`) or a chat citation (`citations[*].recordId`).",
10
+ ),
11
+ });
@@ -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
+ });