Skip to content

Expose MCP server prompts as slash commands #698

Description

@mvanhorn

Problem

jcode's MCP client already knows that servers advertise prompts. crates/jcode-base/src/mcp/protocol.rs:83 deserializes it:

#[derive(Debug, Clone, Deserialize, Default)]
pub struct ServerCapabilities {
    #[serde(default)]
    pub tools: Option<ToolsCapability>,
    #[serde(default)]
    pub resources: Option<ResourcesCapability>,
    #[serde(default)]
    pub prompts: Option<PromptsCapability>,
}

and McpClient::initialize stores the whole struct on the shared handle at client.rs:253. But no code anywhere in the tree ever sends prompts/list or prompts/get. The only JSON-RPC methods the client issues are initialize, notifications/initialized, tools/list, and tools/call. The prompts capability is parsed and then discarded.

Prompts are the half of MCP that is meant for the human, not the model. A server ships a named, parameterized, server-maintained workflow, and the client surfaces it where the user types. That is why every comparable harness exposes them as slash commands:

  • Claude Code registers them as /mcp__<server>__<prompt>, for example /mcp__github__create-issue.
  • opencode registers them as <server>:<prompt> slash commands.
  • VS Code Copilot exposes them in its prompt picker.
  • Codex does not have them yet, and the request is its issue #8342 with 23 reactions.

For jcode specifically the gap is larger than for a greenfield client, because jcode imports MCP servers from other harnesses on first run. McpConfig::import_from_external reads ~/.claude.json, ~/.claude/mcp.json, and ~/.codex/config.toml. A user arriving from Claude Code brings servers whose prompts they were already invoking by name, and in jcode those prompts become invisible with no message explaining why.

The shape jcode needs is already in the codebase twice over. crates/jcode-base/src/mcp/tool.rs namespaces MCP tools as mcp__{server}__{tool}, which is exactly Claude Code's prompt naming. And crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs:345 already appends dynamic slash commands to the static table, for skills:

fn push_skill_commands(
    commands: &mut Vec<(String, &'static str)>,
    seen: &mut std::collections::HashSet<String>,
    skills: &crate::skill::SkillRegistry,
) {
    for skill in skills.list() {
        let command = format!("/{}", skill.name);
        if seen.insert(command.clone()) {
            commands.push((command, "Activate skill"));
        }
    }
}

MCP prompts are the same pattern against a different registry.

This feature was proposed from code analysis without any hands-on usage.

Proposed approach

crates/jcode-base/src/mcp/protocol.rs (implementation)

Add the prompt types next to the existing McpToolDef / ToolsListResult / ToolCallParams block, in the same style:

/// MCP prompt definition from server (`prompts/list`)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPromptDef {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub arguments: Vec<McpPromptArgument>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPromptArgument {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub required: bool,
}

/// prompts/list result
#[derive(Debug, Clone, Deserialize)]
pub struct PromptsListResult {
    pub prompts: Vec<McpPromptDef>,
}

/// prompts/get params
#[derive(Debug, Clone, Serialize)]
pub struct PromptGetParams {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub arguments: Option<Value>,
}

