@pipeshub-ai/mcp 2.3.3 → 2.4.1

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 (46) hide show
  1. package/README.md +2 -2
  2. package/bin/mcp-server.js +328 -210
  3. package/bin/mcp-server.js.map +12 -12
  4. package/bin/pipeshub.js +16 -8
  5. package/bin/pipeshub.js.map +4 -4
  6. package/esm/cli/commands.d.ts +1 -1
  7. package/esm/cli/commands.d.ts.map +1 -1
  8. package/esm/cli/commands.js +4 -1
  9. package/esm/cli/commands.js.map +1 -1
  10. package/esm/cli/pipeshub.js +13 -6
  11. package/esm/cli/pipeshub.js.map +1 -1
  12. package/esm/mcp-server/instructions.d.ts +1 -1
  13. package/esm/mcp-server/instructions.d.ts.map +1 -1
  14. package/esm/mcp-server/instructions.js +9 -6
  15. package/esm/mcp-server/instructions.js.map +1 -1
  16. package/esm/mcp-server/tools/_helpers.d.ts +76 -0
  17. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  18. package/esm/mcp-server/tools/_helpers.js +151 -0
  19. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  20. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  21. package/esm/mcp-server/tools/pipeshubChat.js +25 -11
  22. package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
  23. package/esm/mcp-server/tools/pipeshubDownloadRecord.d.ts.map +1 -1
  24. package/esm/mcp-server/tools/pipeshubDownloadRecord.js +15 -12
  25. package/esm/mcp-server/tools/pipeshubDownloadRecord.js.map +1 -1
  26. package/esm/mcp-server/tools/pipeshubSearch.d.ts +1 -0
  27. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  28. package/esm/mcp-server/tools/pipeshubSearch.js +22 -13
  29. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  30. package/esm/mcp-server/tools/pipeshubSources.d.ts.map +1 -1
  31. package/esm/mcp-server/tools/pipeshubSources.js +15 -27
  32. package/esm/mcp-server/tools/pipeshubSources.js.map +1 -1
  33. package/esm/tool-names.js +3 -3
  34. package/esm/tool-names.js.map +1 -1
  35. package/package.json +1 -1
  36. package/qm/sandbox/Dockerfile +1 -1
  37. package/qm/sandbox/skills/pipeshub/SKILL.md +1 -0
  38. package/src/cli/commands.ts +3 -0
  39. package/src/cli/pipeshub.ts +12 -6
  40. package/src/mcp-server/instructions.ts +9 -6
  41. package/src/mcp-server/tools/_helpers.ts +233 -0
  42. package/src/mcp-server/tools/pipeshubChat.ts +31 -10
  43. package/src/mcp-server/tools/pipeshubDownloadRecord.ts +15 -12
  44. package/src/mcp-server/tools/pipeshubSearch.ts +38 -13
  45. package/src/mcp-server/tools/pipeshubSources.ts +18 -25
  46. package/src/tool-names.ts +3 -3
@@ -8,6 +8,7 @@ import { Security } from "../../models/security.js";
8
8
  import { PipeshubCore } from "../../core.js";
9
9
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
10
10
  import { agentsListAgents } from "../../funcs/agentsListAgents.js";
11
+ import { knowledgeHubGetKnowledgeHubRootNodes } from "../../funcs/knowledgeHubGetKnowledgeHubRootNodes.js";
11
12
  import {
12
13
  AgentListEnvelope$zodSchema,
13
14
  AgentSummary,
@@ -536,3 +537,235 @@ export function trimSearchHit(hit: any) {
536
537
  pageNum: md.pageNum,
537
538
  };
538
539
  }
