import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs';
import {
HumanRequest,
QueuedRequest,
MultipleChoiceQuestion
} from '@ask-me-mcp/askme-shared';
/**
* Simplified MCP client service
*
* @remarks
* This service manages the real-time connection to the MCP server
* using Server-Sent Events (SSE) and HTTP POST for responses.
* No session management - just direct request/response handling.
*/
@Injectable({
providedIn: 'root'
})
export class McpClientService {
private http = inject(HttpClient);
private baseUrl = this.getBaseUrl();
private eventSource: EventSource | null = null;
// Simple reactive state
connected = signal(false);
private requestsSubject = new BehaviorSubject<QueuedRequest[]>([]);
requests = this.requestsSubject.asObservable();
// Track last message type for request clearing context
private _lastClearReason = signal<'timeout' | 'cancelled' | null>(null);
lastClearReason = this._lastClearReason.asReadonly();
/**
* Get the base URL for MCP server connection
* When served from MCP server, uses the same origin
* Otherwise uses ?port= query parameter or defaults to localhost:3000
*/
private getBaseUrl(): string {
// If served from MCP server (same origin), use relative URLs
if (window.location.port && window.location.hostname === 'localhost') {
return '';
}
// For development mode, use query parameter or default
const urlParams = new URLSearchParams(window.location.search);
const port = urlParams.get('port') || '3000';
return `http://localhost:${port}`;
}
/**
* Connect to the MCP server
*/
connect(sessionId: string): void {
if (this.eventSource) {
this.disconnect();
}
// Add small delay to allow tests to observe initial disconnected state
setTimeout(() => {
this.setupEventSource();
}, 100);
}
/**
* Disconnect from the MCP server
*/
disconnect(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.connected.set(false);
this.requestsSubject.next([]);
}
/**
* Send a simple text response
*/
sendResponse(requestId: string, response: string, completionStatus?: 'done' | 'drill-deeper'): Observable<any> {
const headers = new HttpHeaders({
'Content-Type': 'application/json',
});
const payload: any = {
requestId,
response,
type: 'single-question'
};
if (completionStatus) {
payload.completionStatus = completionStatus;
}
return this.http.post(`${this.baseUrl}/mcp/response`, payload, { headers });
}
/**
* Send a multiple choice response
*/
sendMultipleChoiceResponse(requestId: string, questions: MultipleChoiceQuestion[]): Observable<any> {
const headers = new HttpHeaders({
'Content-Type': 'application/json',
});
const payload = {
requestId,
type: 'multiple-choice',
questions
};
console.log('McpClient: Sending multiple choice response:', payload);
return this.http.post(`${this.baseUrl}/mcp/response`, payload, { headers });
}
/**
* Send a hypothesis challenge response
*/
sendHypothesisChallengeResponse(requestId: string, challenge: any, completionStatus?: 'done' | 'drill-deeper'): Observable<any> {
const headers = new HttpHeaders({
'Content-Type': 'application/json',
});
const payload: any = {
requestId,
type: 'hypothesis-challenge',
challenge
};
if (completionStatus) {
payload.completionStatus = completionStatus;
}
console.log('McpClient: Sending hypothesis challenge response:', payload);
return this.http.post(`${this.baseUrl}/mcp/response`, payload, { headers });
}
/**
* Send a choose-next response
*/
sendChooseNextResponse(requestId: string, response: {
type: 'choose-next';
action: 'selected' | 'abort' | 'new-ideas';
selectedOption?: any;
message?: string;
}): Observable<any> {
const headers = new HttpHeaders({
'Content-Type': 'application/json',
});
const payload = {
requestId,
type: 'choose-next',
action: response.action,
selectedOption: response.selectedOption,
message: response.message
};
console.log('McpClient: Sending choose-next response:', payload);
return this.http.post(`${this.baseUrl}/mcp/response`, payload, { headers });
}
/**
* Set up the EventSource connection
*/
private setupEventSource(): void {
const url = `${this.baseUrl}/mcp/browser-events`;
this.eventSource = new EventSource(url);
this.eventSource.onopen = () => {
console.log('SSE connection established');
this.connected.set(true);
};
this.eventSource.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse SSE message:', error);
}
};
this.eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
this.connected.set(false);
// Attempt reconnection after 5 seconds
setTimeout(() => {
console.log('Attempting to reconnect...');
this.setupEventSource();
}, 5000);
};
}
/**
* Handle incoming SSE messages
*/
private handleMessage(message: any): void {
console.log('Received message:', message);
switch (message.type) {
case 'connected':
console.log('Connected to server');
break;
case 'new_request': {
const request: QueuedRequest = {
...message.data,
status: 'active'
};
console.log('New request received:', request);
// Clear any previous clear reason since we have a new request
this._lastClearReason.set(null);
// Replace any existing requests (simple single-request model)
this.requestsSubject.next([request]);
break;
}
case 'request_timeout': {
const { requestId } = message.data;
console.log('Request timed out:', requestId);
// Set clear reason before clearing requests
this._lastClearReason.set('timeout');
// Clear the request from UI
this.requestsSubject.next([]);
// Show user notification about timeout
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Request Timeout', {
body: 'The current question timed out after 5 minutes of no response.',
icon: '/favicon.ico'
});
}
break;
}
case 'request_cancelled': {
const { requestId } = message.data;
console.log('Request cancelled by client:', requestId);
// Set clear reason before clearing requests
this._lastClearReason.set('cancelled');
// Clear the request from UI
this.requestsSubject.next([]);
// Show user notification about cancellation
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Request Cancelled', {
body: 'The current question was cancelled by the client.',
icon: '/favicon.ico'
});
}
break;
}
default:
console.log('Unknown message type:', message.type);
}
}
}