/// prompts/get result
#[derive(Debug, Clone, Deserialize)]
pub struct PromptGetResult {
    #[serde(default)]
    pub description: Option<String>,
    pub messages: Vec<PromptMessage>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct PromptMessage {
    pub role: String,
    pub content: ContentBlock,
}

ContentBlock already exists in this file and already covers text, image, and resource, so prompt message content reuses it directly.

Also declare the prompts capability on the client side. ClientCapabilities is currently an empty struct at line 57; leave it empty for this change, since the client is a consumer and does not need to advertise anything to receive prompts.

crates/jcode-base/src/mcp/client.rs (implementation)

McpHandle already holds capabilities: Arc<RwLock<ServerCapabilities>> and tools: Arc<RwLock<Vec<McpToolDef>>>. Add a sibling prompts: Arc<RwLock<Vec<McpPromptDef>>> initialized at the handle construction site (line 246) and three methods mirroring the existing tool methods:

pub fn supports_prompts(&self) -> bool          // reads capabilities.prompts.is_some()
pub fn prompts(&self) -> Vec<McpPromptDef>      // mirrors tools()
pub async fn refresh_prompts(&self) -> Result<()>   // mirrors refresh_tools(), sends "prompts/list"
pub async fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<PromptGetResult>

In McpClient::connect_in_dir, after the existing refresh_tools() call at line 266, add:

if client.handle.supports_prompts()
    && let Err(e) = client.handle.refresh_prompts().await
{
    crate::logging::warn(&format!(
        "MCP server '{}' advertised prompts but prompts/list failed: {}",
        name, e
    ));
}

Not ?. A prompts failure must not fail the connection, and the swallowed-error ratchet requires the error to be reported rather than dropped.

crates/jcode-base/src/mcp/manager.rs (implementation)

Mirror the existing all_tools() / call_tool() pair with all_prompts() -> Vec<(String, McpPromptDef)> and get_prompt(server, name, arguments), resolving across both pool_handles and owned_clients exactly as the tool path does.

crates/jcode-base/src/mcp/mod.rs (wiring)

pub use protocol::*; already re-exports the whole protocol module, so the new types are exported with no edit. Add the prompt registry helper to the existing pub use tool::{...} line only if a helper is introduced there.

crates/jcode-tui/src/tui/app.rs (wiring)

Add mcp_prompt_names: Vec<(String, String)> next to the existing mcp_server_names: Vec<(String, usize)> field (line 1154 region), holding (server, prompt) pairs. Initialize it in both constructors in crates/jcode-tui/src/tui/app/tui_lifecycle.rs (lines 525 and 964, where remote_mcp_servers: Vec::new() is already initialized).

crates/jcode-tui/src/tui/app/tui_lifecycle_runtime.rs (wiring)

init_mcp already populates self.mcp_server_names from manager.connected_servers() and manager.all_tools() (lines 252-264). Populate self.mcp_prompt_names from manager.all_prompts() in the same block, then call self.invalidate_command_candidates_cache() so the freshly connected servers' prompts appear in the palette without waiting for another invalidation.

crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs (wiring)

In command_candidates() (line 340), alongside push_skill_commands, add:

fn push_mcp_prompt_commands(
    commands: &mut Vec<(String, &'static str)>,
    seen: &mut std::collections::HashSet<String>,
    prompts: &[(String, String)],
) {
    for (server, prompt) in prompts {
        let command = format!("/mcp__{}__{}", server, prompt);
        if seen.insert(command.clone()) {
            commands.push((command, "Run MCP prompt"));
        }
    }
}

The help string must be &'static str to match the existing tuple type, which is why it is a fixed label rather than the server-supplied description. The existing CommandCandidatesCache and invalidate_command_candidates_cache() already handle memoization.

crates/jcode-tui/src/tui/app/input.rs (wiring, dispatch)

Insert a branch before the skill resolution block at line 3514 (let initial_snapshot = self.current_skills_snapshot();), so an /mcp__... command never falls through to "Unknown skill". Parse /mcp__<server>__<prompt> with optional trailing text, call manager.get_prompt, concatenate the PromptMessage text blocks, and submit the result as the turn input.

Failure paths, each a DisplayMessage::error rather than a panic or a silent drop, matching how the unknown-skill path already reports:

  • unknown server or prompt name
  • prompts/get returned an error
  • the prompt declares more than one required argument, in which case list the argument names and stop

Remote sessions: is_remote clients do not hold a local mcp_manager. Follow the existing precedent at line 3495, where input-line ! shell commands report "only available in a local jcode TUI session", and report the same for MCP prompts in this change. Wiring prompts through the remote protocol is a follow-up.

crates/jcode-base/src/mcp/protocol_tests.rs (tests)

Already included via #[path = "protocol_tests.rs"] mod protocol_tests; at protocol.rs:555. Add:

  1. prompts_list_result_deserializes_arguments including a prompt with no arguments key.
  2. prompt_get_result_deserializes_text_and_resource_messages, exercising the reused ContentBlock variants.
  3. prompt_get_params_omits_arguments_when_none, asserting the serialized JSON has no arguments key.

crates/jcode-tui/src/tui/app/tests/mcp_prompt_commands.rs (tests, new file)

Register with include!("tests/mcp_prompt_commands.rs"); in crates/jcode-tui/src/tui/app/tests.rs, matching the existing pattern used for issue_496_input_routing.rs and skill_invocation_multi_word.rs.

  1. With mcp_prompt_names populated, /mcp__ produces the expected suggestion entries.
  2. An unknown /mcp__server__prompt produces an error display message and does not produce "Unknown skill".
  3. A prompt command in a remote session reports the local-only message.

crates/jcode-base/src/mcp/manager.rs:623 already documents a stub-server harness that answers initialize, tools/list, and tools/call with canned JSON-RPC replies. Extend that stub with a prompts/list and prompts/get case rather than adding a new fixture.

Estimated scope: roughly 90 lines in protocol.rs, 70 in client.rs, 50 in manager.rs, 30 across the TUI state and lifecycle files, 70 in input.rs, and about 160 lines of tests.

Willingness to implement

I'm happy to open a PR implementing this if the direction works for you.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions