import { setTimeout } from 'node:timers';
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { QuestionConfig, InputType, QuestionOption, PendingQuestion } from './types.js';
import { generateQuestionUI } from './ui/question-form.js';
const server = new McpServer({
name: 'ask-question-mcp',
version: '1.0.0',
});
let currentQuestion: QuestionConfig | null = null;
const pendingQuestions = new Map<string, PendingQuestion>();
function generateQuestionId(): string {
return `q_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
const RESOURCE_URI = 'ui://ask-question-mcp/question';
registerAppResource(
server,
'Question UI',
RESOURCE_URI,
{
description: 'Interactive question form UI',
},
async () => {
currentQuestion ??= {
question: 'What would you like to do?',
inputType: 'text',
placeholder: 'Type your answer...',
};
return {
contents: [
{
uri: RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: generateQuestionUI(currentQuestion),
_meta: {
ui: {
csp: {},
prefersBorder: false,
},
},
},
],
};
},
);
const OptionSchema = z.object({
value: z.string().describe('Unique value for the option'),
label: z.string().describe('Display label for the option'),
description: z.string().optional().describe('Optional description for the option'),
});
registerAppTool(
server,
'submit_answer',
{
description: 'Submit an answer to a pending question. Used internally by the question UI.',
inputSchema: {
questionId: z.string().describe('The ID of the question being answered'),
answer: z.string().describe('The user answer'),
},
_meta: {},
},
async ({ questionId, answer }) => {
const pending = pendingQuestions.get(questionId);
if (pending) {
pending.resolve(answer);
pendingQuestions.delete(questionId);
return {
content: [{ type: 'text' as const, text: `Answer received for question ${questionId}` }],
};
}
return {
content: [{ type: 'text' as const, text: `No pending question found with ID ${questionId}` }],
isError: true,
};
},
);
registerAppTool(
server,
'ask_question',
{
description:
'Ask the user a question with an interactive UI. Supports text input, single/multi-select options, and yes/no confirmation dialogs. Waits for user response and returns the answer.',
inputSchema: {
question: z.string().describe('The question to ask the user'),
inputType: z
.enum(['text', 'select', 'multiselect', 'confirm'])
.describe(
"Type of input: 'text' for free-form input, 'select' for single choice, 'multiselect' for multiple choices, 'confirm' for yes/no",
),
options: z.array(OptionSchema).optional().describe('Options for select/multiselect input types'),
placeholder: z.string().optional().describe('Placeholder text for text input'),
timeout: z.number().optional().describe('Timeout in milliseconds (default: 5 minutes)'),
},
_meta: {
ui: {
resourceUri: RESOURCE_URI,
},
},
},
async ({ question, inputType, options, placeholder, timeout }) => {
const questionId = generateQuestionId();
const timeoutMs = timeout ?? 5 * 60 * 1_000;
currentQuestion = {
question,
inputType: inputType as InputType,
options: options as QuestionOption[] | undefined,
placeholder,
questionId,
};
const answerPromise = new Promise<string>((resolve, reject) => {
pendingQuestions.set(questionId, {
resolve,
reject,
config: currentQuestion!,
});
setTimeout(() => {
if (pendingQuestions.has(questionId)) {
pendingQuestions.delete(questionId);
reject(new Error('Question timed out waiting for user response'));
}
}, timeoutMs);
});
try {
const answer = await answerPromise;
return {
content: [
{
type: 'text' as const,
text: answer,
},
],
structuredContent: {
questionId,
question,
answer,
},
};
} catch (error) {
return {
content: [
{
type: 'text' as const,
text: error instanceof Error ? error.message : 'Failed to get user response',
},
],
isError: true,
};
}
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
// eslint-disable-next-line unicorn/prefer-top-level-await
void main().catch(console.error);