@pipeshub-ai/mcp 2.3.0 → 2.3.2
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 +1 -1
- package/bin/mcp-server.js +56 -15
- package/bin/mcp-server.js.map +7 -7
- package/bin/pipeshub.js +185 -24
- package/bin/pipeshub.js.map +7 -7
- package/esm/cli/client.d.ts.map +1 -1
- package/esm/cli/client.js +6 -1
- package/esm/cli/client.js.map +1 -1
- package/esm/cli/commands.d.ts.map +1 -1
- package/esm/cli/commands.js +22 -16
- package/esm/cli/commands.js.map +1 -1
- package/esm/cli/config.d.ts +2 -2
- package/esm/cli/config.js +2 -2
- package/esm/cli/init-qm.d.ts +51 -1
- package/esm/cli/init-qm.d.ts.map +1 -1
- package/esm/cli/init-qm.js +209 -16
- package/esm/cli/init-qm.js.map +1 -1
- package/esm/cli/pipeshub.js +4 -3
- package/esm/cli/pipeshub.js.map +1 -1
- 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 +41 -4
- package/esm/mcp-server/tools/_helpers.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 +1 -1
- package/esm/mcp-server/tools/pipeshubSearch.js +1 -1
- 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/availablemodelsresponse.d.ts +1 -1
- package/esm/models/conversation.d.ts +1 -1
- package/esm/models/userteamsresponse.d.ts +1 -1
- package/esm/tool-names.js +1 -1
- package/esm/tool-names.js.map +1 -1
- package/package.json +1 -1
- package/qm/README.md +84 -71
- package/qm/SECURITY.md +7 -5
- package/qm/TROUBLESHOOTING.md +34 -29
- package/qm/qm.config.fragment.jsonc +12 -33
- package/qm/sandbox/Dockerfile +1 -1
- package/qm/sandbox/skills/pipeshub/SKILL.md +27 -7
- package/qm/sandbox/tools/pipeshub/tool.json +1 -1
- package/src/cli/client.ts +8 -1
- package/src/cli/commands.ts +22 -16
- package/src/cli/config.ts +2 -2
- package/src/cli/init-qm.ts +206 -17
- package/src/cli/pipeshub.ts +4 -3
- package/src/mcp-server/tools/_helpers.ts +42 -2
- package/src/mcp-server/tools/pipeshubDirectory.ts +65 -7
- package/src/mcp-server/tools/pipeshubSearch.ts +1 -1
- package/src/mcp-server/tools/pipeshubSources.ts +2 -2
- package/src/tool-names.ts +1 -1
package/src/cli/init-qm.ts
CHANGED
|
@@ -89,10 +89,147 @@ RUN npm install -g "@pipeshub-ai/mcp@${version}" \\
|
|
|
89
89
|
# ----------------------------------------------------------------------------
|
|
90
90
|
`;
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Strip JSONC comments without mangling `//` inside string values.
|
|
94
|
+
*
|
|
95
|
+
* The naive version eats the second slash of `"https://example.com"` and turns
|
|
96
|
+
* a valid config into a parse error, which is a maddening failure to debug in
|
|
97
|
+
* a file the operator did not know we read.
|
|
98
|
+
*/
|
|
99
|
+
export function stripJsonComments(src: string): string {
|
|
100
|
+
let out = "";
|
|
101
|
+
let inString = false;
|
|
102
|
+
let inLine = false;
|
|
103
|
+
let inBlock = false;
|
|
104
|
+
for (let i = 0; i < src.length; i++) {
|
|
105
|
+
const c = src[i];
|
|
106
|
+
const next = src[i + 1];
|
|
107
|
+
if (inLine) {
|
|
108
|
+
if (c === "\n") { inLine = false; out += c; }
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (inBlock) {
|
|
112
|
+
if (c === "*" && next === "/") { inBlock = false; i++; }
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (inString) {
|
|
116
|
+
out += c;
|
|
117
|
+
if (c === "\\") { out += next ?? ""; i++; continue; }
|
|
118
|
+
if (c === '"') inString = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (c === '"') { inString = true; out += c; continue; }
|
|
122
|
+
if (c === "/" && next === "/") { inLine = true; i++; continue; }
|
|
123
|
+
if (c === "/" && next === "*") { inBlock = true; i++; continue; }
|
|
124
|
+
out += c;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Drop trailing commas before `}` or `]`. JSONC allows them and hand-edited
|
|
131
|
+
* configs collect them; `JSON.parse` does not. Without this a stray comma makes
|
|
132
|
+
* the config unreadable, which fails open and writes the Dockerfile we were
|
|
133
|
+
* trying not to write.
|
|
134
|
+
*/
|
|
135
|
+
export function dropTrailingCommas(src: string): string {
|
|
136
|
+
let out = "";
|
|
137
|
+
let inString = false;
|
|
138
|
+
for (let i = 0; i < src.length; i++) {
|
|
139
|
+
const c = src[i];
|
|
140
|
+
if (inString) {
|
|
141
|
+
out += c;
|
|
142
|
+
if (c === "\\") { out += src[i + 1] ?? ""; i++; continue; }
|
|
143
|
+
if (c === '"') inString = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (c === '"') { inString = true; out += c; continue; }
|
|
147
|
+
if (c === ",") {
|
|
148
|
+
let j = i + 1;
|
|
149
|
+
while (j < src.length && /\s/.test(src[j] as string)) j++;
|
|
150
|
+
if (src[j] === "}" || src[j] === "]") continue; // drop it
|
|
151
|
+
}
|
|
152
|
+
out += c;
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface DeploymentShape {
|
|
158
|
+
target?: string | undefined;
|
|
159
|
+
backend?: string | undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read `target` and `sandbox.backend` from the operator's existing
|
|
164
|
+
* qm.config.jsonc. Returns null when there is no readable config — a fresh
|
|
165
|
+
* directory, or a file we cannot parse. Never throws: this only decides how
|
|
166
|
+
* much to scaffold, and a config we cannot read must not stop the scaffold.
|
|
167
|
+
*/
|
|
168
|
+
export async function readDeploymentShape(
|
|
169
|
+
dest: string,
|
|
170
|
+
): Promise<DeploymentShape | null> {
|
|
171
|
+
try {
|
|
172
|
+
const raw = await readFile(join(dest, "qm.config.jsonc"), "utf8");
|
|
173
|
+
const cfg = JSON.parse(dropTrailingCommas(stripJsonComments(raw))) as {
|
|
174
|
+
target?: unknown;
|
|
175
|
+
sandbox?: { backend?: unknown };
|
|
176
|
+
};
|
|
177
|
+
const target = typeof cfg.target === "string" ? cfg.target : undefined;
|
|
178
|
+
const backend = typeof cfg.sandbox?.backend === "string"
|
|
179
|
+
? cfg.sandbox.backend
|
|
180
|
+
: undefined;
|
|
181
|
+
if (!target && !backend) return null;
|
|
182
|
+
return { target, backend };
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Why a custom sandbox image cannot boot for this deployment, or null when it
|
|
190
|
+
* can. A reason rather than a boolean so the report can never describe a
|
|
191
|
+
* deployment as something it is not.
|
|
192
|
+
*
|
|
193
|
+
* `backend` is what decides where sandboxes run, not `target`: the CLI requires
|
|
194
|
+
* `"backend": "aws"` to have `target: "aws"` but not the reverse, so an AWS
|
|
195
|
+
* control plane running Sprites sandboxes is a supported and different thing
|
|
196
|
+
* from Lambda MicroVMs (`config.js:1106-1112`).
|
|
197
|
+
*
|
|
198
|
+
* This describes QM as it behaves today. If yc-software/qm#272 is fixed so
|
|
199
|
+
* Sprites boot a published image, the Sprites case here stops being true.
|
|
200
|
+
*/
|
|
201
|
+
export type ImageSkipReason = "sprites-ignores-image" | "aws-microvm" | null;
|
|
202
|
+
|
|
203
|
+
export function imageSkipReason(shape: DeploymentShape | null): ImageSkipReason {
|
|
204
|
+
if (!shape) return null;
|
|
205
|
+
// Lambda MicroVMs have no install mechanism at all (qm#350).
|
|
206
|
+
if (shape.backend === "aws") return "aws-microvm";
|
|
207
|
+
// Sprites boot the stock base and ignore a published image (qm#272).
|
|
208
|
+
if (shape.backend === "sprites" || shape.target === "fly") {
|
|
209
|
+
return "sprites-ignores-image";
|
|
210
|
+
}
|
|
211
|
+
// An AWS target with no backend declared cannot run agents yet; skip rather
|
|
212
|
+
// than write a file whose fate depends on a choice not made.
|
|
213
|
+
if (shape.target === "aws") return "sprites-ignores-image";
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether to scaffold a Dockerfile. Unknown shapes scaffold as before:
|
|
219
|
+
* guessing wrong in that direction removes a file someone needs.
|
|
220
|
+
*/
|
|
221
|
+
export function customImageBoots(shape: DeploymentShape | null): boolean {
|
|
222
|
+
return imageSkipReason(shape) === null;
|
|
223
|
+
}
|
|
224
|
+
|
|
92
225
|
export interface InitResult {
|
|
93
226
|
written: string[];
|
|
94
227
|
skipped: string[];
|
|
95
|
-
dockerfileAction: "created" | "appended" | "manual";
|
|
228
|
+
dockerfileAction: "created" | "appended" | "manual" | "skipped-unusable";
|
|
229
|
+
shape: DeploymentShape | null;
|
|
230
|
+
skipReason: ImageSkipReason;
|
|
231
|
+
/** A Dockerfile an earlier version already wrote, on a deploy that cannot use it. */
|
|
232
|
+
staleDockerfile: boolean;
|
|
96
233
|
version: string;
|
|
97
234
|
configFragment: string;
|
|
98
235
|
}
|
|
@@ -112,6 +249,7 @@ export async function initQm(
|
|
|
112
249
|
|
|
113
250
|
const version = await packageVersion(root);
|
|
114
251
|
const dest = resolve(targetDir);
|
|
252
|
+
const shape = await readDeploymentShape(dest);
|
|
115
253
|
const written: string[] = [];
|
|
116
254
|
const skipped: string[] = [];
|
|
117
255
|
|
|
@@ -133,7 +271,17 @@ export async function initQm(
|
|
|
133
271
|
// has no PipesHub block yet, and otherwise leave it entirely alone.
|
|
134
272
|
const dockerfile = join(dest, "sandbox", "Dockerfile");
|
|
135
273
|
let dockerfileAction: InitResult["dockerfileAction"];
|
|
136
|
-
|
|
274
|
+
const skipReason = imageSkipReason(shape);
|
|
275
|
+
// An earlier version wrote this unconditionally. Leaving it is not neutral:
|
|
276
|
+
// upcoming QM validation rejects it outright, so the operator needs telling.
|
|
277
|
+
// Not deleted here — it is their file and may carry their own build steps.
|
|
278
|
+
let staleDockerfile = false;
|
|
279
|
+
if (skipReason !== null) {
|
|
280
|
+
// Deliberately not written. The skill installs the CLI on first use, which
|
|
281
|
+
// is the path that actually runs on these deployments.
|
|
282
|
+
staleDockerfile = await exists(dockerfile);
|
|
283
|
+
dockerfileAction = "skipped-unusable";
|
|
284
|
+
} else if (!await exists(dockerfile)) {
|
|
137
285
|
await copyFile(join(bundle, "sandbox", "Dockerfile"), dockerfile);
|
|
138
286
|
// Restamp the pin so it matches the version actually installed.
|
|
139
287
|
const body = await readFile(dockerfile, "utf8");
|
|
@@ -157,9 +305,8 @@ export async function initQm(
|
|
|
157
305
|
}
|
|
158
306
|
}
|
|
159
307
|
|
|
160
|
-
// Do not swallow this. The fragment
|
|
161
|
-
//
|
|
162
|
-
// them with a scaffold that cannot reach PipesHub and no sign of why.
|
|
308
|
+
// Do not swallow this. The fragment is part of the bundle contract; a
|
|
309
|
+
// successful init that cannot read it means the package was assembled wrong.
|
|
163
310
|
const fragmentPath = join(bundle, "qm.config.fragment.jsonc");
|
|
164
311
|
let configFragment: string;
|
|
165
312
|
try {
|
|
@@ -171,7 +318,16 @@ export async function initQm(
|
|
|
171
318
|
);
|
|
172
319
|
}
|
|
173
320
|
|
|
174
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
written,
|
|
323
|
+
skipped,
|
|
324
|
+
dockerfileAction,
|
|
325
|
+
version,
|
|
326
|
+
configFragment,
|
|
327
|
+
shape,
|
|
328
|
+
skipReason,
|
|
329
|
+
staleDockerfile,
|
|
330
|
+
};
|
|
175
331
|
}
|
|
176
332
|
|
|
177
333
|
export function renderInitReport(dest: string, r: InitResult): string {
|
|
@@ -183,6 +339,37 @@ export function renderInitReport(dest: string, r: InitResult): string {
|
|
|
183
339
|
for (const f of r.skipped) lines.push(` kept ${f} (already existed — use --force to replace)`);
|
|
184
340
|
lines.push("");
|
|
185
341
|
|
|
342
|
+
if (r.dockerfileAction === "skipped-unusable") {
|
|
343
|
+
if (r.skipReason === "aws-microvm") {
|
|
344
|
+
lines.push("No sandbox/Dockerfile was written: AWS Lambda MicroVM sandboxes");
|
|
345
|
+
lines.push("have no way to install a binary, so the file could never run.");
|
|
346
|
+
} else {
|
|
347
|
+
lines.push("No sandbox/Dockerfile was written: Fly Sprites boot the stock");
|
|
348
|
+
lines.push("image and ignore a published one, so the file would look like the");
|
|
349
|
+
lines.push("install path while never running.");
|
|
350
|
+
}
|
|
351
|
+
lines.push("The skill installs the CLI on first use instead — that is the line");
|
|
352
|
+
lines.push("that actually executes, and it needs nothing from you.");
|
|
353
|
+
lines.push("");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (r.staleDockerfile) {
|
|
357
|
+
lines.push("ACTION NEEDED: sandbox/Dockerfile already exists here, written by an");
|
|
358
|
+
lines.push("earlier version. This deployment cannot use it, and upcoming QM");
|
|
359
|
+
lines.push("validation rejects it rather than ignoring it — `qm check` will fail");
|
|
360
|
+
lines.push("with an error naming that file. Delete it, or remove the PipesHub");
|
|
361
|
+
lines.push("install block if the rest of it is yours.");
|
|
362
|
+
lines.push("");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (r.skipReason === "aws-microvm") {
|
|
366
|
+
lines.push("Heads up on AWS: with Lambda MicroVM sandboxes the CLI cannot be");
|
|
367
|
+
lines.push("installed at all (yc-software/qm#350). The tool's guidance, network");
|
|
368
|
+
lines.push("allowlist, and approval rules still apply, but the binary will be");
|
|
369
|
+
lines.push("missing. The sprites backend is what this bundle is tested against.");
|
|
370
|
+
lines.push("");
|
|
371
|
+
}
|
|
372
|
+
|
|
186
373
|
if (r.dockerfileAction === "appended") {
|
|
187
374
|
lines.push("Appended the install block to your existing sandbox/Dockerfile.");
|
|
188
375
|
} else if (r.dockerfileAction === "manual") {
|
|
@@ -195,20 +382,22 @@ export function renderInitReport(dest: string, r: InitResult): string {
|
|
|
195
382
|
lines.push("");
|
|
196
383
|
lines.push("Two things left to do:");
|
|
197
384
|
lines.push("");
|
|
198
|
-
lines.push("1. Set
|
|
199
|
-
lines.push("
|
|
200
|
-
lines.push("
|
|
385
|
+
lines.push("1. Set `egress` in sandbox/tools/pipeshub/tool.json to your PipesHub");
|
|
386
|
+
lines.push(" hostname (no scheme, no path). It must be reachable over public HTTPS");
|
|
387
|
+
lines.push(" — QM sandboxes do not run on your machine, so localhost is unreachable.");
|
|
201
388
|
lines.push("");
|
|
202
|
-
lines.push(
|
|
203
|
-
lines.push(
|
|
204
|
-
lines.push("
|
|
389
|
+
lines.push("2. Each person adds two personal keychain entries (service: pipeshub):");
|
|
390
|
+
lines.push(" PIPESHUB_TOKEN → their PAT (never paste it into chat)");
|
|
391
|
+
lines.push(" PIPESHUB_BASE_URL → public HTTPS origin, no /mcp path");
|
|
392
|
+
lines.push(" Do NOT put a token in sandbox.secretEnv (org-wide). Do NOT rely on");
|
|
393
|
+
lines.push(" sandbox.env for the URL — it does not reach the sandbox.");
|
|
205
394
|
lines.push("");
|
|
206
|
-
lines.push("
|
|
207
|
-
lines.push(" would hand one person's credential to everybody. Each person adds");
|
|
208
|
-
lines.push(" their own to their own keychain (service: pipeshub, kind: env).");
|
|
395
|
+
lines.push("Then: qm check && qm up");
|
|
209
396
|
lines.push("");
|
|
210
|
-
|
|
211
|
-
|
|
397
|
+
if (r.dockerfileAction !== "skipped-unusable") {
|
|
398
|
+
lines.push("On Sprites, `qm sandbox publish` does not put pipeshub on PATH.");
|
|
399
|
+
lines.push("The skill installs the CLI on first use.");
|
|
400
|
+
}
|
|
212
401
|
return lines.join("\n");
|
|
213
402
|
}
|
|
214
403
|
|
package/src/cli/pipeshub.ts
CHANGED
|
@@ -244,13 +244,14 @@ async function run(argv: string[]): Promise<number> {
|
|
|
244
244
|
|
|
245
245
|
if (origin === null) {
|
|
246
246
|
// Report the missing credential too when both are absent. Otherwise a
|
|
247
|
-
// person whose actual problem is "I never added my token" is told about
|
|
248
|
-
//
|
|
247
|
+
// person whose actual problem is "I never added my token" is told about a
|
|
248
|
+
// missing URL, and goes looking in the wrong place.
|
|
249
249
|
const alsoNoToken = token === null
|
|
250
250
|
? " Your PipesHub credential is also missing ($PIPESHUB_TOKEN is unset)."
|
|
251
251
|
: "";
|
|
252
252
|
throw new CliError(
|
|
253
|
-
"PIPESHUB_BASE_URL is not set —
|
|
253
|
+
"PIPESHUB_BASE_URL is not set — it must reach the sandbox as an env var "
|
|
254
|
+
+ "(keychain or org service credential, not sandbox.env)."
|
|
254
255
|
+ alsoNoToken
|
|
255
256
|
+ " Run 'pipeshub auth connect-help' for the steps.",
|
|
256
257
|
EXIT.USAGE,
|
|
@@ -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") };
|
|
@@ -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
|
}
|
|
@@ -80,7 +80,7 @@ When presenting results to the user, link each record using its
|
|
|
80
80
|
status?: string;
|
|
81
81
|
message?: string;
|
|
82
82
|
};
|
|
83
|
-
}>(result.value);
|
|
83
|
+
}>(result.value, "PipesHub search");
|
|
84
84
|
if (!parsed.ok) return parsed.result;
|
|
85
85
|
|
|
86
86
|
const sr = parsed.value.searchResponse ?? {};
|
|
@@ -57,7 +57,7 @@ are returned by default; pass \`include\` to override.`,
|
|
|
57
57
|
limit: 200,
|
|
58
58
|
}, { fetchOptions }).$inspect();
|
|
59
59
|
if (!r.ok) return errorResult(`sources: ${r.error.message}`);
|
|
60
|
-
const parsed = await readJson<{ items?: any[] }>(r.value);
|
|
60
|
+
const parsed = await readJson<{ items?: any[] }>(r.value, "Knowledge base listing");
|
|
61
61
|
if (!parsed.ok) return parsed.result;
|
|
62
62
|
result["sources"] = (parsed.value.items ?? []).map((n: any) => ({
|
|
63
63
|
id: n.id,
|
|
@@ -79,7 +79,7 @@ are returned by default; pass \`include\` to override.`,
|
|
|
79
79
|
modelType,
|
|
80
80
|
}, { fetchOptions }).$inspect();
|
|
81
81
|
if (!r.ok) return errorResult(`${key}: ${r.error.message}`);
|
|
82
|
-
const parsed = await readJson<{ models?: any[] }>(r.value);
|
|
82
|
+
const parsed = await readJson<{ models?: any[] }>(r.value, "Model listing");
|
|
83
83
|
if (!parsed.ok) return parsed.result;
|
|
84
84
|
result[key] = (parsed.value.models ?? []).map((m: any) => ({
|
|
85
85
|
modelKey: m.modelKey,
|
package/src/tool-names.ts
CHANGED
|
@@ -22,7 +22,7 @@ export const toolNames: Array<{ name: string; description: string }>= [
|
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"name": "pipeshub_directory",
|
|
25
|
-
"description": "Look up people, groups, and teams in PipesHub. One tool with five\nactions — pick the right `action`:\n\n- `whoami` — who is the caller? Use this whenever you need the\n authenticated user's own id, email, or full name (e.g. before\n `get_user` on themselves).\n- `list_users` — search / page through org users.\n- `get_user` — full `User` document for one user (requires `userId`).\n- `list_groups` — list user groups with `userCount`.\n- `list_my_teams` — teams the caller belongs to, with capability flags\n (`canEdit` / `canDelete` / `canManageMembers`).\n\nOutput shape varies by action; see each action's docs above."
|
|
25
|
+
"description": "Look up people, groups, and teams in PipesHub. One tool with five\nactions — pick the right `action`:\n\n- `whoami` — who is the caller? Use this whenever you need the\n authenticated user's own id, email, or full name (e.g. before\n `get_user` on themselves). Errors if the credential is expired\n or revoked.\n- `list_users` — search / page through org users.\n- `get_user` — full `User` document for one user (requires `userId`).\n- `list_groups` — list user groups with `userCount`.\n- `list_my_teams` — teams the caller belongs to, with capability flags\n (`canEdit` / `canDelete` / `canManageMembers`).\n\nOutput shape varies by action; see each action's docs above."
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"name": "pipeshub_agents",
|