540
+
541
+ // ─── Sources and search scoping ──────────────────────────────────────────────
542
+ //
543
+ // Search and chat each take two scoping lists, and they are not interchangeable.
544
+ // `apps` holds connector ids; `kb` holds collection (knowledge base) ids. When
545
+ // `apps` is set and `kb` is empty, the backend drops every collection id from
546
+ // `apps` and searches no collection at all — so a collection id in the wrong
547
+ // list silently returns nothing. Both kinds are UUIDs, so only the source
548
+ // listing can tell them apart.
549
+
550
+ export type SourceKind = "knowledgeBase" | "connector";
551
+
552
+ export interface SourceItem {
553
+ id: string;
554
+ name: string | undefined;
555
+ kind: SourceKind;
556
+ connector: string | undefined;
557
+ hasChildren: boolean | undefined;
558
+ }
559
+
560
+ /** A knowledge-hub root node as a source. Collections carry `connector: "KB"`. */
561
+ export function toSource(n: any): SourceItem {
562
+ return {
563
+ id: n?.id,
564
+ name: n?.name,
565
+ kind: n?.connector === "KB" ? "knowledgeBase" : "connector",
566
+ connector: n?.connector,
567
+ hasChildren: n?.hasChildren,
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Fetch every root source (connectors and collections), paginating
573
+ * `GET /knowledgeBase/knowledge-hub/nodes` the same way `listAllAgents` does.
574
+ * A partial listing would let a misplaced id slip past the scope router, so
575
+ * this does not stop at the first page.
576
+ *
577
+ * `reason` is a short cause for callers that report the failure as a note
578
+ * rather than an error — `result` carries the full message, including an auth
579
+ * hint that would mislead inside a search that otherwise succeeded.
580
+ */
581
+ export async function listAllSources(
582
+ client: PipeshubCore,
583
+ opts: {
584
+ signal?: AbortSignal | undefined;
585
+ maxPages?: number | undefined;
586
+ } = {},
587
+ ): Promise<
588
+ | { ok: true; sources: SourceItem[]; truncated: boolean }
589
+ | { ok: false; result: CallToolResult; reason: string }
590
+ > {
591
+ const PAGE_SIZE = 200;
592
+ const maxPages = opts.maxPages ?? 5;
593
+ // Only set `signal` when present (exactOptionalPropertyTypes).
594
+ const reqOptions = opts.signal
595
+ ? { fetchOptions: { signal: opts.signal } }
596
+ : {};
597
+
598
+ const all: SourceItem[] = [];
599
+
600
+ for (let page = 1; page <= maxPages; page++) {
601
+ const [r] = await knowledgeHubGetKnowledgeHubRootNodes(client, {
602
+ page,
603
+ limit: PAGE_SIZE,
604
+ }, reqOptions).$inspect();
605
+ if (!r.ok) {
606
+ return {
607
+ ok: false,
608
+ result: errorResult(`sources: ${r.error.message}`),
609
+ reason: r.error.message,
610
+ };
611
+ }
612
+
613
+ const status = r.value.status;
614
+ const httpOk = r.value.ok;
615
+ const parsed = await readJson<{
616
+ items?: any[];
617
+ pagination?: { hasNext?: boolean | null } | null;
618
+ }>(r.value, "Knowledge base listing");
619
+ if (!parsed.ok) {
620
+ return {
621
+ ok: false,
622
+ result: parsed.result,
623
+ reason: httpOk ? "unreadable response" : `HTTP ${status}`,
624
+ };
625
+ }
626
+
627
+ const items = parsed.value.items ?? [];
628
+ all.push(...items.map(toSource));
629
+
630
+ const hasNext = parsed.value.pagination?.hasNext ?? (items.length >= PAGE_SIZE);
631
+ if (!hasNext || items.length === 0) {
632
+ return { ok: true, sources: all, truncated: false };
633
+ }
634
+ }
635
+
636
+ // Hit the page cap with more pages still available.
637
+ return { ok: true, sources: all, truncated: true };
638
+ }
639
+
640
+ const dedupe = (ids: readonly string[]): string[] => [...new Set(ids)];
641
+
642
+ export interface SourceScope {
643
+ apps: string[];
644
+ kb: string[];
645
+ /** Collection ids that arrived in `apps` and were moved to `kb`. */
646
+ movedToKb: string[];
647
+ /** Connector ids that arrived in `kb` and were moved to `apps`. */
648
+ movedToApps: string[];
649
+ }
650
+
651
+ /**
652
+ * Put each id in the list the backend reads it from, whichever list the
653
+ * caller used. `kinds` maps a source id to what the listing says it is. An id
654
+ * the listing does not know stays where the caller put it. Order is kept:
655
+ * ids from `apps` first, then ids from `kb`. Duplicates collapse.
656
+ */
657
+ export function routeSourceScope(
658
+ apps: readonly string[] | undefined,
659
+ kb: readonly string[] | undefined,
660
+ kinds: ReadonlyMap<string, SourceKind>,
661
+ ): SourceScope {
662
+ const appIds = dedupe(apps ?? []);
663
+ const kbIds = dedupe(kb ?? []);
664
+ const movedToKb = appIds.filter((id) => kinds.get(id) === "knowledgeBase");
665
+ const movedToApps = kbIds.filter((id) => kinds.get(id) === "connector");
666
+ return {
667
+ apps: dedupe([
668
+ ...appIds.filter((id) => kinds.get(id) !== "knowledgeBase"),
669
+ ...movedToApps,
670
+ ]),
671
+ kb: dedupe([
672
+ ...movedToKb,
673
+ ...kbIds.filter((id) => kinds.get(id) !== "connector"),
674
+ ]),
675
+ movedToKb,
676
+ movedToApps,
677
+ };
678
+ }
679
+
680
+ /** The `filters` body for `POST /search`; omitted when nothing is scoped. */
681
+ export function searchFilters(
682
+ scope: { apps: string[]; kb: string[] },
683
+ ): { apps: string[]; kb: string[] } | undefined {
684
+ if (scope.apps.length === 0 && scope.kb.length === 0) return undefined;
685
+ return { apps: scope.apps, kb: scope.kb };
686
+ }
687
+
688
+ /** Notes telling the model what the router did, or could not do. */
689
+ export function sourceScopeNotes(input: {
690
+ movedToKb: string[];
691
+ movedToApps: string[];
692
+ lookupError?: string | undefined;
693
+ unlisted?: string[] | undefined;
694
+ }): string[] {
695
+ const notes: string[] = [];
696
+ if (input.movedToKb.length > 0) {
697
+ notes.push(
698
+ `Moved ${input.movedToKb.length} collection id(s) from apps to kb: `
699
+ + `${input.movedToKb.join(", ")}. Collection ids belong in kb.`,
700
+ );
701
+ }
702
+ if (input.movedToApps.length > 0) {
703
+ notes.push(
704
+ `Moved ${input.movedToApps.length} connector id(s) from kb to apps: `
705
+ + `${input.movedToApps.join(", ")}. Connector ids belong in apps.`,
706
+ );
707
+ }
708
+ if (input.lookupError) {
709
+ notes.push(
710
+ "Could not check the ids against the source list "
711
+ + `(${input.lookupError}); sent them unchanged. If this finds nothing, `
712
+ + "check that collection ids are in kb and connector ids in apps.",
713
+ );
714
+ }
715
+ if (input.unlisted && input.unlisted.length > 0) {
716
+ notes.push(
717
+ `Source list was incomplete; ${input.unlisted.join(", ")} were not `
718
+ + "found and were sent unchanged.",
719
+ );
720
+ }
721
+ return notes;
722
+ }
723
+
724
+ /**
725
+ * Route `apps` / `kb` for one request. Every id in either list is checked
726
+ * against the source listing and sent in the list the backend reads it from:
727
+ * collection ids in `kb`, connector ids in `apps`. The lookup costs a request,
728
+ * so it runs only when something is scoped. A failed lookup sends the ids
729
+ * unchanged and says so in `notes`.
730
+ */
731
+ export async function resolveSourceScope(
732
+ client: PipeshubCore,
733
+ apps: readonly string[] | undefined,
734
+ kb: readonly string[] | undefined,
735
+ opts: {
736
+ signal?: AbortSignal | undefined;
737
+ maxPages?: number | undefined;
738
+ } = {},
739
+ ): Promise<{ scope: SourceScope; notes: string[] }> {
740
+ const unchecked = routeSourceScope(apps, kb, new Map());
741
+ if (unchecked.apps.length === 0 && unchecked.kb.length === 0) {
742
+ return { scope: unchecked, notes: [] };
743
+ }
744
+
745
+ const listed = await listAllSources(client, opts);
746
+ if (!listed.ok) {
747
+ return {
748
+ scope: unchecked,
749
+ notes: sourceScopeNotes({
750
+ movedToKb: [],
751
+ movedToApps: [],
752
+ lookupError: listed.reason,
753
+ }),
754
+ };
755
+ }
756
+
757
+ const kinds = new Map(listed.sources.map((s) => [s.id, s.kind] as const));
758
+ const scope = routeSourceScope(apps, kb, kinds);
759
+ const unlisted = listed.truncated
760
+ ? [...scope.apps, ...scope.kb].filter((id) => !kinds.has(id))
761
+ : [];
762
+ return {
763
+ scope,
764
+ notes: sourceScopeNotes({
765
+ movedToKb: scope.movedToKb,
766
+ movedToApps: scope.movedToApps,
767
+ unlisted,
768
+ }),
769
+ };
770
+ }
771
+
@@ -25,19 +25,18 @@ import {
25
25
  httpErrorResult,
26
26
  iterateSSE,
27
27
  jsonResult,
28
+ resolveSourceScope,
28
29
  trimConversation,
29
30
  } from "./_helpers.js";
30
31
 
31
32
  const FiltersShape = z.object({
32
33
  apps: z.array(z.string()).optional().describe(
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.",
34
+ "Connector ids to use. Get them from `pipeshub_sources`, where `kind` is "
35
+ + "\"connector\". Collection ids go in `kb`, not here.",
38
36
  ),
39
37
  kb: z.array(z.string()).optional().describe(
40
- "Legacy / unused. Leave empty.",
38
+ "Collection (knowledge base) ids to use. Get them from "
39
+ + "`pipeshub_sources`, where `kind` is \"knowledgeBase\".",
41
40
  ),
42
41
  }).optional();
43
42
 
@@ -52,8 +51,8 @@ const args = {
52
51
  + "prior messages.",
53
52
  ),
54
53
  filters: FiltersShape.describe(
55
- "Source scoping for retrieval. Pass `apps` ids from `pipeshub_sources`. "
56
- + "Only meaningful on the FIRST turn (when starting a new conversation).",
54
+ "Which sources the answer may use. Leave out to use all sources. Only "
55
+ + "works on the FIRST turn; later turns keep the first turn's sources.",
57
56
  ),
58
57
  modelKey: z.string().optional().describe(
59
58
  "Model id to use (from `pipeshub_sources` `llmModels[*].modelKey`). "
@@ -156,6 +155,26 @@ cited document, take \`citations[*].recordId\` and call
156
155
  : "internal_search";
157
156
  let response: Response;
158
157
 
158
+ // Filters apply only when a conversation starts. Each id is sent in the
159
+ // list the backend reads it from (collection ids in `kb`, connector ids in
160
+ // `apps`). Both keys go out: on agent chat a missing key falls back to the
161
+ // agent's own sources. An explicit empty `{ apps: [], kb: [] }` passes
162
+ // through unchanged.
163
+ let filters = args.filters;
164
+ let notes: string[] = [];
165
+ if (!args.conversationId && filters) {
166
+ const resolved = await resolveSourceScope(
167
+ client,
168
+ filters.apps,
169
+ filters.kb,
170
+ { signal: ctx.signal },
171
+ );
172
+ notes = resolved.notes;
173
+ if (resolved.scope.apps.length > 0 || resolved.scope.kb.length > 0) {
174
+ filters = { apps: resolved.scope.apps, kb: resolved.scope.kb };
175
+ }
176
+ }
177
+
159
178
  if (args.agentId) {
160
179
  // `quick` is the only value the agent stream schemas accept, and it is
161
180
  // required — so ignore whatever the caller passed rather than forwarding
@@ -182,7 +201,7 @@ cited document, take \`citations[*].recordId\` and call
182
201
  agentKey: args.agentId,
183
202
  body: {
184
203
  query: args.query,
185
- filters: args.filters,
204
+ filters,
186
205
  modelKey: args.modelKey,
187
206
  modelName: args.modelName,
188
207
  modelFriendlyName: args.modelFriendlyName,
@@ -210,7 +229,7 @@ cited document, take \`citations[*].recordId\` and call
210
229
  // Start a new (non-agent) conversation.
211
230
  const [result] = await conversationsStreamConversation(client, {
212
231
  query: args.query,
213
- filters: args.filters,
232
+ filters,
214
233
  modelKey: args.modelKey,
215
234
  modelName: args.modelName,
216
235
  modelFriendlyName: args.modelFriendlyName,
@@ -244,6 +263,7 @@ cited document, take \`citations[*].recordId\` and call
244
263
  return jsonResult({
245
264
  ...trimConversation(state.conversation),
246
265
  recordsUsed: state.recordsUsed,
266
+ ...(notes.length > 0 ? { notes } : {}),
247
267
  });
248
268
  }
249
269
 
@@ -264,6 +284,7 @@ cited document, take \`citations[*].recordId\` and call
264
284
  recordsUsed: state.recordsUsed,
265
285
  warning: "Stream ended without a terminal RUN_FINISHED; answer is the "
266
286
  + "accumulated TEXT_MESSAGE_CONTENT and citations are unavailable.",
287
+ ...(notes.length > 0 ? { notes } : {}),
267
288
  });
268
289
  }
269
290
 
@@ -10,26 +10,29 @@ const args = {
10
10
  + "citation (`citations[*].recordId`) or from a `pipeshub_search` hit.",
11
11
  ),
12
12
  convertTo: z.string().optional().describe(
13
- "Optional server-side format conversion target (e.g. `pdf`). When "
14
- + "omitted, the original file bytes are returned.",
13
+ "The only conversion target connectors honour is `application/pdf` "
14
+ + "(the MIME type, not `pdf`). A bare `pdf` is ignored and the "
15
+ + "original file is returned with no error. Omit for the file as "
16
+ + "stored. Does not parse the document — use "
17
+ + "`pipeshub_get_record_content` `mode:\"content\"` for that.",
15
18
  ),
16
19
  };
17
20
 
18
21
  export const tool$pipeshubDownloadRecord: ToolDefinition<typeof args> = {
19
22
  name: "pipeshub_download_record",
20
23
  description:
21
- `Stream the binary content of a single record from PipesHub.
24
+ `Download the file as stored for one record not PipesHub's parsed
25
+ content, metadata header, or summary.
22
26
 
23
- Typical sources for the \`recordId\`:
24
- - A chat citation:
25
- \`pipeshub_chat\` response → \`citations[*].recordId\`.
26
- - A search result:
27
- \`pipeshub_search\` response → \`hits[*].recordId\` /
28
- \`uniqueRecords[*].recordId\`.
27
+ Use this when the user wants the file itself (download, attach, open).
28
+ Get \`recordId\` from a chat citation or a \`pipeshub_search\` hit.
29
29
 
30
- Response \`Content-Type\` is forwarded from the upstream service
31
- \`application/pdf\`, \`application/octet-stream\`, etc. Binary content is
32
- returned base64-encoded; text content is returned inline.`,
30
+ Do not use this to read, summarize, or answer "what does this doc
31
+ say?" regardless of format. That is \`pipeshub_get_record_content\`
32
+ \`mode:"content"\`. Text formats come back inline; images, audio, and
33
+ binary as base64.
34
+
35
+ \`convertTo\` accepts only \`application/pdf\`; anything else is ignored.`,
33
36
  scopes: ["read"],
34
37
  annotations: {
35
38
  title: "Download a document by record id",
@@ -2,19 +2,34 @@
2
2
  import * as z from "zod";
3
3
  import { semanticSearchSearch } from "../../funcs/semanticSearchSearch.js";
4
4
  import { ToolDefinition } from "../tools.js";
5
- import { errorResult, jsonResult, readJson, trimSearchHit } from "./_helpers.js";
5
+ import {
6
+ errorResult,
7
+ jsonResult,
8
+ readJson,
9
+ resolveSourceScope,
10
+ searchFilters,
11
+ trimSearchHit,
12
+ } from "./_helpers.js";
13
+
14
+ /** Matches the SDK request default (`src/models/semanticsearchrequest.ts`). */
15
+ const DEFAULT_LIMIT = 10;
6
16
 
7
17
  const args = {
8
18
  query: z.string().min(1).describe(
9
19
  "Natural language query. Vector search across the org's indexed records.",
10
20
  ),
11
21
  limit: z.number().int().min(1).max(100).optional().describe(
12
- "Max number of result chunks. Default 10. Use a small value (5–10) "
13
- + "when the goal is to resolve a filename / topic into a recordId.",
22
+ "Number of results. Default 10. Use 5–10 when you only need a "
23
+ + "`recordId`.",
14
24
  ),
15
25
  apps: z.array(z.string()).optional().describe(
16
- "Source-scoping ids connector instance UUIDs and / or "
17
- + "`knowledgeBase_<orgId>`. Get them from `pipeshub_sources`.",
26
+ "Connector ids to search (for example a Jira or Google Drive connection). "
27
+ + "Get them from `pipeshub_sources`, where `kind` is \"connector\". "
28
+ + "Collection ids go in `kb`, not here.",
29
+ ),
30
+ kb: z.array(z.string()).optional().describe(
31
+ "Collection (knowledge base) ids to search. Get them from "
32
+ + "`pipeshub_sources`, where `kind` is \"knowledgeBase\".",
18
33
  ),
19
34
  };
20
35
 
@@ -48,13 +63,12 @@ not every record that matches. Never count them to answer "how many" /
48
63
  "all" / "every"; navigate the record group instead, which reports its
49
64
  real total.
50
65
 
51
- The response is trimmed to one row per hit:
52
- \`{ recordId, recordName, score, snippet, mimeType, webUrl, ... }\`.
53
- Highest \`score\` first; multiple hits may share the same \`recordId\`
54
- (different blocks of the same record).
66
+ By default it searches everything. To search only some sources, pass
67
+ connector ids in \`apps\` and collection ids in \`kb\`.
55
68
 
56
- When presenting results to the user, link each record using its
57
- \`webUrl\` (when present).`,
69
+ Each hit is one matching passage, best match first:
70
+ \`{ recordId, recordName, score, snippet, mimeType, webUrl, ... }\`.
71
+ One record can appear in several hits. Link a record by its \`webUrl\`.`,
58
72
  scopes: ["read"],
59
73
  annotations: {
60
74
  title: "Semantic search",
@@ -65,10 +79,20 @@ When presenting results to the user, link each record using its
65
79
  },
66
80
  args,
67
81
  tool: async (client, args, ctx) => {
82
+ const limit = args.limit ?? DEFAULT_LIMIT;
83
+ // Each id is sent in the list the backend reads it from: a collection id
84
+ // in `apps` or a connector id in `kb` is dropped by the backend.
85
+ const { scope, notes } = await resolveSourceScope(
86
+ client,
87
+ args.apps,
88
+ args.kb,
89
+ { signal: ctx.signal },
90
+ );
91
+
68
92
  const [result] = await semanticSearchSearch(client, {
69
93
  query: args.query,
70
- limit: args.limit,
71
- filters: args.apps ? { apps: args.apps, kb: [] } : undefined,
94
+ limit,
95
+ filters: searchFilters(scope),
72
96
  }, { fetchOptions: { signal: ctx.signal } }).$inspect();
73
97
  if (!result.ok) return errorResult(result.error.message);
74
98
 
@@ -94,6 +118,7 @@ When presenting results to the user, link each record using its
94
118
  mimeType: r.mimeType,
95
119
  webUrl: r.webUrl,
96
120
  })),
121
+ ...(notes.length > 0 ? { notes } : {}),
97
122
  });
