The services/ directory contains external service integrations following a consistent domain-driven pattern. Each service domain provides pluggable providers, allowing you to swap implementations without changing business logic.
Service domains:
- llm/ - Large Language Model providers (OpenRouter)
- speech/ - Text-to-Speech and Speech-to-Text providers (ElevenLabs, Whisper)
- graph/ - Graph database operations (no provider currently registered)
┌─────────────────────────────────────────────────┐
│ Application Layer │
│ (Tools, Resources, Logic) │
└────────────────┬────────────────────────────────┘
│
│ Dependency Injection
│
┌────────────────▼────────────────────────────────┐
│ Services Layer │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Service Orchestrators │ │
│ │ - Route requests to providers │ │
│ │ - Handle multi-provider scenarios │ │
│ │ - Aggregate results │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Provider Interface Layer │ │
│ │ - I[Service]Provider contracts │ │
│ │ - Consistent provider API │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
│
│ Provider implementations
│
┌────────────────▼────────────────────────────────┐
│ Provider Layer │
│ (OpenRouter, ElevenLabs, etc.) │
└────────────────┬────────────────────────────────┘
│
│ HTTP/API calls
│
┌────────────────▼────────────────────────────────┐
│ External Services │
│ (Third-party APIs, DBs) │
└─────────────────────────────────────────────────┘
All services follow this consistent structure:
services/[service-name]/
├── core/
│ ├── I[Service]Provider.ts # Provider interface
│ └── [Service]Service.ts # Orchestrator (for multi-provider)
├── providers/
│ ├── provider1.provider.ts
│ └── provider2.provider.ts
├── types.ts # Domain types
└── index.ts # Barrel export
When a domain has a single provider, inject it directly:
import { injectable, inject } from 'tsyringe';
import { LlmProvider } from '@/container/core/tokens.js';
import type { ILlmProvider } from '@/services/llm/core/ILlmProvider.js';
import { requestContextService } from '@/utils/index.js';
@injectable()
class MyTool {
constructor(@inject(LlmProvider) private llmProvider: ILlmProvider) {}
async execute() {
const context = requestContextService.createRequestContext({
operation: 'my-tool-execute',
});
const result = await this.llmProvider.chatCompletion(
{
messages: [{ role: 'user', content: 'Hello, world!' }],
max_tokens: 100,
},
context,
);
return result;
}
}When a domain has multiple providers or complex operations, use an orchestrator:
Speech Service Example:
import { injectable, inject } from 'tsyringe';
import { SpeechService } from '@/container/core/tokens.js';
import type { SpeechService as SpeechServiceType } from '@/services/speech/core/SpeechService.js';
@injectable()
class MyTool {
constructor(@inject(SpeechService) private speech: SpeechServiceType) {}
async execute() {
// Orchestrator routes to appropriate provider (ElevenLabs, Whisper, etc.)
const ttsProvider = this.speech.getTTSProvider();
const audio = await ttsProvider.textToSpeech({
text: 'Hello, world!',
voice: { voiceId: 'en-US-Neural2-F' },
});
return audio;
}
}All providers must adhere to the following requirements:
-
Implement the interface
- Extend
I[Service]Providerinterface - Implement all required methods with correct signatures
- Extend
-
Be injectable
- Add
@injectable()decorator to class - Use constructor injection for dependencies
- Add
-
Include health check
- Implement
healthCheck()method - Return
booleanindicating provider availability
- Implement
-
Throw McpError on failures
- Use
JsonRpcErrorCodeenum for error codes - Include context information in error messages
- Use
-
Follow naming convention
- File name:
[name].provider.ts(kebab-case) - Class name:
[Name]Provider(PascalCase)
- File name:
/**
* @fileoverview [Provider Name] implementation for [Service] service
* @module services/[service]/providers/[name]
*/
import { inject, injectable } from 'tsyringe';
import { McpError, JsonRpcErrorCode } from '@/types-global/errors.js';
import { AppConfig, Logger } from '@/container/core/tokens.js';
import { logger as LoggerType } from '@/utils/internal/logger.js';
import { requestContextService, type RequestContext } from '@/utils/index.js';
import { config as ConfigType } from '@/config/index.js';
import type { I[Service]Provider } from '../core/I[Service]Provider.js';
import type { [Service]Request, [Service]Response } from '../types.js';
/**
* [Provider description]
*
* Configuration:
* - ENV_VAR_NAME: Description
* - ENV_VAR_NAME_2: Description
*/
@injectable()
export class [Name]Provider implements I[Service]Provider {
private readonly apiKey: string;
private readonly baseUrl: string;
constructor(
@inject(AppConfig) private config: typeof ConfigType,
@inject(Logger) private logger: typeof LoggerType,
) {
const context = requestContextService.createRequestContext({
operation: '[Name]Provider.constructor',
});
// Validate configuration
this.apiKey = this.config.someApiKey || '';
if (!this.apiKey) {
this.logger.fatal(
'[Provider] API key is not configured. Please set API_KEY.',
context,
);
throw new McpError(
JsonRpcErrorCode.ConfigurationError,
'[Provider] API key not configured',
context,
);
}
this.baseUrl = this.config.someBaseUrl || 'https://api.example.com';
this.logger.info('[Provider] instance created and ready.', context);
}
/**
* Check provider health
*/
async healthCheck(): Promise<boolean> {
try {
const context = requestContextService.createRequestContext({
operation: '[name]-healthCheck',
});
// Implement health check logic (e.g., lightweight API call)
return true;
} catch (error: unknown) {
this.logger.error(
'[Provider] health check failed',
error instanceof Error ? error : new Error(String(error)),
);
return false;
}
}
/**
* Main service method
*/
async execute(
request: [Service]Request,
context: RequestContext,
): Promise<[Service]Response> {
this.logger.debug('[Provider] executing request', context);
// Validate input
if (!request.someField) {
throw new McpError(
JsonRpcErrorCode.InvalidParams,
'Required field is missing',
context,
);
}
try {
// Implement service logic
const response = await this.callApi(request, context);
const transformed = this.transformResponse(response);
this.logger.info('[Provider] execution successful', context);
return transformed;
} catch (error: unknown) {
if (error instanceof McpError) {
throw error;
}
this.logger.error(
'[Provider] execution failed',
error instanceof Error ? error : new Error(String(error)),
context,
);
throw new McpError(
JsonRpcErrorCode.InternalError,
`[Provider] execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
context,
);
}
}
private async callApi(
request: [Service]Request,
context: RequestContext,
): Promise<unknown> {
// API call implementation using fetchWithTimeout or similar
// Example:
// const response = await fetchWithTimeout(url, timeout, context, {
// method: 'POST',
// headers: { 'Authorization': `Bearer ${this.apiKey}` },
// body: JSON.stringify(request),
// });
// return response.json();
}
private transformResponse(response: unknown): [Service]Response {
// Transform API response to domain type
// Perform any necessary data mapping
return response as [Service]Response;
}
}mkdir -p src/services/[service-name]/{core,providers}
touch src/services/[service-name]/{types.ts,index.ts}core/I[Service]Provider.ts:
/**
* @fileoverview Interface for [Service] providers
* @module services/[service]/core/I[Service]Provider
*/
import type { [Service]Request, [Service]Response } from '../types.js';
/**
* Interface for [Service] providers
*/
export interface I[Service]Provider {
/**
* Health check for provider availability
*/
healthCheck(): Promise<boolean>;
/**
* Execute [service] operation
*/
execute(request: [Service]Request): Promise<[Service]Response>;
}types.ts:
/**
* @fileoverview Type definitions for [Service] service
* @module services/[service]/types
*/
/**
* Request payload for [service] operations
*/
export interface [Service]Request {
// Define request fields
}
/**
* Response from [service] operations
*/
export interface [Service]Response {
// Define response fields
}
/**
* Configuration for [service] providers
*/
export interface [Service]Config {
// Define config fields
}Create providers/[name].provider.ts following the template above.
src/container/core/tokens.ts:
export const [Service]Provider = Symbol.for('I[Service]Provider');src/container/registrations/core.ts:
For single-provider services (like LLM):
import { [Name]Provider } from '@/services/[service]/providers/[name].provider.js';
import { [Service]Provider } from '@/container/core/tokens.js';
import type { I[Service]Provider } from '@/services/[service]/core/I[Service]Provider.js';
// In registerCoreServices():
container.register<I[Service]Provider>([Service]Provider, {
useClass: [Name]Provider,
});For multi-provider services with factory (like Speech):
import { [Service]Service } from '@/services/[service]/core/[Service]Service.js';
import { [Service]Service as [Service]ServiceToken } from '@/container/core/tokens.js';
import type { [Service]ProviderConfig } from '@/services/[service]/types.js';
// In registerCoreServices():
container.register<[Service]Service>([Service]ServiceToken, {
useFactory: (c) => {
const config = c.resolve(AppConfig);
// Configure TTS provider
const ttsConfig: [Service]ProviderConfig | undefined = config.someTtsApiKey
? {
provider: 'provider-name',
apiKey: config.someTtsApiKey,
// ... other config
}
: undefined;
// Configure STT provider
const sttConfig: [Service]ProviderConfig | undefined = config.someSttApiKey
? {
provider: 'provider-name',
apiKey: config.someSttApiKey,
// ... other config
}
: undefined;
return new [Service]Service(ttsConfig, sttConfig);
},
});src/services/[service]/index.ts:
export * from './core/I[Service]Provider.js';
export * from './providers/[name].provider.js';
export * from './types.js';Create tests/services/[service]/providers/[name].provider.test.ts.
Purpose: Large Language Model completions (streaming and non-streaming)
Provider: OpenRouter
Interface: ILlmProvider
Methods:
chatCompletion(params, context)- Chat completion (streaming or non-streaming based on params)chatCompletionStream(params, context)- Streaming chat completion (returns AsyncIterable)
Configuration:
OPENROUTER_API_KEY=sk-or-...
OPENROUTER_APP_URL=https://your-app.com
OPENROUTER_APP_NAME=YourApp
LLM_DEFAULT_MODEL=anthropic/claude-3.5-sonnet
LLM_DEFAULT_MAX_TOKENS=4000
LLM_DEFAULT_TEMPERATURE=0.7Usage Example:
import { inject } from 'tsyringe';
import { LlmProvider } from '@/container/core/tokens.js';
import type { ILlmProvider } from '@/services/llm/core/ILlmProvider.js';
import { requestContextService } from '@/utils/index.js';
// Non-streaming completion
const context = requestContextService.createRequestContext({
operation: 'llm-completion',
});
const response = await llmProvider.chatCompletion(
{
messages: [{ role: 'user', content: 'Explain dependency injection' }],
model: 'anthropic/claude-3.5-sonnet',
max_tokens: 500,
},
context,
);
// Streaming completion
const streamResponse = await llmProvider.chatCompletionStream(
{
messages: [{ role: 'user', content: 'Write a story' }],
model: 'anthropic/claude-3.5-sonnet',
stream: true,
},
context,
);
for await (const chunk of streamResponse) {
console.log(chunk.choices[0]?.delta?.content || '');
}Purpose: Text-to-Speech and Speech-to-Text operations
Providers: ElevenLabs (TTS), Whisper (STT)
Orchestrator: SpeechService
Methods:
textToSpeech(request)- Convert text to audiospeechToText(request)- Transcribe audio to texthealthCheck()- Check all providers
Configuration:
SPEECH_TTS_API_KEY=... # ElevenLabs API key
SPEECH_TTS_MODEL_ID=eleven_multilingual_v2
SPEECH_TTS_VOICE_ID=EXAVITQu4vr4xnSDxMaL
SPEECH_STT_API_KEY=... # Whisper API keyUsage Example:
// Text-to-Speech
const audio = await speechService.textToSpeech({
text: 'Hello, world!',
voice: 'en-US-Neural2-F',
});
// Speech-to-Text
const transcript = await speechService.speechToText({
audioBuffer: audioData,
language: 'en',
});Purpose: Graph database operations (nodes, edges, traversals, pathfinding)
Provider: None currently registered
Orchestrator: GraphService
Interface: IGraphProvider
Methods:
relate(from, edgeTable, to, context, options)- Create relationship between verticesunrelate(edgeId, context)- Remove relationship edgetraverse(startVertexId, context, options)- Graph traversal from starting vertexshortestPath(from, to, context, options)- Find shortest path between verticesgetOutgoingEdges(vertexId, context, edgeTypes)- Get outgoing edges from vertexgetIncomingEdges(vertexId, context, edgeTypes)- Get incoming edges to vertexpathExists(from, to, context, maxDepth)- Check if path exists between verticesgetStats(context)- Get graph statistics (vertex/edge counts, type distributions)healthCheck()- Provider health check
Usage Example:
import { inject } from 'tsyringe';
import { GraphProvider } from '@/container/core/tokens.js';
import type { IGraphProvider } from '@/services/graph/core/IGraphProvider.js';
import { requestContextService } from '@/utils/index.js';
const context = requestContextService.createRequestContext({
operation: 'graph-operations',
});
// Create relationship
const edge = await graphProvider.relate(
'user:alice',
'follows',
'user:bob',
context,
{
data: { since: '2024-01-01' },
allowDuplicates: false,
},
);
// Traverse the graph
const traversal = await graphProvider.traverse('user:alice', context, {
maxDepth: 2,
direction: 'out',
edgeTypes: ['follows'],
});
// Find shortest path
const path = await graphProvider.shortestPath(
'user:alice',
'user:charlie',
context,
{ algorithm: 'bfs', maxLength: 10 },
);
// Get graph statistics
const stats = await graphProvider.getStats(context);
console.log(
`Graph contains ${stats.vertexCount} vertices and ${stats.edgeCount} edges`,
);import { describe, it, expect, vi, beforeEach } from 'vitest';
import { container } from 'tsyringe';
import { [Name]Provider } from '@/services/[service]/providers/[name].provider.js';
import { AppConfig, Logger } from '@/container/core/tokens.js';
import { requestContextService } from '@/utils/index.js';
import { McpError } from '@/types-global/errors.js';
describe('[Name]Provider', () => {
let provider: [Name]Provider;
let mockConfig: any;
let mockLogger: any;
beforeEach(() => {
// Create mocks
mockConfig = {
someApiKey: 'test-key',
someBaseUrl: 'https://api.test.com',
};
mockLogger = {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
};
// Register mocks in container
container.register(AppConfig, { useValue: mockConfig });
container.register(Logger, { useValue: mockLogger });
provider = container.resolve([Name]Provider);
});
describe('healthCheck', () => {
it('returns true when provider is healthy', async () => {
const result = await provider.healthCheck();
expect(result).toBe(true);
});
it('returns false when provider is unhealthy', async () => {
// Mock failure scenario
const result = await provider.healthCheck();
expect(result).toBe(false);
});
});
describe('execute', () => {
it('executes successfully with valid request', async () => {
const context = requestContextService.createRequestContext({
operation: 'test-execute',
});
const request = { /* test data */ };
const result = await provider.execute(request, context);
expect(result).toBeDefined();
expect(mockLogger.debug).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalled();
});
it('throws McpError on failure', async () => {
const context = requestContextService.createRequestContext({
operation: 'test-execute-fail',
});
const request = { /* invalid data */ };
await expect(provider.execute(request, context)).rejects.toThrow(McpError);
expect(mockLogger.error).toHaveBeenCalled();
});
it('validates required fields', async () => {
const context = requestContextService.createRequestContext({
operation: 'test-validate',
});
const invalidRequest = { /* missing required fields */ };
await expect(provider.execute(invalidRequest, context)).rejects.toThrow(McpError);
});
});
});Always use McpError with appropriate JsonRpcErrorCode:
// ✅ Good - using McpError with proper error code
throw new McpError(
JsonRpcErrorCode.InternalError,
'Descriptive error message',
{ additionalContext: 'value' },
);Validate all configuration in constructor:
// ✅ Good - validating required configuration
constructor() {
this.apiKey = process.env.API_KEY || '';
if (!this.apiKey) {
throw new McpError(
JsonRpcErrorCode.ConfigurationError,
'API key not configured: API_KEY environment variable is required'
);
}
}Use structured logging with context:
// ✅ Good - structured logging with context
import { logger } from '@/utils/index.js';
logger.info('Service operation started', {
provider: 'ProviderName',
operation: 'execute',
requestId: context.requestId,
});Implement meaningful health checks:
// ✅ Good - health check with error handling
async healthCheck(): Promise<boolean> {
try {
// Make lightweight API call
const response = await fetch(`${this.baseUrl}/health`);
return response.ok;
} catch {
return false;
}
}Use strict types for all inputs and outputs:
// ❌ Bad - using any types
async execute(data: any): Promise<any>
// ✅ Good - using strict types
async execute(request: ServiceRequest): Promise<ServiceResponse>Error: No matching provider found for token I[Service]Provider
Solution: Ensure provider is registered in src/container/registrations/core.ts:
container.register([Service]Provider, {
useClass: [Name]Provider,
});Error: API key not configured
Solution: Set required environment variables in .env:
API_KEY=your-key-hereError: Provider health check returns false
Solution:
- Verify API credentials are correct
- Check network connectivity
- Verify API endpoint is accessible
- Review provider-specific logs
- Container Module - Dependency injection setup
- Storage Module - Data persistence patterns
- CLAUDE.md - Architectural mandate (Section V)