import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; import type { GetPromptResult, PromptMessage } from '@modelcontextprotocol/sdk/types.js'; import type { McpExtensionState } from './_state.ts'; import { isServerDisabled, type McpConfig, type PromptMetadata } from './_types.ts'; import { formatPromptCommandName } from './_types.ts'; import { lazyConnect } from './_init.ts'; import { isServerCacheValid, loadMetadataCache, reconstructPromptMetadata } from './_metadata-cache.ts'; import { logger } from './_logger.ts'; import { truncateAtWord } from './_utils.ts'; /** * Resolve prompt metadata for slash-command registration at extension load * time. Mirrors `resolveDirectTools`: reads the persistent metadata cache so * commands are available before any server connects. */ export function resolveCachedPrompts(config: McpConfig): PromptMetadata[] { const cache = loadMetadataCache(); if (!cache?.servers) { return []; } const prefix = config.settings?.toolPrefix ?? 'server'; const specs: PromptMetadata[] = []; for (const [serverName, entry] of Object.entries(cache.servers)) { const definition = config.mcpServers[serverName]; if (!definition || isServerDisabled(definition)) { continue; } if (!entry?.prompts?.length || !isServerCacheValid(entry, definition)) { continue; } specs.push(...reconstructPromptMetadata(serverName, entry.prompts, prefix, definition)); } return specs; } /** * Parse a slash-command argument string into positional and named MCP prompt * arguments. Supports bash-style quoting so callers can pass values with * spaces: * * /mcp__demo__brief today "important tasks" * /mcp__demo__brief day=today topic="important tasks" */ export function parsePromptArgs(input: string): { positional: string[]; named: Record } { const positional: string[] = []; const named: Record = {}; const tokens = tokenizeArgs(input); for (const token of tokens) { const eq = findUnquotedEquals(token); if (eq > 0) { const key = token.slice(0, eq).trim(); const value = stripQuotes(token.slice(eq + 1).trim()); if (key) { named[key] = value; continue; } } positional.push(stripQuotes(token)); } return { positional, named }; } function tokenizeArgs(input: string): string[] { const tokens: string[] = []; let current = ''; let quote: '"' | "'" | null = null; let escaped = false; for (const char of input) { if (escaped) { current += char; escaped = false; continue; } if (char === '\\' && quote !== "'") { escaped = true; continue; } if (quote) { current += char; if (char === quote) { quote = null; } continue; } if (char === '"' || char === "'") { quote = char; current += char; continue; } if (/\s/.test(char)) { if (current.length > 0) { tokens.push(current); current = ''; } continue; } current += char; } if (current.length > 0) { tokens.push(current); } return tokens; } function findUnquotedEquals(token: string): number { let quote: '"' | "'" | null = null; for (let i = 0; i < token.length; i++) { const ch = token[i]; if (quote) { if (ch === quote) { quote = null; } continue; } if (ch === '"' || ch === "'") { quote = ch; } else if (ch === '=') { return i; } } return -1; } function stripQuotes(value: string): string { if (value.length >= 2 && (value.startsWith('"') || value.startsWith("'")) && value.endsWith(value[0])) { return value.slice(1, -1); } return value; } export interface ResolvedPromptArgs { ok: boolean; /** Present when `ok === true`. */ args?: Record; /** Present when `ok === false`. */ error?: string; } /** * Map positional and named arguments onto the prompt's declared argument * list. Named arguments win over positional when both target the same slot. * Missing required arguments produce a helpful usage message that the * command handler surfaces via `ctx.ui.notify`. */ export function resolvePromptArgs( metadata: PromptMetadata, parsed: { positional: string[]; named: Record } ): ResolvedPromptArgs { const args: Record = {}; const declared = metadata.arguments; let positionalIndex = 0; for (const argDef of declared) { const value = parsed.named[argDef.name] ?? parsed.positional[positionalIndex++]; if (value !== undefined && value !== '') { args[argDef.name] = value; } } // Preserve named arguments the prompt did not declare so servers with // permissive schemas still receive them. The MCP spec allows arbitrary // string key/values in `prompts/get` params.arguments. for (const [key, value] of Object.entries(parsed.named)) { if (!(key in args)) { args[key] = value; } } const missing = declared.filter((a) => a.required && (args[a.name] === undefined || args[a.name] === '')); if (missing.length > 0) { return { ok: false, error: buildUsageMessage(metadata, missing) }; } return { ok: true, args }; } function buildUsageMessage(metadata: PromptMetadata, missing: PromptMetadata['arguments']): string { const usage = metadata.arguments.map((a) => (a.required ? `<${a.name}>` : `[${a.name}]`)).join(' '); const missingList = missing.map((a) => a.name).join(', '); return `Missing required argument${missing.length > 1 ? 's' : ''}: ${missingList}.\nUsage: /${metadata.commandName} ${usage}`.trim(); } /** * Flatten a `GetPromptResult` into a single string suitable for * `pi.sendUserMessage`. MCP prompts can contain multiple messages with * mixed roles; we preserve role attribution as inline markers so the model * still sees the intended conversational shape without needing a * multi-message replay API on the pi side. */ export function formatPromptResult(result: GetPromptResult): string { const lines: string[] = []; for (const message of result.messages) { const text = extractMessageText(message); if (!text) { continue; } if (message.role === 'user' && result.messages.length === 1) { lines.push(text); } else { lines.push(`[${message.role}] ${text}`); } } return lines.join('\n\n').trim(); } /** * Detect a prompt result that is already a complete, ready-to-display answer * (the pix-* MCP servers return `/pix-* help` this way): a single assistant * text message starting with `Usage: /`. Returns the text to display, or null * when the result should be sent to the model instead. */ export function isInstantHelpResult(result: GetPromptResult): string | null { if (result.messages.length !== 1) { return null; } const message = result.messages[0]; if (message.role !== 'assistant') { return null; } const content = message.content; if (!content || typeof content !== 'object' || content.type !== 'text') { return null; } const text = content.text ?? ''; return text.startsWith('Usage: /') ? text : null; } function extractMessageText(message: PromptMessage): string { const content = message.content; if (!content || typeof content !== 'object') { return ''; } switch (content.type) { case 'text': return content.text ?? ''; case 'resource': { const resource = content.resource; if (!resource) { return ''; } if ('text' in resource && typeof resource.text === 'string') { return `[resource ${resource.uri}]\n${resource.text}`; } return `[resource ${resource.uri}]`; } case 'resource_link': return `[resource_link ${content.uri ?? ''}${content.name ? ` - ${content.name}` : ''}]`; case 'image': return `[image ${content.mimeType ?? 'unknown'}${content.data ? ' (embedded)' : ''}]`; case 'audio': return `[audio ${content.mimeType ?? 'unknown'}]`; default: return ''; } } /** * Build the pi command definition for a single MCP prompt. Registered once * per prompt at extension load time from the metadata cache; the same * factory is reused by tests to invoke the handler without a running pi. */ export function createPromptCommand( pi: ExtensionAPI, getState: () => McpExtensionState | null, metadata: PromptMetadata ) { const description = buildCommandDescription(metadata); return { description, handler: async (args: string, ctx: ExtensionCommandContext) => { const state = getState(); if (!state) { if (ctx.hasUI) { ctx.ui.notify('MCP not initialized', 'error'); } return; } const liveMetadata = findLivePromptMetadata(state, metadata.serverName, metadata.originalName); if (state.promptMetadataLive?.has(metadata.serverName) && !liveMetadata) { if (ctx.hasUI) { ctx.ui.notify( `MCP prompt "${metadata.originalName}" is no longer advertised by server "${metadata.serverName}". Run /mcp reconnect to refresh.`, 'error' ); } return; } const live = liveMetadata ?? metadata; const parsed = parsePromptArgs(args ?? ''); const resolved = resolvePromptArgs(live, parsed); if (!resolved.ok) { if (ctx.hasUI) { ctx.ui.notify(resolved.error ?? 'Invalid prompt arguments', 'error'); } return; } const promptArgs = resolved.args ?? {}; if (!state.config.mcpServers[metadata.serverName]) { if (ctx.hasUI) { ctx.ui.notify( `MCP prompt "${live.originalName}" is no longer configured. Run /mcp reconnect to refresh.`, 'error' ); } return; } const connected = await lazyConnect(state, metadata.serverName, ctx.signal); if (!connected) { if (ctx.hasUI) { const conn = state.manager.getConnection(metadata.serverName); const message = conn?.status === 'needs-auth' ? `MCP server "${metadata.serverName}" needs authentication. Run /mcp-auth ${metadata.serverName}.` : `MCP server "${metadata.serverName}" is not available. Run /mcp reconnect ${metadata.serverName}.`; ctx.ui.notify(message, 'error'); } return; } const refreshed = findLivePromptMetadata(state, metadata.serverName, metadata.originalName); if (state.promptMetadataLive?.has(metadata.serverName) && !refreshed) { if (ctx.hasUI) { ctx.ui.notify( `MCP prompt "${metadata.originalName}" is no longer advertised by server "${metadata.serverName}". Run /mcp reconnect to refresh.`, 'error' ); } return; } const dispatchMetadata = refreshed ?? live; let result: GetPromptResult; try { result = await state.manager.getPrompt( metadata.serverName, dispatchMetadata.originalName, Object.keys(promptArgs).length > 0 ? promptArgs : undefined, ctx.signal ); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.debug(`MCP prompt "${live.originalName}" on ${metadata.serverName} failed: ${message}`); if (ctx.hasUI) { ctx.ui.notify(`MCP prompt "${live.originalName}" failed: ${message}`, 'error'); } return; } const text = formatPromptResult(result); if (!text) { if (ctx.hasUI) { ctx.ui.notify(`MCP prompt "${live.originalName}" returned no text content.`, 'warning'); } return; } // Instant help: a single assistant message that is already the final // answer (pix-* servers return `/pix-* help` this way). Display it // directly instead of sending it to the model as a prompt, so the user // sees the usage immediately with no reasoning / token spend. const instantHelp = isInstantHelpResult(result); if (instantHelp !== null) { if (ctx.hasUI) { ctx.ui.notify(instantHelp, 'info'); } else { pi.sendUserMessage(instantHelp); } return; } pi.sendUserMessage(text); }, }; } function findLivePromptMetadata( state: McpExtensionState, serverName: string, originalName: string ): PromptMetadata | undefined { return state.promptMetadata?.get(serverName)?.find((p) => p.originalName === originalName); } function buildCommandDescription(metadata: PromptMetadata): string { const base = metadata.description || metadata.title || `MCP prompt from ${metadata.serverName}`; return truncateAtWord(`MCP: ${base}`, 120) || `MCP prompt from ${metadata.serverName}`; } /** * Public helper used by `/mcp prompts` to render the list of prompts known * to the adapter, whether from a live connection or the metadata cache. */ export function listAllPromptMetadata(state: McpExtensionState): PromptMetadata[] { const flat: PromptMetadata[] = []; for (const list of state.promptMetadata?.values() ?? []) { flat.push(...list); } flat.sort((a, b) => a.commandName.localeCompare(b.commandName)); return flat; } // Re-exported so index.ts can format cache-only prompt-command names without // duplicating the namespace logic. export { formatPromptCommandName };