/**
* Challenge Hypothesis Tool
*
* This tool allows MCP clients to present hypotheses for human evaluation
* using a 7-step agreement scale with emoji icons.
*/
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { ensureUIAvailable } from '../core/browser-bridge.js';
import { HypothesisChallenge, Hypothesis, AgreementLevel } from '@ask-me-mcp/askme-shared';
/**
* Tool definition for challenge-hypothesis
*/
export const CHALLENGE_HYPOTHESIS_TOOL = {
name: 'challenge-hypothesis',
description: 'Validate assumptions or statements using agreement-based evaluation when you already have context about the situation. Use for testing theories, predictions, or proposals that can be rated as true/false or good/bad. Choose this when you need expert judgment on specific claims, not general questions.',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Title or context for the hypothesis challenge',
},
description: {
type: 'string',
description: 'Optional description or instructions for the challenge',
},
hypotheses: {
type: 'array',
description: 'Array of hypothesis statements to evaluate',
items: {
type: 'string',
},
minItems: 1,
},
},
required: ['title', 'hypotheses'],
},
} as const;
/**
* Input arguments for challenge-hypothesis tool
*/
export interface ChallengeHypothesisArgs {
title: string;
description?: string;
hypotheses: string[];
}
/**
* Get emoji icon for agreement level
*/
function getAgreementEmoji(level: AgreementLevel): string {
switch (level) {
case AgreementLevel.FULLY_DISAGREE: return '😤'; // Fully Disagree
case AgreementLevel.DISAGREE: return '😞'; // Disagree
case AgreementLevel.SLIGHTLY_DISAGREE: return '😕'; // Slightly Disagree
case AgreementLevel.UNSURE: return '😐'; // Unsure
case AgreementLevel.SLIGHTLY_AGREE: return '🙂'; // Slightly Agree
case AgreementLevel.AGREE: return '😊'; // Agree
case AgreementLevel.FULLY_AGREE: return '😍'; // Fully Agree
default: return '❓';
}
}
/**
* Get text description for agreement level
*/
function getAgreementText(level: AgreementLevel): string {
switch (level) {
case AgreementLevel.FULLY_DISAGREE: return 'Fully Disagree';
case AgreementLevel.DISAGREE: return 'Disagree';
case AgreementLevel.SLIGHTLY_DISAGREE: return 'Slightly Disagree';
case AgreementLevel.UNSURE: return 'Unsure';
case AgreementLevel.SLIGHTLY_AGREE: return 'Slightly Agree';
case AgreementLevel.AGREE: return 'Agree';
case AgreementLevel.FULLY_AGREE: return 'Fully Agree';
default: return 'Unknown';
}
}
/**
* Handler function for challenge-hypothesis tool
*/
export async function handleChallengeHypothesis(
args: ChallengeHypothesisArgs,
requestStorage: Map<string, any>,
notifyBrowser: (message: any) => void,
extra?: { signal?: AbortSignal }
): Promise<any> {
const { title, description, hypotheses } = args;
// Check if request was already cancelled
if (extra?.signal?.aborted) {
throw new McpError(ErrorCode.InvalidRequest, 'Request was cancelled');
}
// Ensure UI is available for user interaction
const uiMessage = await ensureUIAvailable();
if (uiMessage) {
// No UI available and couldn't open browser, return instructions to user
return {
content: [
{
type: 'text',
text: uiMessage,
},
],
};
}
const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Transform input format to internal format
const hypothesisChallenge: HypothesisChallenge = {
id: `challenge-${Date.now()}`,
title,
description,
hypotheses: hypotheses.map((hypothesisText, index) => ({
id: `hypothesis-${index}`,
text: hypothesisText,
agreementLevel: undefined,
comment: undefined,
wontAnswer: false,
})),
};
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[challenge-hypothesis] New request: ${requestId}`);
console.error(`[challenge-hypothesis] Challenge: "${title}"`);
if (description) {
console.error(`[challenge-hypothesis] Description: ${description}`);
}
console.error(`[challenge-hypothesis] Hypotheses: ${hypotheses.length}`);
hypotheses.forEach((h, i) => {
console.error(`[challenge-hypothesis] H${i + 1}: ${h}`);
});
console.error(`[challenge-hypothesis] Please respond at http://localhost:4200`);
}
// Create a promise that will be resolved when the human responds
return new Promise((resolve, reject) => {
// Set up abort listener for client-side cancellation
const abortListener = () => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[challenge-hypothesis] Request ${requestId} was cancelled by client`);
}
// Clean up pending request
if (requestStorage.has(requestId)) {
requestStorage.delete(requestId);
// Notify browser that request was cancelled
notifyBrowser({
type: 'request_cancelled',
data: {
requestId,
message: 'Request was cancelled by the client'
}
});
reject(new McpError(ErrorCode.InvalidRequest, 'Request was cancelled by client'));
}
};
// Add abort listener if signal is available
if (extra?.signal) {
extra.signal.addEventListener('abort', abortListener);
}
requestStorage.set(requestId, {
requestId,
resolve: (response) => {
// Format the response for the MCP client
let responseText = `Hypothesis Challenge Response: "${title}"\n\n`;
if (response.type === 'hypothesis-challenge' && response.challenge) {
const challenge = response.challenge as HypothesisChallenge;
if (challenge.whyNotAnswering) {
responseText += `Status: Challenge declined\n`;
responseText += `Reason: ${challenge.whyNotAnswering}\n\n`;
} else {
if (challenge.description) {
responseText += `Description: ${challenge.description}\n\n`;
}
responseText += 'Hypothesis Evaluations:\n\n';
challenge.hypotheses.forEach((hypothesis, index) => {
responseText += `${index + 1}. "${hypothesis.text}"\n`;
if (hypothesis.wontAnswer) {
responseText += ` Response: Won't evaluate this hypothesis\n`;
} else if (hypothesis.agreementLevel !== undefined) {
const emoji = getAgreementEmoji(hypothesis.agreementLevel);
const text = getAgreementText(hypothesis.agreementLevel);
responseText += ` Agreement: ${emoji} ${text} (${hypothesis.agreementLevel > 0 ? '+' : ''}${hypothesis.agreementLevel})\n`;
if (hypothesis.comment) {
responseText += ` Comment: ${hypothesis.comment}\n`;
}
} else {
responseText += ` Response: Not evaluated\n`;
}
responseText += '\n';
});
}
} else {
responseText += 'No response provided';
}
// Add completion status and instructions for client
if (response.completionStatus) {
responseText += '\n--- COMPLETION STATUS ---\n';
if (response.completionStatus === 'done') {
responseText += '✅ User indicated they are DONE with answering questions on this topic.\n';
responseText += 'INSTRUCTION: Do not ask additional questions. Proceed with implementation based on the answers provided.\n\n';
} else if (response.completionStatus === 'drill-deeper') {
responseText += '🔍 User wants to DRILL DEEPER with more questions on this topic.\n';
responseText += 'INSTRUCTION: Ask more detailed follow-up questions using the challenge-hypothesis tool to get more specific hypothesis evaluations on this topic.\n\n';
}
}
resolve({
content: [
{
type: 'text',
text: responseText,
},
],
});
},
reject
});
// Send the request to the browser via server-sent events
notifyBrowser({
type: 'new_request',
data: {
id: requestId,
question: `Hypothesis Challenge: ${title}`,
context: {
type: 'hypothesis-challenge',
challenge: hypothesisChallenge
},
timestamp: new Date(),
type: 'hypothesis-challenge',
},
});
// Set a timeout to prevent indefinite waiting
const timeoutId = setTimeout(() => {
if (requestStorage.has(requestId)) {
requestStorage.delete(requestId);
// Clean up abort listener
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
// Notify browser that request timed out
notifyBrowser({
type: 'request_timeout',
data: {
requestId,
message: 'Request timed out after 5 minutes - no response received'
}
});
reject(new Error('Request timeout'));
}
}, 300000); // 5 minutes timeout
// Override the resolve/reject in storage to clean up listeners
const originalStoredData = requestStorage.get(requestId);
if (originalStoredData) {
requestStorage.set(requestId, {
...originalStoredData,
resolve: (response: any) => {
clearTimeout(timeoutId);
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
originalStoredData.resolve(response);
},
reject: (error: any) => {
clearTimeout(timeoutId);
if (extra?.signal) {
extra.signal.removeEventListener('abort', abortListener);
}
originalStoredData.reject(error);
}
});
}
});
}