Skip to content

Commit 8bc03d1

Browse files
authored
feat(mcp): add MCP UI variants and TUI output (#545)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent f6e0a5b commit 8bc03d1

59 files changed

Lines changed: 8264 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# MCP UI Bakeoff - Instructions & Test Plan
2+
3+
Last updated: 2026-02-02
4+
5+
## Scope
6+
7+
Compare three presentation paths for Basic Memory MCP tools:
8+
9+
1. **Tool‑UI (React)** via MCP App resources.
10+
2. **MCP‑UI Python SDK** embedded UI resources (legacy host path).
11+
3. **ASCII/ANSI** output for TUI clients.
12+
13+
This doc is the running instruction set and test plan. Update as implementation progresses.
14+
15+
---
16+
17+
## Prerequisites
18+
19+
- Repo: `basic-memory` (worktree: `basic-memory-mcp-ui-poc`)
20+
- Node for tool‑ui build (already used for POC)
21+
- Python 3.12+ with `uv`
22+
23+
Optional (for MCP‑UI Python SDK path):
24+
25+
- Local repo: `/Users/phernandez/dev/mcp-ui`
26+
- Install the server SDK into the Basic Memory venv:
27+
- `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server`
28+
29+
---
30+
31+
## Build / Refresh Steps
32+
33+
### Tool‑UI React bundle
34+
35+
```bash
36+
cd ui/tool-ui-react
37+
npm install
38+
npm run build
39+
```
40+
41+
This regenerates:
42+
43+
- `src/basic_memory/mcp/ui/html/search-results-tool-ui.html`
44+
- `src/basic_memory/mcp/ui/html/note-preview-tool-ui.html`
45+
46+
---
47+
48+
## How to Run the MCP Server
49+
50+
```bash
51+
basic-memory mcp --transport stdio
52+
```
53+
54+
Optional to pick UI variant for MCP App resources:
55+
56+
```bash
57+
export BASIC_MEMORY_MCP_UI_VARIANT=tool-ui # or vanilla | mcp-ui
58+
```
59+
60+
---
61+
62+
## Test Cases
63+
64+
### 1) MCP App Resource UI (tool‑ui / vanilla / mcp‑ui)
65+
66+
Tools:
67+
- `search_notes`
68+
- `read_note`
69+
70+
Expect:
71+
- Tool meta points to `ui://basic-memory/search-results` and `ui://basic-memory/note-preview`
72+
- Resource content differs by `BASIC_MEMORY_MCP_UI_VARIANT`
73+
- Variant‑specific URIs also available:
74+
- `ui://basic-memory/search-results/vanilla`
75+
- `ui://basic-memory/search-results/tool-ui`
76+
- `ui://basic-memory/search-results/mcp-ui`
77+
- `ui://basic-memory/note-preview/vanilla`
78+
- `ui://basic-memory/note-preview/tool-ui`
79+
- `ui://basic-memory/note-preview/mcp-ui`
80+
81+
Manual check:
82+
- Trigger tool in MCP‑App‑capable host and confirm UI renders.
83+
84+
---
85+
86+
### 2) ASCII / ANSI TUI Output
87+
88+
Tools:
89+
- `search_notes(output_format="ascii" | "ansi")`
90+
- `read_note(output_format="ascii" | "ansi")`
91+
92+
Expect:
93+
- ASCII table for search, header + content preview for note.
94+
- ANSI variants include color escape codes.
95+
96+
Automated:
97+
- `uv run pytest test-int/mcp/test_output_format_ascii_integration.py`
98+
99+
---
100+
101+
### 3) MCP‑UI Python SDK (embedded UI resource)
102+
103+
Tools (embedded resource responses):
104+
- `search_notes_ui` (MCP‑UI SDK)
105+
- `read_note_ui` (MCP‑UI SDK)
106+
107+
Expected output:
108+
- Tool response content contains an EmbeddedResource (`type: "resource"`)
109+
- `mimeType` is `text/html`
110+
- `_meta` includes:
111+
- `mcpui.dev/ui-preferred-frame-size`
112+
- `mcpui.dev/ui-initial-render-data`
113+
114+
Manual check:
115+
- Render tool responses using `UIResourceRenderer` (legacy host flow).
116+
117+
Automated (if SDK installed):
118+
- `uv run pytest test-int/mcp/test_ui_sdk_integration.py`
119+
120+
---
121+
122+
## Bakeoff Notes Template
123+
124+
Fill in after running:
125+
126+
- Tool‑UI (React): __
127+
- MCP‑UI SDK (embedded): __
128+
- ASCII/ANSI: __
129+
130+
Decision + rationale: __

src/basic_memory/cli/commands/mcp.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def mcp(
5353
# Import mcp tools/prompts to register them with the server
5454
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
5555
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
56+
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
5657

5758
# Initialize logging for MCP (file only, stdout breaks protocol)
5859
init_mcp_logging()

src/basic_memory/mcp/formatting.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Formatting helpers for MCP tool outputs."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Iterable, Sequence
6+
7+
from basic_memory.schemas.search import SearchResponse, SearchResult
8+
9+
ANSI_RESET = "\x1b[0m"
10+
ANSI_BOLD = "\x1b[1m"
11+
ANSI_DIM = "\x1b[2m"
12+
ANSI_CYAN = "\x1b[36m"
13+
14+
15+
def _apply_style(text: str, style: str, enabled: bool) -> str:
16+
if not enabled:
17+
return text
18+
return f"{style}{text}{ANSI_RESET}"
19+
20+
21+
def _strip_frontmatter(text: str) -> str:
22+
lines = text.splitlines()
23+
if not lines or lines[0].strip() != "---":
24+
return text
25+
26+
for idx in range(1, len(lines)):
27+
if lines[idx].strip() == "---":
28+
return "\n".join(lines[idx + 1 :]).lstrip()
29+
return text
30+
31+
32+
def _parse_title(text: str) -> str | None:
33+
for line in text.splitlines():
34+
if line.startswith("# "):
35+
return line[2:].strip()
36+
return None
37+
38+
39+
def _truncate(text: str, width: int) -> str:
40+
if width <= 0:
41+
return ""
42+
if len(text) <= width:
43+
return text
44+
if width <= 3:
45+
return text[:width]
46+
return text[: width - 3] + "..."
47+
48+
49+
def _make_separator(widths: Sequence[int]) -> str:
50+
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
51+
52+
53+
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
54+
cells = []
55+
for value, width in zip(values, widths, strict=True):
56+
cells.append(f" {_truncate(value, width).ljust(width)} ")
57+
return "|" + "|".join(cells) + "|"
58+
59+
60+
def _get_result_tags(result: SearchResult) -> str:
61+
metadata = result.metadata or {}
62+
if isinstance(metadata, dict):
63+
tags = metadata.get("tags")
64+
if isinstance(tags, list):
65+
return ", ".join(str(tag) for tag in tags if tag)
66+
return ""
67+
68+
69+
def _get_result_path(result: SearchResult) -> str:
70+
return result.permalink or result.file_path or ""
71+
72+
73+
def format_search_results_ascii(
74+
result: SearchResponse,
75+
query: str | None = None,
76+
color: bool = False,
77+
) -> str:
78+
"""Format search results as an ASCII table for TUI clients."""
79+
80+
results = result.results or []
81+
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
82+
lines = [header_line]
83+
84+
if query:
85+
lines.append(f"Query: {query}")
86+
87+
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
88+
lines.append(_apply_style(summary, ANSI_DIM, color))
89+
90+
if not results:
91+
lines.append("No results.")
92+
return "\n".join(lines).strip()
93+
94+
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
95+
rows = []
96+
for idx, item in enumerate(results, start=1):
97+
rows.append(
98+
[
99+
str(idx),
100+
item.title or "Untitled",
101+
item.type.value if hasattr(item.type, "value") else str(item.type),
102+
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
103+
_get_result_path(item),
104+
_get_result_tags(item),
105+
]
106+
)
107+
108+
max_widths = [3, 32, 10, 7, 36, 24]
109+
widths = []
110+
for index, header in enumerate(headers):
111+
column_values = [header] + [row[index] for row in rows]
112+
max_len = max(len(value) for value in column_values)
113+
widths.append(min(max_widths[index], max_len))
114+
115+
table = [_make_separator(widths)]
116+
header_row = _format_row(headers, widths)
117+
if color:
118+
header_cells = []
119+
for value, width in zip(headers, widths, strict=True):
120+
padded = f" {_truncate(value, width).ljust(width)} "
121+
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
122+
header_row = "|" + "|".join(header_cells) + "|"
123+
table.append(header_row)
124+
table.append(_make_separator(widths))
125+
126+
for row in rows:
127+
table.append(_format_row(row, widths))
128+
129+
table.append(_make_separator(widths))
130+
131+
lines.append("")
132+
lines.extend(table)
133+
return "\n".join(lines).rstrip()
134+
135+
136+
def format_note_preview_ascii(
137+
content: str,
138+
identifier: str | None = None,
139+
color: bool = False,
140+
) -> str:
141+
"""Format note content for ASCII/TUI display."""
142+
143+
identifier = identifier or ""
144+
cleaned = _strip_frontmatter(content)
145+
title = _parse_title(cleaned) or identifier or "Note Preview"
146+
147+
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
148+
lines = [header, f"Title: {title}"]
149+
150+
if identifier:
151+
lines.append(f"Identifier: {identifier}")
152+
153+
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
154+
155+
if content.strip():
156+
lines.append(content.rstrip())
157+
else:
158+
lines.append("(empty note)")
159+
160+
return "\n".join(lines).rstrip()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""MCP resources for Basic Memory."""
2+
3+
from basic_memory.mcp.resources.project_info import project_info
4+
from basic_memory.mcp.resources.ui import (
5+
note_preview_ui,
6+
note_preview_ui_mcp_ui,
7+
note_preview_ui_tool_ui,
8+
note_preview_ui_vanilla,
9+
search_results_ui,
10+
search_results_ui_mcp_ui,
11+
search_results_ui_tool_ui,
12+
search_results_ui_vanilla,
13+
)
14+
15+
__all__ = [
16+
"project_info",
17+
"note_preview_ui",
18+
"note_preview_ui_mcp_ui",
19+
"note_preview_ui_tool_ui",
20+
"note_preview_ui_vanilla",
21+
"search_results_ui",
22+
"search_results_ui_mcp_ui",
23+
"search_results_ui_tool_ui",
24+
"search_results_ui_vanilla",
25+
]

0 commit comments

Comments
 (0)