From c5a1b800fce60498312510cbe9a87f3dc5a1f820 Mon Sep 17 00:00:00 2001 From: repi Date: Tue, 10 Mar 2026 16:47:26 +0000 Subject: [PATCH] feat(core): add shared types, config validation, and error utilities Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus --- src/config/__tests__/config.test.ts | 113 +++++++++++++++++++++++++++ src/config/index.ts | 28 +++++++ src/types/index.ts | 1 + src/types/sage.ts | 82 +++++++++++++++++++ src/utils/__tests__/errors.test.ts | 95 ++++++++++++++++++++++ src/utils/__tests__/response.test.ts | 49 ++++++++++++ src/utils/errors.ts | 60 ++++++++++++++ src/utils/index.ts | 2 + src/utils/response.ts | 16 ++++ 9 files changed, 446 insertions(+) create mode 100644 src/config/__tests__/config.test.ts create mode 100644 src/config/index.ts create mode 100644 src/types/index.ts create mode 100644 src/types/sage.ts create mode 100644 src/utils/__tests__/errors.test.ts create mode 100644 src/utils/__tests__/response.test.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/response.ts diff --git a/src/config/__tests__/config.test.ts b/src/config/__tests__/config.test.ts new file mode 100644 index 0000000..7a95828 --- /dev/null +++ b/src/config/__tests__/config.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { loadConfig, getTransportConfig } from '../index.js'; + +describe('loadConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should exit with error when SAGE_X3_URL is missing', () => { + delete process.env['SAGE_X3_URL']; + delete process.env['SAGE_X3_USER']; + delete process.env['SAGE_X3_PASSWORD']; + delete process.env['SAGE_X3_ENDPOINT']; + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => loadConfig()).toThrow('process.exit called'); + expect(errorSpy).toHaveBeenCalledWith( + 'FATAL: Missing required environment variable: SAGE_X3_URL', + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should return valid SageConfig when all required vars are present', () => { + process.env['SAGE_X3_URL'] = 'https://x3.example.com'; + process.env['SAGE_X3_USER'] = 'admin'; + process.env['SAGE_X3_PASSWORD'] = 'secret'; + process.env['SAGE_X3_ENDPOINT'] = 'SEED'; + + const config = loadConfig(); + + expect(config).toEqual({ + url: 'https://x3.example.com', + user: 'admin', + password: 'secret', + endpoint: 'SEED', + poolAlias: 'SEED', + language: 'ENG', + rejectUnauthorized: true, + }); + }); + + it('should use default values for optional vars', () => { + process.env['SAGE_X3_URL'] = 'https://x3.example.com'; + process.env['SAGE_X3_USER'] = 'admin'; + process.env['SAGE_X3_PASSWORD'] = 'secret'; + process.env['SAGE_X3_ENDPOINT'] = 'SEED'; + + const config = loadConfig(); + + expect(config.poolAlias).toBe('SEED'); + expect(config.language).toBe('ENG'); + expect(config.rejectUnauthorized).toBe(true); + }); + + it('should respect custom optional var values', () => { + process.env['SAGE_X3_URL'] = 'https://x3.example.com'; + process.env['SAGE_X3_USER'] = 'admin'; + process.env['SAGE_X3_PASSWORD'] = 'secret'; + process.env['SAGE_X3_ENDPOINT'] = 'SEED'; + process.env['SAGE_X3_POOL_ALIAS'] = 'CUSTOM'; + process.env['SAGE_X3_LANGUAGE'] = 'FRA'; + process.env['SAGE_X3_REJECT_UNAUTHORIZED'] = 'false'; + + const config = loadConfig(); + + expect(config.poolAlias).toBe('CUSTOM'); + expect(config.language).toBe('FRA'); + expect(config.rejectUnauthorized).toBe(false); + }); +}); + +describe('getTransportConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should default to stdio transport and port 3000', () => { + delete process.env['MCP_TRANSPORT']; + delete process.env['MCP_HTTP_PORT']; + + const config = getTransportConfig(); + + expect(config.transport).toBe('stdio'); + expect(config.httpPort).toBe(3000); + }); + + it('should use http transport when configured', () => { + process.env['MCP_TRANSPORT'] = 'http'; + process.env['MCP_HTTP_PORT'] = '8080'; + + const config = getTransportConfig(); + + expect(config.transport).toBe('http'); + expect(config.httpPort).toBe(8080); + }); +}); diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..d2e8dcc --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,28 @@ +import type { SageConfig } from '../types/index.js'; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + console.error(`FATAL: Missing required environment variable: ${name}`); + process.exit(1); + } + return value; +} + +export function loadConfig(): SageConfig { + return { + url: requireEnv('SAGE_X3_URL'), + user: requireEnv('SAGE_X3_USER'), + password: requireEnv('SAGE_X3_PASSWORD'), + endpoint: requireEnv('SAGE_X3_ENDPOINT'), + poolAlias: process.env['SAGE_X3_POOL_ALIAS'] ?? 'SEED', + language: process.env['SAGE_X3_LANGUAGE'] ?? 'ENG', + rejectUnauthorized: process.env['SAGE_X3_REJECT_UNAUTHORIZED'] !== 'false', + }; +} + +export function getTransportConfig(): { transport: 'stdio' | 'http'; httpPort: number } { + const transport = process.env['MCP_TRANSPORT'] === 'http' ? 'http' : 'stdio'; + const httpPort = parseInt(process.env['MCP_HTTP_PORT'] ?? '3000', 10); + return { transport, httpPort }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..ea0abf0 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './sage.js'; diff --git a/src/types/sage.ts b/src/types/sage.ts new file mode 100644 index 0000000..4b919e5 --- /dev/null +++ b/src/types/sage.ts @@ -0,0 +1,82 @@ +export interface SageConfig { + url: string; + user: string; + password: string; + endpoint: string; + poolAlias: string; + language: string; + rejectUnauthorized: boolean; +} + +export interface RestQueryOptions { + entity: string; + representation?: string; + where?: string; + orderBy?: string; + count?: number; + nextUrl?: string; + select?: string; +} + +export interface RestQueryResult { + records: unknown[]; + pagination: { + returned: number; + hasMore: boolean; + nextUrl?: string; + }; +} + +export interface RestDetailResult { + record: unknown; +} + +export interface SoapCallContext { + codeLang: string; + poolAlias: string; + poolId: string; + requestConfig: string; +} + +export interface SoapReadOptions { + publicName: string; + objectName?: string; + key: Record; +} + +export interface SoapQueryOptions { + publicName: string; + objectName?: string; + listSize?: number; + inputXml?: string; +} + +export interface SoapResult { + status: number; + data: unknown; + messages: SoapMessage[]; + technicalInfos: SoapTechInfo[]; +} + +export interface SoapMessage { + type: number; + message: string; +} + +export interface SoapTechInfo { + type: string; + message: string; +} + +export interface ToolResponse { + records?: unknown[]; + record?: unknown; + pagination?: { returned: number; hasMore: boolean; nextUrl?: string }; + error?: string; + hint?: string; +} + +export interface HealthStatus { + rest: { status: string; latencyMs: number; endpoint: string }; + soap: { status: string; latencyMs: number; poolAlias: string }; +} diff --git a/src/utils/__tests__/errors.test.ts b/src/utils/__tests__/errors.test.ts new file mode 100644 index 0000000..854ff5a --- /dev/null +++ b/src/utils/__tests__/errors.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { formatToolError, classifyError, getErrorHint } from '../errors.js'; + +describe('formatToolError', () => { + it('should return isError: true with error content', () => { + const result = formatToolError(new Error('something broke')); + + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + expect(result.content[0]).toEqual({ + type: 'text', + text: 'Error: something broke', + }); + }); + + it('should include hint when provided', () => { + const result = formatToolError(new Error('failed'), 'Try again later'); + + expect(result.content[0]).toEqual({ + type: 'text', + text: 'Error: failed\nHint: Try again later', + }); + }); + + it('should handle non-Error values', () => { + const result = formatToolError('string error'); + + expect(result.isError).toBe(true); + expect(result.content[0]).toEqual({ + type: 'text', + text: 'Error: string error', + }); + }); +}); + +describe('classifyError', () => { + it('should classify 401 status as auth_error', () => { + expect(classifyError(new Error('Request failed with status: 401'))).toBe('auth_error'); + }); + + it('should classify 403 status as auth_error', () => { + expect(classifyError(new Error('Request failed with status: 403'))).toBe('auth_error'); + }); + + it('should classify timeout errors', () => { + expect(classifyError(new Error('Request timeout'))).toBe('timeout'); + expect(classifyError(new Error('ETIMEDOUT'))).toBe('timeout'); + }); + + it('should classify 404 as not_found', () => { + expect(classifyError(new Error('status: 404'))).toBe('not_found'); + }); + + it('should classify connection errors', () => { + expect(classifyError(new Error('ECONNREFUSED'))).toBe('connection_error'); + }); + + it('should classify x3 business errors', () => { + expect(classifyError(new Error('X3 business rule violation'))).toBe('x3_error'); + }); + + it('should return unknown for non-Error values', () => { + expect(classifyError('not an error')).toBe('unknown'); + }); + + it('should return unknown for unrecognized errors', () => { + expect(classifyError(new Error('something weird'))).toBe('unknown'); + }); +}); + +describe('getErrorHint', () => { + it('should return correct hint for auth_error', () => { + expect(getErrorHint('auth_error')).toContain('SAGE_X3_USER'); + }); + + it('should return correct hint for timeout', () => { + expect(getErrorHint('timeout')).toContain('slow to respond'); + }); + + it('should return correct hint for not_found', () => { + expect(getErrorHint('not_found')).toContain('sage_list_entities'); + }); + + it('should return correct hint for connection_error', () => { + expect(getErrorHint('connection_error')).toContain('SAGE_X3_URL'); + }); + + it('should return correct hint for x3_error', () => { + expect(getErrorHint('x3_error')).toContain('business error'); + }); + + it('should return correct hint for unknown', () => { + expect(getErrorHint('unknown')).toContain('unexpected error'); + }); +}); diff --git a/src/utils/__tests__/response.test.ts b/src/utils/__tests__/response.test.ts new file mode 100644 index 0000000..e461677 --- /dev/null +++ b/src/utils/__tests__/response.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { formatQueryResponse, formatReadResponse } from '../response.js'; + +function extractText(result: { content: { type: string }[] }): string { + const item = result.content[0]; + if (item && 'text' in item && typeof item.text === 'string') return item.text; + throw new Error('Expected text content'); +} + +describe('formatQueryResponse', () => { + it('should return minified JSON with records and pagination', () => { + const records = [{ id: 1, name: 'Test' }]; + const pagination = { returned: 1, hasMore: false }; + + const result = formatQueryResponse(records, pagination); + + expect(result.content).toHaveLength(1); + expect(result.content[0]?.type).toBe('text'); + + const text = extractText(result); + expect(text).toBe('{"records":[{"id":1,"name":"Test"}],"pagination":{"returned":1,"hasMore":false}}'); + expect(() => JSON.parse(text)).not.toThrow(); + }); + + it('should include nextUrl in pagination when present', () => { + const records = [{ id: 1 }]; + const pagination = { returned: 1, hasMore: true, nextUrl: '/next?page=2' }; + + const result = formatQueryResponse(records, pagination); + + const parsed = JSON.parse(extractText(result)); + expect(parsed.pagination.nextUrl).toBe('/next?page=2'); + }); +}); + +describe('formatReadResponse', () => { + it('should return minified JSON with record', () => { + const record = { id: 1, name: 'Test', nested: { key: 'value' } }; + + const result = formatReadResponse(record); + + expect(result.content).toHaveLength(1); + expect(result.content[0]?.type).toBe('text'); + + const text = extractText(result); + expect(text).toBe('{"record":{"id":1,"name":"Test","nested":{"key":"value"}}}'); + expect(() => JSON.parse(text)).not.toThrow(); + }); +}); diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..062e5e0 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,60 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export type ErrorClassification = + | 'auth_error' + | 'timeout' + | 'not_found' + | 'connection_error' + | 'x3_error' + | 'unknown'; + +export function formatToolError(error: unknown, hint?: string): CallToolResult { + const message = error instanceof Error ? error.message : String(error); + const parts = [`Error: ${message}`]; + if (hint) { + parts.push(`\nHint: ${hint}`); + } + return { + isError: true as const, + content: [{ type: 'text' as const, text: parts.join('') }], + }; +} + +export function classifyError(error: unknown): ErrorClassification { + if (!(error instanceof Error)) return 'unknown'; + + const msg = error.message.toLowerCase(); + const statusMatch = msg.match(/status[:\s]*(\d{3})/); + const status = statusMatch ? parseInt(statusMatch[1], 10) : undefined; + + if (status === 401 || status === 403 || msg.includes('unauthorized') || msg.includes('authentication')) { + return 'auth_error'; + } + if (msg.includes('timeout') || msg.includes('etimedout') || msg.includes('econnaborted')) { + return 'timeout'; + } + if (status === 404 || msg.includes('not found')) { + return 'not_found'; + } + if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('connect')) { + return 'connection_error'; + } + if (msg.includes('x3') || msg.includes('sage') || msg.includes('business')) { + return 'x3_error'; + } + + return 'unknown'; +} + +const ERROR_HINTS: Record = { + auth_error: 'Check SAGE_X3_USER and SAGE_X3_PASSWORD. Ensure the web service user has API access.', + timeout: 'The X3 server is slow to respond. Try a simpler query or smaller count.', + not_found: 'Record not found. Verify the entity name and key. Use sage_list_entities to discover available entities.', + connection_error: 'Cannot connect to X3 server. Check SAGE_X3_URL and network connectivity.', + x3_error: 'X3 returned a business error. Check the error messages for details.', + unknown: 'An unexpected error occurred. Check server logs for details.', +}; + +export function getErrorHint(classification: ErrorClassification): string { + return ERROR_HINTS[classification]; +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..6d56a23 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './errors.js'; +export * from './response.js'; diff --git a/src/utils/response.ts b/src/utils/response.ts new file mode 100644 index 0000000..99426e6 --- /dev/null +++ b/src/utils/response.ts @@ -0,0 +1,16 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +export function formatQueryResponse( + records: unknown[], + pagination: { returned: number; hasMore: boolean; nextUrl?: string }, +): CallToolResult { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ records, pagination }) }], + }; +} + +export function formatReadResponse(record: unknown): CallToolResult { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ record }) }], + }; +}