diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a36e7e6..e29f06ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +- Added `show_markdown` MCP tool: displays markdown text in a NetBeans Markdown Preview tab +- Added `show_markdown_file` MCP tool: opens a live-updating Markdown Preview tab for a .md file on disk + # 0.22.40 (2026-05-12) - Fixed markdown preview rendering fenced code blocks indented inside list items outside the list instead of nested within their list item diff --git a/claude-launch-tests/test_show_markdown_poc.py b/claude-launch-tests/test_show_markdown_poc.py new file mode 100644 index 00000000..f0cb7a1e --- /dev/null +++ b/claude-launch-tests/test_show_markdown_poc.py @@ -0,0 +1,232 @@ +""" +POC: проверяем, что Claude Code вызывает инструмент show_markdown +в интерактивном режиме (без --print). + +Тест A: план-режим (--permission-mode plan), явный запрос вызвать show_markdown +Тест B: план-режим + --append-system-prompt, промпт без упоминания инструмента +Тест C: обычный режим + --append-system-prompt, промпт без упоминания инструмента + +Для каждого теста проверяем: вызван ли show_markdown и что передано +в filePath / workingDirectory. +""" +import json, os, queue, tempfile, threading, time +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import ThreadingMixIn + +import pexpect + +CLAUDE = '/usr/local/bin/claude' +CWD = '/tmp' +ENV = {k: v for k, v in os.environ.items() + if k not in ('CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT')} + +SHOW_MARKDOWN_SCHEMA = { + "type": "object", + "properties": { + "markdown": {"type": "string", "description": "Markdown content to display"}, + "title": {"type": "string", "description": "Tab title (optional)"}, + "filePath": {"type": "string", "description": ( + "Absolute path to the source .md file (optional). " + "If the plan was written to a file (e.g. ~/.claude/plans/foo.md), " + "pass that path here so the IDE can resolve relative image paths " + "and use the filename as the tab name." + )}, + "workingDirectory": {"type": "string", "description": ( + "Absolute path to the current project working directory (optional). " + "Pass this so the IDE can associate the preview with the correct project." + )} + }, + "required": ["markdown"] +} + +APPEND_SYSTEM_PROMPT = ( + 'When you produce a plan or structured output, always call the show_markdown ' + 'MCP tool (server: poc) to display it in the IDE. ' + 'Pass the plan markdown in the markdown argument, a short title in title, ' + 'the current working directory in workingDirectory, ' + 'and — if you wrote the plan to a file — its absolute path in filePath.' +) + +PROMPT = 'Make a short 3-step plan for building a hello-world web app.' + + +# ── MCP server ──────────────────────────────────────────────────────────────── + +class MCPHandler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): pass + + def do_GET(self): + if self.path != '/sse': + self.send_response(404); self.end_headers(); return + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Cache-Control', 'no-cache') + self.send_header('Connection', 'keep-alive') + self.end_headers() + self._sse('endpoint', '/messages') + q = self.server.sse_queue + while True: + try: + msg = q.get(timeout=120) + if msg is None: break + self._sse('message', msg) + except Exception: break + + def _sse(self, event, data): + try: + self.wfile.write(f'event: {event}\ndata: {data}\n\n'.encode()) + self.wfile.flush() + except Exception: pass + + def _reply(self, resp): + self.server.sse_queue.put(json.dumps(resp)) + + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(length) + self.send_response(202); self.end_headers() + try: + msg = json.loads(body) + method = msg.get('method', '') + req_id = msg.get('id') + + if method == 'initialize': + self._reply({'jsonrpc': '2.0', 'id': req_id, 'result': { + 'protocolVersion': '2024-11-05', + 'capabilities': {'tools': {}}, + 'serverInfo': {'name': 'poc-server', 'version': '1.0'} + }}) + elif method == 'notifications/initialized': + pass + elif method == 'tools/list': + self._reply({'jsonrpc': '2.0', 'id': req_id, 'result': {'tools': [{ + 'name': 'show_markdown', + 'description': ( + 'Display markdown content in the IDE Markdown Preview tab. ' + 'Call this to show plans, summaries, or structured output ' + 'with rich formatting outside the terminal.' + ), + 'inputSchema': SHOW_MARKDOWN_SCHEMA + }]}}) + elif method == 'tools/call': + tool_name = msg.get('params', {}).get('name', '') + arguments = msg.get('params', {}).get('arguments', {}) + if tool_name == 'show_markdown': + self.server.tool_called.set() + self.server.tool_args.update(arguments) + self._reply({'jsonrpc': '2.0', 'id': req_id, + 'result': {'content': [{'type': 'text', 'text': 'ok'}]}}) + else: + self._reply({'jsonrpc': '2.0', 'id': req_id, + 'error': {'code': -32601, 'message': 'Unknown tool'}}) + else: + self._reply({'jsonrpc': '2.0', 'id': req_id, + 'error': {'code': -32601, 'message': 'Method not found'}}) + except Exception as e: + print(f'[server] POST error: {e}', flush=True) + + +class MCPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sse_queue = queue.Queue() + self.tool_called = threading.Event() + self.tool_args = {} + + +def start_server(): + srv = MCPServer(('127.0.0.1', 0), MCPHandler) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, port + + +# ── test runner ─────────────────────────────────────────────────────────────── + +def run_test(label, extra_args, prompt, logpath): + srv, port = start_server() + print(f'\n=== {label} (port={port}) ===', flush=True) + + cfg = json.dumps({'mcpServers': {'poc': { + 'type': 'sse', 'url': f'http://127.0.0.1:{port}/sse' + }}}) + cfg_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) + cfg_file.write(cfg); cfg_file.close() + + args = (['--mcp-config', cfg_file.name, + '--allowedTools', 'mcp__poc__show_markdown'] + + extra_args) + + logfile = open(logpath, 'w') + child = pexpect.spawn(CLAUDE, args, cwd=CWD, encoding='utf-8', + timeout=120, env=ENV, logfile=logfile) + + try: + # wait for the input prompt + child.expect(r'❯', timeout=30) + time.sleep(0.5) + child.sendline(prompt) + + # wait until show_markdown is called OR Claude goes idle again + deadline = time.time() + 90 + while time.time() < deadline: + if srv.tool_called.is_set(): + break + try: + child.expect(r'❯', timeout=3) + # back at prompt — Claude finished without calling the tool + break + except pexpect.TIMEOUT: + pass + except pexpect.TIMEOUT: + print(' TIMEOUT waiting for prompt', flush=True) + except Exception as e: + print(f' ERROR: {e}', flush=True) + finally: + child.close(force=True) + logfile.close() + os.unlink(cfg_file.name) + + if srv.tool_called.is_set(): + args_out = srv.tool_args + print(f' ✓ PASS: show_markdown called', flush=True) + print(f' title: {args_out.get("title", "(not passed)")}', flush=True) + print(f' filePath: {args_out.get("filePath", "(not passed)")}', flush=True) + print(f' workingDirectory: {args_out.get("workingDirectory", "(not passed)")}', flush=True) + print(f' markdown[:80]: {args_out.get("markdown","")[:80]}', flush=True) + else: + print(f' ✗ FAIL: show_markdown NOT called — see {logpath}', flush=True) + + srv.shutdown() + + +def main(): + # A: plan mode, user explicitly asks to call show_markdown + run_test( + label = 'A: plan-mode + explicit call in prompt', + extra_args = ['--permission-mode', 'plan'], + prompt = PROMPT + ' Then use the show_markdown tool to display the plan.', + logpath = '/tmp/poc_A.log', + ) + + # B: plan mode + system prompt, user prompt doesn't mention the tool + run_test( + label = 'B: plan-mode + append-system-prompt', + extra_args = ['--permission-mode', 'plan', + '--append-system-prompt', APPEND_SYSTEM_PROMPT], + prompt = PROMPT, + logpath = '/tmp/poc_B.log', + ) + + # C: no plan mode + system prompt only + run_test( + label = 'C: normal mode + append-system-prompt', + extra_args = ['--append-system-prompt', APPEND_SYSTEM_PROMPT], + prompt = PROMPT, + logpath = '/tmp/poc_C.log', + ) + + +if __name__ == '__main__': + main() diff --git a/pom.xml b/pom.xml index 816fba30..094b59f7 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.22.53-SNAPSHOT + 0.23.1-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java index 7acf4688..3cea166d 100644 --- a/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java +++ b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java @@ -57,6 +57,8 @@ import io.github.nbclaudecodegui.mcp.tools.OpenDiff; import io.github.nbclaudecodegui.mcp.tools.OpenFile; import io.github.nbclaudecodegui.mcp.tools.PermissionPromptTool; +import io.github.nbclaudecodegui.mcp.tools.ShowMarkdown; +import io.github.nbclaudecodegui.mcp.tools.ShowMarkdownFile; import org.netbeans.api.project.ui.OpenProjects; import org.openide.util.Lookup; import org.openbeans.claude.netbeans.tools.AsyncHandler; @@ -99,6 +101,8 @@ public class NetBeansMCPHandler { private final OpenFile openFileTool; private final PermissionPromptTool permissionPromptTool; private final SaveDocument saveDocument; + private final ShowMarkdown showMarkdownTool; + private final ShowMarkdownFile showMarkdownFileTool; /** Creates a new handler and initializes all MCP tool instances. */ public NetBeansMCPHandler() { @@ -114,6 +118,8 @@ public NetBeansMCPHandler() { this.openFileTool = new OpenFile(); this.permissionPromptTool = new PermissionPromptTool(); this.saveDocument = new SaveDocument(); + this.showMarkdownTool = new ShowMarkdown(); + this.showMarkdownFileTool = new ShowMarkdownFile(); } /** @@ -250,6 +256,14 @@ private JsonNode handleToolsList() { tools.add(createToolDefinition("permission_prompt", "Shows proposed file changes as a diff and asks the user to allow or deny the operation.", "permission_prompt")); + tools.add(createToolDefinition("show_markdown", + "Display markdown content in the IDE Markdown Preview tab. " + + "Call this to show plans, summaries, or structured output with rich formatting.", + "show_markdown")); + tools.add(createToolDefinition("show_markdown_file", + "Open a live-updating Markdown Preview tab for a .md file. " + + "The tab refreshes automatically when the file changes on disk.", + "show_markdown_file")); ObjectNode result = responseBuilder.objectNode(); result.set("tools", tools); @@ -318,6 +332,16 @@ private JsonNode handleToolsCall(JsonNode params, Integer requestId, this.permissionPromptTool.parseArguments(arguments)); break; + case "show_markdown": + result = this.showMarkdownTool.run( + this.showMarkdownTool.parseArguments(arguments)); + break; + + case "show_markdown_file": + result = this.showMarkdownFileTool.run( + this.showMarkdownFileTool.parseArguments(arguments)); + break; + default: throw new IllegalArgumentException("Unknown tool: " + toolName); } diff --git a/src/main/java/io/github/nbclaudecodegui/mcp/tools/ShowMarkdown.java b/src/main/java/io/github/nbclaudecodegui/mcp/tools/ShowMarkdown.java new file mode 100644 index 00000000..8a3814a6 --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/mcp/tools/ShowMarkdown.java @@ -0,0 +1,50 @@ +package io.github.nbclaudecodegui.mcp.tools; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.github.nbclaudecodegui.ui.MarkdownPreviewTab; +import java.util.logging.Logger; +import javax.swing.SwingUtilities; +import org.openbeans.claude.netbeans.tools.Tool; + +/** + * MCP tool that renders markdown text in a NetBeans Markdown Preview tab. + * The tab is identified by title and updated in-place on repeated calls. + */ +public class ShowMarkdown implements Tool { + + private static final Logger LOGGER = Logger.getLogger(ShowMarkdown.class.getName()); + + public static class Params { + @JsonProperty("markdown") + private String markdown; + + @JsonProperty("title") + private String title; + + public String getMarkdown() { return markdown; } + public String getTitle() { return title; } + } + + @Override + public String getName() { return "show_markdown"; } + + @Override + public String getDescription() { + return "Display markdown content in the IDE Markdown Preview tab. " + + "Call this to show plans, summaries, or structured output with rich formatting."; + } + + @Override + public Class getParameterClass() { return Params.class; } + + @Override + public String run(Params params) throws Exception { + String title = params.getTitle() != null && !params.getTitle().isBlank() + ? params.getTitle() : "Plan"; + String key = "mcp://show_markdown/" + title; + String markdown = params.getMarkdown() != null ? params.getMarkdown() : ""; + LOGGER.fine("show_markdown: title=" + title); + SwingUtilities.invokeLater(() -> MarkdownPreviewTab.openLive(key, markdown, null)); + return "ok"; + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/mcp/tools/ShowMarkdownFile.java b/src/main/java/io/github/nbclaudecodegui/mcp/tools/ShowMarkdownFile.java new file mode 100644 index 00000000..92fa7efc --- /dev/null +++ b/src/main/java/io/github/nbclaudecodegui/mcp/tools/ShowMarkdownFile.java @@ -0,0 +1,69 @@ +package io.github.nbclaudecodegui.mcp.tools; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.github.nbclaudecodegui.ui.MarkdownPreviewTab; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.SwingUtilities; +import org.openbeans.claude.netbeans.tools.Tool; +import org.openide.filesystems.FileUtil; + +/** + * MCP tool that opens a live-updating Markdown Preview tab for a file on disk. + * The tab watches the file for changes and refreshes automatically. + */ +public class ShowMarkdownFile implements Tool { + + private static final Logger LOGGER = Logger.getLogger(ShowMarkdownFile.class.getName()); + + public static class Params { + @JsonProperty("filePath") + private String filePath; + + @JsonProperty("title") + private String title; + + public String getFilePath() { return filePath; } + public String getTitle() { return title; } + } + + @Override + public String getName() { return "show_markdown_file"; } + + @Override + public String getDescription() { + return "Open a live-updating Markdown Preview tab for a .md file. " + + "The tab refreshes automatically when the file changes on disk."; + } + + @Override + public Class getParameterClass() { return Params.class; } + + @Override + public String run(Params params) throws Exception { + if (params.getFilePath() == null || params.getFilePath().isBlank()) { + return "error: filePath is required"; + } + File file = new File(params.getFilePath()); + String absPath = file.getAbsolutePath(); + LOGGER.fine("show_markdown_file: " + absPath); + + String content; + try { + content = file.exists() + ? Files.readString(file.toPath(), StandardCharsets.UTF_8) + : ""; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Cannot read file: " + absPath, e); + content = ""; + } + + var fo = FileUtil.toFileObject(FileUtil.normalizeFile(file)); + String markdown = content; + SwingUtilities.invokeLater(() -> MarkdownPreviewTab.openLive(absPath, markdown, fo)); + return "ok"; + } +} diff --git a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java index e9d73196..c3acdebd 100644 --- a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java @@ -60,6 +60,12 @@ public final class ClaudeProcess { /** Key the plugin registers under {@code mcpServers}. */ static final String OUR_MCP_KEY = "netbeans"; + /** MCP tools the plugin adds to {@code permissions.allow}. */ + static final List OUR_ALLOWED_TOOLS = List.of( + "mcp__netbeans__show_markdown", + "mcp__netbeans__show_markdown_file" + ); + /** Creates a new, idle {@code ClaudeProcess} instance. */ public ClaudeProcess() {} @@ -488,6 +494,26 @@ static String mergeSettingsJson(String existingJson, int port, ClaudeProfile pro root.set("hooks", hooks); + // --- permissions.allow --- + ObjectNode permissions = root.has("permissions") + ? (ObjectNode) root.get("permissions") + : MAPPER.createObjectNode(); + ArrayNode allow = permissions.has("allow") + ? (ArrayNode) permissions.get("allow") + : MAPPER.createArrayNode(); + for (String tool : OUR_ALLOWED_TOOLS) { + boolean found = false; + for (JsonNode n : allow) { + if (tool.equals(n.asText())) { found = true; break; } + } + if (!found) allow.add(tool); + } + permissions.set("allow", allow); + if (!permissions.has("deny")) { + permissions.set("deny", MAPPER.createArrayNode()); + } + root.set("permissions", permissions); + // apiKeyHelper for CLAUDE_API (only apiKey set, no baseUrl) if (profile != null && !profile.getApiKey().isBlank() && profile.getBaseUrl().isBlank()) { @@ -597,6 +623,33 @@ static String cleanedSettingsJson(String existingJson) { root.remove("apiKeyHelper"); + // Remove our tools from permissions.allow + if (root.has("permissions")) { + ObjectNode permissions = (ObjectNode) root.get("permissions"); + if (permissions.has("allow")) { + ArrayNode allow = (ArrayNode) permissions.get("allow"); + ArrayNode filteredAllow = MAPPER.createArrayNode(); + for (JsonNode n : allow) { + if (!OUR_ALLOWED_TOOLS.contains(n.asText())) { + filteredAllow.add(n); + } + } + if (filteredAllow.isEmpty()) { + permissions.remove("allow"); + } else { + permissions.set("allow", filteredAllow); + } + } + // Remove empty deny array that we added during merge + JsonNode deny = permissions.get("deny"); + if (deny != null && deny.isArray() && deny.isEmpty()) { + permissions.remove("deny"); + } + if (permissions.isEmpty()) { + root.remove("permissions"); + } + } + return root.isEmpty() ? null : MAPPER.writeValueAsString(root); } catch (Exception e) { diff --git a/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/show_markdown.json b/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/show_markdown.json new file mode 100644 index 00000000..003801fe --- /dev/null +++ b/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/show_markdown.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "markdown": { + "type": "string", + "description": "Markdown text to display" + }, + "title": { + "type": "string", + "description": "Tab title (optional, default: Plan)" + } + }, + "required": ["markdown"] +} diff --git a/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/show_markdown_file.json b/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/show_markdown_file.json new file mode 100644 index 00000000..a5499273 --- /dev/null +++ b/src/main/resources/org/openbeans/claude/netbeans/tools/schemas/show_markdown_file.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path to the .md file to preview" + }, + "title": { + "type": "string", + "description": "Tab title (optional, default: filename)" + } + }, + "required": ["filePath"] +}