Skip to main content
Glama
EXAMPLES.md18.2 kB
# Error Handling Examples Practical examples of using the error handling system in the FreshBooks MCP server. ## Table of Contents - [Basic Usage](#basic-usage) - [Tool Implementation](#tool-implementation) - [Error Detection](#error-detection) - [Custom Error Creation](#custom-error-creation) - [Testing](#testing) - [Advanced Patterns](#advanced-patterns) ## Basic Usage ### Normalizing Any Error ```typescript import { ErrorHandler } from "./errors/index.js"; try { // Some operation that might fail const result = await riskyOperation(); } catch (error) { // Convert any error to MCP format const mcpError = ErrorHandler.normalizeError(error, { tool: "my_tool", accountId: "ABC123", requestId: "req_xyz" }); // mcpError is now a structured MCPError console.error(`Error ${mcpError.code}: ${mcpError.message}`); console.error(`Suggestion: ${mcpError.data.suggestion}`); throw mcpError; } ``` ### Using the Wrapper (Recommended) ```typescript import { ErrorHandler } from "./errors/index.js"; // Wrap your handler - errors are automatically normalized const myToolHandler = ErrorHandler.wrapHandler( "my_tool", async (input, context) => { // Any error thrown here will be automatically normalized const result = await doSomething(input); return result; } ); // Use the wrapped handler try { const result = await myToolHandler({ foo: "bar" }, { accountId: "ABC123" }); } catch (error) { // error is already a normalized MCPError console.error(error.message); } ``` ## Tool Implementation ### Complete Time Entry Tool ```typescript // src/tools/time-entry/timeentry-single.ts import { ErrorHandler, ToolContext } from "../../errors/index.js"; import { z } from "zod"; import { Client } from "@freshbooks/api"; // Input validation schema const InputSchema = z.object({ accountId: z.string().min(1, "Account ID is required"), timeEntryId: z.number().int().positive("Time entry ID must be positive") }); // The handler implementation async function handler(input: unknown, context: ToolContext) { // Step 1: Validate input (Zod errors automatically normalized) const { accountId, timeEntryId } = InputSchema.parse(input); // Step 2: Get FreshBooks client const client = getAuthenticatedClient(context); // Step 3: Call FreshBooks API (API errors automatically normalized) const response = await client.timeEntries.single(accountId, timeEntryId); // Step 4: Check response if (!response.ok) { // This will be normalized to MCPError automatically throw response; } // Step 5: Return result return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } // Export wrapped handler export const timeEntrySingleHandler = ErrorHandler.wrapHandler( "timeentry_single", handler ); ``` ### List Tool with Pagination ```typescript // src/tools/time-entry/timeentry-list.ts import { ErrorHandler, ToolContext } from "../../errors/index.js"; import { z } from "zod"; const InputSchema = z.object({ accountId: z.string(), page: z.number().int().positive().default(1), perPage: z.number().int().min(1).max(100).default(30), filters: z.object({ projectId: z.number().optional(), billable: z.boolean().optional(), startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional() }).optional() }); async function handler(input: unknown, context: ToolContext) { const { accountId, page, perPage, filters } = InputSchema.parse(input); const client = getAuthenticatedClient(context); // Build query const queryBuilder = []; if (filters?.projectId) { queryBuilder.push({ equals: { projectId: filters.projectId } }); } if (filters?.billable !== undefined) { queryBuilder.push({ boolean: { billable: filters.billable } }); } if (filters?.startDate) { queryBuilder.push({ greaterThanOrEqual: { startedAt: filters.startDate } }); } if (filters?.endDate) { queryBuilder.push({ lessThanOrEqual: { startedAt: filters.endDate } }); } // Call API const response = await client.timeEntries.list( accountId, queryBuilder, { page, perPage } ); if (!response.ok) { throw response; } return { content: [{ type: "text", text: JSON.stringify({ timeEntries: response.data, pagination: { page, perPage, total: response.pages?.total || 0, pages: response.pages?.pages || 0 } }, null, 2) }] }; } export const timeEntryListHandler = ErrorHandler.wrapHandler( "timeentry_list", handler ); ``` ### Create Tool with Validation ```typescript // src/tools/time-entry/timeentry-create.ts import { ErrorHandler, ToolContext } from "../../errors/index.js"; import { z } from "zod"; const InputSchema = z.object({ accountId: z.string(), data: z.object({ startedAt: z.string().datetime("Invalid date format"), duration: z.number().int().nonnegative(), isLogged: z.boolean().default(true), projectId: z.number().int().positive().optional(), serviceId: z.number().int().positive().optional(), note: z.string().max(5000).optional(), billable: z.boolean().optional() }) }); async function handler(input: unknown, context: ToolContext) { // Validate - if this fails, Zod error is automatically normalized const { accountId, data } = InputSchema.parse(input); const client = getAuthenticatedClient(context); // Additional business logic validation if (data.billable && !data.projectId) { throw ErrorHandler.createValidationError( "Billable time entries require a project", { tool: "timeentry_create", accountId } ); } // Create time entry const response = await client.timeEntries.create(data, accountId); if (!response.ok) { throw response; } return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } export const timeEntryCreateHandler = ErrorHandler.wrapHandler( "timeentry_create", handler ); ``` ## Error Detection ### Checking Error Types ```typescript import { isMCPError, isFreshBooksError, isOAuthError, isNetworkError } from "./errors/index.js"; async function someOperation() { try { await riskyCall(); } catch (error) { if (isMCPError(error)) { console.log("Already normalized:", error.code); console.log("Recoverable:", error.data.recoverable); } if (isFreshBooksError(error)) { console.log("FreshBooks error code:", error.error.code); console.log("Field:", error.error.field); } if (isOAuthError(error)) { console.log("OAuth error:", error.code); // Re-authenticate } if (isNetworkError(error)) { console.log("Network issue, retrying..."); // Implement retry logic } } } ``` ### Handling Specific Error Codes ```typescript import { ErrorHandler, MCPErrorCode } from "./errors/index.js"; async function callWithRetry() { try { return await someApiCall(); } catch (error) { const mcpError = ErrorHandler.normalizeError(error); // Handle rate limiting if (mcpError.code === MCPErrorCode.RATE_LIMITED) { const delay = mcpError.data.retryAfter || 60; console.log(`Rate limited, waiting ${delay}s...`); await sleep(delay * 1000); return await someApiCall(); // Retry } // Handle authentication if (mcpError.code === MCPErrorCode.NOT_AUTHENTICATED) { console.log("Re-authenticating..."); await reAuthenticate(); return await someApiCall(); // Retry } // Handle token expiration if (mcpError.code === MCPErrorCode.TOKEN_EXPIRED) { console.log("Refreshing token..."); await refreshToken(); return await someApiCall(); // Retry } // Non-recoverable error if (!mcpError.data.recoverable) { console.error("Non-recoverable error:", mcpError.message); throw mcpError; } throw mcpError; } } ``` ## Custom Error Creation ### Validation Errors ```typescript import { ErrorHandler } from "./errors/index.js"; function validateBusinessRules(data: TimeEntryData) { // Check if timer already running if (data.active && hasActiveTimer()) { throw ErrorHandler.createValidationError( "Only one timer can be active at a time", { tool: "timer_start" } ); } // Check if time entry is in the future if (new Date(data.startedAt) > new Date()) { throw ErrorHandler.createValidationError( "Cannot create time entries in the future", { tool: "timeentry_create", field: "startedAt" } ); } // Check duration limits if (data.duration > 24 * 60 * 60) { throw ErrorHandler.createValidationError( "Duration cannot exceed 24 hours", { tool: "timeentry_create", field: "duration" } ); } } ``` ### Authentication Errors ```typescript import { ErrorHandler } from "./errors/index.js"; function getAuthenticatedClient(context: ToolContext): Client { const tokens = getStoredTokens(); if (!tokens) { throw ErrorHandler.createAuthError( "No authentication found", { tool: context.tool } ); } if (isTokenExpired(tokens.accessToken)) { throw ErrorHandler.createAuthError( "Access token has expired", { tool: context.tool } ); } return new Client(tokens.accessToken); } ``` ### Not Found Errors ```typescript import { ErrorHandler } from "./errors/index.js"; async function getTimeEntry(accountId: string, timeEntryId: number) { const response = await client.timeEntries.single(accountId, timeEntryId); if (!response.ok && response.error.code === "NOT_FOUND") { throw ErrorHandler.createNotFoundError( "TimeEntry", timeEntryId, { tool: "timeentry_single", accountId } ); } return response.data; } ``` ## Testing ### Testing Error Normalization ```typescript // tests/errors/error-mapper.test.ts import { describe, test, expect } from "vitest"; import { ErrorMapper, MCPErrorCode } from "../../src/errors/index.js"; describe("ErrorMapper", () => { describe("mapFreshBooksError", () => { test("maps NOT_FOUND error", () => { const fbError = { ok: false, error: { code: "NOT_FOUND", message: "TimeEntry with id 99999 was not found", errno: 1012, field: "timeEntryId" }, statusCode: 404 }; const mcpError = ErrorMapper.mapFreshBooksError(fbError, { tool: "timeentry_single", entityId: 99999 }); expect(mcpError.code).toBe(MCPErrorCode.RESOURCE_NOT_FOUND); expect(mcpError.message).toContain("not found"); expect(mcpError.data.recoverable).toBe(false); expect(mcpError.data.suggestion).toBeDefined(); expect(mcpError.data.freshbooksError?.code).toBe("NOT_FOUND"); expect(mcpError.data.context?.tool).toBe("timeentry_single"); }); test("maps RATE_LIMIT_EXCEEDED error", () => { const fbError = { ok: false, error: { code: "RATE_LIMIT_EXCEEDED", message: "Rate limit exceeded" }, retryAfter: 60, statusCode: 429 }; const mcpError = ErrorMapper.mapFreshBooksError(fbError); expect(mcpError.code).toBe(MCPErrorCode.RATE_LIMITED); expect(mcpError.data.recoverable).toBe(true); expect(mcpError.data.retryAfter).toBe(60); }); }); describe("mapValidationError", () => { test("maps Zod validation error", () => { const zodError = new z.ZodError([ { code: "invalid_type", expected: "string", received: "undefined", path: ["accountId"], message: "Required" }, { code: "invalid_type", expected: "number", received: "string", path: ["timeEntryId"], message: "Expected number, received string" } ]); const mcpError = ErrorMapper.mapValidationError(zodError, { tool: "timeentry_single" }); expect(mcpError.code).toBe(MCPErrorCode.INVALID_PARAMS); expect(mcpError.data.validationErrors).toHaveLength(2); expect(mcpError.data.validationErrors?.[0]?.path).toBe("accountId"); expect(mcpError.data.suggestion).toContain("accountId"); }); }); }); ``` ### Testing Tool Error Handling ```typescript // tests/tools/timeentry-single.test.ts import { describe, test, expect, vi } from "vitest"; import { timeEntrySingleHandler } from "../../src/tools/time-entry/timeentry-single.js"; import { MCPErrorCode } from "../../src/errors/index.js"; describe("timeentry_single", () => { test("handles not found error", async () => { // Mock FreshBooks client to return not found vi.mock("@freshbooks/api", () => ({ Client: vi.fn(() => ({ timeEntries: { single: vi.fn(() => Promise.resolve({ ok: false, error: { code: "NOT_FOUND", message: "TimeEntry not found" }, statusCode: 404 })) } })) })); await expect( timeEntrySingleHandler( { accountId: "ABC123", timeEntryId: 99999 }, { accountId: "ABC123" } ) ).rejects.toMatchObject({ code: MCPErrorCode.RESOURCE_NOT_FOUND, message: expect.stringContaining("not found"), data: { recoverable: false, freshbooksError: { code: "NOT_FOUND" } } }); }); test("handles validation error", async () => { await expect( timeEntrySingleHandler( { accountId: "", timeEntryId: -1 }, { accountId: "ABC123" } ) ).rejects.toMatchObject({ code: MCPErrorCode.INVALID_PARAMS, data: { recoverable: true, validationErrors: expect.arrayContaining([ expect.objectContaining({ path: expect.stringContaining("accountId") }) ]) } }); }); }); ``` ## Advanced Patterns ### Retry with Exponential Backoff ```typescript import { ErrorHandler, MCPErrorCode } from "./errors/index.js"; async function callWithRetry<T>( fn: () => Promise<T>, maxRetries = 3, baseDelay = 1000 ): Promise<T> { let lastError: any; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { const mcpError = ErrorHandler.normalizeError(error); lastError = mcpError; // Don't retry non-recoverable errors if (!mcpError.data.recoverable) { throw mcpError; } // Don't retry on last attempt if (attempt === maxRetries) { break; } // Calculate delay let delay: number; if (mcpError.code === MCPErrorCode.RATE_LIMITED && mcpError.data.retryAfter) { delay = mcpError.data.retryAfter * 1000; } else { delay = baseDelay * Math.pow(2, attempt); } console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`); await sleep(delay); } } throw lastError; } // Usage const result = await callWithRetry( () => client.timeEntries.list(accountId), 3, // max 3 retries 1000 // 1s base delay ); ``` ### Error Aggregation ```typescript import { ErrorHandler, MCPError } from "./errors/index.js"; async function batchOperation(items: any[]): Promise<{ succeeded: any[]; failed: Array<{ item: any; error: MCPError }>; }> { const results = await Promise.allSettled( items.map(item => processItem(item)) ); const succeeded: any[] = []; const failed: Array<{ item: any; error: MCPError }> = []; results.forEach((result, index) => { if (result.status === "fulfilled") { succeeded.push(result.value); } else { const mcpError = ErrorHandler.normalizeError(result.reason); failed.push({ item: items[index], error: mcpError }); } }); return { succeeded, failed }; } // Usage const { succeeded, failed } = await batchOperation(timeEntries); console.log(`Successfully processed ${succeeded.length} items`); if (failed.length > 0) { console.error(`Failed to process ${failed.length} items:`); failed.forEach(({ item, error }) => { console.error(`- ${item.id}: ${error.message}`); console.error(` Suggestion: ${error.data.suggestion}`); }); } ``` ### Conditional Error Handling ```typescript import { ErrorHandler, MCPErrorCode } from "./errors/index.js"; async function smartOperation() { try { return await client.timeEntries.create(data, accountId); } catch (error) { const mcpError = ErrorHandler.normalizeError(error); // Handle specific scenarios switch (mcpError.code) { case MCPErrorCode.TOKEN_EXPIRED: // Try to refresh token await refreshToken(); // Retry operation return await client.timeEntries.create(data, accountId); case MCPErrorCode.CONFLICT: // Resource exists, try to update instead return await client.timeEntries.update(data, accountId, existingId); case MCPErrorCode.VALIDATION_ERROR: // Try with default values const dataWithDefaults = { ...defaultData, ...data }; return await client.timeEntries.create(dataWithDefaults, accountId); case MCPErrorCode.RATE_LIMITED: // Wait and retry await sleep(mcpError.data.retryAfter! * 1000); return await client.timeEntries.create(data, accountId); default: // Can't handle, re-throw throw mcpError; } } } ``` ## Summary Key takeaways: 1. **Always use ErrorHandler.wrapHandler()** for tool implementations 2. **Validate input with Zod** - validation errors are automatically normalized 3. **Let FreshBooks errors bubble up** - they're automatically normalized 4. **Use type guards** to check error types before accessing properties 5. **Include context** in all error normalization calls 6. **Check recoverable flag** before implementing retry logic 7. **Test error paths** just like success paths 8. **Preserve debugging info** by always including context

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Good-Samaritan-Software-LLC/freshbooks-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server