@ateam-ai/mcp 0.4.48 → 0.4.49
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/package.json +1 -1
- package/src/mcpFailure.js +48 -0
- package/src/mcpFailure.test.js +64 -0
- package/src/tools.js +22 -1
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/mcpFailure.js
|
|
2
|
+
//
|
|
3
|
+
// Failure classification for the MCP tool dispatcher (used by handleToolCall in
|
|
4
|
+
// tools.js). A tool call can succeed at the TRANSPORT layer (MCP returns a
|
|
5
|
+
// result) while FAILING logically — the classic case being an upstream that
|
|
6
|
+
// answers HTTP 200 with "Authentication required" sitting in the body. MCP's
|
|
7
|
+
// `isError` and a machine-readable `code` are exactly for that gap: they let a
|
|
8
|
+
// caller (e.g. the ateam-proxy connector) ask "did this actually work?" WITHOUT
|
|
9
|
+
// parsing English.
|
|
10
|
+
//
|
|
11
|
+
// The human sentence STAYS in the result's content[].text — the reasoning loop
|
|
12
|
+
// reads it to decide what to do next — we only ADD `isError` +
|
|
13
|
+
// `structuredContent.code` alongside it. Never strip the prose.
|
|
14
|
+
//
|
|
15
|
+
// node --test src/mcpFailure.test.js
|
|
16
|
+
|
|
17
|
+
// This is the ONE boundary where recognizing the auth phrase from text is
|
|
18
|
+
// correct: we translate the upstream lie into a structured code exactly once,
|
|
19
|
+
// here, so nothing downstream ever has to.
|
|
20
|
+
export const AUTH_SIGNAL_RX = /\b(unauthenticated|authentication required|authentication failed|not authenticated|no api_key in session|call ateam_auth|master key required|invalid api key|expired token|401)\b/i;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map a failure to a machine-readable code. An explicit code (set by a handler
|
|
24
|
+
* or carried on a thrown error) always wins; otherwise recognize the auth
|
|
25
|
+
* signal at this single boundary; otherwise a generic TOOL_FAILED.
|
|
26
|
+
* @param {string|object} source the message/result the failure came with
|
|
27
|
+
* @param {string} [explicit] a code the handler/error already set
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function deriveErrorCode(source, explicit) {
|
|
31
|
+
if (explicit && typeof explicit === "string") return explicit;
|
|
32
|
+
const s = typeof source === "string"
|
|
33
|
+
? source
|
|
34
|
+
: (() => { try { return JSON.stringify(source || ""); } catch { return String(source); } })();
|
|
35
|
+
if (AUTH_SIGNAL_RX.test(s)) return "UNAUTHENTICATED";
|
|
36
|
+
return "TOOL_FAILED";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A top-level object result with ok:false is a logical failure. Nested *.ok
|
|
41
|
+
* (widget_health.ok / validation.valid) are their own advisory signals and do
|
|
42
|
+
* NOT flip the tool to error — only the tool's OWN primary `ok` does.
|
|
43
|
+
* @param {any} result
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
export function isLogicalFailure(result) {
|
|
47
|
+
return Boolean(result) && typeof result === "object" && !Array.isArray(result) && result.ok === false;
|
|
48
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/mcpFailure.test.js
|
|
2
|
+
//
|
|
3
|
+
// Proves the dispatcher's failure-classification rule: a logically-failed tool
|
|
4
|
+
// result (returned ok:false, or an upstream 200-with-auth-text) is recognized
|
|
5
|
+
// structurally and mapped to a machine-readable code — so the ateam-proxy never
|
|
6
|
+
// has to read English to know a call failed. Tests the REAL exported functions.
|
|
7
|
+
// node --test src/mcpFailure.test.js
|
|
8
|
+
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { deriveErrorCode, isLogicalFailure, AUTH_SIGNAL_RX } from "./mcpFailure.js";
|
|
12
|
+
|
|
13
|
+
test("auth phrases → UNAUTHENTICATED (the upstream 200-with-auth-text lie)", () => {
|
|
14
|
+
for (const s of [
|
|
15
|
+
"Authentication required — call ateam_auth first.",
|
|
16
|
+
"No api_key in session — call ateam_auth(api_key) first.",
|
|
17
|
+
"Master key required. Call ateam_auth(master_key) first.",
|
|
18
|
+
"Authentication failed: bad key",
|
|
19
|
+
"upstream said 401 Unauthorized",
|
|
20
|
+
"invalid API key",
|
|
21
|
+
]) {
|
|
22
|
+
assert.equal(deriveErrorCode(s), "UNAUTHENTICATED", `expected UNAUTHENTICATED for: ${s}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("non-auth failure → TOOL_FAILED", () => {
|
|
27
|
+
assert.equal(deriveErrorCode("could not read solution definition"), "TOOL_FAILED");
|
|
28
|
+
assert.equal(deriveErrorCode("redeploy timed out"), "TOOL_FAILED");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("an explicit handler/error code always wins over text sniffing", () => {
|
|
32
|
+
// Even auth-looking text must not override a code the handler already set.
|
|
33
|
+
assert.equal(deriveErrorCode("Authentication required", "RATE_LIMITED"), "RATE_LIMITED");
|
|
34
|
+
assert.equal(deriveErrorCode("boom", "MISSING_SOLUTION"), "MISSING_SOLUTION");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("object sources are stringified before matching (not just plain strings)", () => {
|
|
38
|
+
assert.equal(deriveErrorCode({ ok: false, message: "Authentication required" }), "UNAUTHENTICATED");
|
|
39
|
+
assert.equal(deriveErrorCode({ ok: false, error: "disk full" }), "TOOL_FAILED");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("nullish / weird sources never throw, fall back to TOOL_FAILED", () => {
|
|
43
|
+
assert.equal(deriveErrorCode(null), "TOOL_FAILED");
|
|
44
|
+
assert.equal(deriveErrorCode(undefined), "TOOL_FAILED");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("top-level ok:false is a logical failure; ok:true / missing / nested are not", () => {
|
|
48
|
+
assert.equal(isLogicalFailure({ ok: false, message: "x" }), true);
|
|
49
|
+
assert.equal(isLogicalFailure({ ok: true }), false);
|
|
50
|
+
assert.equal(isLogicalFailure({}), false);
|
|
51
|
+
// Nested advisory signals must NOT flip the tool to error (patch succeeded,
|
|
52
|
+
// widget just isn't rendering / def isn't valid yet).
|
|
53
|
+
assert.equal(isLogicalFailure({ ok: true, widget_health: { ok: false } }), false);
|
|
54
|
+
assert.equal(isLogicalFailure({ ok: true, validation: { valid: false } }), false);
|
|
55
|
+
// Arrays / primitives / null are never a logical failure.
|
|
56
|
+
assert.equal(isLogicalFailure([{ ok: false }]), false);
|
|
57
|
+
assert.equal(isLogicalFailure(null), false);
|
|
58
|
+
assert.equal(isLogicalFailure("ok:false"), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("AUTH_SIGNAL_RX is exported for reuse and is case-insensitive", () => {
|
|
62
|
+
assert.ok(AUTH_SIGNAL_RX.test("AUTHENTICATION REQUIRED"));
|
|
63
|
+
assert.ok(!AUTH_SIGNAL_RX.test("everything is fine"));
|
|
64
|
+
});
|
package/src/tools.js
CHANGED
|
@@ -29,6 +29,7 @@ const STAMP_WHERE_TOOLS = new Set([
|
|
|
29
29
|
"ateam_verify_surface",
|
|
30
30
|
]);
|
|
31
31
|
import { renderAgentDocHeader, mergeAgentDoc, AGENT_DOC_SENTINEL } from "./agentDoc.js";
|
|
32
|
+
import { deriveErrorCode, isLogicalFailure } from "./mcpFailure.js";
|
|
32
33
|
|
|
33
34
|
// ─── Async deploy helper ────────────────────────────────────────────
|
|
34
35
|
//
|
|
@@ -5216,6 +5217,10 @@ function summarizeLargeResult(result, toolName) {
|
|
|
5216
5217
|
return JSON.stringify(result, null, 2).slice(0, MAX_RESPONSE_CHARS);
|
|
5217
5218
|
}
|
|
5218
5219
|
|
|
5220
|
+
// Failure classification (isError + a machine-readable code, WITHOUT parsing
|
|
5221
|
+
// English) lives in ./mcpFailure.js — imported at the top — so the rule is
|
|
5222
|
+
// unit-testable in isolation (mcpFailure.test.js).
|
|
5223
|
+
|
|
5219
5224
|
// ─── Dispatcher ─────────────────────────────────────────────────────
|
|
5220
5225
|
|
|
5221
5226
|
export async function handleToolCall(name, args, sessionId) {
|
|
@@ -5259,6 +5264,7 @@ export async function handleToolCall(name, args, sessionId) {
|
|
|
5259
5264
|
].join("\n"),
|
|
5260
5265
|
}],
|
|
5261
5266
|
isError: true,
|
|
5267
|
+
structuredContent: { ok: false, code: "UNAUTHENTICATED" },
|
|
5262
5268
|
};
|
|
5263
5269
|
}
|
|
5264
5270
|
|
|
@@ -5315,13 +5321,28 @@ export async function handleToolCall(name, args, sessionId) {
|
|
|
5315
5321
|
} catch { /* non-fatal — unauthed sessions or API blips shouldn't break bootstrap */ }
|
|
5316
5322
|
}
|
|
5317
5323
|
|
|
5324
|
+
const text = formatResult(result, name);
|
|
5325
|
+
if (isLogicalFailure(result)) {
|
|
5326
|
+
// Logical failure RETURNED (not thrown) — e.g. { ok:false, message:"…
|
|
5327
|
+
// Authentication required" } or an upstream 200-with-auth-text. Flag it
|
|
5328
|
+
// so a caller detects it from isError/code, not by reading the prose.
|
|
5329
|
+
// The sentence stays in content[].text for the reasoning loop.
|
|
5330
|
+
const code = deriveErrorCode(result.message || result.error || text, result.code);
|
|
5331
|
+
return {
|
|
5332
|
+
content: [{ type: "text", text }],
|
|
5333
|
+
isError: true,
|
|
5334
|
+
structuredContent: { ok: false, code },
|
|
5335
|
+
};
|
|
5336
|
+
}
|
|
5318
5337
|
return {
|
|
5319
|
-
content: [{ type: "text", text
|
|
5338
|
+
content: [{ type: "text", text }],
|
|
5320
5339
|
};
|
|
5321
5340
|
} catch (err) {
|
|
5341
|
+
const code = deriveErrorCode(err.message, err.code);
|
|
5322
5342
|
return {
|
|
5323
5343
|
content: [{ type: "text", text: err.message }],
|
|
5324
5344
|
isError: true,
|
|
5345
|
+
structuredContent: { ok: false, code },
|
|
5325
5346
|
};
|
|
5326
5347
|
}
|
|
5327
5348
|
}
|