/**
* Cloud Relay Client
*
* Handles communication with the PartnerCore Cloud MCP server.
* Uses JSON-RPC protocol to communicate with /mcp endpoint.
*/
import axios, { AxiosInstance, AxiosError } from 'axios';
import { getLogger } from '../utils/logger.js';
/**
* JSON-RPC request structure
*/
interface JsonRpcRequest {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
id: number | string;
}
/**
* JSON-RPC response structure
*/
interface JsonRpcResponse<T = unknown> {
jsonrpc: '2.0';
result?: T;
error?: {
code: number;
message: string;
data?: unknown;
};
id: number | string | null;
}
/**
* Tool call request
*/
export interface ToolCallRequest {
name: string;
arguments: Record<string, unknown>;
}
/**
* Tool call response
*/
export interface ToolCallResponse {
success: boolean;
result?: unknown;
error?: string;
}
/**
* Available cloud tools
*/
export interface CloudToolDefinition {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
/**
* Cloud Relay Client configuration
*/
export interface CloudRelayConfig {
cloudUrl: string;
apiKey: string;
}
/**
* Cloud Relay Client
*
* Communicates with PartnerCore Cloud MCP server using JSON-RPC protocol.
*/
export class CloudRelayClient {
private client: AxiosInstance;
private logger = getLogger();
private toolsCache: CloudToolDefinition[] | null = null;
private requestId = 0;
private initialized = false;
constructor(config: CloudRelayConfig | string, apiKey?: string) {
// Support both old (cloudUrl, apiKey) and new (config object) signatures
const cloudUrl = typeof config === 'string' ? config : config.cloudUrl;
const key = typeof config === 'string' ? (apiKey ?? '') : config.apiKey;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-API-Key': key, // PartnerCore uses X-API-Key header
'X-Client-Type': 'partnercore-proxy',
};
this.client = axios.create({
baseURL: cloudUrl,
timeout: 60000, // 60 second timeout for long operations
headers,
});
// Add request interceptor for logging
this.client.interceptors.request.use(
(reqConfig) => {
this.logger.debug(`Cloud request: ${reqConfig.method?.toUpperCase()} ${reqConfig.url}`);
return reqConfig;
},
(error) => {
this.logger.error('Cloud request error:', error);
return Promise.reject(error);
}
);
// Add response interceptor for error handling
this.client.interceptors.response.use(
(response) => {
return response;
},
(error: AxiosError) => {
if (error.response) {
this.logger.error(`Cloud response error: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
this.logger.error('Cloud request failed: No response received');
} else {
this.logger.error('Cloud request failed:', error.message);
}
return Promise.reject(error);
}
);
}
/**
* Generate next request ID
*/
private nextId(): number {
return ++this.requestId;
}
/**
* Send JSON-RPC request to /mcp endpoint
*/
private async rpcCall<T>(method: string, params?: Record<string, unknown>): Promise<JsonRpcResponse<T>> {
const request: JsonRpcRequest = {
jsonrpc: '2.0',
method,
params,
id: this.nextId(),
};
try {
const response = await this.client.post<JsonRpcResponse<T>>('/mcp', request);
return response.data;
} catch (error) {
const axiosError = error as AxiosError;
throw new Error(axiosError.message || 'RPC call failed');
}
}
/**
* Initialize connection with cloud MCP server
*/
async initialize(): Promise<boolean> {
if (this.initialized) {
return true;
}
try {
const response = await this.rpcCall<{ capabilities: Record<string, unknown> }>('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: {
name: 'partnercore-proxy',
version: '0.1.2',
},
});
if (response.error) {
this.logger.error(`Initialize failed: ${response.error.message}`);
return false;
}
this.initialized = true;
this.logger.info('Cloud MCP initialized successfully');
return true;
} catch (error) {
this.logger.error('Failed to initialize cloud MCP:', error);
return false;
}
}
/**
* Check if connection to cloud is available
*/
async checkConnection(): Promise<boolean> {
try {
// Use /health endpoint for connection check
await this.client.get('/health');
this.logger.info('Cloud MCP connection verified');
return true;
} catch {
this.logger.warn('Cloud MCP connection unavailable');
return false;
}
}
/**
* Validate API key by trying to list tools
*/
async validateApiKey(): Promise<boolean> {
try {
// Initialize first
if (!this.initialized) {
const initOk = await this.initialize();
if (!initOk) {
return false;
}
}
// Try to list tools - this will fail if API key is invalid
const response = await this.rpcCall<{ tools: CloudToolDefinition[] }>('tools/list');
if (response.error) {
this.logger.warn(`API key validation failed: ${response.error.message}`);
return false;
}
this.logger.info('API key validated successfully');
return true;
} catch {
this.logger.warn('Cloud connection available but API key invalid');
return false;
}
}
/**
* Get available tools from cloud server
*/
async getTools(): Promise<CloudToolDefinition[]> {
if (this.toolsCache) {
return this.toolsCache;
}
try {
// Ensure initialized
if (!this.initialized) {
await this.initialize();
}
const response = await this.rpcCall<{ tools: CloudToolDefinition[] }>('tools/list');
if (response.error) {
this.logger.error(`Get tools failed: ${response.error.message}`);
return [];
}
this.toolsCache = response.result?.tools || [];
this.logger.info(`Loaded ${this.toolsCache.length} cloud tools`);
return this.toolsCache;
} catch (error) {
this.logger.error('Failed to get cloud tools:', error);
return [];
}
}
/**
* Call a tool on the cloud server
*/
async callTool(request: ToolCallRequest): Promise<ToolCallResponse> {
this.logger.debug(`Calling cloud tool: ${request.name}`);
try {
// Ensure initialized
if (!this.initialized) {
await this.initialize();
}
const response = await this.rpcCall<{ content: Array<{ type: string; text: string }> }>('tools/call', {
name: request.name,
arguments: request.arguments,
});
if (response.error) {
return {
success: false,
error: response.error.message,
};
}
// Extract text content from MCP response
const content = response.result?.content;
if (content && Array.isArray(content) && content.length > 0) {
const textContent = content.find(c => c.type === 'text');
if (textContent) {
return {
success: true,
result: textContent.text,
};
}
}
return {
success: true,
result: response.result,
};
} catch (error) {
const axiosError = error as AxiosError<{ error?: string }>;
return {
success: false,
error: axiosError.response?.data?.error || axiosError.message || 'Unknown error',
};
}
}
/**
* Search knowledge base using al-assistant learn action
*/
async searchKnowledgeBase(query: string, filters?: Record<string, unknown>): Promise<unknown[]> {
try {
const response = await this.callTool({
name: 'al-assistant',
arguments: {
action: 'learn',
params: {
query,
...filters,
},
},
});
if (!response.success) {
this.logger.error('KB search failed:', response.error);
return [];
}
// Try to parse as JSON if it's a string
if (typeof response.result === 'string') {
try {
const parsed = JSON.parse(response.result) as unknown;
if (Array.isArray(parsed)) {
return parsed as unknown[];
}
return [parsed];
} catch {
return [{ content: response.result }];
}
}
const result: unknown = response.result;
if (Array.isArray(result)) {
return result as unknown[];
}
return [result];
} catch (error) {
this.logger.error('KB search failed:', error);
return [];
}
}
/**
* Get templates using al-assistant list-templates action
*/
async getTemplates(type?: string): Promise<unknown[]> {
try {
const response = await this.callTool({
name: 'al-assistant',
arguments: {
action: 'list-templates',
params: type ? { category: type } : {},
},
});
if (!response.success) {
this.logger.error('Get templates failed:', response.error);
return [];
}
// Try to parse as JSON if it's a string
if (typeof response.result === 'string') {
try {
const parsed = JSON.parse(response.result) as unknown;
if (Array.isArray(parsed)) {
return parsed as unknown[];
}
return [parsed];
} catch {
return [{ content: response.result }];
}
}
const result: unknown = response.result;
if (Array.isArray(result)) {
return result as unknown[];
}
return [result];
} catch (error) {
this.logger.error('Get templates failed:', error);
return [];
}
}
/**
* Validate subscription using al-assistant status action
*/
async validateSubscription(): Promise<{ valid: boolean; tier?: string; features?: string[] }> {
try {
const response = await this.callTool({
name: 'al-assistant',
arguments: {
action: 'status',
params: {},
},
});
if (!response.success) {
return { valid: false };
}
return {
valid: true,
tier: 'standard',
features: ['learn', 'generate', 'process', 'review'],
};
} catch {
return { valid: false };
}
}
/**
* Get available prompts from cloud MCP
*/
async getPrompts(): Promise<Array<{ name: string; description: string }>> {
try {
const response = await this.callTool({
name: 'al-assistant',
arguments: {
action: 'get-prompt',
params: {}, // Empty params returns list of all prompts
},
});
if (!response.success) {
this.logger.debug('Get prompts failed:', response.error);
return this.getDefaultPrompts();
}
// Try to parse the result
if (typeof response.result === 'string') {
try {
const parsed = JSON.parse(response.result) as unknown;
if (Array.isArray(parsed)) {
return parsed as Array<{ name: string; description: string }>;
}
// If it's a single prompt list object
if (parsed && typeof parsed === 'object' && 'prompts' in parsed) {
return (parsed as { prompts: Array<{ name: string; description: string }> }).prompts;
}
} catch {
// Return default prompts if parsing fails
}
}
return this.getDefaultPrompts();
} catch (error) {
this.logger.debug('Get prompts error:', error);
return this.getDefaultPrompts();
}
}
/**
* Get default prompts when cloud is unavailable
*/
private getDefaultPrompts(): Array<{ name: string; description: string }> {
return [
{ name: 'al-create-table', description: 'Create a new AL table with best practices' },
{ name: 'al-create-page', description: 'Create a new AL page (Card, List, API, etc.)' },
{ name: 'al-create-codeunit', description: 'Create a new AL codeunit' },
{ name: 'al-create-enum', description: 'Create a new AL enum' },
{ name: 'al-create-report', description: 'Create a new AL report' },
{ name: 'al-create-tableextension', description: 'Create a table extension' },
{ name: 'al-create-pageextension', description: 'Create a page extension' },
{ name: 'al-code-review', description: 'Review AL code for AppSource compliance' },
{ name: 'al-generate-tests', description: 'Generate test codeunits' },
{ name: 'al-plan-feature', description: 'Plan a new feature with phases' },
];
}
/**
* Get server status including cloud connection info
*/
async getStatus(): Promise<{
connected: boolean;
initialized: boolean;
toolCount: number;
promptCount: number;
}> {
const connected = await this.checkConnection();
const tools = await this.getTools();
const prompts = await this.getPrompts();
return {
connected,
initialized: this.initialized,
toolCount: tools.length,
promptCount: prompts.length,
};
}
/**
* Clear tools cache (call when reconnecting)
*/
clearCache(): void {
this.toolsCache = null;
this.initialized = false;
}
}