@pipeshub-ai/mcp 2.0.1 → 2.2.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 (117) hide show
  1. package/README.md +90 -2
  2. package/bin/mcp-server.js +1156 -213
  3. package/bin/mcp-server.js.map +30 -16
  4. package/esm/funcs/agentsListAgents.d.ts +10 -0
  5. package/esm/funcs/agentsListAgents.d.ts.map +1 -0
  6. package/esm/funcs/agentsListAgents.js +83 -0
  7. package/esm/funcs/agentsListAgents.js.map +1 -0
  8. package/esm/funcs/agentsStreamConversation.d.ts +10 -0
  9. package/esm/funcs/agentsStreamConversation.d.ts.map +1 -0
  10. package/esm/funcs/agentsStreamConversation.js +83 -0
  11. package/esm/funcs/agentsStreamConversation.js.map +1 -0
  12. package/esm/funcs/agentsStreamMessage.d.ts +10 -0
  13. package/esm/funcs/agentsStreamMessage.d.ts.map +1 -0
  14. package/esm/funcs/agentsStreamMessage.js +86 -0
  15. package/esm/funcs/agentsStreamMessage.js.map +1 -0
  16. package/esm/funcs/connectorGetRecordContent.d.ts +43 -0
  17. package/esm/funcs/connectorGetRecordContent.d.ts.map +1 -0
  18. package/esm/funcs/connectorGetRecordContent.js +114 -0
  19. package/esm/funcs/connectorGetRecordContent.js.map +1 -0
  20. package/esm/funcs/conversationsStreamConversation.d.ts.map +1 -1
  21. package/esm/funcs/conversationsStreamConversation.js +2 -4
  22. package/esm/funcs/conversationsStreamConversation.js.map +1 -1
  23. package/esm/funcs/conversationsStreamMessage.d.ts.map +1 -1
  24. package/esm/funcs/conversationsStreamMessage.js +0 -7
  25. package/esm/funcs/conversationsStreamMessage.js.map +1 -1
  26. package/esm/funcs/userGroupsGetAllUserGroups.js +1 -1
  27. package/esm/funcs/userGroupsGetAllUserGroups.js.map +1 -1
  28. package/esm/hooks/registration.d.ts.map +1 -1
  29. package/esm/hooks/registration.js +2 -1
  30. package/esm/hooks/registration.js.map +1 -1
  31. package/esm/hooks/request-context.d.ts +10 -0
  32. package/esm/hooks/request-context.d.ts.map +1 -0
  33. package/esm/hooks/request-context.js +38 -0
  34. package/esm/hooks/request-context.js.map +1 -0
  35. package/esm/hooks/requestid.d.ts +5 -0
  36. package/esm/hooks/requestid.d.ts.map +1 -0
  37. package/esm/hooks/requestid.js +58 -0
  38. package/esm/hooks/requestid.js.map +1 -0
  39. package/esm/mcp-server/instructions.d.ts +2 -0
  40. package/esm/mcp-server/instructions.d.ts.map +1 -0
  41. package/esm/mcp-server/instructions.js +106 -0
  42. package/esm/mcp-server/instructions.js.map +1 -0
  43. package/esm/mcp-server/prompts/pipeshubAssistant.d.ts +8 -0
  44. package/esm/mcp-server/prompts/pipeshubAssistant.d.ts.map +1 -0
  45. package/esm/mcp-server/prompts/pipeshubAssistant.js +31 -0
  46. package/esm/mcp-server/prompts/pipeshubAssistant.js.map +1 -0
  47. package/esm/mcp-server/server.d.ts.map +1 -1
  48. package/esm/mcp-server/server.js +16 -62
  49. package/esm/mcp-server/server.js.map +1 -1
  50. package/esm/mcp-server/tools/_helpers.d.ts +78 -0
  51. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  52. package/esm/mcp-server/tools/_helpers.js +155 -1
  53. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  54. package/esm/mcp-server/tools/pipeshubAgents.d.ts +8 -0
  55. package/esm/mcp-server/tools/pipeshubAgents.d.ts.map +1 -0
  56. package/esm/mcp-server/tools/pipeshubAgents.js +62 -0
  57. package/esm/mcp-server/tools/pipeshubAgents.js.map +1 -0
  58. package/esm/mcp-server/tools/pipeshubChat.d.ts +6 -1
  59. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  60. package/esm/mcp-server/tools/pipeshubChat.js +79 -23
  61. package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
  62. package/esm/mcp-server/tools/pipeshubDirectory.d.ts.map +1 -1
  63. package/esm/mcp-server/tools/pipeshubDirectory.js +0 -1
  64. package/esm/mcp-server/tools/pipeshubDirectory.js.map +1 -1
  65. package/esm/mcp-server/tools/pipeshubDownloadRecord.d.ts.map +1 -1
  66. package/esm/mcp-server/tools/pipeshubDownloadRecord.js +0 -3
  67. package/esm/mcp-server/tools/pipeshubDownloadRecord.js.map +1 -1
  68. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +8 -0
  69. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -0
  70. package/esm/mcp-server/tools/pipeshubGetRecordContent.js +63 -0
  71. package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -0
  72. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  73. package/esm/mcp-server/tools/pipeshubSearch.js +8 -7
  74. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  75. package/esm/mcp-server/tools/pipeshubSources.d.ts.map +1 -1
  76. package/esm/mcp-server/tools/pipeshubSources.js +0 -4
  77. package/esm/mcp-server/tools/pipeshubSources.js.map +1 -1
  78. package/esm/mcp-server/tools.d.ts.map +1 -1
  79. package/esm/mcp-server/tools.js +13 -1
  80. package/esm/mcp-server/tools.js.map +1 -1
  81. package/esm/models/agentops.d.ts +119 -0
  82. package/esm/models/agentops.d.ts.map +1 -0
  83. package/esm/models/agentops.js +97 -0
  84. package/esm/models/agentops.js.map +1 -0
  85. package/esm/models/getrecordcontentop.d.ts +6 -0
  86. package/esm/models/getrecordcontentop.d.ts.map +1 -0
  87. package/esm/models/getrecordcontentop.js +5 -0
  88. package/esm/models/getrecordcontentop.js.map +1 -0
  89. package/esm/tool-names.d.ts.map +1 -1
  90. package/esm/tool-names.js +10 -2
  91. package/esm/tool-names.js.map +1 -1
  92. package/package.json +3 -2
  93. package/src/funcs/agentsListAgents.ts +141 -0
  94. package/src/funcs/agentsStreamConversation.ts +144 -0
  95. package/src/funcs/agentsStreamMessage.ts +147 -0
  96. package/src/funcs/connectorGetRecordContent.ts +179 -0
  97. package/src/funcs/conversationsStreamConversation.ts +2 -4
  98. package/src/funcs/conversationsStreamMessage.ts +0 -8
  99. package/src/funcs/userGroupsGetAllUserGroups.ts +1 -1
  100. package/src/hooks/registration.ts +2 -1
  101. package/src/hooks/request-context.ts +47 -0
  102. package/src/hooks/requestid.ts +62 -0
  103. package/src/mcp-server/instructions.ts +106 -0
  104. package/src/mcp-server/prompts/pipeshubAssistant.ts +37 -0
  105. package/src/mcp-server/server.ts +18 -64
  106. package/src/mcp-server/tools/_helpers.ts +216 -1
  107. package/src/mcp-server/tools/pipeshubAgents.ts +68 -0
  108. package/src/mcp-server/tools/pipeshubChat.ts +78 -23
  109. package/src/mcp-server/tools/pipeshubDirectory.ts +0 -1
  110. package/src/mcp-server/tools/pipeshubDownloadRecord.ts +0 -4
  111. package/src/mcp-server/tools/pipeshubGetRecordContent.ts +67 -0
  112. package/src/mcp-server/tools/pipeshubSearch.ts +8 -7
  113. package/src/mcp-server/tools/pipeshubSources.ts +0 -5
  114. package/src/mcp-server/tools.ts +14 -1
  115. package/src/models/agentops.ts +160 -0
  116. package/src/models/getrecordcontentop.ts +11 -0
  117. package/src/tool-names.ts +10 -2
