From 1fd2d770d2f154cc9442a1758005d11351dbfb0c Mon Sep 17 00:00:00 2001 From: Dmitry Pogrebnoy Date: Thu, 4 Jun 2026 17:50:42 +0200 Subject: [PATCH] RUBY-35650 Don't duplicate the first line of a tool description GitOrigin-RevId: 02a838f5afac9238eef944c8f0861a706ba5d154 --- .../mcpserver/McpToolsMarkdownExporter.kt | 38 ++++++++++++++----- .../toolsets/general/ExecutionToolset.kt | 4 +- .../mcpserver/McpToolsMarkdownExporterTest.kt | 25 +++++++++++- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/McpToolsMarkdownExporter.kt b/plugins/mcp-server/src/com/intellij/mcpserver/McpToolsMarkdownExporter.kt index e0889d124b47..ec8efe3d680a 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/McpToolsMarkdownExporter.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/McpToolsMarkdownExporter.kt @@ -22,20 +22,22 @@ object McpToolsMarkdownExporter { appendLine("## ${category.shortName}") appendLine() for (tool in categoryTools) { - appendToolSection(tool, headingLevel = 3) + appendToolSection(tool, headingLevel = 3, omitFirstDescriptionLine = false) } } } } /** - * Generates a single-page markdown document for one MCP tool: top-level heading, full description, + * Generates a single-page markdown document for one MCP tool: top-level heading, description, * input parameters table, and output schema table (when present). * - * Use together with [generateMarkdownTree] when emitting per-tool reference files. + * [omitFirstDescriptionLine] drops the first non-blank line of the description while keeping the rest. + * The per-tool files of [generateMarkdownTree] pass `true`: that first line is already shown as the + * tool's summary in the index, so repeating it in the per-tool file is duplication and wasted context. */ - fun generateMarkdownForTool(tool: McpTool): String = buildString { - appendToolSection(tool, headingLevel = 1) + fun generateMarkdownForTool(tool: McpTool, omitFirstDescriptionLine: Boolean = false): String = buildString { + appendToolSection(tool, headingLevel = 1, omitFirstDescriptionLine = omitFirstDescriptionLine) } /** @@ -71,7 +73,7 @@ object McpToolsMarkdownExporter { } for ((_, categoryTools) in byCategory) { for (tool in categoryTools) { - result["$TREE_TOOLS_SUBDIR/${tool.descriptor.name}.md"] = generateMarkdownForTool(tool) + result["$TREE_TOOLS_SUBDIR/${tool.descriptor.name}.md"] = generateMarkdownForTool(tool, omitFirstDescriptionLine = true) } } return result @@ -99,10 +101,15 @@ object McpToolsMarkdownExporter { "Nested `*` is scoped to the parent object's presence — required nested fields are only guaranteed " + "when the optional parent object is emitted." - private fun StringBuilder.appendToolSection(tool: McpTool, headingLevel: Int) { + private fun StringBuilder.appendToolSection(tool: McpTool, headingLevel: Int, omitFirstDescriptionLine: Boolean) { val heading = "#".repeat(headingLevel) appendLine("$heading ${tool.descriptor.name}") - appendLine(tool.descriptor.description.trimIndent().escapeLineBreaks().escapeMarkdown()) + val description = + if (omitFirstDescriptionLine) tool.descriptor.description.dropFirstNonBlankLine() + else tool.descriptor.description.trimIndent() + if (description.isNotBlank()) { + appendLine(description.escapeLineBreaks().escapeMarkdown()) + } appendLine() appendLine("$heading# Parameters") val inputRows = tool.descriptor.inputSchema.toSchemaTableRows() @@ -243,7 +250,20 @@ object McpToolsMarkdownExporter { return required.mapNotNull { (it as? JsonPrimitive)?.content }.toSet() } - private fun String.escapeLineBreaks(): String = + /** + * Drops the first non-blank line (and any blank lines preceding it) and returns the remaining body, + * trimmed. That first line is the tool summary shown in the index ([generateMarkdownTree]); see + * [generateMarkdownForTool]'s `omitFirstDescriptionLine`. Returns an empty string for a single-line + * description. + */ + private fun String.dropFirstNonBlankLine(): String { + val lines = trim().lines() + val firstNonBlank = lines.indexOfFirst { it.isNotBlank() } + if (firstNonBlank < 0) return "" + return lines.drop(firstNonBlank + 1).joinToString("\n").trim() + } + + private fun String.escapeLineBreaks(): String = replace("\r\n", "
").replace("\n", "
").replace("\r", "
") private fun String.escapeMarkdown(): String = replace("|", "\\|") diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt b/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt index 6e5e341b135d..3bd40734e801 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt @@ -97,8 +97,8 @@ class ExecutionToolset : McpToolset { @McpTool(name = ExecutionToolNames.EXECUTE_RUN_CONFIGURATION) @McpDescription(""" - |Run either an existing run configuration by name or a temporary run configuration created from a code location - |(`filePath` + `line`) in the current project, then wait up to specified timeout for it to finish. + |Run either an existing run configuration by name or a temporary run configuration created from a code location (`filePath` + `line`) in the current project, then wait up to specified timeout for it to finish. + | |Use this tool with either a configuration name returned by `get_run_configurations`, or with a run point |(`filePath` + `line`) returned by `get_run_configurations(filePath = ...)`. | diff --git a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolsMarkdownExporterTest.kt b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolsMarkdownExporterTest.kt index caef8ddbba6f..c532bee168c7 100644 --- a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolsMarkdownExporterTest.kt +++ b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolsMarkdownExporterTest.kt @@ -296,16 +296,37 @@ class McpToolsMarkdownExporterTest { } @Test - fun generateMarkdownTreePerToolFileMatchesGenerateMarkdownForToolOutput() { + fun generateMarkdownTreePerToolFileMatchesFirstLineOmittedSingleToolOutput() { val t = setMarkerTool() val result = McpToolsMarkdownExporter.generateMarkdownTree(listOf(t)) assertEquals( - McpToolsMarkdownExporter.generateMarkdownForTool(t), + McpToolsMarkdownExporter.generateMarkdownForTool(t, omitFirstDescriptionLine = true), result.getValue("$TREE_TOOLS_SUBDIR/set_marker.md"), ) } + @Test + fun generateMarkdownTreePerToolFileDropsFirstDescriptionLineButKeepsBody() { + val t = startTaskTool( + description = "Starts a task using the supplied configuration.\nReturns the task handle.", + ) + + val result = McpToolsMarkdownExporter.generateMarkdownTree(listOf(t)) + val index = result.getValue(TREE_INDEX_FILE) + val perTool = result.getValue("$TREE_TOOLS_SUBDIR/start_task.md") + + assert(index.contains("Starts a task using the supplied configuration.")) { + "Index should keep the first description line: $index" + } + assert(!perTool.contains("Starts a task using the supplied configuration.")) { + "Per-tool file should drop the first description line (it lives in the index): $perTool" + } + assert(perTool.contains("Returns the task handle.")) { + "Per-tool file should keep the rest of the description body: $perTool" + } + } + @Test fun generateMarkdownTreeEmptyListReturnsOnlyIndexHeaderAndLegend() { val result = McpToolsMarkdownExporter.generateMarkdownTree(emptyList())