@ateam-ai/mcp 0.4.47 → 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 +67 -4
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
|
//
|
|
@@ -795,7 +796,8 @@ export const tools = [
|
|
|
795
796
|
"- Add a tool: updates: { \"tools_push\": [{ name: \"conn.tool\", description: \"...\", inputs: [...], output: {...} }] }\n" +
|
|
796
797
|
"- Change intent: updates: { \"intents.supported_update\": [{ id: \"i1\", description: \"new desc\" }] }\n" +
|
|
797
798
|
"- CREATE a new skill: target='skill', skill_id='my-new-skill', updates: { \"problem.statement\": \"...\", \"role.persona\": \"...\" } — auto-scaffolded and added to solution topology.\n\n" +
|
|
798
|
-
"PREVIEW BEFORE WRITING: pass dry_run:true to see the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids) without applying. Use this before any destructive-looking edit
|
|
799
|
+
"PREVIEW BEFORE WRITING: pass dry_run:true to see the diff (arrays_merged, arrays_replaced, dropped_ids, added_ids) without applying. Use this before any destructive-looking edit.\n\n" +
|
|
800
|
+
"VERDICT (skill target): the response carries a NON-BLOCKING `validation` block { skill_id, valid, ready_to_export, error_count, incomplete_sections[], unresolved_refs } — the patch always saves even if the def is now invalid, so CHECK valid: false and fix incomplete_sections before relying on it (build_and_run will refuse to deploy an invalid skill). error_count can include auto-import connector-tool artifacts, so act on incomplete_sections first.",
|
|
799
801
|
inputSchema: {
|
|
800
802
|
type: "object",
|
|
801
803
|
properties: {
|
|
@@ -3831,6 +3833,46 @@ const handlers = {
|
|
|
3831
3833
|
} catch { /* advisory — never downgrade a successful patch on the health check */ }
|
|
3832
3834
|
}
|
|
3833
3835
|
|
|
3836
|
+
// Phase 5: validation verdict (skill target only). NON-BLOCKING — "silent is
|
|
3837
|
+
// the bug": a patch that leaves the definition invalid used to return ok:true
|
|
3838
|
+
// with NO verdict, so an agent (or a persona routing here as the "cheapest
|
|
3839
|
+
// correct tool") never saw it went red, and an invalid def slipped toward
|
|
3840
|
+
// Core (build_and_run refuses on errors, but the patch path reported nothing).
|
|
3841
|
+
// Report the verdict keyed by skill_id; never block. error_count can be
|
|
3842
|
+
// inflated by auto-imported connector tools (INVALID_TOOL_INPUTS /
|
|
3843
|
+
// MISSING_TOOL_OUTPUT fire on every solution because Core resolves their
|
|
3844
|
+
// contract at deploy, not the author) — so lead with the author-facing
|
|
3845
|
+
// signal: which sections are still incomplete, plus any unresolved refs.
|
|
3846
|
+
let validation = null;
|
|
3847
|
+
if (target === "skill" && skill_id) {
|
|
3848
|
+
try {
|
|
3849
|
+
const vr = await get(`/deploy/solutions/${solution_id}/skills/${encodeURIComponent(skill_id)}/validation`, sid);
|
|
3850
|
+
const v = vr?.validation || vr || {};
|
|
3851
|
+
const incomplete_sections = Object.entries(v.sections || {})
|
|
3852
|
+
.filter(([, s]) => s && s.complete === false)
|
|
3853
|
+
.map(([name]) => name);
|
|
3854
|
+
const unresolved = Object.entries(v.unresolved_refs || {}).filter(([, n]) => Number(n) > 0);
|
|
3855
|
+
validation = {
|
|
3856
|
+
skill_id,
|
|
3857
|
+
valid: v.valid === true,
|
|
3858
|
+
ready_to_export: v.ready_to_export === true,
|
|
3859
|
+
error_count: v.error_count ?? null,
|
|
3860
|
+
warning_count: v.warning_count ?? null,
|
|
3861
|
+
...(incomplete_sections.length && { incomplete_sections }),
|
|
3862
|
+
...(unresolved.length && { unresolved_refs: Object.fromEntries(unresolved) }),
|
|
3863
|
+
...(v.error_count > 0 && {
|
|
3864
|
+
_note: `error_count can include auto-import connector-tool artifacts — INVALID_TOOL_INPUTS / MISSING_TOOL_OUTPUT fire on every solution because Core resolves an auto-imported tool's contract at deploy, not the author. Act on incomplete_sections${unresolved.length ? " + unresolved_refs" : ""} first.`,
|
|
3865
|
+
}),
|
|
3866
|
+
...(v.valid === false && {
|
|
3867
|
+
_verdict: `Skill "${skill_id}" is INVALID — the patch was still saved + redeployed (non-blocking), but build_and_run will REFUSE to deploy while errors stand. Fix the above, then re-check.`,
|
|
3868
|
+
}),
|
|
3869
|
+
};
|
|
3870
|
+
} catch { /* advisory — a validation hiccup never downgrades a saved patch */ }
|
|
3871
|
+
}
|
|
3872
|
+
const validationStatus = (validation && validation.valid === false)
|
|
3873
|
+
? ` ⚠️ Skill "${skill_id}" is INVALID (${validation.error_count ?? "?"} error(s)) — see validation (non-blocking; build_and_run will refuse until fixed).`
|
|
3874
|
+
: "";
|
|
3875
|
+
|
|
3834
3876
|
return {
|
|
3835
3877
|
ok: true,
|
|
3836
3878
|
solution_id,
|
|
@@ -3860,13 +3902,14 @@ const handlers = {
|
|
|
3860
3902
|
}),
|
|
3861
3903
|
...(isNewSkill && { created_skill: skill_id }),
|
|
3862
3904
|
...(redeployResult && { redeploy: redeployResult }),
|
|
3905
|
+
...(validation && { validation }),
|
|
3863
3906
|
...(widget_health && { widget_health }),
|
|
3864
3907
|
...(test_result && { test_result }),
|
|
3865
|
-
_status: redeployOk
|
|
3908
|
+
_status: (redeployOk
|
|
3866
3909
|
? (widget_health && !widget_health.ok
|
|
3867
3910
|
? `✅ Patched on ${store} + redeployed. ⚠️ ${widget_health.issues?.length || 0} widget(s) not rendering — see widget_health.`
|
|
3868
3911
|
: `✅ Patched on ${store} + redeployed.`)
|
|
3869
|
-
: `⚠️ Patched on ${store} ✅ but redeploy timed out. Run: ateam_redeploy(solution_id` + (skill_id ? `, skill_id: "${skill_id}"` : '') + ')',
|
|
3912
|
+
: `⚠️ Patched on ${store} ✅ but redeploy timed out. Run: ateam_redeploy(solution_id` + (skill_id ? `, skill_id: "${skill_id}"` : '') + ')') + validationStatus,
|
|
3870
3913
|
_next: isLocal
|
|
3871
3914
|
? 'Local edit saved + redeployed. When the tenant connects a GitHub repo, the local state is pushed → GitHub (which then becomes master).'
|
|
3872
3915
|
: 'Create a checkpoint before making more changes: ateam_github_promote(solution_id)',
|
|
@@ -5174,6 +5217,10 @@ function summarizeLargeResult(result, toolName) {
|
|
|
5174
5217
|
return JSON.stringify(result, null, 2).slice(0, MAX_RESPONSE_CHARS);
|
|
5175
5218
|
}
|
|
5176
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
|
+
|
|
5177
5224
|
// ─── Dispatcher ─────────────────────────────────────────────────────
|
|
5178
5225
|
|
|
5179
5226
|
export async function handleToolCall(name, args, sessionId) {
|
|
@@ -5217,6 +5264,7 @@ export async function handleToolCall(name, args, sessionId) {
|
|
|
5217
5264
|
].join("\n"),
|
|
5218
5265
|
}],
|
|
5219
5266
|
isError: true,
|
|
5267
|
+
structuredContent: { ok: false, code: "UNAUTHENTICATED" },
|
|
5220
5268
|
};
|
|
5221
5269
|
}
|
|
5222
5270
|
|
|
@@ -5273,13 +5321,28 @@ export async function handleToolCall(name, args, sessionId) {
|
|
|
5273
5321
|
} catch { /* non-fatal — unauthed sessions or API blips shouldn't break bootstrap */ }
|
|
5274
5322
|
}
|
|
5275
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
|
+
}
|
|
5276
5337
|
return {
|
|
5277
|
-
content: [{ type: "text", text
|
|
5338
|
+
content: [{ type: "text", text }],
|
|
5278
5339
|
};
|
|
5279
5340
|
} catch (err) {
|
|
5341
|
+
const code = deriveErrorCode(err.message, err.code);
|
|
5280
5342
|
return {
|
|
5281
5343
|
content: [{ type: "text", text: err.message }],
|
|
5282
5344
|
isError: true,
|
|
5345
|
+
structuredContent: { ok: false, code },
|
|
5283
5346
|
};
|
|
5284
5347
|
}
|
|
5285
5348
|
}
|