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"]
+}