Repository navigation
Expand file tree
/
Copy patherrors.ts
More file actions
194 lines (171 loc) · 6.66 KB
/
Copy patherrors.ts
File metadata and controls
194 lines (171 loc) · 6.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// McpError shape + the HTTP-envelope -> code mapping (DOC-ERROR-MODEL, DATATYPE-MCP-ERROR).
//
// Every failure surfaced to a caller is an McpError. `code` is the contract;
// callers ACT ON THE CODE and never parse `message`. The Pyramid API returns
// the canonical envelope `{ "error": { code, message, details } }` on non-2xx.
import type { McpErrorCode } from './types.js';
export class McpError extends Error {
readonly code: McpErrorCode;
readonly hint?: string;
readonly candidates?: string[];
constructor(
code: McpErrorCode,
message: string,
opts?: { hint?: string; candidates?: string[] }
) {
super(message);
this.name = 'McpError';
this.code = code;
if (opts?.hint !== undefined) this.hint = opts.hint;
if (opts?.candidates !== undefined) this.candidates = opts.candidates;
// Restore prototype chain for instanceof across transpilation targets.
Object.setPrototypeOf(this, McpError.prototype);
}
/** Plain serializable shape — what goes into MCP tool result text + CLI --json. */
toJSON(): { code: McpErrorCode; message: string; hint?: string; candidates?: string[] } {
const out: { code: McpErrorCode; message: string; hint?: string; candidates?: string[] } = {
code: this.code,
message: this.message,
};
if (this.hint !== undefined) out.hint = this.hint;
if (this.candidates !== undefined) out.candidates = this.candidates;
return out;
}
}
/** True when `e` is an McpError (instanceof, tolerant of realm differences). */
export function isMcpError(e: unknown): e is McpError {
return e instanceof McpError;
}
/**
* Render an McpError as an MCP tool result (DOC-ERROR-MODEL). The error's
* serializable `{ code, message, hint?, candidates? }` shape is JSON-encoded into
* a single text block with `isError: true`. Surfaces NEVER throw to the MCP
* transport — they return this instead so the model sees a typed, parseable error.
*/
export function toToolError(e: McpError): {
content: { type: 'text'; text: string }[];
isError: true;
} {
return {
content: [{ type: 'text', text: JSON.stringify(e.toJSON()) }],
isError: true,
};
}
const REGENERATE_KEY_HINT = 'Regenerate your key in Pyramid → Settings → API Keys.';
// ============ HTTP-envelope -> McpError ============
/** The Pyramid error envelope: `{ "error": { code, message, details } }`. */
interface PyramidEnvelope {
error?: { code?: string; message?: string; details?: unknown };
}
/** Defensively read `{ error: { code, message } }` out of a parsed body. */
function readEnvelope(body: unknown): { code?: string; message?: string } {
if (body && typeof body === 'object' && 'error' in body) {
const err = (body as PyramidEnvelope).error;
if (err && typeof err === 'object') {
return {
code: typeof err.code === 'string' ? err.code : undefined,
message: typeof err.message === 'string' ? err.message : undefined,
};
}
}
return {};
}
/**
* Best-effort 404 disambiguation from the server `error.code` / message. The
* envelope codes (`project_not_found`, `task_not_found`, …) are preferred; we
* fall back to a substring sniff, then to `unknown` when nothing is conclusive.
*/
function notFoundCode(env: { code?: string; message?: string }): McpErrorCode {
const hay = `${env.code ?? ''} ${env.message ?? ''}`.toLowerCase();
if (hay.includes('project')) return 'project_not_found';
if (hay.includes('task')) return 'task_not_found';
if (hay.includes('status')) return 'status_not_found';
if (hay.includes('stage')) return 'stage_not_found';
if (hay.includes('label')) return 'label_not_found';
if (hay.includes('user')) return 'user_not_found';
if (hay.includes('field')) return 'field_not_found';
return 'unknown';
}
/**
* Map a non-2xx HTTP status + parsed envelope body to an McpError, per the
* DOC-ERROR-MODEL table:
* 401 -> auth_invalid (regenerate-key hint; "expired" sniff -> auth_expired)
* 403 -> permission_denied
* 404 -> project/task/... (best-effort) else unknown
* 400|422 -> validation_failed
* 429 -> rate_limited
* 5xx / anything else -> unknown
*/
export function mapHttpError(status: number, body: unknown): McpError {
const env = readEnvelope(body);
const message = env.message ?? `Pyramid request failed (HTTP ${status}).`;
if (status === 401) {
const expired =
(env.code ?? '').toLowerCase().includes('expired') ||
(env.message ?? '').toLowerCase().includes('expired');
return new McpError(expired ? 'auth_expired' : 'auth_invalid', message, {
hint: REGENERATE_KEY_HINT,
});
}
if (status === 403) {
return new McpError('permission_denied', message);
}
if (status === 404) {
return new McpError(notFoundCode(env), message);
}
if (status === 400 || status === 422) {
return new McpError('validation_failed', message);
}
if (status === 409) {
// pyramid-server returns 409 `conflict` for both slug/prefix collisions and a
// missing/stale If-Match precondition (DOC-CONCURRENCY). The read-first retry in
// the client handles the racy case; a surfaced conflict means reread + retry.
return new McpError('conflict', message, {
hint: 'The resource changed since you last read it — reread it and try again.',
});
}
if (status === 429) {
// The backend has no rate limiting today; kept defensively for proxies/CDNs.
return new McpError('rate_limited', message, { hint: 'Slow down and retry.' });
}
// 5xx and any other non-2xx fall through to unknown.
return new McpError('unknown', message);
}
/**
* Coerce any thrown value into an McpError. McpErrors pass through unchanged;
* undici/network failures (and any other non-HTTP throw) become `network`,
* with a final `unknown` fallback for the truly opaque.
*/
export function toMcpError(e: unknown): McpError {
if (e instanceof McpError) return e;
if (e instanceof Error) {
if (isNetworkError(e)) {
return new McpError('network', e.message, {
hint: 'Check PYRAMID_BASE_URL and your network connection.',
});
}
return new McpError('unknown', e.message);
}
return new McpError('unknown', typeof e === 'string' ? e : 'Unknown error.');
}
/** Heuristic: does this Error look like an undici/Node network failure? */
function isNetworkError(e: Error): boolean {
const code = (e as NodeJS.ErrnoException).code ?? '';
const name = e.name ?? '';
const msg = e.message ?? '';
if (
code === 'ECONNREFUSED' ||
code === 'ENOTFOUND' ||
code === 'ECONNRESET' ||
code === 'ETIMEDOUT' ||
code === 'EAI_AGAIN' ||
code === 'UND_ERR_CONNECT_TIMEOUT' ||
code === 'UND_ERR_SOCKET' ||
code === 'UND_ERR_HEADERS_TIMEOUT'
) {
return true;
}
if (name === 'AbortError' || name === 'TimeoutError') return true;
// undici surfaces low-level failures as a generic TypeError: "fetch failed".
return /fetch failed|network|socket|connect/i.test(msg);
}