98
123
  },
99
124
  };
@@ -1,8 +1,12 @@
1
1
  import * as z from "zod";
2
- import { knowledgeHubGetKnowledgeHubRootNodes } from "../../funcs/knowledgeHubGetKnowledgeHubRootNodes.js";
3
2
  import { aiModelsProvidersGetAvailableModelsByType } from "../../funcs/aiModelsProvidersGetAvailableModelsByType.js";
4
3
  import { ToolDefinition } from "../tools.js";
5
- import { errorResult, jsonResult, readJson } from "./_helpers.js";
4
+ import {
5
+ errorResult,
6
+ jsonResult,
7
+ listAllSources,
8
+ readJson,
9
+ } from "./_helpers.js";
6
10
 
7
11
  const args = {
8
12
  include: z.array(z.enum(["sources", "llmModels", "embeddingModels"]))
@@ -20,20 +24,19 @@ export const tool$pipeshubSources: ToolDefinition<typeof args> = {
20
24
 
21
25
  Returns up to three sections:
22
26
 
23
- - \`sources\` — every connector instance the org has wired up plus the
24
- synthetic \`knowledgeBase_<orgId>\` entry for the org's KB. Each
25
- item's \`id\` is exactly the value to put in \`pipeshub_chat\`'s or
26
- \`pipeshub_search\`'s \`apps\` filter.
27
+ - \`sources\` — connectors (\`kind: "connector"\`) and collections
28
+ (\`kind: "knowledgeBase"\`). For \`pipeshub_search\` and
29
+ \`pipeshub_chat\`, put a connector \`id\` in \`apps\` and a collection
30
+ \`id\` in \`kb\`. \`sourcesTruncated: true\` means the list stopped at
31
+ 1,000 sources.
27
32
  - \`llmModels\` — chat / generation models. Each item's \`modelKey\`
28
- is the value to pass on \`pipeshub_chat\` / \`pipeshub_search\` as
29
- \`modelKey\`. Pick \`isDefault: true\` unless the user asks for a
30
- specific model.
33
+ is the value to pass on \`pipeshub_chat\` as \`modelKey\`. Pick
34
+ \`isDefault: true\` unless the user asks for a specific model.
31
35
  - \`embeddingModels\` — vector embedding models (only fetched when
32
36
  explicitly requested via \`include\`).
33
37
 
34
38
  Call this once at the start of a session and cache the result —
35
- sources and models change infrequently. \`sources\` and \`llmModels\`
36
- are returned by default; pass \`include\` to override.`,
39
+ sources and models change infrequently.`,
37
40
  scopes: ["read"],
38
41
  annotations: {
39
42
  title: "List PipesHub sources and AI models",
@@ -52,20 +55,10 @@ are returned by default; pass \`include\` to override.`,
52
55
  const result: Record<string, unknown> = {};
53
56
 
54
57
  if (want("sources")) {
55
- const [r] = await knowledgeHubGetKnowledgeHubRootNodes(client, {
56
- page: 1,
57
- limit: 200,
58
- }, { fetchOptions }).$inspect();
59
- if (!r.ok) return errorResult(`sources: ${r.error.message}`);
60
- const parsed = await readJson<{ items?: any[] }>(r.value, "Knowledge base listing");
61
- if (!parsed.ok) return parsed.result;
62
- result["sources"] = (parsed.value.items ?? []).map((n: any) => ({
63
- id: n.id,
64
- name: n.name,
65
- kind: n.connector === "KB" ? "knowledgeBase" : "connector",
66
- connector: n.connector,
67
- hasChildren: n.hasChildren,
68
- }));
58
+ const listed = await listAllSources(client, { signal: ctx.signal });
59
+ if (!listed.ok) return listed.result;
60
+ result["sources"] = listed.sources;
61
+ if (listed.truncated) result["sourcesTruncated"] = true;
69
62
  }
70
63
 
71
64
  for (
package/src/tool-names.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export const toolNames: Array<{ name: string; description: string }>= [
3
3
  {
4
4
  "name": "pipeshub_sources",
5
- "description": "Discover available chat sources and AI models in one call.\n\nReturns up to three sections:\n\n- `sources` — every connector instance the org has wired up plus the\n synthetic `knowledgeBase_<orgId>` entry for the org's KB. Each\n item's `id` is exactly the value to put in `pipeshub_chat`'s or\n `pipeshub_search`'s `apps` filter.\n- `llmModels` — chat / generation models. Each item's `modelKey`\n is the value to pass on `pipeshub_chat` / `pipeshub_search` as\n `modelKey`. Pick `isDefault: true` unless the user asks for a\n specific model.\n- `embeddingModels` — vector embedding models (only fetched when\n explicitly requested via `include`).\n\nCall this once at the start of a session and cache the result —\nsources and models change infrequently. `sources` and `llmModels`\nare returned by default; pass `include` to override."
5
+ "description": "Discover available chat sources and AI models in one call.\n\nReturns up to three sections:\n\n- `sources` — connectors (`kind: \"connector\"`) and collections\n (`kind: \"knowledgeBase\"`). For `pipeshub_search` and\n `pipeshub_chat`, put a connector `id` in `apps` and a collection\n `id` in `kb`. `sourcesTruncated: true` means the list stopped at\n 1,000 sources.\n- `llmModels` — chat / generation models. Each item's `modelKey`\n is the value to pass on `pipeshub_chat` as `modelKey`. Pick\n `isDefault: true` unless the user asks for a specific model.\n- `embeddingModels` — vector embedding models (only fetched when\n explicitly requested via `include`).\n\nCall this once at the start of a session and cache the result —\nsources and models change infrequently."
6
6
  },
7
7
  {
8
8
  "name": "pipeshub_chat",
@@ -10,11 +10,11 @@ export const toolNames: Array<{ name: string; description: string }>= [
10
10
  },
11
11
  {
12
12
  "name": "pipeshub_search",
13
- "description": "Vector / semantic search across the org's indexed documents.\n\n**Use this when the user wants to LOCATE a document** — by name, topic,\nor a phrase to grep for — and to resolve it to a `recordId`. For\nopen-ended questions across many documents, use `pipeshub_chat`\ninstead, which does the retrieval internally and grounds the answer in\ncitations.\n\nTypical uses:\n- Resolve a doc name / topic into a `recordId` for\n `pipeshub_get_record_content` — step 1 of any full-document task\n (summarize, extract, review, \"what does the doc say?\").\n- Resolve a filename / phrase into a `recordId` for\n `pipeshub_download_record`.\n- Show the user a ranked list of matching files when they ask \"find /\n search for X\".\n\nNot for structural questions — what is under this epic, which pages are\nin this space, what links to this ticket. Ranking by content cannot show\nhow records relate; use `pipeshub_get_record_content` `mode:\"navigate\"`.\n\n**A ranked sample, never a complete list.** Hits are the top-scoring\nblocks from the best-matching records — not all blocks of any record, and\nnot every record that matches. Never count them to answer \"how many\" /\n\"all\" / \"every\"; navigate the record group instead, which reports its\nreal total.\n\nThe response is trimmed to one row per hit:\n`{ recordId, recordName, score, snippet, mimeType, webUrl, ... }`.\nHighest `score` first; multiple hits may share the same `recordId`\n(different blocks of the same record).\n\nWhen presenting results to the user, link each record using its\n`webUrl` (when present)."
13
+ "description": "Vector / semantic search across the org's indexed documents.\n\n**Use this when the user wants to LOCATE a document** — by name, topic,\nor a phrase to grep for — and to resolve it to a `recordId`. For\nopen-ended questions across many documents, use `pipeshub_chat`\ninstead, which does the retrieval internally and grounds the answer in\ncitations.\n\nTypical uses:\n- Resolve a doc name / topic into a `recordId` for\n `pipeshub_get_record_content` — step 1 of any full-document task\n (summarize, extract, review, \"what does the doc say?\").\n- Resolve a filename / phrase into a `recordId` for\n `pipeshub_download_record`.\n- Show the user a ranked list of matching files when they ask \"find /\n search for X\".\n\nNot for structural questions — what is under this epic, which pages are\nin this space, what links to this ticket. Ranking by content cannot show\nhow records relate; use `pipeshub_get_record_content` `mode:\"navigate\"`.\n\n**A ranked sample, never a complete list.** Hits are the top-scoring\nblocks from the best-matching records — not all blocks of any record, and\nnot every record that matches. Never count them to answer \"how many\" /\n\"all\" / \"every\"; navigate the record group instead, which reports its\nreal total.\n\nBy default it searches everything. To search only some sources, pass\nconnector ids in `apps` and collection ids in `kb`.\n\nEach hit is one matching passage, best match first:\n`{ recordId, recordName, score, snippet, mimeType, webUrl, ... }`.\nOne record can appear in several hits. Link a record by its `webUrl`."
14
14
  },
15
15
  {
16
16
  "name": "pipeshub_download_record",
17
- "description": "Stream the binary content of a single record from PipesHub.\n\nTypical sources for the `recordId`:\n- A chat citation:\n `pipeshub_chat` response `citations[*].recordId`.\n- A search result:\n `pipeshub_search` response `hits[*].recordId` /\n `uniqueRecords[*].recordId`.\n\nResponse `Content-Type` is forwarded from the upstream service —\n`application/pdf`, `application/octet-stream`, etc. Binary content is\nreturned base64-encoded; text content is returned inline."
17
+ "description": "Download the file as stored for one record not PipesHub's parsed\ncontent, metadata header, or summary.\n\nUse this when the user wants the file itself (download, attach, open).\nGet `recordId` from a chat citation or a `pipeshub_search` hit.\n\nDo not use this to read, summarize, or answer \"what does this doc\nsay?\" regardless of format. That is `pipeshub_get_record_content`\n`mode:\"content\"`. Text formats come back inline; images, audio, and\nbinary as base64.\n\n`convertTo` accepts only `application/pdf`; anything else is ignored."
18
18
  },
19
19
  {
20
20
  "name": "pipeshub_get_record_content",