From e60bf697fcaaaaa3d4758c764c107affc33a0aee Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 10:56:04 -0500 Subject: [PATCH 01/10] feat: auto-file GitHub issues on internal errors When the MCP server encounters an internal_error, it now automatically files a GitHub issue on supermodeltools/mcp with reproduction context (error type, code, message, details, platform info). Deduplication: - Session-level: same error code won't file twice per process lifetime - GitHub-level: searches for existing open issues before creating Uses gh CLI (fire-and-forget, non-blocking). If gh is unavailable or fails, the error response is unaffected. Closes #74 --- src/tools/create-supermodel-graph.ts | 19 +++- src/utils/error-reporter.test.ts | 147 +++++++++++++++++++++++++++ src/utils/error-reporter.ts | 83 +++++++++++++++ 3 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/utils/error-reporter.test.ts create mode 100644 src/utils/error-reporter.ts diff --git a/src/tools/create-supermodel-graph.ts b/src/tools/create-supermodel-graph.ts index 1ec01cc..6e14ae2 100644 --- a/src/tools/create-supermodel-graph.ts +++ b/src/tools/create-supermodel-graph.ts @@ -17,6 +17,7 @@ import { executeQuery, getAvailableQueries, isQueryError, QueryType, graphCache import { IndexedGraph } from '../cache/graph-cache'; import { zipRepository } from '../utils/zip-repository'; import * as logger from '../utils/logger'; +import { reportError } from '../utils/error-reporter'; export const metadata: Metadata = { resource: 'graphs', @@ -367,13 +368,15 @@ export const handler: HandlerFunction = async (client: ClientContext, args: Reco }); } - return asErrorResult({ + const zipError: StructuredError = { type: 'internal_error', message: `Failed to create ZIP archive: ${message}`, code: 'ZIP_CREATION_FAILED', recoverable: false, details: { directory, errorType: error.name || 'Error' }, - }); + }; + reportError(zipError).catch(() => {}); + return asErrorResult(zipError); } // Execute query with cleanup handling @@ -528,7 +531,11 @@ async function handleQueryMode( result = await executeQuery(queryParams, apiResponse); } catch (error: any) { // Error details are already logged by fetchFromApi and logErrorResponse - return asErrorResult(classifyApiError(error)); + const classified = classifyApiError(error); + if (classified.type === 'internal_error') { + reportError(classified).catch(() => {}); + } + return asErrorResult(classified); } } @@ -960,7 +967,11 @@ async function handleLegacyMode( } // Error details are already logged by fetchFromApi and logErrorResponse - return asErrorResult(classifyApiError(error)); + const classified = classifyApiError(error); + if (classified.type === 'internal_error') { + reportError(classified).catch(() => {}); + } + return asErrorResult(classified); } } diff --git a/src/utils/error-reporter.test.ts b/src/utils/error-reporter.test.ts new file mode 100644 index 0000000..9295a31 --- /dev/null +++ b/src/utils/error-reporter.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { StructuredError } from '../types'; + +jest.mock('child_process', () => ({ + execFile: jest.fn(), +})); + +let reportError: (error: StructuredError) => Promise; +let mockExecFile: jest.Mock; + +beforeEach(async () => { + jest.resetModules(); + jest.mock('child_process', () => ({ + execFile: jest.fn(), + })); + const mod = await import('./error-reporter'); + reportError = mod.reportError; + const cp = await import('child_process'); + mockExecFile = cp.execFile as unknown as jest.Mock; +}); + +describe('error-reporter', () => { + it('should not report non-internal_error types', async () => { + await reportError({ + type: 'validation_error', + message: 'Bad input', + code: 'MISSING_DIRECTORY', + recoverable: false, + }); + + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('should call gh to search for existing issues before creating', async () => { + mockExecFile.mockImplementation((...args: any[]) => { + const ghArgs = args[1] as string[]; + const cb = args[3] as (err: any, stdout: string, stderr: string) => void; + if (ghArgs.includes('list')) { + cb(null, '[]', ''); + } else if (ghArgs.includes('create')) { + cb(null, 'https://github.com/supermodeltools/mcp/issues/99', ''); + } + }); + + await reportError({ + type: 'internal_error', + message: 'Something broke', + code: 'UNKNOWN_ERROR', + recoverable: false, + details: { errorType: 'Error' }, + }); + + expect(mockExecFile).toHaveBeenCalledTimes(2); + + const searchCall = mockExecFile.mock.calls[0] as any[]; + expect(searchCall[0]).toBe('gh'); + expect(searchCall[1]).toContain('list'); + expect(searchCall[1]).toContain('--search'); + + const createCall = mockExecFile.mock.calls[1] as any[]; + expect(createCall[0]).toBe('gh'); + expect(createCall[1]).toContain('create'); + expect(createCall[1]).toContain('--title'); + }); + + it('should not create issue if one already exists', async () => { + mockExecFile.mockImplementation((...args: any[]) => { + const ghArgs = args[1] as string[]; + const cb = args[3] as (err: any, stdout: string, stderr: string) => void; + if (ghArgs.includes('list')) { + cb(null, '[{"number": 42}]', ''); + } + }); + + await reportError({ + type: 'internal_error', + message: 'Server error', + code: 'SERVER_ERROR', + recoverable: true, + }); + + expect(mockExecFile).toHaveBeenCalledTimes(1); + expect((mockExecFile.mock.calls[0] as any[])[1]).toContain('list'); + }); + + it('should deduplicate by error code within a session', async () => { + mockExecFile.mockImplementation((...args: any[]) => { + const ghArgs = args[1] as string[]; + const cb = args[3] as (err: any, stdout: string, stderr: string) => void; + if (ghArgs.includes('list')) cb(null, '[]', ''); + else cb(null, '', ''); + }); + + const error: StructuredError = { + type: 'internal_error', + message: 'Broke again', + code: 'ZIP_CREATION_FAILED', + recoverable: false, + }; + + await reportError(error); + await reportError(error); + + // Only 2 calls (list + create) for the first invocation, none for the second + expect(mockExecFile).toHaveBeenCalledTimes(2); + }); + + it('should not throw if gh CLI fails', async () => { + mockExecFile.mockImplementation((...args: any[]) => { + const cb = args[3] as (err: any, stdout: string, stderr: string) => void; + cb(new Error('gh not found'), '', 'command not found: gh'); + }); + + await expect(reportError({ + type: 'internal_error', + message: 'Error', + code: 'API_ERROR', + recoverable: false, + })).resolves.toBeUndefined(); + }); + + it('should include error details in issue body', async () => { + mockExecFile.mockImplementation((...args: any[]) => { + const ghArgs = args[1] as string[]; + const cb = args[3] as (err: any, stdout: string, stderr: string) => void; + if (ghArgs.includes('list')) cb(null, '[]', ''); + else cb(null, '', ''); + }); + + await reportError({ + type: 'internal_error', + message: 'API request failed with HTTP 418.', + code: 'API_ERROR_2', + recoverable: false, + details: { httpStatus: 418 }, + }); + + const createCall = mockExecFile.mock.calls[1] as any[]; + const ghArgs = createCall[1] as string[]; + const bodyIdx = ghArgs.indexOf('--body'); + const body = ghArgs[bodyIdx + 1]; + expect(body).toContain('API_ERROR_2'); + expect(body).toContain('API request failed with HTTP 418'); + expect(body).toContain('418'); + expect(body).toContain('@supermodeltools/mcp-server'); + }); +}); diff --git a/src/utils/error-reporter.ts b/src/utils/error-reporter.ts new file mode 100644 index 0000000..8129608 --- /dev/null +++ b/src/utils/error-reporter.ts @@ -0,0 +1,83 @@ +import { execFile } from 'child_process'; +import { StructuredError } from '../types'; +import * as logger from './logger'; + +const REPO = 'supermodeltools/mcp'; + +// In-memory set of error codes already reported this session to avoid duplicates +const reportedThisSession = new Set(); + +/** + * Auto-file a GitHub issue for an internal error. + * Uses `gh` CLI. Non-blocking — failures are silently logged. + * Deduplicates by error code per session and by searching existing open issues. + */ +export async function reportError(error: StructuredError): Promise { + // Only report internal errors + if (error.type !== 'internal_error') return; + + // Session-level dedup: don't file the same error code twice per process lifetime + if (reportedThisSession.has(error.code)) return; + reportedThisSession.add(error.code); + + const title = `[auto-report] ${error.code}: ${error.message}`; + + try { + // Check if an open issue with this error code already exists + const existing = await ghExec(['issue', 'list', '--repo', REPO, '--state', 'open', '--search', `[auto-report] ${error.code}`, '--limit', '1', '--json', 'number']); + const issues = JSON.parse(existing); + if (issues.length > 0) { + logger.debug(`Issue already exists for ${error.code}: #${issues[0].number}`); + return; + } + + // Build issue body with reproduction context + const body = buildIssueBody(error); + + await ghExec(['issue', 'create', '--repo', REPO, '--title', title, '--body', body, '--label', 'auto-report,bug']); + logger.debug(`Filed issue for ${error.code}`); + } catch (err: any) { + // Don't let reporting failures affect the user + logger.debug(`Failed to auto-report error: ${err.message || err}`); + } +} + +function buildIssueBody(error: StructuredError): string { + const detailsBlock = error.details + ? `\n\n### Details\n\n\`\`\`json\n${JSON.stringify(error.details, null, 2)}\n\`\`\`` + : ''; + + return `## Auto-reported internal error + +This issue was automatically filed by the MCP server when it encountered an unrecoverable internal error. + +### Error + +| Field | Value | +|-------|-------| +| **Type** | \`${error.type}\` | +| **Code** | \`${error.code}\` | +| **Message** | ${error.message} | +| **Recoverable** | ${error.recoverable} | +${detailsBlock} + +### Context + +- **Package**: \`@supermodeltools/mcp-server\` +- **Timestamp**: ${new Date().toISOString()} +- **Node version**: ${process.version} +- **Platform**: ${process.platform} ${process.arch} +`; +} + +function ghExec(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile('gh', args, { timeout: 15000 }, (err, stdout, stderr) => { + if (err) { + reject(new Error(stderr || err.message)); + } else { + resolve(stdout.trim()); + } + }); + }); +} From 75330ccf0de7ee28dc88fa19318cb51ab1bf7fe2 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 10:58:59 -0500 Subject: [PATCH 02/10] fix: use lowercase kebab-case for error codes in issue titles [auto-report] unknown-error: ... instead of [auto-report] UNKNOWN_ERROR: ... --- src/utils/error-reporter.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/error-reporter.ts b/src/utils/error-reporter.ts index 8129608..f4f514a 100644 --- a/src/utils/error-reporter.ts +++ b/src/utils/error-reporter.ts @@ -20,11 +20,12 @@ export async function reportError(error: StructuredError): Promise { if (reportedThisSession.has(error.code)) return; reportedThisSession.add(error.code); - const title = `[auto-report] ${error.code}: ${error.message}`; + const code = error.code.toLowerCase().replace(/_/g, '-'); + const title = `[auto-report] ${code}: ${error.message}`; try { // Check if an open issue with this error code already exists - const existing = await ghExec(['issue', 'list', '--repo', REPO, '--state', 'open', '--search', `[auto-report] ${error.code}`, '--limit', '1', '--json', 'number']); + const existing = await ghExec(['issue', 'list', '--repo', REPO, '--state', 'open', '--search', `[auto-report] ${code}`, '--limit', '1', '--json', 'number']); const issues = JSON.parse(existing); if (issues.length > 0) { logger.debug(`Issue already exists for ${error.code}: #${issues[0].number}`); From f8733ea7160f86d123a296d9f188a5fad4dd6ff9 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:01:32 -0500 Subject: [PATCH 03/10] feat: add reportable flag and repo link to internal errors Replace auto-filing machinery with simple metadata on error responses. Internal errors now include reportable: true, repo, and a suggestion with a link to the issues page. Agents can choose to file an issue based on their own permissions and context. Removes error-reporter.ts (auto-filing via gh CLI) in favor of surfacing intent in the conversation context. Closes #74 --- src/tools/create-supermodel-graph.ts | 35 ++++--- src/types.ts | 2 + src/utils/error-reporter.test.ts | 147 --------------------------- src/utils/error-reporter.ts | 84 --------------- 4 files changed, 21 insertions(+), 247 deletions(-) delete mode 100644 src/utils/error-reporter.test.ts delete mode 100644 src/utils/error-reporter.ts diff --git a/src/tools/create-supermodel-graph.ts b/src/tools/create-supermodel-graph.ts index 6e14ae2..03dddfc 100644 --- a/src/tools/create-supermodel-graph.ts +++ b/src/tools/create-supermodel-graph.ts @@ -17,7 +17,6 @@ import { executeQuery, getAvailableQueries, isQueryError, QueryType, graphCache import { IndexedGraph } from '../cache/graph-cache'; import { zipRepository } from '../utils/zip-repository'; import * as logger from '../utils/logger'; -import { reportError } from '../utils/error-reporter'; export const metadata: Metadata = { resource: 'graphs', @@ -368,15 +367,16 @@ export const handler: HandlerFunction = async (client: ClientContext, args: Reco }); } - const zipError: StructuredError = { + return asErrorResult({ type: 'internal_error', message: `Failed to create ZIP archive: ${message}`, code: 'ZIP_CREATION_FAILED', recoverable: false, + reportable: true, + repo: 'supermodeltools/mcp', + suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', details: { directory, errorType: error.name || 'Error' }, - }; - reportError(zipError).catch(() => {}); - return asErrorResult(zipError); + }); } // Execute query with cleanup handling @@ -531,11 +531,7 @@ async function handleQueryMode( result = await executeQuery(queryParams, apiResponse); } catch (error: any) { // Error details are already logged by fetchFromApi and logErrorResponse - const classified = classifyApiError(error); - if (classified.type === 'internal_error') { - reportError(classified).catch(() => {}); - } - return asErrorResult(classified); + return asErrorResult(classifyApiError(error)); } } @@ -843,6 +839,9 @@ function classifyApiError(error: any): StructuredError { message: typeof error === 'string' ? error : 'An unexpected error occurred.', code: 'UNKNOWN_ERROR', recoverable: false, + reportable: true, + repo: 'supermodeltools/mcp', + suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', details: { errorType: typeof error }, }; } @@ -896,7 +895,9 @@ function classifyApiError(error: any): StructuredError { message: `Supermodel API server error (HTTP ${status}).`, code: 'SERVER_ERROR', recoverable: true, - suggestion: 'The API is temporarily unavailable. Wait a few minutes and retry.', + reportable: true, + repo: 'supermodeltools/mcp', + suggestion: 'The API may be temporarily unavailable. If persistent, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', details: { httpStatus: status }, }; default: @@ -905,6 +906,9 @@ function classifyApiError(error: any): StructuredError { message: `API request failed with HTTP ${status}.`, code: 'API_ERROR', recoverable: false, + reportable: true, + repo: 'supermodeltools/mcp', + suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', details: { httpStatus: status }, }; } @@ -938,6 +942,9 @@ function classifyApiError(error: any): StructuredError { message: error.message || 'An unexpected error occurred.', code: 'UNKNOWN_ERROR', recoverable: false, + reportable: true, + repo: 'supermodeltools/mcp', + suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', details: { errorType: error.name || 'Error' }, }; } @@ -967,11 +974,7 @@ async function handleLegacyMode( } // Error details are already logged by fetchFromApi and logErrorResponse - const classified = classifyApiError(error); - if (classified.type === 'internal_error') { - reportError(classified).catch(() => {}); - } - return asErrorResult(classified); + return asErrorResult(classifyApiError(error)); } } diff --git a/src/types.ts b/src/types.ts index 874fa56..73bb4e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,8 @@ export interface StructuredError { recoverable: boolean; suggestion?: string; details?: Record; + reportable?: boolean; + repo?: string; } export function asErrorResult(error: string | StructuredError): ToolCallResult { diff --git a/src/utils/error-reporter.test.ts b/src/utils/error-reporter.test.ts deleted file mode 100644 index 9295a31..0000000 --- a/src/utils/error-reporter.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -import { StructuredError } from '../types'; - -jest.mock('child_process', () => ({ - execFile: jest.fn(), -})); - -let reportError: (error: StructuredError) => Promise; -let mockExecFile: jest.Mock; - -beforeEach(async () => { - jest.resetModules(); - jest.mock('child_process', () => ({ - execFile: jest.fn(), - })); - const mod = await import('./error-reporter'); - reportError = mod.reportError; - const cp = await import('child_process'); - mockExecFile = cp.execFile as unknown as jest.Mock; -}); - -describe('error-reporter', () => { - it('should not report non-internal_error types', async () => { - await reportError({ - type: 'validation_error', - message: 'Bad input', - code: 'MISSING_DIRECTORY', - recoverable: false, - }); - - expect(mockExecFile).not.toHaveBeenCalled(); - }); - - it('should call gh to search for existing issues before creating', async () => { - mockExecFile.mockImplementation((...args: any[]) => { - const ghArgs = args[1] as string[]; - const cb = args[3] as (err: any, stdout: string, stderr: string) => void; - if (ghArgs.includes('list')) { - cb(null, '[]', ''); - } else if (ghArgs.includes('create')) { - cb(null, 'https://github.com/supermodeltools/mcp/issues/99', ''); - } - }); - - await reportError({ - type: 'internal_error', - message: 'Something broke', - code: 'UNKNOWN_ERROR', - recoverable: false, - details: { errorType: 'Error' }, - }); - - expect(mockExecFile).toHaveBeenCalledTimes(2); - - const searchCall = mockExecFile.mock.calls[0] as any[]; - expect(searchCall[0]).toBe('gh'); - expect(searchCall[1]).toContain('list'); - expect(searchCall[1]).toContain('--search'); - - const createCall = mockExecFile.mock.calls[1] as any[]; - expect(createCall[0]).toBe('gh'); - expect(createCall[1]).toContain('create'); - expect(createCall[1]).toContain('--title'); - }); - - it('should not create issue if one already exists', async () => { - mockExecFile.mockImplementation((...args: any[]) => { - const ghArgs = args[1] as string[]; - const cb = args[3] as (err: any, stdout: string, stderr: string) => void; - if (ghArgs.includes('list')) { - cb(null, '[{"number": 42}]', ''); - } - }); - - await reportError({ - type: 'internal_error', - message: 'Server error', - code: 'SERVER_ERROR', - recoverable: true, - }); - - expect(mockExecFile).toHaveBeenCalledTimes(1); - expect((mockExecFile.mock.calls[0] as any[])[1]).toContain('list'); - }); - - it('should deduplicate by error code within a session', async () => { - mockExecFile.mockImplementation((...args: any[]) => { - const ghArgs = args[1] as string[]; - const cb = args[3] as (err: any, stdout: string, stderr: string) => void; - if (ghArgs.includes('list')) cb(null, '[]', ''); - else cb(null, '', ''); - }); - - const error: StructuredError = { - type: 'internal_error', - message: 'Broke again', - code: 'ZIP_CREATION_FAILED', - recoverable: false, - }; - - await reportError(error); - await reportError(error); - - // Only 2 calls (list + create) for the first invocation, none for the second - expect(mockExecFile).toHaveBeenCalledTimes(2); - }); - - it('should not throw if gh CLI fails', async () => { - mockExecFile.mockImplementation((...args: any[]) => { - const cb = args[3] as (err: any, stdout: string, stderr: string) => void; - cb(new Error('gh not found'), '', 'command not found: gh'); - }); - - await expect(reportError({ - type: 'internal_error', - message: 'Error', - code: 'API_ERROR', - recoverable: false, - })).resolves.toBeUndefined(); - }); - - it('should include error details in issue body', async () => { - mockExecFile.mockImplementation((...args: any[]) => { - const ghArgs = args[1] as string[]; - const cb = args[3] as (err: any, stdout: string, stderr: string) => void; - if (ghArgs.includes('list')) cb(null, '[]', ''); - else cb(null, '', ''); - }); - - await reportError({ - type: 'internal_error', - message: 'API request failed with HTTP 418.', - code: 'API_ERROR_2', - recoverable: false, - details: { httpStatus: 418 }, - }); - - const createCall = mockExecFile.mock.calls[1] as any[]; - const ghArgs = createCall[1] as string[]; - const bodyIdx = ghArgs.indexOf('--body'); - const body = ghArgs[bodyIdx + 1]; - expect(body).toContain('API_ERROR_2'); - expect(body).toContain('API request failed with HTTP 418'); - expect(body).toContain('418'); - expect(body).toContain('@supermodeltools/mcp-server'); - }); -}); diff --git a/src/utils/error-reporter.ts b/src/utils/error-reporter.ts deleted file mode 100644 index f4f514a..0000000 --- a/src/utils/error-reporter.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { execFile } from 'child_process'; -import { StructuredError } from '../types'; -import * as logger from './logger'; - -const REPO = 'supermodeltools/mcp'; - -// In-memory set of error codes already reported this session to avoid duplicates -const reportedThisSession = new Set(); - -/** - * Auto-file a GitHub issue for an internal error. - * Uses `gh` CLI. Non-blocking — failures are silently logged. - * Deduplicates by error code per session and by searching existing open issues. - */ -export async function reportError(error: StructuredError): Promise { - // Only report internal errors - if (error.type !== 'internal_error') return; - - // Session-level dedup: don't file the same error code twice per process lifetime - if (reportedThisSession.has(error.code)) return; - reportedThisSession.add(error.code); - - const code = error.code.toLowerCase().replace(/_/g, '-'); - const title = `[auto-report] ${code}: ${error.message}`; - - try { - // Check if an open issue with this error code already exists - const existing = await ghExec(['issue', 'list', '--repo', REPO, '--state', 'open', '--search', `[auto-report] ${code}`, '--limit', '1', '--json', 'number']); - const issues = JSON.parse(existing); - if (issues.length > 0) { - logger.debug(`Issue already exists for ${error.code}: #${issues[0].number}`); - return; - } - - // Build issue body with reproduction context - const body = buildIssueBody(error); - - await ghExec(['issue', 'create', '--repo', REPO, '--title', title, '--body', body, '--label', 'auto-report,bug']); - logger.debug(`Filed issue for ${error.code}`); - } catch (err: any) { - // Don't let reporting failures affect the user - logger.debug(`Failed to auto-report error: ${err.message || err}`); - } -} - -function buildIssueBody(error: StructuredError): string { - const detailsBlock = error.details - ? `\n\n### Details\n\n\`\`\`json\n${JSON.stringify(error.details, null, 2)}\n\`\`\`` - : ''; - - return `## Auto-reported internal error - -This issue was automatically filed by the MCP server when it encountered an unrecoverable internal error. - -### Error - -| Field | Value | -|-------|-------| -| **Type** | \`${error.type}\` | -| **Code** | \`${error.code}\` | -| **Message** | ${error.message} | -| **Recoverable** | ${error.recoverable} | -${detailsBlock} - -### Context - -- **Package**: \`@supermodeltools/mcp-server\` -- **Timestamp**: ${new Date().toISOString()} -- **Node version**: ${process.version} -- **Platform**: ${process.platform} ${process.arch} -`; -} - -function ghExec(args: string[]): Promise { - return new Promise((resolve, reject) => { - execFile('gh', args, { timeout: 15000 }, (err, stdout, stderr) => { - if (err) { - reject(new Error(stderr || err.message)); - } else { - resolve(stdout.trim()); - } - }); - }); -} From 0e5861e53943a1cab1b08158d8a2d27ffa22bc91 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:07:55 -0500 Subject: [PATCH 04/10] fix: restore retry advice for SERVER_ERROR and add reportable field tests - SERVER_ERROR suggestion now includes explicit retry guidance alongside the issue-reporting link, since this error is marked recoverable: true - Export classifyApiError for direct testing - Add 12 tests covering reportable metadata: - Internal errors (UNKNOWN_ERROR, SERVER_ERROR, API_ERROR) include reportable: true, repo, and suggestion with issues link - Client errors (auth, forbidden, not_found, rate_limit, timeout, network) do NOT include reportable flag --- src/tools/create-supermodel-graph.test.ts | 90 ++++++++++++++++++++++- src/tools/create-supermodel-graph.ts | 4 +- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/tools/create-supermodel-graph.test.ts b/src/tools/create-supermodel-graph.test.ts index 350795a..eb01fa8 100644 --- a/src/tools/create-supermodel-graph.test.ts +++ b/src/tools/create-supermodel-graph.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -import { handler } from './create-supermodel-graph'; +import { handler, classifyApiError } from './create-supermodel-graph'; import { ClientContext } from '../types'; import { execSync } from 'child_process'; import { createHash } from 'crypto'; @@ -190,6 +190,94 @@ describe('create-supermodel-graph', () => { }); }); + describe('reportable error metadata', () => { + describe('internal errors include reportable fields', () => { + it('should mark UNKNOWN_ERROR (non-object) as reportable with repo and suggestion', () => { + const result = classifyApiError(null); + expect(result.type).toBe('internal_error'); + expect(result.code).toBe('UNKNOWN_ERROR'); + expect(result.reportable).toBe(true); + expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); + }); + + it('should mark UNKNOWN_ERROR (object with message) as reportable', () => { + const result = classifyApiError(new Error('something broke')); + expect(result.type).toBe('internal_error'); + expect(result.code).toBe('UNKNOWN_ERROR'); + expect(result.reportable).toBe(true); + expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.suggestion).toBeDefined(); + }); + + it('should mark SERVER_ERROR (5xx) as reportable with retry advice', () => { + const result = classifyApiError({ response: { status: 500 } }); + expect(result.type).toBe('internal_error'); + expect(result.code).toBe('SERVER_ERROR'); + expect(result.reportable).toBe(true); + expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.suggestion).toContain('Wait a few minutes and retry'); + expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); + }); + + it('should mark SERVER_ERROR for 502/503/504 as reportable', () => { + for (const status of [502, 503, 504]) { + const result = classifyApiError({ response: { status } }); + expect(result.code).toBe('SERVER_ERROR'); + expect(result.reportable).toBe(true); + expect(result.repo).toBe('supermodeltools/mcp'); + } + }); + + it('should mark API_ERROR (unexpected HTTP status) as reportable', () => { + const result = classifyApiError({ response: { status: 418 } }); + expect(result.type).toBe('internal_error'); + expect(result.code).toBe('API_ERROR'); + expect(result.reportable).toBe(true); + expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); + }); + }); + + describe('client errors do NOT include reportable flag', () => { + it('should not mark authentication_error as reportable', () => { + const result = classifyApiError({ response: { status: 401 } }); + expect(result.type).toBe('authentication_error'); + expect(result.reportable).toBeUndefined(); + }); + + it('should not mark authorization_error as reportable', () => { + const result = classifyApiError({ response: { status: 403 } }); + expect(result.type).toBe('authorization_error'); + expect(result.reportable).toBeUndefined(); + }); + + it('should not mark not_found_error as reportable', () => { + const result = classifyApiError({ response: { status: 404 } }); + expect(result.type).toBe('not_found_error'); + expect(result.reportable).toBeUndefined(); + }); + + it('should not mark rate_limit_error as reportable', () => { + const result = classifyApiError({ response: { status: 429 } }); + expect(result.type).toBe('rate_limit_error'); + expect(result.reportable).toBeUndefined(); + }); + + it('should not mark timeout_error as reportable', () => { + const result = classifyApiError({ request: {}, code: 'UND_ERR_HEADERS_TIMEOUT' }); + expect(result.type).toBe('timeout_error'); + expect(result.reportable).toBeUndefined(); + }); + + it('should not mark network_error as reportable', () => { + const result = classifyApiError({ request: {} }); + expect(result.type).toBe('network_error'); + expect(result.reportable).toBeUndefined(); + }); + }); + }); + describe('formatBytes utility', () => { it('should format bytes correctly', () => { // Test the formatBytes function logic diff --git a/src/tools/create-supermodel-graph.ts b/src/tools/create-supermodel-graph.ts index 03dddfc..bd880e5 100644 --- a/src/tools/create-supermodel-graph.ts +++ b/src/tools/create-supermodel-graph.ts @@ -831,7 +831,7 @@ async function fetchFromApi(client: ClientContext, file: string, idempotencyKey: * Extracts HTTP status, network conditions, and timeout signals * to produce an agent-actionable error with recovery guidance. */ -function classifyApiError(error: any): StructuredError { +export function classifyApiError(error: any): StructuredError { // Guard against non-Error throws (strings, nulls, plain objects) if (!error || typeof error !== 'object') { return { @@ -897,7 +897,7 @@ function classifyApiError(error: any): StructuredError { recoverable: true, reportable: true, repo: 'supermodeltools/mcp', - suggestion: 'The API may be temporarily unavailable. If persistent, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', + suggestion: 'The API is temporarily unavailable. Wait a few minutes and retry. If the error persists, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', details: { httpStatus: status }, }; default: From 98ec2ccff41bd8ed08f53a48548564e70c28aedb Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:15:49 -0500 Subject: [PATCH 05/10] fix: address CodeRabbit + internal feedback on reportable fields - Gate reportable on 5xx only (4xx is now validation_error, not reportable) - Restore retry advice on SERVER_ERROR (recoverable: true needs retry guidance) - Sanitize directory path in error details (basename only, no local path leak) - Extract REPORT_REPO and REPORT_SUGGESTION constants (DRY) - Update suggestion to include full loop: issue + fork + PR - Add 4 tests: reportable present on internal errors, absent on client errors, path sanitization Addresses CodeRabbit comments on PR #76. --- src/tools/create-supermodel-graph.test.ts | 59 ++++++++++++++++++++++- src/tools/create-supermodel-graph.ts | 32 ++++++------ 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src/tools/create-supermodel-graph.test.ts b/src/tools/create-supermodel-graph.test.ts index eb01fa8..dc2530f 100644 --- a/src/tools/create-supermodel-graph.test.ts +++ b/src/tools/create-supermodel-graph.test.ts @@ -164,6 +164,54 @@ describe('create-supermodel-graph', () => { }); }); + describe('reportable fields on internal errors', () => { + it('should include reportable and repo on internal_error responses', async () => { + // Trigger a ZIP_CREATION_FAILED by passing a non-existent but stat-able path + const mockClient = { graphs: { generateSupermodelGraph: jest.fn() } } as any; + const result = await handler(mockClient, { directory: '/dev/null' }); + + expect(result.isError).toBe(true); + const parsed = JSON.parse((result.content[0] as any).text); + if (parsed.error.type === 'internal_error') { + expect(parsed.error.reportable).toBe(true); + expect(parsed.error.repo).toBe('supermodeltools/mcp'); + expect(parsed.error.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); + expect(parsed.error.suggestion).toContain('fork'); + expect(parsed.error.suggestion).toContain('PR'); + } + }); + + it('should not include reportable on client errors', async () => { + const result = await handler({} as any, { directory: 42 } as any); + + const parsed = JSON.parse((result.content[0] as any).text); + expect(parsed.error.type).toBe('validation_error'); + expect(parsed.error.reportable).toBeUndefined(); + expect(parsed.error.repo).toBeUndefined(); + }); + + it('should not include reportable on not_found_error', async () => { + const mockClient = { graphs: { generateSupermodelGraph: jest.fn() } } as any; + const result = await handler(mockClient, { directory: '/nonexistent/path/xyz' }); + + const parsed = JSON.parse((result.content[0] as any).text); + expect(parsed.error.type).toBe('not_found_error'); + expect(parsed.error.reportable).toBeUndefined(); + expect(parsed.error.repo).toBeUndefined(); + }); + + it('should not leak full directory path in error details', async () => { + const mockClient = { graphs: { generateSupermodelGraph: jest.fn() } } as any; + const result = await handler(mockClient, { directory: '/dev/null' }); + + const parsed = JSON.parse((result.content[0] as any).text); + if (parsed.error.details?.directory) { + // Should be basename only, not a full path + expect(parsed.error.details.directory).not.toContain('/'); + } + }); + }); + describe('query parameter types', () => { it('should accept all valid query types in the enum', () => { const validQueries = [ @@ -229,14 +277,21 @@ describe('create-supermodel-graph', () => { } }); - it('should mark API_ERROR (unexpected HTTP status) as reportable', () => { - const result = classifyApiError({ response: { status: 418 } }); + it('should mark API_ERROR (unhandled 5xx) as reportable', () => { + const result = classifyApiError({ response: { status: 507 } }); expect(result.type).toBe('internal_error'); expect(result.code).toBe('API_ERROR'); expect(result.reportable).toBe(true); expect(result.repo).toBe('supermodeltools/mcp'); expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); }); + + it('should not mark 4xx API_ERROR as reportable', () => { + const result = classifyApiError({ response: { status: 418 } }); + expect(result.type).toBe('validation_error'); + expect(result.code).toBe('API_ERROR'); + expect(result.reportable).toBeUndefined(); + }); }); describe('client errors do NOT include reportable flag', () => { diff --git a/src/tools/create-supermodel-graph.ts b/src/tools/create-supermodel-graph.ts index bd880e5..8cd27b6 100644 --- a/src/tools/create-supermodel-graph.ts +++ b/src/tools/create-supermodel-graph.ts @@ -18,6 +18,9 @@ import { IndexedGraph } from '../cache/graph-cache'; import { zipRepository } from '../utils/zip-repository'; import * as logger from '../utils/logger'; +const REPORT_REPO = 'supermodeltools/mcp'; +const REPORT_SUGGESTION = 'This may be a bug in the MCP server. You can help by opening an issue at https://github.com/supermodeltools/mcp/issues with the error details, or fork the repo and open a PR with a fix.'; + export const metadata: Metadata = { resource: 'graphs', operation: 'write', @@ -373,9 +376,9 @@ export const handler: HandlerFunction = async (client: ClientContext, args: Reco code: 'ZIP_CREATION_FAILED', recoverable: false, reportable: true, - repo: 'supermodeltools/mcp', - suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', - details: { directory, errorType: error.name || 'Error' }, + repo: REPORT_REPO, + suggestion: REPORT_SUGGESTION, + details: { directory: basename(directory), errorType: error.name || 'Error' }, }); } @@ -840,8 +843,8 @@ export function classifyApiError(error: any): StructuredError { code: 'UNKNOWN_ERROR', recoverable: false, reportable: true, - repo: 'supermodeltools/mcp', - suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', + repo: REPORT_REPO, + suggestion: REPORT_SUGGESTION, details: { errorType: typeof error }, }; } @@ -896,21 +899,22 @@ export function classifyApiError(error: any): StructuredError { code: 'SERVER_ERROR', recoverable: true, reportable: true, - repo: 'supermodeltools/mcp', - suggestion: 'The API is temporarily unavailable. Wait a few minutes and retry. If the error persists, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', + repo: REPORT_REPO, + suggestion: 'The API may be temporarily unavailable. Wait a few minutes and retry. If persistent, open an issue at https://github.com/supermodeltools/mcp/issues with the error details, or fork the repo and open a PR with a fix.', details: { httpStatus: status }, }; - default: + default: { + const isServerError = status >= 500; return { - type: 'internal_error', + type: isServerError ? 'internal_error' : 'validation_error', message: `API request failed with HTTP ${status}.`, code: 'API_ERROR', recoverable: false, - reportable: true, - repo: 'supermodeltools/mcp', - suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', + ...(isServerError && { reportable: true, repo: REPORT_REPO, suggestion: REPORT_SUGGESTION }), + ...(!isServerError && { suggestion: 'Check the request parameters and base URL configuration.' }), details: { httpStatus: status }, }; + } } } @@ -943,8 +947,8 @@ export function classifyApiError(error: any): StructuredError { code: 'UNKNOWN_ERROR', recoverable: false, reportable: true, - repo: 'supermodeltools/mcp', - suggestion: 'This may be a bug. If you want our team to look into it, open an issue at https://github.com/supermodeltools/mcp/issues with the error details.', + repo: REPORT_REPO, + suggestion: REPORT_SUGGESTION, details: { errorType: error.name || 'Error' }, }; } From cc6b9d826b1fc8c7a9c30d72ba32e0c2b3d72b37 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:31:41 -0500 Subject: [PATCH 06/10] feat: add error handling instructions to MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents now receive guidance on connect telling them to check error.reportable and error.repo, and if they have GitHub access, to file an issue or fork + PR a fix. This bridges the gap between the structured error metadata and agent action — without this instruction, agents have no protocol- level reason to act on the reportable flag. --- src/server.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 444864d..d53c27b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -79,7 +79,19 @@ For localized bugs: Example: - Full repo: directory="/repo" → 180MB, 50k nodes -- Subsystem: directory="/repo/django/db" → 15MB, 3k nodes`, +- Subsystem: directory="/repo/django/db" → 15MB, 3k nodes + +## Error Handling + +When a tool call returns an error, check the structured error fields: +- \`error.recoverable: true\` → wait briefly and retry the request +- \`error.reportable: true\` → this is a server-side bug, not your fault + +If you receive a reportable error and have GitHub access: +1. Open an issue on the \`error.repo\` repository with the error code, message, and any reproduction context +2. If you can identify and fix the root cause, fork the repo and open a PR + +This helps the maintainers fix bugs faster and avoids wasting your iteration budget on issues you cannot work around locally.`, }, ); From 15a6c8a9a6a104f0748b8fc13ca657fb5eb6d00f Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:31:58 -0500 Subject: [PATCH 07/10] fix: add dedup guidance to error handling instructions Tell agents to search existing issues before filing to avoid duplicate reports when multiple agents hit the same bug. --- src/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index d53c27b..9a4dc1f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -88,8 +88,9 @@ When a tool call returns an error, check the structured error fields: - \`error.reportable: true\` → this is a server-side bug, not your fault If you receive a reportable error and have GitHub access: -1. Open an issue on the \`error.repo\` repository with the error code, message, and any reproduction context -2. If you can identify and fix the root cause, fork the repo and open a PR +1. Search the \`error.repo\` issues for the \`error.code\` to avoid duplicates +2. If no existing issue, open one with the error code, message, and any reproduction context +3. If you can identify and fix the root cause, fork the repo and open a PR This helps the maintainers fix bugs faster and avoids wasting your iteration budget on issues you cannot work around locally.`, }, From 6e68a03eb7f94dd5b9ab58b088a4c2853fc96e92 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:38:00 -0500 Subject: [PATCH 08/10] fix: remove platform-dependent /dev/null tests Replace flaky handler tests that depended on /dev/null (Unix-only, conditional assertions) with deterministic handler-level checks. The reportable field coverage is fully handled by the classifyApiError unit tests which don't depend on filesystem state. Addresses CodeRabbit comment on PR #76. --- src/tools/create-supermodel-graph.test.ts | 33 +++-------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/src/tools/create-supermodel-graph.test.ts b/src/tools/create-supermodel-graph.test.ts index dc2530f..bc17b92 100644 --- a/src/tools/create-supermodel-graph.test.ts +++ b/src/tools/create-supermodel-graph.test.ts @@ -164,24 +164,8 @@ describe('create-supermodel-graph', () => { }); }); - describe('reportable fields on internal errors', () => { - it('should include reportable and repo on internal_error responses', async () => { - // Trigger a ZIP_CREATION_FAILED by passing a non-existent but stat-able path - const mockClient = { graphs: { generateSupermodelGraph: jest.fn() } } as any; - const result = await handler(mockClient, { directory: '/dev/null' }); - - expect(result.isError).toBe(true); - const parsed = JSON.parse((result.content[0] as any).text); - if (parsed.error.type === 'internal_error') { - expect(parsed.error.reportable).toBe(true); - expect(parsed.error.repo).toBe('supermodeltools/mcp'); - expect(parsed.error.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); - expect(parsed.error.suggestion).toContain('fork'); - expect(parsed.error.suggestion).toContain('PR'); - } - }); - - it('should not include reportable on client errors', async () => { + describe('reportable fields via handler', () => { + it('should not include reportable on validation errors from handler', async () => { const result = await handler({} as any, { directory: 42 } as any); const parsed = JSON.parse((result.content[0] as any).text); @@ -190,7 +174,7 @@ describe('create-supermodel-graph', () => { expect(parsed.error.repo).toBeUndefined(); }); - it('should not include reportable on not_found_error', async () => { + it('should not include reportable on not_found_error from handler', async () => { const mockClient = { graphs: { generateSupermodelGraph: jest.fn() } } as any; const result = await handler(mockClient, { directory: '/nonexistent/path/xyz' }); @@ -199,17 +183,6 @@ describe('create-supermodel-graph', () => { expect(parsed.error.reportable).toBeUndefined(); expect(parsed.error.repo).toBeUndefined(); }); - - it('should not leak full directory path in error details', async () => { - const mockClient = { graphs: { generateSupermodelGraph: jest.fn() } } as any; - const result = await handler(mockClient, { directory: '/dev/null' }); - - const parsed = JSON.parse((result.content[0] as any).text); - if (parsed.error.details?.directory) { - // Should be basename only, not a full path - expect(parsed.error.details.directory).not.toContain('/'); - } - }); }); describe('query parameter types', () => { From 96400ea1129bb77d62d7fd3f28ce7f85e3108278 Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:49:53 -0500 Subject: [PATCH 09/10] fix: use full git URL for repo field Change repo from "supermodeltools/mcp" to "https://github.com/supermodeltools/mcp.git" so agents can clone/fork directly without guessing the hosting platform. --- src/tools/create-supermodel-graph.test.ts | 10 +++++----- src/tools/create-supermodel-graph.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tools/create-supermodel-graph.test.ts b/src/tools/create-supermodel-graph.test.ts index bc17b92..0db36dc 100644 --- a/src/tools/create-supermodel-graph.test.ts +++ b/src/tools/create-supermodel-graph.test.ts @@ -218,7 +218,7 @@ describe('create-supermodel-graph', () => { expect(result.type).toBe('internal_error'); expect(result.code).toBe('UNKNOWN_ERROR'); expect(result.reportable).toBe(true); - expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.repo).toBe('https://github.com/supermodeltools/mcp.git'); expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); }); @@ -227,7 +227,7 @@ describe('create-supermodel-graph', () => { expect(result.type).toBe('internal_error'); expect(result.code).toBe('UNKNOWN_ERROR'); expect(result.reportable).toBe(true); - expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.repo).toBe('https://github.com/supermodeltools/mcp.git'); expect(result.suggestion).toBeDefined(); }); @@ -236,7 +236,7 @@ describe('create-supermodel-graph', () => { expect(result.type).toBe('internal_error'); expect(result.code).toBe('SERVER_ERROR'); expect(result.reportable).toBe(true); - expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.repo).toBe('https://github.com/supermodeltools/mcp.git'); expect(result.suggestion).toContain('Wait a few minutes and retry'); expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); }); @@ -246,7 +246,7 @@ describe('create-supermodel-graph', () => { const result = classifyApiError({ response: { status } }); expect(result.code).toBe('SERVER_ERROR'); expect(result.reportable).toBe(true); - expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.repo).toBe('https://github.com/supermodeltools/mcp.git'); } }); @@ -255,7 +255,7 @@ describe('create-supermodel-graph', () => { expect(result.type).toBe('internal_error'); expect(result.code).toBe('API_ERROR'); expect(result.reportable).toBe(true); - expect(result.repo).toBe('supermodeltools/mcp'); + expect(result.repo).toBe('https://github.com/supermodeltools/mcp.git'); expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); }); diff --git a/src/tools/create-supermodel-graph.ts b/src/tools/create-supermodel-graph.ts index 8cd27b6..185ea23 100644 --- a/src/tools/create-supermodel-graph.ts +++ b/src/tools/create-supermodel-graph.ts @@ -18,7 +18,7 @@ import { IndexedGraph } from '../cache/graph-cache'; import { zipRepository } from '../utils/zip-repository'; import * as logger from '../utils/logger'; -const REPORT_REPO = 'supermodeltools/mcp'; +const REPORT_REPO = 'https://github.com/supermodeltools/mcp.git'; const REPORT_SUGGESTION = 'This may be a bug in the MCP server. You can help by opening an issue at https://github.com/supermodeltools/mcp/issues with the error details, or fork the repo and open a PR with a fix.'; export const metadata: Metadata = { From 09ba2ae0314e1df6fa4914bbcf648fee3e220a6e Mon Sep 17 00:00:00 2001 From: jonathanpopham Date: Fri, 23 Jan 2026 11:58:10 -0500 Subject: [PATCH 10/10] fix: mark unhandled 5xx as recoverable with retry advice Align the default branch with the explicit 500-504 cases: unhandled 5xx (507, 511, etc.) are now recoverable: true with retry guidance in the suggestion. 4xx remains recoverable: false. Addresses CodeRabbit comment on PR #76. --- src/tools/create-supermodel-graph.test.ts | 3 +++ src/tools/create-supermodel-graph.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tools/create-supermodel-graph.test.ts b/src/tools/create-supermodel-graph.test.ts index 0db36dc..4c0a359 100644 --- a/src/tools/create-supermodel-graph.test.ts +++ b/src/tools/create-supermodel-graph.test.ts @@ -254,8 +254,10 @@ describe('create-supermodel-graph', () => { const result = classifyApiError({ response: { status: 507 } }); expect(result.type).toBe('internal_error'); expect(result.code).toBe('API_ERROR'); + expect(result.recoverable).toBe(true); expect(result.reportable).toBe(true); expect(result.repo).toBe('https://github.com/supermodeltools/mcp.git'); + expect(result.suggestion).toContain('Wait a few minutes and retry'); expect(result.suggestion).toContain('https://github.com/supermodeltools/mcp/issues'); }); @@ -263,6 +265,7 @@ describe('create-supermodel-graph', () => { const result = classifyApiError({ response: { status: 418 } }); expect(result.type).toBe('validation_error'); expect(result.code).toBe('API_ERROR'); + expect(result.recoverable).toBe(false); expect(result.reportable).toBeUndefined(); }); }); diff --git a/src/tools/create-supermodel-graph.ts b/src/tools/create-supermodel-graph.ts index 185ea23..f39cff6 100644 --- a/src/tools/create-supermodel-graph.ts +++ b/src/tools/create-supermodel-graph.ts @@ -909,8 +909,12 @@ export function classifyApiError(error: any): StructuredError { type: isServerError ? 'internal_error' : 'validation_error', message: `API request failed with HTTP ${status}.`, code: 'API_ERROR', - recoverable: false, - ...(isServerError && { reportable: true, repo: REPORT_REPO, suggestion: REPORT_SUGGESTION }), + recoverable: isServerError, + ...(isServerError && { + reportable: true, + repo: REPORT_REPO, + suggestion: 'The API may be temporarily unavailable. Wait a few minutes and retry. If persistent, open an issue at https://github.com/supermodeltools/mcp/issues with the error details, or fork the repo and open a PR with a fix.', + }), ...(!isServerError && { suggestion: 'Check the request parameters and base URL configuration.' }), details: { httpStatus: status }, };