@ziggs-ai/ziggs-mcp 0.9.1 → 0.9.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.
@@ -1,26 +1,26 @@
1
1
  /**
2
2
  * ZIG-667 — one error surface for every MCP tool.
3
3
  *
4
- * Raw client exceptions read like
5
- * `ContextReadClient.read messages 403 {"error":"not authorized for this scope"}`
6
- * an internal class.method name, a raw HTTP status, and a raw backend body.
7
- * LLM callers stall on stack-trace prose; they recover from errors they can
8
- * parse. This module strips the internals, keeps a stable machine-readable
9
- * code, and for authorization denials says what to do next.
4
+ * ZIG-1124 classify from the thrown {@link ApiError} fields (`status`,
5
+ * `code`, `body` / `message`). No client-prefix strip, no HTTP-status regex,
6
+ * no re-parsing a JSON body that was concatenated into the message string.
10
7
  */
11
8
  export interface ToolErrorShape {
12
9
  code: string;
13
10
  message: string;
14
11
  hint?: string;
15
12
  }
16
- /** Classify a raw client error message into a stable shape. */
17
- export declare function classifyToolError(rawMessage: string, knownStatus?: number | null): ToolErrorShape;
13
+ /**
14
+ * Classify a thrown client error (or a plain string this layer raised) into a
15
+ * stable MCP shape.
16
+ */
17
+ export declare function classifyToolError(input: unknown): ToolErrorShape;
18
18
  /**
19
19
  * MCP tool error result: machine-readable code + cleaned message (+ hint).
20
20
  *
21
21
  * Pass the caught error itself, not `err.message` — an `ApiError` carries the
22
- * HTTP status that decides the code and the hint (ZIG-1086). A plain string is
23
- * still accepted for refusals this layer raises on its own.
22
+ * HTTP status and server `code` that decide the hint (ZIG-1086 / ZIG-1124).
23
+ * A plain string is still accepted for refusals this layer raises on its own.
24
24
  */