@@ -0,0 +1,47 @@
1
+ /*
2
+ * Ambient per-tool-call request-id context (AsyncLocalStorage). Mirrors the
3
+ * pipeshub-ai backend's own request-context pattern (same header name,
4
+ * `x-request-id`) so a single id traces one MCP tool/resource/prompt
5
+ * invocation across every backend HTTP call it makes.
6
+ *
7
+ * `tools.ts` / `resources.ts` / `prompts.ts` are Speakeasy-generated and
8
+ * regenerated on every build, so there is no `next()`-style wrapper point to
9
+ * bind context around a call. Instead `bindNewRequestId` is called from the
10
+ * hand-written `getClient` closure in `server.ts`, which every generated
11
+ * dispatch site calls synchronously as the first thing before invoking a
12
+ * tool/resource/prompt handler — `enterWith` binds the store for the rest of
13
+ * that handler's execution (sync remainder + everything it awaits).
14
+ *
15
+ * The final id (`mcp-<userId>-<random>` or `mcp-<random>`) can't be composed
16
+ * at bind time — `getClient` only has a `PipeshubCore`, not yet a resolved
17
+ * `Authorization` header, and resolving security may be async (OAuth2)
18
+ * while `getClient` must stay sync (generated call sites pass its return
19
+ * value straight into a handler, never await it). So only the random suffix
20
+ * is fixed here; `requestid.ts`'s hook fills in `finalId` off the first
21
+ * outbound request's `Authorization` header and every later call in the
22
+ * same tool invocation reuses that cached value.
23
+ */
24
+
25
+ import { AsyncLocalStorage } from "node:async_hooks";
26
+ import { nanoid } from "nanoid";
27
+
28
+ export interface RequestContextStore {
29
+ random: string;
30
+ finalId?: string;
31
+ }
32
+
33
+ const storage = new AsyncLocalStorage<RequestContextStore>();
34
+
35
+ /** Bind a fresh request-id context for the remainder of the current async chain. */
36
+ export function bindNewRequestId(): void {
37
+ storage.enterWith({ random: nanoid() });
38
+ }
39
+
40
+ export function getRequestContextStore(): RequestContextStore | undefined {
41
+ return storage.getStore();
42
+ }
43
+
44
+ /** One-off id for the rare case a backend call happens with no bound tool-call context. */
45
+ export function newRequestId(): string {
46
+ return `mcp-${nanoid()}`;
47
+ }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Stamp an x-request-id trace header onto every outbound SDK request, reusing
3
+ * the id bound for the current MCP tool/resource/prompt call (see
4
+ * request-context.ts) so all backend calls from one invocation share it.
5
+ *
6
+ * Format mirrors the TypeScript SDK's own RequestIDHook: `mcp-<userId>-<random>`
7
+ * when the bearer JWT carries a userId claim, else `mcp-<random>`. The userId
8
+ * is only knowable once a request actually carries a resolved Authorization
9
+ * header, so it's decoded off the first outbound request of the tool call and
10
+ * cached on the bound context for every subsequent call to reuse verbatim.
11
+ */
12
+
13
+ import { getRequestContextStore, newRequestId } from "./request-context.js";
14
+ import { BeforeRequestContext, BeforeRequestHook } from "./types.js";
15
+
16
+ const REQUEST_ID_HEADER = "x-request-id";
17
+
18
+ /** Decodes the JWT payload without verifying the signature. */
19
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
20
+ try {
21
+ const base64Url = token.split(".")[1];
22
+ if (!base64Url) return null;
23
+ const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
24
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
25
+ const claims = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
26
+ return typeof claims === "object" && claims !== null ? claims : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function userIdFromRequest(request: Request): string | null {
33
+ const auth = request.headers.get("authorization") ?? "";
34
+ if (!auth.toLowerCase().startsWith("bearer ")) return null;
35
+ const token = auth.slice(7).trim();
36
+ const claims = decodeJwtPayload(token);
37
+ return (claims?.["userId"] as string) || null;
38
+ }
39
+
40
+ export class RequestIDHook implements BeforeRequestHook {
41
+ beforeRequest(_hookCtx: BeforeRequestContext, request: Request): Request {
42
+ if (request.headers.get(REQUEST_ID_HEADER)) {
43
+ return request;
44
+ }
45
+
46
+ const store = getRequestContextStore();
47
+ if (!store) {
48
+ // No bound tool-call context (shouldn't happen in practice).
49
+ request.headers.set(REQUEST_ID_HEADER, newRequestId());
50
+ return request;
51
+ }
52
+
53
+ if (!store.finalId) {
54
+ const userId = userIdFromRequest(request);
55
+ store.finalId = userId
56
+ ? `mcp-${userId}-${store.random}`
57
+ : `mcp-${store.random}`;
58
+ }
59
+ request.headers.set(REQUEST_ID_HEADER, store.finalId);
60
+ return request;
61
+ }
62
+ }
@@ -0,0 +1,106 @@
1
+ // Shared routing guidance for the host LLM. Surfaced two ways:
2
+ // 1. As the server `instructions` (always present at `initialize`).
3
+ // 2. As the body of the user-invokable `pipeshub-assistant` prompt.
4
+ // Keeping it in one place avoids the two copies drifting apart.
5
+
6
+ export const PIPESHUB_INSTRUCTIONS = `# PipesHub MCP — instructions for the LLM
7
+
8
+ PipesHub is the user's workplace AI platform. It indexes their documents,
9
+ knowledge base content, and connector sources (Drive, Box, Confluence,
10
+ Slack, Jira, Gmail, ...). When in doubt, the answer is in PipesHub.
11
+
12
+ ## Full-document tasks: \`pipeshub_search\` → \`pipeshub_get_record_content\`
13
+
14
+ \`pipeshub_chat\` answers from a handful of retrieved passages — it never
15
+ reads a whole document. Whenever the task depends on a document's
16
+ COMPLETE content, fetch the document itself:
17
+
18
+ 1. \`pipeshub_search\` with the document's name / topic.
19
+ 2. Take the top hit's \`recordId\`.
20
+ 3. \`pipeshub_get_record_content\`, and answer from the returned content.
21
+
22
+ Tasks that need this path — anything where missing part of the document
23
+ could make the answer wrong:
24
+
25
+ - Summarize / TL;DR / key points / takeaways / action items of a doc.
26
+ - Extract or list ALL of something (dates, owners, requirements, ...).
27
+ - Check whether / where a doc mentions something.
28
+ - Translate, rewrite, outline, review, or reformat a doc.
29
+ - Compare named docs (fetch each \`recordId\`).
30
+ - Any question explicitly scoped to ONE named document — chat retrieval
31
+ cannot be restricted to a single record.
32
+
33
+ ## Default tool: \`pipeshub_chat\`
34
+
35
+ **Use \`pipeshub_chat\` for any question that could plausibly be answered
36
+ by the user's PipesHub-indexed data** and is not a full-document task:
37
+
38
+ - A question that may span several documents, or where you don't yet
39
+ know which record holds the answer (e.g. "what did Aashil say about
40
+ onboarding?").
41
+ - Anything about company / org policies, processes, decisions, or
42
+ history (e.g. "what's our vacation policy?", "who owns the auth
43
+ service?").
44
+ - Anything explicitly mentioning PipesHub itself, or its sources
45
+ (Drive / Box / Confluence / Slack / Gmail / Jira / etc.) when the
46
+ user has those connected.
47
+ - Open-ended "what do we know about X" questions where the answer
48
+ likely lives in the org's documents.
49
+
50
+ Do NOT answer those from your own knowledge — \`pipeshub_chat\` grounds
51
+ the answer in the user's actual indexed content and returns citations
52
+ the user can verify.
53
+
54
+ ## When to use the other tools
55
+
56
+ - \`pipeshub_search\` — locate a document by name or topic and resolve it
57
+ to a \`recordId\`. To read or summarize one specific document, search,
58
+ then pass the top hit's \`recordId\` to \`pipeshub_get_record_content\`.
59
+ - \`pipeshub_download_record\` — when the user wants the actual file
60
+ bytes (download, attach, open). Get the \`recordId\` either from
61
+ citations on a prior \`pipeshub_chat\` response or from
62
+ \`pipeshub_search\`.
63
+ - \`pipeshub_get_record_content\` — when you need a record's full parsed
64
+ content (returned as a single \`content\` string: metadata header plus
65
+ the document's text) without downloading the original file. Prefer this
66
+ over download when the question is about what the record says.
67
+ - \`pipeshub_directory\` — people, groups, teams, and \`whoami\` lookups.
68
+ Not for documents.
69
+ - \`pipeshub_sources\` — call once at the start of a session to discover
70
+ which connectors / KB / models are available, then cache the result.
71
+ - \`pipeshub_agents\` — list the org's configured **agents** (specialized
72
+ assistants with their own prompt, tools, and knowledge scope).
73
+
74
+ ## Agents
75
+
76
+ Some orgs configure **agents** for specific jobs (e.g. a Slack messenger, a
77
+ Jira ticket creator, a Salesforce CRM updater). To run a turn against an
78
+ agent, call \`pipeshub_chat\` with its \`agentId\` (from \`pipeshub_agents\`)
79
+ — optionally with an agent \`chatMode\` (\`auto\` by default, or \`quick\` /
80
+ \`verification\` / \`deep\`). Keep passing the same \`agentId\` plus the
81
+ returned \`conversationId\` on follow-up turns.
82
+
83
+ **When to route to an agent vs plain chat:**
84
+ - The user names a system/action that maps to an agent (e.g. "post to
85
+ Slack", "create a Jira ticket", "update the Salesforce deal") → call
86
+ \`pipeshub_agents\`, pick the matching agent, and use it.
87
+ - Plain question about the org's knowledge or the public web → just use
88
+ \`pipeshub_chat\` (no \`agentId\`) with \`internal_search\` or \`web_search\`.
89
+
90
+ **If you are unsure which agent or route fits the request**, call
91
+ \`pipeshub_agents\` FIRST and read the returned names/descriptions, then
92
+ either pick the best match or present the list to the user and let them
93
+ choose. Do NOT guess an agent blindly, and do NOT refuse — list the agents
94
+ and decide from real data. The list may be empty (no agents configured),
95
+ in which case fall back to plain \`pipeshub_chat\`.
96
+
97
+ ## Conversation lifecycle
98
+
99
+ \`pipeshub_chat\` is a single tool that handles both starting and
100
+ continuing conversations (plain or with an \`agentId\`). On the first turn
101
+ omit \`conversationId\`; on every subsequent turn pass back the
102
+ \`conversationId\` returned by the previous call (along with the same
103
+ \`agentId\` if you used one). Server-side context is preserved — do NOT
104
+ replay prior messages. Only start a fresh conversation when the user
105
+ explicitly asks to clear context.
106
+ `;
@@ -0,0 +1,37 @@
1
+ import * as z from "zod";
2
+ import { PromptDefinition } from "../prompts.js";
3
+ import { PIPESHUB_INSTRUCTIONS } from "../instructions.js";
4
+
5
+ const args = {
6
+ query: z.string().optional().describe(
7
+ "Optional. The user's request to route. Provide it to have the guidance "
8
+ + "applied to a concrete request; omit it to just load the routing "
9
+ + "guidance as a seed system prompt at the start of a session.",
10
+ ),
11
+ };
12
+
13
+ export const prompt$pipeshubAssistant: PromptDefinition<typeof args> = {
14
+ name: "pipeshub-assistant",
15
+ description:
16
+ "Load PipesHub tool-routing guidance so the assistant picks the correct "
17
+ + "tool (pipeshub_chat / search / agents / directory) and lists agents "
18
+ + "with pipeshub_agents when unsure which route fits the query.",
19
+ scopes: ["read"],
20
+ args,
21
+ prompt: (_client, args, _extra) => {
22
+ const text = args.query
23
+ ? `${PIPESHUB_INSTRUCTIONS}\n\n---\n\nFollow the routing rules above to `
24
+ + `handle this request, picking the right tool (and listing agents via `
25
+ + `\`pipeshub_agents\` if unsure which route fits):\n\n${args.query}`
26
+ : PIPESHUB_INSTRUCTIONS;
27
+
28
+ return {
29
+ messages: [
30
+ {
31
+ role: "user",
32
+ content: { type: "text", text },
33
+ },
34
+ ],
35
+ };
36
+ },
37
+ };
@@ -1,66 +1,8 @@
1
- // Hand-written; replaces the Speakeasy-generated server.ts. Registers the
2
- // five curated PipesHub tools instead of one tool per OpenAPI operation.
3
-
4
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
2
 
6
- // Top-level instructions surfaced to the host LLM at server initialize.
7
- // These set defaults so the model picks `pipeshub_chat` as the natural
8
- // entry point for anything that touches the user's PipesHub data —
9
- // documents, files, knowledge base content, citations, etc.
10
- const PIPESHUB_INSTRUCTIONS = `# PipesHub MCP — instructions for the LLM
11
-
12
- PipesHub is the user's workplace AI platform. It indexes their documents,
13
- knowledge base content, and connector sources (Drive, Box, Confluence,
14
- Slack, Jira, Gmail, ...). When in doubt, the answer is in PipesHub.
15
-
16
- ## Default tool: \`pipeshub_chat\`
17
-
18
- **Use \`pipeshub_chat\` for any question that could plausibly be answered
19
- by the user's PipesHub-indexed data.** That includes:
20
-
21
- - Anything about a specific document, file, report, ticket, message, or
22
- page (e.g. "what's in the Q4 sales report?", "summarize the langchain
23
- doc", "what did Aashil say about onboarding?").
24
- - Anything about company / org policies, processes, decisions, or
25
- history (e.g. "what's our vacation policy?", "who owns the auth
26
- service?").
27
- - Anything explicitly mentioning PipesHub itself, or its sources
28
- (Drive / Box / Confluence / Slack / Gmail / Jira / etc.) when the
29
- user has those connected.
30
- - Open-ended "what do we know about X" questions where the answer
31
- likely lives in the org's documents.
32
-
33
- Do NOT answer those from your own knowledge — \`pipeshub_chat\` grounds
34
- the answer in the user's actual indexed content and returns citations
35
- the user can verify.
36
-
37
- ## When to use the other tools
38
-
39
- - \`pipeshub_search\` — only when the user asks specifically to **find /
40
- locate** a document by name or topic (so you can hand them a list, or
41
- resolve a filename to a \`recordId\` for download). For "what does
42
- the doc say?" use \`pipeshub_chat\` instead — it does the retrieval
43
- internally.
44
- - \`pipeshub_download_record\` — when the user wants the actual file
45
- bytes (download, attach, open). Get the \`recordId\` either from
46
- citations on a prior \`pipeshub_chat\` response or from
47
- \`pipeshub_search\`.
48
- - \`pipeshub_directory\` — people, groups, teams, and \`whoami\` lookups.
49
- Not for documents.
50
- - \`pipeshub_sources\` — call once at the start of a session to discover
51
- which connectors / KB / models are available, then cache the result.
52
-
53
- ## Conversation lifecycle
54
-
55
- \`pipeshub_chat\` is a single tool that handles both starting and
56
- continuing conversations. On the first turn omit \`conversationId\`; on
57
- every subsequent turn pass back the \`conversationId\` returned by the
58
- previous call. Server-side context is preserved — do NOT replay prior
59
- messages. Only start a fresh conversation when the user explicitly
60
- asks to clear context.
61
- `;
62
-
3
+ import { PIPESHUB_INSTRUCTIONS } from "./instructions.js";
63
4
  import { PipeshubCore } from "../core.js";
5
+ import { bindNewRequestId } from "../hooks/request-context.js";
64
6
  import { SDKOptions } from "../lib/config.js";
65
7
  import type { ConsoleLogger } from "./console-logger.js";
66
8
  import { createRegisterPrompt } from "./prompts.js";
@@ -73,8 +15,11 @@ import { createRegisterTool, registerDynamicTools } from "./tools.js";
73
15
  import { tool$pipeshubChat } from "./tools/pipeshubChat.js";
74
16
  import { tool$pipeshubSearch } from "./tools/pipeshubSearch.js";
75
17
  import { tool$pipeshubDownloadRecord } from "./tools/pipeshubDownloadRecord.js";
18
+ import { tool$pipeshubGetRecordContent } from "./tools/pipeshubGetRecordContent.js";
76
19
  import { tool$pipeshubDirectory } from "./tools/pipeshubDirectory.js";
77
20
  import { tool$pipeshubSources } from "./tools/pipeshubSources.js";
21
+ import { tool$pipeshubAgents } from "./tools/pipeshubAgents.js";
22
+ import { prompt$pipeshubAssistant } from "./prompts/pipeshubAssistant.js";
78
23
 
79
24
  export function createMCPServer(deps: {
80
25
  logger: ConsoleLogger;
@@ -97,7 +42,7 @@ export function createMCPServer(deps: {
97
42
  },
98
43
  );
99
44
 
100
- const getClient = deps.getSDK || (() =>
45
+ const resolveClient = deps.getSDK || (() =>
101
46
  new PipeshubCore({
102
47
  security: deps.security,
103
48
  serverURL: deps.serverURL,
@@ -112,6 +57,11 @@ export function createMCPServer(deps: {
112
57
  : undefined,
113
58
  }));
114
59
 
60
+ const getClient = () => {
61
+ bindNewRequestId();
62
+ return resolveClient();
63
+ };
64
+
115
65
  const scopes = new Set(deps.scopes);
116
66
 
117
67
  const allowedTools = deps.allowedTools && new Set(deps.allowedTools);
@@ -139,13 +89,17 @@ export function createMCPServer(deps: {
139
89
  const register = { tool, resource, resourceTemplate, prompt };
140
90
  void register; // suppress unused warnings
141
91
 
142
- // Curated tool set. Five tools instead of ten one-per-op generated tools.
143
- // Order = preferred discovery order shown to the LLM.
92
+ // Curated tool set. Order = preferred discovery order shown to the LLM.
144
93
  tool(tool$pipeshubSources); // 1. discover sources + models
145
94
  tool(tool$pipeshubChat); // 2. ask questions (start + continue)
146
95
  tool(tool$pipeshubSearch); // 3. resolve filename → recordId
147
96
  tool(tool$pipeshubDownloadRecord); // 4. fetch a document by id
148
- tool(tool$pipeshubDirectory); // 5. people / groups / teams / whoami
97
+ tool(tool$pipeshubGetRecordContent); // 5. fetch a record's parsed content
98
+ tool(tool$pipeshubDirectory); // 6. people / groups / teams / whoami
99
+ tool(tool$pipeshubAgents); // 7. discover org agents
100
+
101
+ // Curated prompt: user-invokable tool-routing guidance.
102
+ prompt(prompt$pipeshubAssistant);
149
103
 
150
104
  if (deps.dynamic) {
151
105
  registerDynamicTools(deps.logger, server, getClient, toolMap, scopes);
@@ -1,12 +1,17 @@
1
- // Curated-tool helpers. Hand-written, NOT regenerated by Speakeasy.
2
1
  //
3
2
  // Each curated tool fetches a Response from one of the SDK funcs and then
4
3
  // trims / restructures the body before handing it to the LLM. The helpers
5
4
  // here keep that work out of the individual tool files.
6
5
 
6
+ import * as z from "zod";
7
7
  import { Security } from "../../models/security.js";
8
8
  import { PipeshubCore } from "../../core.js";
9
9
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
10
+ import { agentsListAgents } from "../../funcs/agentsListAgents.js";
11
+ import {
12
+ AgentListEnvelope$zodSchema,
13
+ AgentSummary,
14
+ } from "../../models/agentops.js";
10
15
 
11
16
  /**
12
17
  * Parse a fetch Response as JSON, returning a CallToolResult error when the
@@ -58,6 +63,94 @@ export function errorResult(message: string): CallToolResult {
58
63
  };
59
64
  }
60
65
 
66
+ /**
67
+ * Guard a (possibly streaming) `Response` on its HTTP status. The streaming
68
+ * SDK funcs configure `errorCodes: []`, so the request layer treats ANY HTTP
69
+ * response — including 401 / 403 / 404 / 5xx — as a success and hands back the
70
+ * raw `Response`. Without this check those failures slip into the SSE drain
71
+ * and surface as the useless "stream ended without usable frames".
72
+ *
73
+ * Returns a `CallToolResult` error (status + best-effort message from the body)
74
+ * when the response is not ok, or `null` when it is ok (body left untouched so
75
+ * the caller can still stream it).
76
+ */
77
+ export async function httpErrorResult(
78
+ response: Response,
79
+ context: string,
80
+ ): Promise<CallToolResult | null> {
81
+ if (response.ok) return null;
82
+
83
+ let body = "";
84
+ try {
85
+ body = await response.text();
86
+ } catch {
87
+ // body unreadable (e.g. already-consumed or transport error)
88
+ }
89
+
90
+ let message: string = body;
91
+ if (body) {
92
+ try {
93
+ const parsed = JSON.parse(body);
94
+ // Only accept string fields — error envelopes often nest an object
95
+ // under `error`, which must not stringify to "[object Object]".
96
+ const candidate =
97
+ (typeof parsed?.message === "string" && parsed.message)
98
+ || (typeof parsed?.error === "string" && parsed.error)
99
+ || (typeof parsed?.error?.message === "string" && parsed.error.message)
100
+ || (typeof parsed?.detail === "string" && parsed.detail)
101
+ || "";
102
+ message = candidate || body; // fall back to the raw JSON text
103
+ } catch {
104
+ // not JSON — keep the raw text
105
+ }
106
+ }
107
+
108
+ const detail = message ? ` ${message.slice(0, 400)}` : "";
109
+ const auth = (response.status === 401 || response.status === 403)
110
+ ? " Check that the bearer token / credentials are valid and not expired."
111
+ : "";
112
+ return errorResult(
113
+ `${context} failed (HTTP ${response.status} ${response.statusText}).${detail}${auth}`,
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Read a `Response` body as JSON and validate it against a zod `schema`,
119
+ * returning the typed value or a `CallToolResult` error (empty body, invalid
120
+ * JSON, or a shape that doesn't match the schema).
121
+ */
122
+ export async function readValidated<T>(
123
+ response: Response,
124
+ schema: z.ZodType<T>,
125
+ ): Promise<{ ok: true; value: T } | { ok: false; result: CallToolResult }> {
126
+ const text = await response.text();
127
+ if (!text) {
128
+ return { ok: false, result: errorResult("Empty response from server") };
129
+ }
130
+ let json;
131
+ try {
132
+ json = JSON.parse(text);
133
+ } catch (e) {
134
+ return {
135
+ ok: false,
136
+ result: errorResult(
137
+ `Failed to parse response as JSON: ${(e as Error).message}`
138
+ + `\n\nRaw body:\n${text.slice(0, 500)}`,
139
+ ),
140
+ };
141
+ }
142
+ const parsed = schema.safeParse(json);
143
+ if (!parsed.success) {
144
+ return {
145
+ ok: false,
146
+ result: errorResult(
147
+ `Unexpected response shape:\n${z.prettifyError(parsed.error)}`,
148
+ ),
149
+ };
150
+ }
151
+ return { ok: true, value: parsed.data };
152
+ }
153
+
61
154
  /** Resolve `client._options.security` (which may be a function) to a value. */
62
155
  export async function resolveSecurity(
63
156
  client: PipeshubCore,
@@ -257,6 +350,128 @@ export async function* iterateSSE(
257
350
  }
258
351
  }
259
352
 
353
+ // ── Trimmed agent shape handed to the LLM ────────────────────────────────────
354
+
355
+ interface AgentToolsetView {
356
+ /** Integration / connector key, e.g. `jira`, `gmail`. */
357
+ name: string | null | undefined;
358
+ /** Fully-qualified tool ids the agent can run, e.g. `jira.create_issue`. */
359
+ tools: string[];
360
+ }
361
+
362
+ interface AgentKnowledgeView {
363
+ name: string | null | undefined;
364
+ type: string | null | undefined;
365
+ }
366
+
367
+ export interface EnrichedAgent {
368
+ agentId: string;
369
+ name: string;
370
+ description: string | null;
371
+ systemPrompt: string | null;
372
+ startMessage: string | null;
373
+ tags: string[];
374
+ webSearch: boolean;
375
+ isActive: boolean | null | undefined;
376
+ toolsets: AgentToolsetView[];
377
+ knowledge: AgentKnowledgeView[];
378
+ }
379
+
380
+ /**
381
+ * Trim an enriched `AgentSummary` (from `GET /agents`) to just what the LLM
382
+ * needs to pick and converse with an agent. Strips ids/revs/timestamps/perms
383
+ * and the heavy nested tool/knowledge metadata, keeping the routing-critical
384
+ * signal: each toolset's connector `name` + its tool ids (e.g.
385
+ * `jira.create_issue`), and each knowledge source's `name`/`type`.
386
+ */
387
+ export function trimAgent(a: AgentSummary): EnrichedAgent {
388
+ return {
389
+ agentId: a._key,
390
+ name: a.name,
391
+ description: a.description ?? null,
392
+ systemPrompt: a.systemPrompt ?? null,
393
+ startMessage: a.startMessage ?? null,
394
+ tags: a.tags,
395
+ webSearch: !!a.webSearch,
396
+ isActive: a.isActive,
397
+ toolsets: a.toolsets.map((ts) => ({
398
+ name: ts.name,
399
+ tools: ts.tools
400
+ .map((t) => t.fullName ?? t.name)
401
+ .filter((id): id is string => typeof id === "string"),
402
+ })),
403
+ knowledge: a.knowledge.map((k) => ({
404
+ name: k.name ?? k.displayName,
405
+ type: k.type,
406
+ })),
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Fetch the COMPLETE agent list, auto-paginating `GET /agents` so callers
412
+ * (discovery + routing) never have to manage page numbers. Pages are fetched
413
+ * at the max page size until the backend reports no more (`hasNext` /
414
+ * `hasNextPage`, with a short-page fallback), capped by `maxPages` as a
415
+ * runaway guard. Returns the raw `AgentListItem`s plus a normalized `total`
416
+ * and a `truncated` flag (true if the cap was hit before exhausting pages).
417
+ */
418
+ export async function listAllAgents(
419
+ client: PipeshubCore,
420
+ opts: {
421
+ search?: string | undefined;
422
+ signal?: AbortSignal | undefined;
423
+ maxPages?: number | undefined;
424
+ } = {},
425
+ ): Promise<
426
+ | { ok: true; agents: AgentSummary[]; total: number; truncated: boolean }
427
+ | { ok: false; result: CallToolResult }
428
+ > {
429
+ const PAGE_SIZE = 200;
430
+ const maxPages = opts.maxPages ?? 25;
431
+ // Only set `signal` when present (exactOptionalPropertyTypes).
432
+ const reqOptions = opts.signal
433
+ ? { fetchOptions: { signal: opts.signal } }
434
+ : {};
435
+
436
+ const all: AgentSummary[] = [];
437
+ let total = 0;
438
+
439
+ for (let page = 1; page <= maxPages; page++) {
440
+ const [r] = await agentsListAgents(client, {
441
+ page,
442
+ limit: PAGE_SIZE,
443
+ search: opts.search,
444
+ }, reqOptions).$inspect();
445
+ if (!r.ok) return { ok: false, result: errorResult(r.error.message) };
446
+
447
+ const httpErr = await httpErrorResult(r.value, "Agent list request");
448
+ if (httpErr) return { ok: false, result: httpErr };
449
+
450
+ const parsed = await readValidated(r.value, AgentListEnvelope$zodSchema);
451
+ if (!parsed.ok) return { ok: false, result: parsed.result };
452
+ if (parsed.value.success === false) {
453
+ return {
454
+ ok: false,
455
+ result: errorResult("Agent list request returned success: false"),
456
+ };
457
+ }
458
+
459
+ const agents = parsed.value.agents;
460
+ all.push(...agents);
461
+
462
+ const pg = parsed.value.pagination;
463
+ total = pg?.totalItems ?? pg?.total ?? all.length;
464
+
465
+ const hasNext = pg?.hasNext ?? pg?.hasNextPage ?? (agents.length >= PAGE_SIZE);
466
+ if (!hasNext || agents.length === 0) {
467
+ return { ok: true, agents: all, total, truncated: false };
468
+ }
469
+ }
470
+
471
+ // Hit the page cap with more pages still available.
472
+ return { ok: true, agents: all, total: total || all.length, truncated: true };
473
+ }
474
+
260
475
  /** Trim a single search hit to what the LLM actually needs. */
261
476
  export function trimSearchHit(hit: any) {
262
477
  const md = hit?.metadata ?? {};
@@ -0,0 +1,68 @@
1
+ // (`GET /agents`) — each already carrying its `toolsets` and `knowledge` — so
2
+ // the LLM can route on real capability rather than the often-generic
3
+ // description. Pick an agent's `agentId` and pass it to `pipeshub_chat`'s
4
+ // `agentId` to converse with it. Single API call, no per-agent fan-out.
5
+
6
+ import * as z from "zod";
7
+ import { ToolDefinition } from "../tools.js";
8
+ import { jsonResult, listAllAgents, trimAgent } from "./_helpers.js";
9
+
10
+ const args = {
11
+ search: z.string().optional().describe(
12
+ "Optional case-insensitive substring match across agent name, "
13
+ + "description, and tags. Omit to return every agent.",
14
+ ),
15
+ };
16
+
17
+ export const tool$pipeshubAgents: ToolDefinition<typeof args> = {
18
+ name: "pipeshub_agents",
19
+ description:
20
+ `List the PipesHub **agents** configured for this org, each with its
21
+ capabilities.
22
+
23
+ Agents are specialized assistants (custom system prompt, tools, knowledge
24
+ scope). To converse with one, take its \`agentId\` and pass it to
25
+ \`pipeshub_chat\`'s \`agentId\` argument.
26
+
27
+ Each agent is returned as:
28
+ \`{ agentId, name, description, systemPrompt, startMessage, tags, webSearch,
29
+ isActive, toolsets, knowledge }\`.
30
+ - \`toolsets\` — what the agent can DO: each \`{ name, tools }\` where \`name\`
31
+ is the connector (e.g. \`jira\`, \`gmail\`) and \`tools\` are the runnable
32
+ tool ids (e.g. \`jira.create_issue\`, \`gmail.send_email\`).
33
+ - \`knowledge\` — what the agent can READ: each \`{ name, type }\` (e.g.
34
+ \`Confluence-2\` / \`Confluence\`).
35
+
36
+ **Route on \`toolsets\`/\`knowledge\`, not the name** — names and descriptions
37
+ are often generic or misleading. Match the request to the agent whose tools can
38
+ actually perform it (e.g. "create a Jira ticket" → the agent whose toolset is
39
+ \`jira\` and whose tools include \`jira.create_issue\`). If NO agent has a tool
40
+ for the requested action, say so — don't force an unrelated agent.
41
+
42
+ The list **may be empty** (no agents configured). For plain Q&A when no
43
+ specific agent is needed, use \`pipeshub_chat\` WITHOUT \`agentId\` and pick a
44
+ \`chatMode\`: \`internal_search\` (org's indexed knowledge) or \`web_search\`
45
+ (live web). Use \`agentId\` everywhere an agent is referenced.`,
46
+ scopes: ["read"],
47
+ annotations: {
48
+ title: "List PipesHub agents (with toolsets & knowledge)",
49
+ destructiveHint: false,
50
+ idempotentHint: true,
51
+ openWorldHint: false,
52
+ readOnlyHint: true,
53
+ },
54
+ args,
55
+ tool: async (client, args, ctx) => {
56
+ const all = await listAllAgents(client, {
57
+ search: args.search,
58
+ signal: ctx.signal,
59
+ });
60
+ if (!all.ok) return all.result;
61
+
62
+ return jsonResult({
63
+ agents: all.agents.map(trimAgent),
64
+ total: all.total,
65
+ ...(all.truncated ? { truncated: true } : {}),
66
+ });
67
+ },
68
+ };