@pipeshub-ai/mcp 2.2.0 → 2.3.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 (112) hide show
  1. package/README.md +2 -0
  2. package/bin/mcp-server.js +703 -189
  3. package/bin/mcp-server.js.map +19 -13
  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 +235 -0
  9. package/esm/cli/client.js.map +1 -0
  10. package/esm/cli/commands.d.ts +30 -0
  11. package/esm/cli/commands.d.ts.map +1 -0
  12. package/esm/cli/commands.js +321 -0
  13. package/esm/cli/commands.js.map +1 -0
  14. package/esm/cli/config.d.ts +73 -0
  15. package/esm/cli/config.d.ts.map +1 -0
  16. package/esm/cli/config.js +218 -0
  17. package/esm/cli/config.js.map +1 -0
  18. package/esm/cli/init-qm.d.ts +11 -0
  19. package/esm/cli/init-qm.d.ts.map +1 -0
  20. package/esm/cli/init-qm.js +170 -0
  21. package/esm/cli/init-qm.js.map +1 -0
  22. package/esm/cli/pipeshub.d.ts +2 -0
  23. package/esm/cli/pipeshub.d.ts.map +1 -0
  24. package/esm/cli/pipeshub.js +296 -0
  25. package/esm/cli/pipeshub.js.map +1 -0
  26. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts +68 -0
  27. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts.map +1 -0
  28. package/esm/funcs/connectorLookupRecordByIdentifier.js +138 -0
  29. package/esm/funcs/connectorLookupRecordByIdentifier.js.map +1 -0
  30. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts +75 -0
  31. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts.map +1 -0
  32. package/esm/funcs/connectorNavigateKnowledgeGraph.js +152 -0
  33. package/esm/funcs/connectorNavigateKnowledgeGraph.js.map +1 -0
  34. package/esm/lib/base64.d.ts +1 -1
  35. package/esm/lib/base64.d.ts.map +1 -1
  36. package/esm/lib/base64.js +6 -3
  37. package/esm/lib/base64.js.map +1 -1
  38. package/esm/lib/bytes-to-base64.d.ts +16 -0
  39. package/esm/lib/bytes-to-base64.d.ts.map +1 -0
  40. package/esm/lib/bytes-to-base64.js +23 -0
  41. package/esm/lib/bytes-to-base64.js.map +1 -0
  42. package/esm/mcp-server/instructions.d.ts +1 -1
  43. package/esm/mcp-server/instructions.d.ts.map +1 -1
  44. package/esm/mcp-server/instructions.js +30 -0
  45. package/esm/mcp-server/instructions.js.map +1 -1
  46. package/esm/mcp-server/shared.d.ts +2 -2
  47. package/esm/mcp-server/shared.d.ts.map +1 -1
  48. package/esm/mcp-server/shared.js +7 -3
  49. package/esm/mcp-server/shared.js.map +1 -1
  50. package/esm/mcp-server/tools/_agui.d.ts +34 -0
  51. package/esm/mcp-server/tools/_agui.d.ts.map +1 -0
  52. package/esm/mcp-server/tools/_agui.js +91 -0
  53. package/esm/mcp-server/tools/_agui.js.map +1 -0
  54. package/esm/mcp-server/tools/_helpers.d.ts +20 -3
  55. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  56. package/esm/mcp-server/tools/_helpers.js +54 -10
  57. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  58. package/esm/mcp-server/tools/pipeshubChat.d.ts +0 -3
  59. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  60. package/esm/mcp-server/tools/pipeshubChat.js +75 -98
  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 +62 -8
  64. package/esm/mcp-server/tools/pipeshubDirectory.js.map +1 -1
  65. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +118 -1
  66. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -1
  67. package/esm/mcp-server/tools/pipeshubGetRecordContent.js +180 -29
  68. package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -1
  69. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  70. package/esm/mcp-server/tools/pipeshubSearch.js +15 -2
  71. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  72. package/esm/mcp-server/tools/pipeshubSources.js +2 -2
  73. package/esm/mcp-server/tools/pipeshubSources.js.map +1 -1
  74. package/esm/models/lookuprecordbyidentifierop.d.ts +7 -0
  75. package/esm/models/lookuprecordbyidentifierop.d.ts.map +1 -0
  76. package/esm/models/lookuprecordbyidentifierop.js +14 -0
  77. package/esm/models/lookuprecordbyidentifierop.js.map +1 -0
  78. package/esm/models/navigateknowledgegraphop.d.ts +14 -0
  79. package/esm/models/navigateknowledgegraphop.d.ts.map +1 -0
  80. package/esm/models/navigateknowledgegraphop.js +25 -0
  81. package/esm/models/navigateknowledgegraphop.js.map +1 -0
  82. package/esm/tool-names.js +4 -4
  83. package/esm/tool-names.js.map +1 -1
  84. package/package.json +3 -2
  85. package/qm/README.md +247 -0
  86. package/qm/SECURITY.md +134 -0
  87. package/qm/TROUBLESHOOTING.md +218 -0
  88. package/qm/qm.config.fragment.jsonc +42 -0
  89. package/qm/sandbox/Dockerfile +22 -0
  90. package/qm/sandbox/skills/pipeshub/SKILL.md +69 -0
  91. package/qm/sandbox/tools/pipeshub/tool.json +36 -0
  92. package/src/cli/client.ts +279 -0
  93. package/src/cli/commands.ts +403 -0
  94. package/src/cli/config.ts +229 -0
  95. package/src/cli/init-qm.ts +215 -0
  96. package/src/cli/pipeshub.ts +333 -0
  97. package/src/funcs/connectorLookupRecordByIdentifier.ts +200 -0
  98. package/src/funcs/connectorNavigateKnowledgeGraph.ts +215 -0
  99. package/src/lib/base64.ts +6 -3
  100. package/src/lib/bytes-to-base64.ts +24 -0
  101. package/src/mcp-server/instructions.ts +30 -0
  102. package/src/mcp-server/shared.ts +7 -3
  103. package/src/mcp-server/tools/_agui.ts +138 -0
  104. package/src/mcp-server/tools/_helpers.ts +55 -8
  105. package/src/mcp-server/tools/pipeshubChat.ts +78 -97
  106. package/src/mcp-server/tools/pipeshubDirectory.ts +65 -7
  107. package/src/mcp-server/tools/pipeshubGetRecordContent.ts +213 -29
  108. package/src/mcp-server/tools/pipeshubSearch.ts +15 -2
  109. package/src/mcp-server/tools/pipeshubSources.ts +2 -2
  110. package/src/models/lookuprecordbyidentifierop.ts +26 -0
  111. package/src/models/navigateknowledgegraphop.ts +56 -0
  112. package/src/tool-names.ts +4 -4