25
25
  export declare function toolError(input: unknown): {
26
26
  content: {
package/dist/toolError.js CHANGED
@@ -1,15 +1,10 @@
1
1
  /**
2
2
  * ZIG-667 — one error surface for every MCP tool.
3
3
  *
4
- * Raw client exceptions read like
5
- * `ContextReadClient.read messages 403 {"error":"not authorized for this scope"}`
6
- * an internal class.method name, a raw HTTP status, and a raw backend body.
7
- * LLM callers stall on stack-trace prose; they recover from errors they can
8
- * parse. This module strips the internals, keeps a stable machine-readable
9
- * code, and for authorization denials says what to do next.
4
+ * ZIG-1124 classify from the thrown {@link ApiError} fields (`status`,
5
+ * `code`, `body` / `message`). No client-prefix strip, no HTTP-status regex,
6
+ * no re-parsing a JSON body that was concatenated into the message string.
10
7
  */
11
- const CLIENT_PREFIX = /^[A-Z][A-Za-z0-9]*Client\.[A-Za-z0-9_]+\s+/;
12
- const HTTP_STATUS = /(?:^|\s)([1-5]\d{2})(?=\s|$)/;
13
8
  const SCOPE_DENIED_HINT = 'You are not authorized for this scope. To get access: ask the counterparty ' +
14
9
  'to issue you a context grant (they run ziggs_context_issue_grant), or propose a ' +
15
10
  'bilateral link first (ziggs_agreement_propose with engagementKind "link"). Check what you can already ' +
@@ -39,51 +34,52 @@ function codeForStatus(status) {
39
34
  return 'BAD_REQUEST';
40
35
  return 'TOOL_ERROR';
41
36
  }
42
- /** Pull a human reason out of an embedded backend JSON body, if any. */
43
- function extractBodyReason(raw) {
44
- const start = raw.indexOf('{');
45
- if (start === -1)
46
- return null;
47
- try {
48
- const parsed = JSON.parse(raw.slice(start));
49
- const reason = parsed['error'] ?? parsed['message'];
50
- return typeof reason === 'string' && reason.trim() ? reason.trim() : null;
51
- }
52
- catch {
53
- return null;
54
- }
55
- }
56
37
  /**
57
38
  * An `ApiError` from api-client, recognised by shape rather than `instanceof`:
58
39
  * a bundled or duplicated copy of the class would fail an identity check while
59
40
  * carrying exactly the fields we need.
60
41
  */
61
- function thrownStatus(err) {
62
- if (typeof err !== 'object' || err === null)
63
- return null;
64
- const status = err.status;
65
- return typeof status === 'number' && status >= 100 && status < 600 ? status : null;
42
+ function thrownFields(err) {
43
+ if (typeof err === 'string') {
44
+ return { message: err, status: null, code: null };
45
+ }
46
+ if (typeof err !== 'object' || err === null) {
47
+ return { message: String(err), status: null, code: null };
48
+ }
49
+ const o = err;
50
+ const status = typeof o.status === 'number' && o.status >= 100 && o.status < 600
51
+ ? o.status
52
+ : null;
53
+ const code = typeof o.code === 'string' && o.code ? o.code : null;
54
+ const message = typeof o.message === 'string' && o.message ? o.message : String(err);
55
+ return { message, status, code };
66
56
  }
67
- /** Classify a raw client error message into a stable shape. */
68
- export function classifyToolError(rawMessage, knownStatus) {
69
- const cleaned = rawMessage.replace(CLIENT_PREFIX, '').trim();
70
- // ZIG-1086: the status is scraped from the message only as a LAST resort.
71
- // `ApiError` carries it as a real field, and every caller that dropped it
72
- // (passing `err.message` instead of `err`) landed here with nothing to match:
73
- // "missing scope: context:admin" has no digits, so it fell through to a bare
74
- // TOOL_ERROR and the agent never saw the recovery hint it needed.
75
- const statusMatch = HTTP_STATUS.exec(cleaned);
76
- const status = knownStatus ?? (statusMatch ? Number(statusMatch[1]) : null);
77
- const bodyReason = extractBodyReason(cleaned);
57
+ /**
58
+ * Classify a thrown client error (or a plain string this layer raised) into a
59
+ * stable MCP shape.
60
+ */
61
+ export function classifyToolError(input) {
62
+ const { message, status, code: machineCode } = thrownFields(input);
63
+ if (machineCode === 'AGENT_LACKS_HUMAN_AUTHORITY') {
64
+ return {
65
+ code: 'AGENT_LACKS_HUMAN_AUTHORITY',
66
+ message,
67
+ hint: HUMAN_AUTHORITY_HINT,
68
+ };
69
+ }
70
+ if (machineCode === 'NOT_AUTHORIZED_FOR_SCOPE') {
71
+ return {
72
+ code: 'NOT_AUTHORIZED_FOR_SCOPE',
73
+ message,
74
+ hint: SCOPE_DENIED_HINT,
75
+ };
76
+ }
78
77
  if (status === null) {
79
- return { code: 'TOOL_ERROR', message: cleaned || rawMessage };
78
+ return { code: 'TOOL_ERROR', message };
80
79
  }
81
80
  const code = codeForStatus(status);
82
- // Prefer the backend's own reason over the transport framing.
83
- const message = bodyReason ?? cleaned;
84
- // The human-authority guard refuses every impersonated agent, whatever it
85
- // asked for. Checked before the scope branch below: its message also mentions
86
- // scopes, and "ask for a grant on this scope" is the wrong advice here.
81
+ // Backend human-authority already sends AGENT_LACKS_HUMAN_AUTHORITY; keep a
82
+ // message fallback for older bodies / tests that omit `code`.
87
83
  if (code === 'NOT_AUTHORIZED' && /impersonated agent cannot perform/i.test(message)) {
88
84
  return {
89
85
  code: 'AGENT_LACKS_HUMAN_AUTHORITY',
@@ -91,9 +87,8 @@ export function classifyToolError(rawMessage, knownStatus) {
91
87
  hint: HUMAN_AUTHORITY_HINT,
92
88
  };
93
89
  }
94
- // Context-scope denials (the backend says "…for this scope") get the scope
95
- // code and a recovery path. Other 403s (connection grants, party checks)
96
- // keep the generic code — their fixes live in other domains.
90
+ // Many scope denials still throw a bare ForbiddenException string with no
91
+ // machine code classify those by the word "scope" in the message.
97
92
  if (code === 'NOT_AUTHORIZED' && /\bscope\b/i.test(message)) {
98
93
  return {
99
94
  code: 'NOT_AUTHORIZED_FOR_SCOPE',
@@ -107,14 +102,11 @@ export function classifyToolError(rawMessage, knownStatus) {
107
102
  * MCP tool error result: machine-readable code + cleaned message (+ hint).
108
103
  *
109
104
  * Pass the caught error itself, not `err.message` — an `ApiError` carries the
110
- * HTTP status that decides the code and the hint (ZIG-1086). A plain string is
111
- * still accepted for refusals this layer raises on its own.
105
+ * HTTP status and server `code` that decide the hint (ZIG-1086 / ZIG-1124).
106
+ * A plain string is still accepted for refusals this layer raises on its own.
112
107
  */
113
108
  export function toolError(input) {
114
- const message = typeof input === 'string'
115
- ? input
116
- : (input?.message ?? String(input));
117
- const shape = classifyToolError(String(message), thrownStatus(input));
109
+ const shape = classifyToolError(input);
118
110
  return {
119
111
  content: [
120
112
  { type: 'text', text: JSON.stringify({ error: shape }, null, 2) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/ziggs-mcp",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "MCP server for Claude Code, Cursor, and other MCP hosts \u2014 act as your Ziggs delegate agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
- "@ziggs-ai/api-client": "^0.9.0",
39
+ "@ziggs-ai/api-client": "^0.9.1",
40
40
  "dotenv": "^16.6.1",
41
41
  "zod": "^3.24.2"
42
42
  },