Skip to content

Commit c5cadde

Browse files
authored
Add PipelineError for plugin pipeline failures (#22)
Adds support for detecting and handling mcpd plugin pipeline failures: - Add PipelineError class with pipelineFlow, serverName, and operation properties - Detect Mcpd-Error-Type header on 500 responses (request-pipeline-failure, response-pipeline-failure) - Enrich PipelineError with server/tool context when called through performCall - Export PipelineFlow type and PIPELINE_FLOW_REQUEST/PIPELINE_FLOW_RESPONSE constants - Add comprehensive tests for pipeline error handling - Update README with PipelineError documentation
1 parent 415721a commit c5cadde

File tree

5 files changed

+312
-2
lines changed

5 files changed

+312
-2
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,12 +609,20 @@ import {
609609
ToolExecutionError, // Tool execution failed
610610
ValidationError, // Input validation failed
611611
TimeoutError, // Operation timed out
612+
PipelineError, // Pipeline processing failed
612613
} from "@mozilla-ai/mcpd";
613614

614615
try {
615616
const result = await client.servers.unknown.tools.tool();
616617
} catch (error) {
617-
if (error instanceof ToolNotFoundError) {
618+
if (error instanceof PipelineError) {
619+
// Pipeline failure - a required plugin failed during processing.
620+
console.error(`Pipeline ${error.pipelineFlow} failure:`, error.message);
621+
if (error.pipelineFlow === "response") {
622+
// Tool was called but results cannot be delivered.
623+
console.error("A required plugin failed during response processing");
624+
}
625+
} else if (error instanceof ToolNotFoundError) {
618626
console.error(
619627
`Tool not found: ${error.toolName} on server ${error.serverName}`,
620628
);
@@ -626,6 +634,20 @@ try {
626634
}
627635
```
628636

637+
### PipelineError
638+
639+
The `PipelineError` is thrown when mcpd's plugin pipeline fails. This indicates a problem with a plugin or an external system that a plugin depends on (e.g., audit service, authentication provider), not a problem with your request or the tool itself.
640+
641+
- **Response Pipeline Failure** (`pipelineFlow === 'response'`): The upstream request was processed (the tool was called), but results cannot be returned because a required plugin (like audit logging) failed during response processing. This does not indicate whether the tool itself succeeded or failed.
642+
- **Request Pipeline Failure** (`pipelineFlow === 'request'`): The request was rejected before reaching the upstream server because a required plugin (like authentication) failed during request processing.
643+
644+
Properties:
645+
646+
- `message: string` - Descriptive error message
647+
- `serverName?: string` - The server name (when called through tool execution)
648+
- `operation?: string` - The operation (e.g., "time.get_current_time")
649+
- `pipelineFlow?: 'request' | 'response'` - Which pipeline flow failed
650+
629651
## Development
630652

631653
### Setup

src/client.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
ServerUnhealthyError,
2020
ToolExecutionError,
2121
TimeoutError,
22+
PipelineError,
23+
PIPELINE_FLOW_REQUEST,
24+
PIPELINE_FLOW_RESPONSE,
25+
type PipelineFlow,
2226
} from "./errors";
2327
import {
2428
HealthStatusHelpers,
@@ -74,6 +78,19 @@ const SERVER_HEALTH_CACHE_MAXSIZE = 100;
7478
*/
7579
const TOOL_SEPARATOR = "__";
7680

81+
/**
82+
* Header name for mcpd pipeline error type.
83+
*/
84+
const MCPD_ERROR_TYPE_HEADER = "Mcpd-Error-Type";
85+
86+
/**
87+
* Maps mcpd error type header values to pipeline flows.
88+
*/
89+
const PIPELINE_ERROR_FLOWS: Record<string, PipelineFlow> = {
90+
"request-pipeline-failure": PIPELINE_FLOW_REQUEST,
91+
"response-pipeline-failure": PIPELINE_FLOW_RESPONSE,
92+
};
93+
7794
/**
7895
* Type alias for agent functions in array format.
7996
* @internal
@@ -236,6 +253,26 @@ export class McpdClient {
236253
errorModel = null;
237254
}
238255

256+
// Check for pipeline failure (500 with Mcpd-Error-Type header).
257+
if (response.status === 500) {
258+
const errorType = response.headers
259+
.get(MCPD_ERROR_TYPE_HEADER)
260+
?.toLowerCase();
261+
262+
const flow = errorType ? PIPELINE_ERROR_FLOWS[errorType] : undefined;
263+
264+
if (flow) {
265+
const message = errorModel?.detail || body || "Pipeline failure";
266+
267+
throw new PipelineError(
268+
message,
269+
undefined, // serverName - enriched by caller if available.
270+
undefined, // operation - enriched by caller if available.
271+
flow,
272+
);
273+
}
274+
}
275+
239276
if (errorModel && errorModel.detail) {
240277
const errorDetails = errorModel.errors
241278
?.map((e) => `${e.location}: ${e.message}`)
@@ -803,6 +840,17 @@ export class McpdClient {
803840
// Return the response as-is (already parsed or not a string)
804841
return response;
805842
} catch (error) {
843+
// Enrich PipelineError with server/tool context.
844+
if (error instanceof PipelineError) {
845+
throw new PipelineError(
846+
error.message,
847+
serverName,
848+
`${serverName}.${toolName}`,
849+
error.pipelineFlow,
850+
error.cause as Error | undefined,
851+
);
852+
}
853+
806854
if (error instanceof McpdError) {
807855
throw error;
808856
}

src/errors.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@
77

88
import type { ErrorModel } from "./types";
99

10+
/**
11+
* Pipeline flow constant for request processing failures.
12+
*/
13+
export const PIPELINE_FLOW_REQUEST = "request" as const;
14+
15+
/**
16+
* Pipeline flow constant for response processing failures.
17+
*/
18+
export const PIPELINE_FLOW_RESPONSE = "response" as const;
19+
20+
/**
21+
* Pipeline flow indicating where in the pipeline the failure occurred.
22+
*/
23+
export type PipelineFlow =
24+
| typeof PIPELINE_FLOW_REQUEST
25+
| typeof PIPELINE_FLOW_RESPONSE;
26+
1027
/**
1128
* Base exception for all mcpd SDK errors.
1229
*
@@ -201,3 +218,74 @@ export class TimeoutError extends McpdError {
201218
Error.captureStackTrace(this, this.constructor);
202219
}
203220
}
221+
222+
/**
223+
* Raised when required pipeline processing fails.
224+
*
225+
* This indicates that required processing failed in the mcpd pipeline.
226+
* The error occurs when a required plugin (such as authentication, validation,
227+
* audit logging, monitoring, or response transformation) fails during request
228+
* or response processing.
229+
*
230+
* Pipeline Flow Distinction:
231+
* - **response-pipeline-failure**: The upstream request was processed (the tool
232+
* was called), but results cannot be returned due to a required response
233+
* processing step failure. Note: This does not indicate whether the tool
234+
* itself succeeded or failed - only that the response cannot be delivered.
235+
*
236+
* - **request-pipeline-failure**: The request was rejected before reaching the
237+
* upstream server due to a required request processing step failure (such as
238+
* authentication, authorization, validation, or rate limiting plugin failure).
239+
*
240+
* This typically indicates a problem with a plugin or an external system
241+
* that a plugin depends on (e.g., audit service, authentication provider).
242+
* Retrying is unlikely to help as this usually indicates a configuration
243+
* or dependency problem rather than a transient failure.
244+
*
245+
* @example
246+
* ```typescript
247+
* import { McpdClient, PipelineError } from '@mozilla-ai/mcpd';
248+
*
249+
* const client = new McpdClient({ apiEndpoint: 'http://localhost:8090' });
250+
*
251+
* try {
252+
* const result = await client.servers.time.tools.get_current_time();
253+
* } catch (error) {
254+
* if (error instanceof PipelineError) {
255+
* console.log(`Pipeline failure: ${error.message}`);
256+
* console.log(`Flow: ${error.pipelineFlow}`);
257+
*
258+
* if (error.pipelineFlow === 'response') {
259+
* console.log('Tool was called but results cannot be delivered');
260+
* } else {
261+
* console.log('Request was rejected by pipeline');
262+
* console.log('Check authentication, authorization, or rate limiting');
263+
* }
264+
* }
265+
* }
266+
* ```
267+
*
268+
* @remarks
269+
* This exception indicates a problem with a plugin or its dependencies, not
270+
* with your request or the tool itself.
271+
*/
272+
export class PipelineError extends McpdError {
273+
public readonly serverName: string | undefined;
274+
public readonly operation: string | undefined;
275+
public readonly pipelineFlow: PipelineFlow | undefined;
276+
277+
constructor(
278+
message: string,
279+
serverName?: string,
280+
operation?: string,
281+
pipelineFlow?: PipelineFlow,
282+
cause?: Error,
283+
) {
284+
super(message, cause);
285+
this.name = "PipelineError";
286+
this.serverName = serverName;
287+
this.operation = operation;
288+
this.pipelineFlow = pipelineFlow;
289+
Error.captureStackTrace(this, this.constructor);
290+
}
291+
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,16 @@ export {
2222
McpdError,
2323
AuthenticationError,
2424
ConnectionError,
25+
PipelineError,
26+
PIPELINE_FLOW_REQUEST,
27+
PIPELINE_FLOW_RESPONSE,
2528
ServerNotFoundError,
2629
ServerUnhealthyError,
2730
TimeoutError,
2831
ToolExecutionError,
2932
ToolNotFoundError,
3033
ValidationError,
34+
type PipelineFlow,
3135
} from "./errors";
3236

3337
// Export type definitions

tests/unit/client.test.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import { McpdClient } from "../../src/client";
3-
import { ConnectionError, AuthenticationError } from "../../src/errors";
3+
import {
4+
ConnectionError,
5+
AuthenticationError,
6+
McpdError,
7+
PipelineError,
8+
} from "../../src/errors";
49
import { HealthStatusHelpers } from "../../src/types";
510
import { createFetchMock } from "./utils/mockApi";
611
import { API_PATHS } from "../../src/apiPaths";
@@ -1286,4 +1291,147 @@ describe("McpdClient", () => {
12861291
);
12871292
});
12881293
});
1294+
1295+
describe("PipelineError handling", () => {
1296+
it("should throw PipelineError for response pipeline failure", async () => {
1297+
mockFetch.mockResolvedValueOnce({
1298+
ok: false,
1299+
status: 500,
1300+
statusText: "Internal Server Error",
1301+
headers: new Headers({
1302+
"Mcpd-Error-Type": "response-pipeline-failure",
1303+
}),
1304+
text: async () => "Response processing failed\n",
1305+
});
1306+
1307+
const error = await client.listServers().catch((e: unknown) => e);
1308+
1309+
expect(error).toBeInstanceOf(PipelineError);
1310+
const pipelineError = error as PipelineError;
1311+
expect(pipelineError.pipelineFlow).toBe("response");
1312+
expect(pipelineError.message).toContain("Response processing failed");
1313+
});
1314+
1315+
it("should throw PipelineError for request pipeline failure", async () => {
1316+
mockFetch.mockResolvedValueOnce({
1317+
ok: false,
1318+
status: 500,
1319+
statusText: "Internal Server Error",
1320+
headers: new Headers({
1321+
"Mcpd-Error-Type": "request-pipeline-failure",
1322+
}),
1323+
text: async () => "Request processing failed\n",
1324+
});
1325+
1326+
const error = await client.listServers().catch((e: unknown) => e);
1327+
1328+
expect(error).toBeInstanceOf(PipelineError);
1329+
const pipelineError = error as PipelineError;
1330+
expect(pipelineError.pipelineFlow).toBe("request");
1331+
expect(pipelineError.message).toContain("Request processing failed");
1332+
});
1333+
1334+
it("should throw McpdError for 500 without Mcpd-Error-Type header", async () => {
1335+
mockFetch.mockResolvedValueOnce({
1336+
ok: false,
1337+
status: 500,
1338+
statusText: "Internal Server Error",
1339+
headers: new Headers(),
1340+
text: async () => "Internal Server Error",
1341+
});
1342+
1343+
const error = await client.listServers().catch((e: unknown) => e);
1344+
1345+
expect(error).not.toBeInstanceOf(PipelineError);
1346+
expect(error).toBeInstanceOf(McpdError);
1347+
});
1348+
1349+
it("should handle case-insensitive header value", async () => {
1350+
mockFetch.mockResolvedValueOnce({
1351+
ok: false,
1352+
status: 500,
1353+
statusText: "Internal Server Error",
1354+
headers: new Headers({
1355+
"Mcpd-Error-Type": "RESPONSE-PIPELINE-FAILURE",
1356+
}),
1357+
text: async () => "Response processing failed\n",
1358+
});
1359+
1360+
const error = await client.listServers().catch((e: unknown) => e);
1361+
1362+
expect(error).toBeInstanceOf(PipelineError);
1363+
const pipelineError = error as PipelineError;
1364+
expect(pipelineError.pipelineFlow).toBe("response");
1365+
});
1366+
1367+
it("should enrich PipelineError with server and tool context in performCall", async () => {
1368+
// First mock: health check for ensureServerHealthy.
1369+
mockFetch.mockResolvedValueOnce({
1370+
ok: true,
1371+
json: async () => ({
1372+
name: "time",
1373+
status: "ok",
1374+
latency: "2ms",
1375+
lastChecked: "2025-10-07T15:00:00Z",
1376+
lastSuccessful: "2025-10-07T15:00:00Z",
1377+
}),
1378+
});
1379+
1380+
// Second mock: tools list for getTools (to verify tool exists).
1381+
mockFetch.mockResolvedValueOnce({
1382+
ok: true,
1383+
json: async () => ({
1384+
tools: [
1385+
{
1386+
name: "get_current_time",
1387+
description: "Get current time",
1388+
inputSchema: {
1389+
type: "object",
1390+
properties: { timezone: { type: "string" } },
1391+
},
1392+
},
1393+
],
1394+
}),
1395+
});
1396+
1397+
// Third mock: tool call returns pipeline error.
1398+
mockFetch.mockResolvedValueOnce({
1399+
ok: false,
1400+
status: 500,
1401+
statusText: "Internal Server Error",
1402+
headers: new Headers({
1403+
"Mcpd-Error-Type": "response-pipeline-failure",
1404+
}),
1405+
text: async () => "Response processing failed\n",
1406+
});
1407+
1408+
const error = await client.servers.time!.tools.get_current_time!({
1409+
timezone: "UTC",
1410+
}).catch((e: unknown) => e);
1411+
1412+
expect(error).toBeInstanceOf(PipelineError);
1413+
const pipelineError = error as PipelineError;
1414+
expect(pipelineError.pipelineFlow).toBe("response");
1415+
expect(pipelineError.serverName).toBe("time");
1416+
expect(pipelineError.operation).toBe("time.get_current_time");
1417+
expect(pipelineError.message).toContain("Response processing failed");
1418+
});
1419+
1420+
it("should not throw PipelineError for other 500 errors", async () => {
1421+
mockFetch.mockResolvedValueOnce({
1422+
ok: false,
1423+
status: 500,
1424+
statusText: "Internal Server Error",
1425+
headers: new Headers({
1426+
"Mcpd-Error-Type": "some-other-error-type",
1427+
}),
1428+
text: async () => "Some other error",
1429+
});
1430+
1431+
const error = await client.listServers().catch((e: unknown) => e);
1432+
1433+
expect(error).not.toBeInstanceOf(PipelineError);
1434+
expect(error).toBeInstanceOf(McpdError);
1435+
});
1436+
});
12891437
});

0 commit comments

Comments
 (0)