@@ -15,11 +15,23 @@ import {
15
15
 
16
16
  /**
17
17
  * Parse a fetch Response as JSON, returning a CallToolResult error when the
18
- * body is missing / malformed.
18
+ * status is not ok, or the body is missing / malformed.
19
+ *
20
+ * The status check has to happen here rather than in each caller. The SDK funcs
21
+ * are generated with `errorCodes: []`, so `result.ok` reports transport
22
+ * failures only and a 401 arrives looking exactly like a success. Parsing that
23
+ * body yields an envelope with no results in it, which every caller then
24
+ * reports as an empty corpus — a failed credential becomes "no documents
25
+ * found". Guarding at the single point where a body is turned into a value
26
+ * closes that for every present and future caller.
19
27
  */
20
28
  export async function readJson<T = unknown>(
21
29
  response: Response,
30
+ context = "PipesHub request",
22
31
  ): Promise<{ ok: true; value: T } | { ok: false; result: CallToolResult }> {
32
+ const httpErr = await httpErrorResult(response, context);
33
+ if (httpErr) return { ok: false, result: httpErr };
34
+
23
35
  const text = await response.text();
24
36
  if (!text) {
25
37
  return {
@@ -48,6 +60,24 @@ export async function readJson<T = unknown>(
48
60
  }
49
61
  }
50
62
 
63
+ /**
64
+ * Reject an access token whose own expiry has already passed.
65
+ *
66
+ * Expiry is the one part of a credential's validity that can be established
67
+ * without asking the server, so it is worth checking before spending a
68
+ * round-trip — and it still answers when the server is unreachable. Returns
69
+ * `null` when the token is unexpired or carries no usable `exp`.
70
+ */
71
+ export function expiredTokenError(exp: unknown): CallToolResult | null {
72
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
73
+ if (exp * 1000 > Date.now()) return null;
74
+ return errorResult(
75
+ `The access token expired on ${new Date(exp * 1000).toISOString()}. `
76
+ + "Mint a new personal access token in PipesHub under "
77
+ + "Developer Settings → Personal Access Tokens.",
78
+ );
79
+ }
80
+
51
81
  /** Return a CallToolResult holding a single JSON-stringified text block. */
52
82
  export function jsonResult(value: unknown): CallToolResult {
53
83
  return {
@@ -105,7 +135,12 @@ export async function httpErrorResult(
105
135
  }
106
136
  }
107
137
 
108
- const detail = message ? ` ${message.slice(0, 400)}` : "";
138
+ // The server's reason rarely ends in punctuation, which runs it straight
139
+ // into the hint below ("Invalid token Check that ...").
140
+ const reason = message.slice(0, 400).trim();
141
+ const detail = reason
142
+ ? ` ${/[.!?]$/.test(reason) ? reason : `${reason}.`}`
143
+ : "";
109
144
  const auth = (response.status === 401 || response.status === 403)
110
145
  ? " Check that the bearer token / credentials are valid and not expired."
111
146
  : "";
@@ -122,7 +157,12 @@ export async function httpErrorResult(
122
157
  export async function readValidated<T>(
123
158
  response: Response,
124
159
  schema: z.ZodType<T>,
160
+ context = "PipesHub request",
125
161
  ): Promise<{ ok: true; value: T } | { ok: false; result: CallToolResult }> {
162
+ // Same reasoning as readJson: never let a non-2xx body reach the parser.
163
+ const httpErr = await httpErrorResult(response, context);
164
+ if (httpErr) return { ok: false, result: httpErr };
165
+
126
166
  const text = await response.text();
127
167
  if (!text) {
128
168
  return { ok: false, result: errorResult("Empty response from server") };
@@ -224,17 +264,24 @@ export function trimConversation(conv: any) {
224
264
  export function trimCitations(citations: unknown) {
225
265
  if (!Array.isArray(citations)) return [];
226
266
  return citations.map((c: any) => {
227
- const md = c?.metadata ?? {};
267
+ // Two wire shapes exist. Flat: `{ content, chunkIndex, metadata }`.
268
+ // Nested (current backend): `{ citationId, citationData: { content,
269
+ // chunkIndex, metadata } }`. Reading only the flat one silently yields
270
+ // a citation with every field `undefined` — which JSON.stringify drops,
271
+ // leaving `{"snippet": null}` and no way to identify the source at all.
272
+ const cd = c?.citationData ?? c;
273
+ const md = cd?.metadata ?? c?.metadata ?? {};
274
+ const content = typeof cd?.content === "string" ? cd.content : c?.content;
228
275
  return {
229
- recordId: md.recordId ?? c?.recordId,
230
- recordName: md.recordName ?? c?.recordName,
231
- snippet: typeof c?.content === "string"
232
- ? c.content.slice(0, 280)
276
+ recordId: md.recordId ?? cd?.recordId ?? c?.recordId,
277
+ recordName: md.recordName ?? cd?.recordName ?? c?.recordName,
278
+ snippet: typeof content === "string"
279
+ ? content.slice(0, 280)
233
280
  : md.blockText ?? null,
234
281
  mimeType: md.mimeType,
235
282
  webUrl: md.webUrl,
236
283
  pageNum: md.pageNum,
237
- chunkIndex: c?.chunkIndex ?? md.chunkIndex,
284
+ chunkIndex: cd?.chunkIndex ?? c?.chunkIndex ?? md.chunkIndex,
238
285
  };
239
286
  });
240
287
  }
@@ -1,24 +1,13 @@
1
1
  // `POST /conversations/stream` (and
2
2
  // `POST /conversations/{conversationId}/messages/stream` for follow-ups),
3
3
  // accumulate the frames server-side, and hand the LLM a single trimmed
4
- // `CallToolResult` once the stream emits its terminal `complete` (or
5
- // `error`) frame.
4
+ // `CallToolResult` once the stream reaches a terminal frame.
6
5
  //
7
- // Frame types we know about (from live wire traces):
8
- // - connected — handshake; ignored
9
- // - status — { status: "started" | "searching" | "processing"
10
- // | "checking_tools" | ... }; ignored (UI-only)
11
- // - answer_chunk — { content, accumulated, ... }; we keep the latest
12
- // `accumulated` as a fallback in case the stream
13
- // ends without a `complete` frame
14
- // - tool_call — model invoked an internal tool; we collect these
15
- // as observability breadcrumbs but the LLM doesn't
16
- // need them to answer
17
- // - tool_success — paired with tool_call; same treatment
18
- // - complete — terminal; payload is `{ conversation, meta }`,
19
- // matching the non-stream response shape, so we
20
- // feed it through `trimConversation` like before
21
- // - error — terminal; surface to the LLM as a tool error
6
+ // The wire protocol is AG-UI. The legacy `connected` / `answer_chunk` /
7
+ // `complete` / `error` vocabulary was removed with the new agent loop and is
8
+ // never emitted. The frames that matter CUSTOM{conversation_created},
9
+ // TEXT_MESSAGE_CONTENT, RUN_FINISHED, RUN_ERROR are folded by `./_agui.js`,
10
+ // which is kept separate so the fold stays a pure function over frames.
22
11
 
23
12
  import * as z from "zod";
24
13
  import { conversationsStreamConversation } from "../../funcs/conversationsStreamConversation.js";
@@ -26,6 +15,11 @@ import { conversationsStreamMessage } from "../../funcs/conversationsStreamMessa
26
15
  import { agentsStreamConversation } from "../../funcs/agentsStreamConversation.js";
27
16
  import { agentsStreamMessage } from "../../funcs/agentsStreamMessage.js";
28
17
  import { ToolDefinition } from "../tools.js";
18
+ import {
19
+ applyAGUIFrame,
20
+ newChatStreamState,
21
+ salvagedText,
22
+ } from "./_agui.js";
29
23
  import {
30
24
  errorResult,
31
25
  httpErrorResult,
@@ -36,9 +30,11 @@ import {
36
30
 
37
31
  const FiltersShape = z.object({
38
32
  apps: z.array(z.string()).optional().describe(
39
- "Source-scoping ids. Mix connector instance UUIDs with the synthetic "
40
- + "`knowledgeBase_<orgId>` id (use pipeshub_sources to discover them). "
41
- + "Empty / omitted means no app-side restriction.",
33
+ "Source-scoping ids from `pipeshub_sources` — connector instance and / "
34
+ + "or knowledge base ids, mixed freely. The legacy org-wide "
35
+ + "`knowledgeBase_<orgId>` id is still accepted on deployments that "
36
+ + "predate per-KB sources. Empty / omitted means no app-side "
37
+ + "restriction.",
42
38
  ),
43
39
  kb: z.array(z.string()).optional().describe(
44
40
  "Legacy / unused. Leave empty.",
@@ -76,48 +72,53 @@ const args = {
76
72
  chatMode: z.enum([
77
73
  "internal_search",
78
74
  "web_search",
79
- "auto",
80
75
  "quick",
81
- "verification",
82
- "deep",
83
76
  ]).optional().describe(
84
77
  "Response strategy. The valid values depend on whether `agentId` is set:\n"
85
78
  + "- WITHOUT `agentId` (plain chat): `internal_search` — answer from the "
86
79
  + "org's indexed knowledge (default) — or `web_search` — answer from the "
87
80
  + "live web.\n"
88
- + "- WITH `agentId` (agent chat): `auto` (let the agent decide; default), "
89
- + "`quick`, `verification`, or `deep`.",
81
+ + "- WITH `agentId` (agent chat): `quick` is the only supported mode and "
82
+ + "is sent automatically, so this argument can be omitted.",
90
83
  ),
91
84
  };
92
85
 
93
86
  export const tool$pipeshubChat: ToolDefinition<typeof args> = {
94
87
  name: "pipeshub_chat",
95
88
  description:
96
- `**Primary chat tool handles both internal knowledge queries and web search.**
89
+ `Ask a question, get an answer grounded in the org's indexed data with
90
+ citations. It reads a few retrieved passages — never a whole document,
91
+ never a complete list.
92
+
93
+ **Three questions this tool gets WRONG. Check them first:**
94
+ - **Structure** — "what's under this epic?", "which pages are in this
95
+ space?", "what links to this ticket?", "what's in this folder?" →
96
+ \`pipeshub_get_record_content\` \`mode:"navigate"\`. Ranking cannot see how
97
+ records relate.
98
+ - **Exhaustive** — "how many X?", "list ALL the Y", "every Z" →
99
+ \`mode:"navigate"\`, which reports the group's real total. This tool
100
+ undercounts and will not say so.
101
+ - **One named document** — summarize it, extract from it, what does it say
102
+ about X → \`pipeshub_search\` for the \`recordId\`, then \`mode:"content"\`.
103
+
104
+ Everything else about the org's knowledge belongs here: policies,
105
+ processes, decisions, history, "what do we know about X", and any question
106
+ spanning several documents.
97
107
 
98
- **Internal search** (default, \`chatMode: "internal_search"\`): Use whenever
99
- the user asks about their documents, files, knowledge base, company policies,
100
- or anything that could plausibly be answered by content in their PipesHub-indexed
101
- sources (Drive, Box, Confluence, Slack, Gmail, Jira, the org's KB, ...).
102
- Grounds the answer in the user's actual data and returns citations.
103
- Answers come from a few retrieved passages, not whole documents — for
104
- any task needing a document's full content, use
105
- \`pipeshub_get_record_content\` instead.
108
+ **Internal search** (default, \`chatMode: "internal_search"\`): the user's
109
+ documents, files, knowledge base, company policies — anything in their
110
+ PipesHub-indexed sources (Drive, Box, Confluence, Slack, Gmail, Jira, the
111
+ org's KB, ...).
106
112
 
107
- **Web search** (\`chatMode: "web_search"\`): Use when the user asks about
108
- current events, public information, or anything unlikely to be in the org's
109
- internal knowledge base. Pass \`chatMode: "web_search"\` and this tool will
110
- search the public web instead.
113
+ **Web search** (\`chatMode: "web_search"\`): current events or public
114
+ information unlikely to be in the org's knowledge base.
115
+
116
+ Both are plain-chat modes. **Agent chat** — pass an \`agentId\` from
117
+ \`pipeshub_agents\` — runs against that agent's own prompt, tools and knowledge;
118
+ \`quick\` is its only mode, requires the \`agentId\`, and is sent automatically.
111
119
 
112
- **When to pick this over other tools:**
113
- - "Summarize <doc>" / "key points of <doc>" / "what does <document> say
114
- about X?" → NOT this tool. Use \`pipeshub_search\` →
115
- \`pipeshub_get_record_content\`: answering for a specific document
116
- requires its full content, and chat only sees a few retrieved
117
- passages, never the whole document.
118
120
  - "What's our policy on Y?" → \`pipeshub_chat\` (internal_search)
119
121
  - "What's in the news about Z?" → \`pipeshub_chat\` (web_search)
120
- - "What is the latest version of <library>?" → \`pipeshub_chat\` (web_search)
121
122
  - "Find / locate the file named X" → \`pipeshub_search\` (then
122
123
  \`pipeshub_download_record\` if the user wants the bytes).
123
124
 
@@ -147,11 +148,19 @@ cited document, take \`citations[*].recordId\` and call
147
148
  args,
148
149
  tool: async (client, args, ctx) => {
149
150
  const fetchOptions = { signal: ctx.signal };
151
+ // `quick` is agent-only. The plain stream schemas accept internal_search /
152
+ // web_search / agent, so collapse anything else to the default rather than
153
+ // forwarding a value that path would reject.
154
+ const plainChatMode = args.chatMode === "web_search"
155
+ ? "web_search"
156
+ : "internal_search";
150
157
  let response: Response;
151
158
 
152
159
  if (args.agentId) {
153
- // Agent conversation. Agent chatMode vocabulary defaults to `auto`.
154
- const agentChatMode = args.chatMode ?? "auto";
160
+ // `quick` is the only value the agent stream schemas accept, and it is
161
+ // required so ignore whatever the caller passed rather than forwarding
162
+ // a value the gateway would reject.
163
+ const agentChatMode = "quick" as const;
155
164
  if (args.conversationId) {
156
165
  // Continue an existing agent conversation.
157
166
  const [result] = await agentsStreamMessage(client, {
@@ -192,7 +201,7 @@ cited document, take \`citations[*].recordId\` and call
192
201
  modelKey: args.modelKey,
193
202
  modelName: args.modelName,
194
203
  modelFriendlyName: args.modelFriendlyName,
195
- chatMode: args.chatMode ?? "internal_search",
204
+ chatMode: plainChatMode,
196
205
  },
197
206
  }, { fetchOptions }).$inspect();
198
207
  if (!result.ok) return errorResult(result.error.message);
@@ -205,7 +214,7 @@ cited document, take \`citations[*].recordId\` and call
205
214
  modelKey: args.modelKey,
206
215
  modelName: args.modelName,
207
216
  modelFriendlyName: args.modelFriendlyName,
208
- chatMode: args.chatMode ?? "internal_search",
217
+ chatMode: plainChatMode,
209
218
  }, { fetchOptions }).$inspect();
210
219
  if (!result.ok) return errorResult(result.error.message);
211
220
  response = result.value;
@@ -217,72 +226,44 @@ cited document, take \`citations[*].recordId\` and call
217
226
  const httpErr = await httpErrorResult(response, "PipesHub chat request");
218
227
  if (httpErr) return httpErr;
219
228
 
220
- // Drain the SSE stream. We only need the terminal `complete` (or
221
- // `error`) frame; everything else is observability and ignored.
222
- let finalConversation: any = null;
223
- let recordsUsed: number | undefined;
224
- let lastAccumulated: string | null = null;
225
- let errorMessage: string | null = null;
226
-
229
+ // Drain the AG-UI stream. `applyAGUIFrame` returns true only on a
230
+ // genuinely terminal frame a sub-agent's RUN_FINISHED carries no
231
+ // `result` and must not end the loop.
232
+ const state = newChatStreamState();
227
233
  try {
228
234
  for await (const frame of iterateSSE(response)) {
229
- switch (frame.event) {
230
- case "complete": {
231
- const d = frame.data ?? {};
232
- finalConversation = d.conversation ?? null;
233
- recordsUsed = d.recordsUsed ?? d.meta?.recordsUsed;
234
- break;
235
- }
236
- case "error": {
237
- const d = frame.data ?? {};
238
- errorMessage = typeof d === "string"
239
- ? d
240
- : (d.error ?? d.message ?? frame.raw ?? "Stream error");
241
- break;
242
- }
243
- case "answer_chunk": {
244
- const d = frame.data ?? {};
245
- if (typeof d.accumulated === "string") {
246
- lastAccumulated = d.accumulated;
247
- } else if (typeof d.content === "string") {
248
- lastAccumulated = (lastAccumulated ?? "") + d.content;
249
- }
250
- break;
251
- }
252
- // status / tool_call / tool_success / connected — ignored.
253
- default:
254
- break;
255
- }
256
- if (finalConversation || errorMessage) break;
235
+ if (applyAGUIFrame(state, frame)) break;
257
236
  }
258
237
  } catch (e: unknown) {
259
238
  return errorResult(`SSE stream failed: ${(e as Error).message}`);
260
239
  }
261
240
 
262
- if (errorMessage) return errorResult(errorMessage);
241
+ if (state.error) return errorResult(state.error);
263
242
 
264
- if (finalConversation) {
243
+ if (state.conversation) {
265
244
  return jsonResult({
266
- ...trimConversation(finalConversation),
267
- recordsUsed,
245
+ ...trimConversation(state.conversation),
246
+ recordsUsed: state.recordsUsed,
268
247
  });
269
248
  }
270
249
 
271
- // Stream ended without a terminal frame — unusual, but salvage what
272
- // we accumulated so the LLM has something to work with.
273
- if (lastAccumulated) {
250
+ // Stream ended without a terminal frame — unusual, but salvage what we
251
+ // accumulated so the LLM has something to work with. `conversation_created`
252
+ // arrives before any answer text, so a follow-up turn can still resume.
253
+ const salvaged = salvagedText(state);
254
+ if (salvaged) {
274
255
  return jsonResult({
275
- conversationId: null,
276
- title: null,
256
+ conversationId: state.conversationId,
257
+ title: state.title,
277
258
  status: "Inprogress",
278
- answer: lastAccumulated,
259
+ answer: salvaged,
279
260
  confidence: null,
280
261
  citations: [],
281
262
  followUpQuestions: [],
282
263
  messageCount: 0,
283
- recordsUsed,
284
- warning: "Stream ended without a `complete` frame; answer is "
285
- + "the last accumulated chunk and citations are unavailable.",
264
+ recordsUsed: state.recordsUsed,
265
+ warning: "Stream ended without a terminal RUN_FINISHED; answer is the "
266
+ + "accumulated TEXT_MESSAGE_CONTENT and citations are unavailable.",
286
267
  });
287
268
  }
288
269
 
@@ -12,6 +12,7 @@ import {
12
12
  decodeBearer,
13
13
  errorResult,
14
14
  jsonResult,
15
+ expiredTokenError,
15
16
  readJson,
16
17
  } from "./_helpers.js";
17
18
 
@@ -24,8 +25,8 @@ const args = {
24
25
  "list_my_teams",
25
26
  ]).describe(
26
27
  "What to do:\n"
27
- + "- `whoami` — return the authenticated user's identity (decoded from "
28
- + "the bearer JWT). No other args needed.\n"
28
+ + "- `whoami` — return the authenticated user's identity, confirmed "
29
+ + "against the server. No other args needed.\n"
29
30
  + "- `list_users` — paginated list of org users. Optional `page`, "
30
31
  + "`limit`, `search` (substring match against name or email).\n"
31
32
  + "- `get_user` — full profile for one user. Required `userId`. "
@@ -56,7 +57,8 @@ actions — pick the right \`action\`:
56
57
 
57
58
  - \`whoami\` — who is the caller? Use this whenever you need the
58
59
  authenticated user's own id, email, or full name (e.g. before
59
- \`get_user\` on themselves).
60
+ \`get_user\` on themselves). Errors if the credential is expired
61
+ or revoked.
60
62
  - \`list_users\` — search / page through org users.
61
63
  - \`get_user\` — full \`User\` document for one user (requires \`userId\`).
62
64
  - \`list_groups\` — list user groups with \`userCount\`.
@@ -86,6 +88,56 @@ Output shape varies by action; see each action's docs above.`,
86
88
  + "their email and use `list_users` with `search`.",
87
89
  );
88
90
  }
91
+ // The claims come out of the local token, which proves nothing about
92
+ // whether the server still accepts it — a revoked token carries a
93
+ // perfectly good name and org. Since whoami is the command people run
94
+ // to check "is my login working?", answering from the token alone
95
+ // gives a confident yes in exactly the case that matters.
96
+ //
97
+ // Expiry is checkable offline, so check it first: it is the common
98
+ // case and costs no round-trip.
99
+ const exp = claims["exp"];
100
+ const expired = expiredTokenError(exp);
101
+ if (expired) return expired;
102
+ const tokenExpiresAt = typeof exp === "number"
103
+ ? new Date(exp * 1000).toISOString()
104
+ : undefined;
105
+
106
+ // Revocation can only be established by asking the server. get_user on
107
+ // the caller's own id needs `user:read`, which whoami's callers already
108
+ // hold, and returns 401 for a rejected credential.
109
+ const userId = claims["userId"];
110
+ let verified: true | "unchecked" = "unchecked";
111
+ let unverifiedReason: string | undefined =
112
+ "No userId claim in the token, so the identity could not be "
113
+ + "confirmed with the server.";
114
+
115
+ if (typeof userId === "string" && userId) {
116
+ const [probe] = await usersGetUserById(client, { id: userId }, {
117
+ fetchOptions,
118
+ }).$inspect();
119
+
120
+ if (!probe.ok) {
121
+ unverifiedReason = `Could not reach PipesHub to confirm the `
122
+ + `identity (${probe.error.message}). The details below come `
123
+ + `from the token itself.`;
124
+ } else if (probe.value.status === 401) {
125
+ return errorResult(
126
+ "PipesHub rejected this access token (HTTP 401 Unauthorized), "
127
+ + "so the identity in it is no longer valid — it has most "
128
+ + "likely been revoked. Mint a new personal access token "
129
+ + "under Developer Settings → Personal Access Tokens.",
130
+ );
131
+ } else if (probe.value.ok) {
132
+ verified = true;
133
+ unverifiedReason = undefined;
134
+ } else {
135
+ unverifiedReason = `PipesHub returned HTTP ${probe.value.status} `
136
+ + `when confirming the identity, so it could not be checked. `
137
+ + `The details below come from the token itself.`;
138
+ }
139
+ }
140
+
89
141
  return jsonResult({
90
142
  userId: claims["userId"],
91
143
  orgId: claims["orgId"],
@@ -93,6 +145,12 @@ Output shape varies by action; see each action's docs above.`,
93
145
  fullName: claims["fullName"],
94
146
  mobile: claims["mobile"],
95
147
  userSlug: claims["userSlug"],
148
+ tokenExpiresAt,
149
+ // Never `false`: that reads as "the server rejected this identity",
150
+ // which is a different and much more alarming claim than "this was
151
+ // not checked". A rejection returns an error above instead.
152
+ identityVerified: verified,
153
+ note: unverifiedReason,
96
154
  });
97
155
  }
98
156
 
@@ -103,7 +161,7 @@ Output shape varies by action; see each action's docs above.`,
103
161
  search: args.search,
104
162
  }, { fetchOptions }).$inspect();
105
163
  if (!result.ok) return errorResult(result.error.message);
106
- const parsed = await readJson(result.value);
164
+ const parsed = await readJson(result.value, "User listing");
107
165
  if (!parsed.ok) return parsed.result;
108
166
  return jsonResult(parsed.value);
109
167
  }
@@ -120,7 +178,7 @@ Output shape varies by action; see each action's docs above.`,
120
178
  id: args.userId,
121
179
  }, { fetchOptions }).$inspect();
122
180
  if (!result.ok) return errorResult(result.error.message);
123
- const parsed = await readJson(result.value);
181
+ const parsed = await readJson(result.value, "User lookup");
124
182
  if (!parsed.ok) return parsed.result;
125
183
  return jsonResult(parsed.value);
126
184
  }
@@ -132,7 +190,7 @@ Output shape varies by action; see each action's docs above.`,
132
190
  search: args.search,
133
191
  }, { fetchOptions }).$inspect();
134
192
  if (!result.ok) return errorResult(result.error.message);
135
- const parsed = await readJson(result.value);
193
+ const parsed = await readJson(result.value, "Group listing");
136
194
  if (!parsed.ok) return parsed.result;
137
195
  return jsonResult(parsed.value);
138
196
  }
@@ -144,7 +202,7 @@ Output shape varies by action; see each action's docs above.`,
144
202
  search: args.search,
145
203
  }, { fetchOptions }).$inspect();
146
204
  if (!result.ok) return errorResult(result.error.message);
147
- const parsed = await readJson(result.value);
205
+ const parsed = await readJson(result.value, "Team listing");
148
206
  if (!parsed.ok) return parsed.result;
149
207
  return jsonResult(parsed.value);
150
208
  }