@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.
- package/README.md +2 -0
- package/bin/mcp-server.js +703 -189
- package/bin/mcp-server.js.map +19 -13
- package/bin/pipeshub.js +946 -0
- package/bin/pipeshub.js.map +14 -0
- package/esm/cli/client.d.ts +54 -0
- package/esm/cli/client.d.ts.map +1 -0
- package/esm/cli/client.js +235 -0
- package/esm/cli/client.js.map +1 -0
- package/esm/cli/commands.d.ts +30 -0
- package/esm/cli/commands.d.ts.map +1 -0
- package/esm/cli/commands.js +321 -0
- package/esm/cli/commands.js.map +1 -0
- package/esm/cli/config.d.ts +73 -0
- package/esm/cli/config.d.ts.map +1 -0
- package/esm/cli/config.js +218 -0
- package/esm/cli/config.js.map +1 -0
- package/esm/cli/init-qm.d.ts +11 -0
- package/esm/cli/init-qm.d.ts.map +1 -0
- package/esm/cli/init-qm.js +170 -0
- package/esm/cli/init-qm.js.map +1 -0
- package/esm/cli/pipeshub.d.ts +2 -0
- package/esm/cli/pipeshub.d.ts.map +1 -0
- package/esm/cli/pipeshub.js +296 -0
- package/esm/cli/pipeshub.js.map +1 -0
- package/esm/funcs/connectorLookupRecordByIdentifier.d.ts +68 -0
- package/esm/funcs/connectorLookupRecordByIdentifier.d.ts.map +1 -0
- package/esm/funcs/connectorLookupRecordByIdentifier.js +138 -0
- package/esm/funcs/connectorLookupRecordByIdentifier.js.map +1 -0
- package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts +75 -0
- package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts.map +1 -0
- package/esm/funcs/connectorNavigateKnowledgeGraph.js +152 -0
- package/esm/funcs/connectorNavigateKnowledgeGraph.js.map +1 -0
- package/esm/lib/base64.d.ts +1 -1
- package/esm/lib/base64.d.ts.map +1 -1
- package/esm/lib/base64.js +6 -3
- package/esm/lib/base64.js.map +1 -1
- package/esm/lib/bytes-to-base64.d.ts +16 -0
- package/esm/lib/bytes-to-base64.d.ts.map +1 -0
- package/esm/lib/bytes-to-base64.js +23 -0
- package/esm/lib/bytes-to-base64.js.map +1 -0
- package/esm/mcp-server/instructions.d.ts +1 -1
- package/esm/mcp-server/instructions.d.ts.map +1 -1
- package/esm/mcp-server/instructions.js +30 -0
- package/esm/mcp-server/instructions.js.map +1 -1
- package/esm/mcp-server/shared.d.ts +2 -2
- package/esm/mcp-server/shared.d.ts.map +1 -1
- package/esm/mcp-server/shared.js +7 -3
- package/esm/mcp-server/shared.js.map +1 -1
- package/esm/mcp-server/tools/_agui.d.ts +34 -0
- package/esm/mcp-server/tools/_agui.d.ts.map +1 -0
- package/esm/mcp-server/tools/_agui.js +91 -0
- package/esm/mcp-server/tools/_agui.js.map +1 -0
- package/esm/mcp-server/tools/_helpers.d.ts +20 -3
- package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
- package/esm/mcp-server/tools/_helpers.js +54 -10
- package/esm/mcp-server/tools/_helpers.js.map +1 -1
- package/esm/mcp-server/tools/pipeshubChat.d.ts +0 -3
- package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
- package/esm/mcp-server/tools/pipeshubChat.js +75 -98
- package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
- package/esm/mcp-server/tools/pipeshubDirectory.d.ts.map +1 -1
- package/esm/mcp-server/tools/pipeshubDirectory.js +62 -8
- package/esm/mcp-server/tools/pipeshubDirectory.js.map +1 -1
- package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +118 -1
- package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -1
- package/esm/mcp-server/tools/pipeshubGetRecordContent.js +180 -29
- package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -1
- package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
- package/esm/mcp-server/tools/pipeshubSearch.js +15 -2
- package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
- package/esm/mcp-server/tools/pipeshubSources.js +2 -2
- package/esm/mcp-server/tools/pipeshubSources.js.map +1 -1
- package/esm/models/lookuprecordbyidentifierop.d.ts +7 -0
- package/esm/models/lookuprecordbyidentifierop.d.ts.map +1 -0
- package/esm/models/lookuprecordbyidentifierop.js +14 -0
- package/esm/models/lookuprecordbyidentifierop.js.map +1 -0
- package/esm/models/navigateknowledgegraphop.d.ts +14 -0
- package/esm/models/navigateknowledgegraphop.d.ts.map +1 -0
- package/esm/models/navigateknowledgegraphop.js +25 -0
- package/esm/models/navigateknowledgegraphop.js.map +1 -0
- package/esm/tool-names.js +4 -4
- package/esm/tool-names.js.map +1 -1
- package/package.json +3 -2
- package/qm/README.md +247 -0
- package/qm/SECURITY.md +134 -0
- package/qm/TROUBLESHOOTING.md +218 -0
- package/qm/qm.config.fragment.jsonc +42 -0
- package/qm/sandbox/Dockerfile +22 -0
- package/qm/sandbox/skills/pipeshub/SKILL.md +69 -0
- package/qm/sandbox/tools/pipeshub/tool.json +36 -0
- package/src/cli/client.ts +279 -0
- package/src/cli/commands.ts +403 -0
- package/src/cli/config.ts +229 -0
- package/src/cli/init-qm.ts +215 -0
- package/src/cli/pipeshub.ts +333 -0
- package/src/funcs/connectorLookupRecordByIdentifier.ts +200 -0
- package/src/funcs/connectorNavigateKnowledgeGraph.ts +215 -0
- package/src/lib/base64.ts +6 -3
- package/src/lib/bytes-to-base64.ts +24 -0
- package/src/mcp-server/instructions.ts +30 -0
- package/src/mcp-server/shared.ts +7 -3
- package/src/mcp-server/tools/_agui.ts +138 -0
- package/src/mcp-server/tools/_helpers.ts +55 -8
- package/src/mcp-server/tools/pipeshubChat.ts +78 -97
- package/src/mcp-server/tools/pipeshubDirectory.ts +65 -7
- package/src/mcp-server/tools/pipeshubGetRecordContent.ts +213 -29
- package/src/mcp-server/tools/pipeshubSearch.ts +15 -2
- package/src/mcp-server/tools/pipeshubSources.ts +2 -2
- package/src/models/lookuprecordbyidentifierop.ts +26 -0
- package/src/models/navigateknowledgegraphop.ts +56 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
?
|
|
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
|
|
5
|
-
// `error`) frame.
|
|
4
|
+
// `CallToolResult` once the stream reaches a terminal frame.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
40
|
-
+ "
|
|
41
|
-
+ "
|
|
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): `
|
|
89
|
-
+ "
|
|
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
|
-
|
|
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"\`):
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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"\`):
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
//
|
|
154
|
-
|
|
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:
|
|
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:
|
|
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
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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 (
|
|
241
|
+
if (state.error) return errorResult(state.error);
|
|
263
242
|
|
|
264
|
-
if (
|
|
243
|
+
if (state.conversation) {
|
|
265
244
|
return jsonResult({
|
|
266
|
-
...trimConversation(
|
|
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
|
-
//
|
|
273
|
-
|
|
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:
|
|
276
|
-
title:
|
|
256
|
+
conversationId: state.conversationId,
|
|
257
|
+
title: state.title,
|
|
277
258
|
status: "Inprogress",
|
|
278
|
-
answer:
|
|
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
|
|
285
|
-
+ "
|
|
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
|
|
28
|
-
+ "the
|
|
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
|
}
|