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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
113
src/config/__tests__/config.test.ts
Normal file
113
src/config/__tests__/config.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/config/index.ts
Normal file
28
src/config/index.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
1
src/types/index.ts
Normal file
1
src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './sage.js';
|
||||||
82
src/types/sage.ts
Normal file
82
src/types/sage.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
95
src/utils/__tests__/errors.test.ts
Normal file
95
src/utils/__tests__/errors.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/utils/__tests__/response.test.ts
Normal file
49
src/utils/__tests__/response.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/utils/errors.ts
Normal file
60
src/utils/errors.ts
Normal file
@@ -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<ErrorClassification, string> = {
|
||||||
|
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];
|
||||||
|
}
|
||||||
2
src/utils/index.ts
Normal file
2
src/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './errors.js';
|
||||||
|
export * from './response.js';
|
||||||
16
src/utils/response.ts
Normal file
16
src/utils/response.ts
Normal file
@@ -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 }) }],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user