feat(mcp): add MCP UI variants and TUI output (#545)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-02-13 15:21:48 -06:00
committed by GitHub
parent f6e0a5b5bb
commit 8bc03d1357
59 changed files with 8264 additions and 3 deletions
+1
View File
@@ -53,6 +53,7 @@ def mcp(
# Import mcp tools/prompts to register them with the server
import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
import basic_memory.mcp.resources # noqa: F401 # pragma: no cover
# Initialize logging for MCP (file only, stdout breaks protocol)
init_mcp_logging()
+160
View File
@@ -0,0 +1,160 @@
"""Formatting helpers for MCP tool outputs."""
from __future__ import annotations
from typing import Iterable, Sequence
from basic_memory.schemas.search import SearchResponse, SearchResult
ANSI_RESET = "\x1b[0m"
ANSI_BOLD = "\x1b[1m"
ANSI_DIM = "\x1b[2m"
ANSI_CYAN = "\x1b[36m"
def _apply_style(text: str, style: str, enabled: bool) -> str:
if not enabled:
return text
return f"{style}{text}{ANSI_RESET}"
def _strip_frontmatter(text: str) -> str:
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return text
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
return "\n".join(lines[idx + 1 :]).lstrip()
return text
def _parse_title(text: str) -> str | None:
for line in text.splitlines():
if line.startswith("# "):
return line[2:].strip()
return None
def _truncate(text: str, width: int) -> str:
if width <= 0:
return ""
if len(text) <= width:
return text
if width <= 3:
return text[:width]
return text[: width - 3] + "..."
def _make_separator(widths: Sequence[int]) -> str:
return "+" + "+".join("-" * (width + 2) for width in widths) + "+"
def _format_row(values: Sequence[str], widths: Sequence[int]) -> str:
cells = []
for value, width in zip(values, widths, strict=True):
cells.append(f" {_truncate(value, width).ljust(width)} ")
return "|" + "|".join(cells) + "|"
def _get_result_tags(result: SearchResult) -> str:
metadata = result.metadata or {}
if isinstance(metadata, dict):
tags = metadata.get("tags")
if isinstance(tags, list):
return ", ".join(str(tag) for tag in tags if tag)
return ""
def _get_result_path(result: SearchResult) -> str:
return result.permalink or result.file_path or ""
def format_search_results_ascii(
result: SearchResponse,
query: str | None = None,
color: bool = False,
) -> str:
"""Format search results as an ASCII table for TUI clients."""
results = result.results or []
header_line = _apply_style("Search results", f"{ANSI_BOLD}{ANSI_CYAN}", color)
lines = [header_line]
if query:
lines.append(f"Query: {query}")
summary = f"Results: {len(results)} | Page: {result.current_page} | Page size: {result.page_size}"
lines.append(_apply_style(summary, ANSI_DIM, color))
if not results:
lines.append("No results.")
return "\n".join(lines).strip()
headers = ["#", "Title", "Type", "Score", "Path", "Tags"]
rows = []
for idx, item in enumerate(results, start=1):
rows.append(
[
str(idx),
item.title or "Untitled",
item.type.value if hasattr(item.type, "value") else str(item.type),
f"{item.score:.2f}" if isinstance(item.score, (int, float)) else "",
_get_result_path(item),
_get_result_tags(item),
]
)
max_widths = [3, 32, 10, 7, 36, 24]
widths = []
for index, header in enumerate(headers):
column_values = [header] + [row[index] for row in rows]
max_len = max(len(value) for value in column_values)
widths.append(min(max_widths[index], max_len))
table = [_make_separator(widths)]
header_row = _format_row(headers, widths)
if color:
header_cells = []
for value, width in zip(headers, widths, strict=True):
padded = f" {_truncate(value, width).ljust(width)} "
header_cells.append(_apply_style(padded, f"{ANSI_BOLD}{ANSI_CYAN}", color))
header_row = "|" + "|".join(header_cells) + "|"
table.append(header_row)
table.append(_make_separator(widths))
for row in rows:
table.append(_format_row(row, widths))
table.append(_make_separator(widths))
lines.append("")
lines.extend(table)
return "\n".join(lines).rstrip()
def format_note_preview_ascii(
content: str,
identifier: str | None = None,
color: bool = False,
) -> str:
"""Format note content for ASCII/TUI display."""
identifier = identifier or ""
cleaned = _strip_frontmatter(content)
title = _parse_title(cleaned) or identifier or "Note Preview"
header = _apply_style("Note preview", f"{ANSI_BOLD}{ANSI_CYAN}", color)
lines = [header, f"Title: {title}"]
if identifier:
lines.append(f"Identifier: {identifier}")
lines.append(_apply_style("-" * 72, ANSI_DIM, color))
if content.strip():
lines.append(content.rstrip())
else:
lines.append("(empty note)")
return "\n".join(lines).rstrip()
@@ -0,0 +1,25 @@
"""MCP resources for Basic Memory."""
from basic_memory.mcp.resources.project_info import project_info
from basic_memory.mcp.resources.ui import (
note_preview_ui,
note_preview_ui_mcp_ui,
note_preview_ui_tool_ui,
note_preview_ui_vanilla,
search_results_ui,
search_results_ui_mcp_ui,
search_results_ui_tool_ui,
search_results_ui_vanilla,
)
__all__ = [
"project_info",
"note_preview_ui",
"note_preview_ui_mcp_ui",
"note_preview_ui_tool_ui",
"note_preview_ui_vanilla",
"search_results_ui",
"search_results_ui_mcp_ui",
"search_results_ui_tool_ui",
"search_results_ui_vanilla",
]
+89
View File
@@ -0,0 +1,89 @@
"""UI resources for MCP Apps integration."""
from basic_memory.mcp.server import mcp
from basic_memory.mcp.ui import load_html, load_variant_html
# FastMCP's MIME type validator currently accepts only type/subtype, so we
# use text/html here. MCP Apps hosts typically expect text/html;profile=mcp-app.
MIME_TYPE = "text/html"
@mcp.resource(
uri="ui://basic-memory/search-results",
name="Basic Memory Search Results",
description="Search results UI for Basic Memory tools.",
mime_type=MIME_TYPE,
)
def search_results_ui() -> str:
return load_variant_html("search-results")
@mcp.resource(
uri="ui://basic-memory/note-preview",
name="Basic Memory Note Preview",
description="Note preview UI for Basic Memory read_note tool.",
mime_type=MIME_TYPE,
)
def note_preview_ui() -> str:
return load_variant_html("note-preview")
# Variant-specific resource URIs for bakeoff comparisons.
@mcp.resource(
uri="ui://basic-memory/search-results/vanilla",
name="Basic Memory Search Results (Vanilla)",
description="Vanilla HTML search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_vanilla() -> str:
return load_html("search-results-vanilla.html")
@mcp.resource(
uri="ui://basic-memory/search-results/tool-ui",
name="Basic Memory Search Results (Tool UI)",
description="Tool UI styled search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_tool_ui() -> str:
return load_html("search-results-tool-ui.html")
@mcp.resource(
uri="ui://basic-memory/search-results/mcp-ui",
name="Basic Memory Search Results (MCP UI)",
description="MCP UI styled search results UI.",
mime_type=MIME_TYPE,
)
def search_results_ui_mcp_ui() -> str:
return load_html("search-results-mcp-ui.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/vanilla",
name="Basic Memory Note Preview (Vanilla)",
description="Vanilla HTML note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_vanilla() -> str:
return load_html("note-preview-vanilla.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/tool-ui",
name="Basic Memory Note Preview (Tool UI)",
description="Tool UI styled note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_tool_ui() -> str:
return load_html("note-preview-tool-ui.html")
@mcp.resource(
uri="ui://basic-memory/note-preview/mcp-ui",
name="Basic Memory Note Preview (MCP UI)",
description="MCP UI styled note preview UI.",
mime_type=MIME_TYPE,
)
def note_preview_ui_mcp_ui() -> str:
return load_html("note-preview-mcp-ui.html")
+3
View File
@@ -11,6 +11,7 @@ from basic_memory.mcp.tools.read_content import read_content
from basic_memory.mcp.tools.build_context import build_context
from basic_memory.mcp.tools.recent_activity import recent_activity
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.ui_sdk import read_note_ui, search_notes_ui
from basic_memory.mcp.tools.view_note import view_note
from basic_memory.mcp.tools.write_note import write_note
from basic_memory.mcp.tools.cloud_info import cloud_info
@@ -47,6 +48,7 @@ __all__ = [
"read_content",
"read_note",
"release_notes",
"read_note_ui",
"recent_activity",
"schema_diff",
"schema_infer",
@@ -54,6 +56,7 @@ __all__ = [
"search",
"search_by_metadata",
"search_notes",
"search_notes_ui",
"view_note",
"write_note",
]
+18 -1
View File
@@ -1,13 +1,14 @@
"""Read note tool for Basic Memory MCP server."""
from textwrap import dedent
from typing import Optional
from typing import Optional, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.server import mcp
from basic_memory.mcp.formatting import format_note_preview_ascii
from basic_memory.mcp.tools.search import search_notes
from basic_memory.schemas.memory import memory_url_path
from basic_memory.utils import validate_project_path
@@ -15,12 +16,14 @@ from basic_memory.utils import validate_project_path
@mcp.tool(
description="Read a markdown note by title or permalink.",
meta={"ui/resourceUri": "ui://basic-memory/note-preview"},
)
async def read_note(
identifier: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
output_format: Literal["default", "ascii", "ansi"] = "default",
context: Context | None = None,
) -> str:
"""Return the raw markdown for a note, or guidance text if no match is found.
@@ -46,6 +49,8 @@ async def read_note(
Can be a full memory:// URL, a permalink, a title, or search text
page: Page number for paginated results (default: 1)
page_size: Number of items per page (default: 10)
output_format: "default" returns markdown, "ascii" returns a plain text preview,
"ansi" returns a colorized preview for TUI clients.
context: Optional FastMCP context for performance caching.
Returns:
@@ -116,6 +121,12 @@ async def read_note(
# If successful, return the content
if response.status_code == 200:
logger.info("Returning read_note result from resource: {path}", path=entity_path)
if output_format in ("ascii", "ansi"):
return format_note_preview_ascii(
response.text,
identifier=identifier,
color=output_format == "ansi",
)
return response.text
except Exception as e: # pragma: no cover
logger.info(f"Direct lookup failed for '{entity_path}': {e}")
@@ -140,6 +151,12 @@ async def read_note(
if response.status_code == 200:
logger.info(f"Found note by title search: {result.permalink}")
if output_format in ("ascii", "ansi"):
return format_note_preview_ascii(
response.text,
identifier=identifier,
color=output_format == "ansi",
)
return response.text
except Exception as e: # pragma: no cover
logger.info(
+13 -1
View File
@@ -1,12 +1,13 @@
"""Search tools for Basic Memory MCP server."""
from textwrap import dedent
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Literal
from loguru import logger
from fastmcp import Context
from basic_memory.mcp.project_context import get_project_client
from basic_memory.mcp.formatting import format_search_results_ascii
from basic_memory.mcp.server import mcp
from basic_memory.schemas.search import (
SearchItemType,
@@ -230,6 +231,7 @@ Error searching for '{query}': {error_message}
@mcp.tool(
description="Search across all content in the knowledge base with advanced syntax support.",
meta={"ui/resourceUri": "ui://basic-memory/search-results"},
)
async def search_notes(
query: str,
@@ -237,6 +239,7 @@ async def search_notes(
page: int = 1,
page_size: int = 10,
search_type: str = "text",
output_format: Literal["default", "ascii", "ansi"] = "default",
types: List[str] | None = None,
entity_types: List[str] | None = None,
after_date: Optional[str] = None,
@@ -320,6 +323,8 @@ async def search_notes(
page_size: The number of results to return per page (default 10)
search_type: Type of search to perform, one of:
"text", "title", "permalink", "vector", "hybrid" (default: "text")
output_format: "default" returns structured data, "ascii" returns a plain text table,
"ansi" returns a colorized table for TUI clients.
types: Optional list of note types to search (e.g., ["note", "person"])
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
@@ -452,6 +457,13 @@ async def search_notes(
# Don't treat this as an error, but the user might want guidance
# We return the empty result as normal - the user can decide if they need help
if output_format in ("ascii", "ansi"):
return format_search_results_ascii(
result,
query=query,
color=output_format == "ansi",
)
return result
except Exception as e:
+123
View File
@@ -0,0 +1,123 @@
"""Embedded UI tools using the MCP-UI Python SDK."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastmcp import Context
from mcp.types import ContentBlock, TextContent
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.mcp.tools.search import search_notes
from basic_memory.mcp.ui.sdk import MissingMCPUIServerError, build_embedded_ui_resource
def _text_block(message: str) -> List[ContentBlock]:
return [TextContent(type="text", text=message)]
@mcp.tool(
description="Search notes and return an embedded MCP-UI resource (raw HTML).",
output_schema=None,
)
async def search_notes_ui(
query: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
search_type: str = "text",
types: List[str] | None = None,
entity_types: List[str] | None = None,
after_date: Optional[str] = None,
metadata_filters: Optional[Dict[str, Any]] = None,
tags: Optional[List[str]] = None,
status: Optional[str] = None,
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a search results UI as an embedded MCP-UI resource."""
result = await search_notes.fn(
query=query,
project=project,
page=page,
page_size=page_size,
search_type=search_type,
output_format="default",
types=types,
entity_types=entity_types,
after_date=after_date,
metadata_filters=metadata_filters,
tags=tags,
status=status,
context=context,
)
if isinstance(result, str):
return _text_block(result)
render_data = {
"toolInput": {
"query": query,
"search_type": search_type,
"page": page,
"page_size": page_size,
},
"toolOutput": result.model_dump(),
}
try:
resource = build_embedded_ui_resource(
uri="ui://basic-memory/search-results/mcp-ui-sdk",
html_filename="search-results-mcp-ui.html",
render_data=render_data,
preferred_frame_size=["100%", "540px"],
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
)
except MissingMCPUIServerError as exc:
return _text_block(str(exc))
return [resource]
@mcp.tool(
description="Read a note and return an embedded MCP-UI resource (raw HTML).",
output_schema=None,
)
async def read_note_ui(
identifier: str,
project: Optional[str] = None,
page: int = 1,
page_size: int = 10,
context: Context | None = None,
) -> List[ContentBlock]:
"""Return a note preview UI as an embedded MCP-UI resource."""
content = await read_note.fn(
identifier=identifier,
project=project,
page=page,
page_size=page_size,
output_format="default",
context=context,
)
render_data = {
"toolInput": {
"identifier": identifier,
"page": page,
"page_size": page_size,
},
"toolOutput": content,
}
try:
resource = build_embedded_ui_resource(
uri="ui://basic-memory/note-preview/mcp-ui-sdk",
html_filename="note-preview-mcp-ui.html",
render_data=render_data,
preferred_frame_size=["100%", "640px"],
metadata={"basic-memory.ui-variant": "mcp-ui-sdk"},
)
except MissingMCPUIServerError as exc:
return _text_block(str(exc))
return [resource]
+5
View File
@@ -0,0 +1,5 @@
"""UI helpers for MCP Apps resources."""
from basic_memory.mcp.ui.templates import get_ui_variant, load_html, load_variant_html
__all__ = ["get_ui_variant", "load_html", "load_variant_html"]
@@ -0,0 +1,176 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory Note Preview - MCP UI</title>
<style>
:root {
color-scheme: light;
--bg: #fdf2f2;
--panel: #ffffff;
--ink: #1a1a1d;
--muted: #6b6770;
--border: #ecd7dd;
--accent: #b91c1c;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
background: radial-gradient(circle at top, #fff7f7 0%, #f7ecec 60%, #f0e3e4 100%);
color: var(--ink);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header h1 {
margin: 0;
font-size: 19px;
}
.badge {
font-size: 11px;
border-radius: 999px;
padding: 4px 10px;
border: 1px solid var(--border);
background: #fee2e2;
color: var(--accent);
}
.subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 12px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.1);
}
pre {
margin: 0;
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: #2a2a2a;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 18px;
}
</style>
</head>
<body>
<div class="header">
<h1 id="title">Note Preview</h1>
<div class="badge">mcp-ui style</div>
</div>
<div class="subtitle" id="subtitle">Waiting for note content...</div>
<div class="panel">
<div id="empty" class="empty" style="display: none;">No content available.</div>
<pre id="content"></pre>
</div>
<script>
(function () {
const title = document.getElementById("title");
const subtitle = document.getElementById("subtitle");
const contentEl = document.getElementById("content");
const emptyEl = document.getElementById("empty");
let hasData = false;
function stripFrontmatter(text) {
if (!text.startsWith("---")) return text;
const end = text.indexOf("---", 3);
if (end === -1) return text;
return text.slice(end + 3).trim();
}
function parseTitle(text) {
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("# ")) return line.replace("# ", "").trim();
}
return null;
}
function extractText(toolOutput) {
if (!toolOutput) return "";
if (typeof toolOutput === "string") return toolOutput;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) return textBlock.text;
}
if (toolOutput.structuredContent) {
if (typeof toolOutput.structuredContent === "string") {
return toolOutput.structuredContent;
}
}
return "";
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const identifier = renderData.toolInput && renderData.toolInput.identifier;
const rawText = extractText(renderData.toolOutput);
if (!rawText) {
emptyEl.style.display = "block";
contentEl.textContent = "";
return;
}
emptyEl.style.display = "none";
const text = stripFrontmatter(rawText);
const previewTitle = parseTitle(text) || identifier || "Note Preview";
title.textContent = previewTitle;
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
contentEl.textContent = text.slice(0, 1400).trim();
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory Note Preview</title>
<style>
:root {
color-scheme: light;
--bg: #f5f1ea;
--panel: #ffffff;
--ink: #1d1d1f;
--muted: #6d6d6d;
--accent: #0f766e;
--border: #e1d7cc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
background: linear-gradient(140deg, #f7f3ed, #efe8dd);
color: var(--ink);
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.subtitle {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
margin-bottom: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
pre {
margin: 0;
font-family: "IBM Plex Mono", "SFMono-Regular", ui-monospace, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
color: #2a2a2a;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 18px;
}
</style>
</head>
<body>
<h1 id="title">Note Preview</h1>
<div class="subtitle" id="subtitle">Waiting for note content...</div>
<div class="panel">
<div id="empty" class="empty" style="display: none;">No content available.</div>
<pre id="content"></pre>
</div>
<script>
(function () {
const title = document.getElementById("title");
const subtitle = document.getElementById("subtitle");
const contentEl = document.getElementById("content");
const emptyEl = document.getElementById("empty");
let hasData = false;
function stripFrontmatter(text) {
if (!text.startsWith("---")) return text;
const end = text.indexOf("---", 3);
if (end === -1) return text;
return text.slice(end + 3).trim();
}
function parseTitle(text) {
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("# ")) return line.replace("# ", "").trim();
}
return null;
}
function extractText(toolOutput) {
if (!toolOutput) return "";
if (typeof toolOutput === "string") return toolOutput;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) return textBlock.text;
}
if (toolOutput.structuredContent) {
if (typeof toolOutput.structuredContent === "string") {
return toolOutput.structuredContent;
}
}
return "";
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const identifier = renderData.toolInput && renderData.toolInput.identifier;
const rawText = extractText(renderData.toolOutput);
if (!rawText) {
emptyEl.style.display = "block";
contentEl.textContent = "";
return;
}
emptyEl.style.display = "none";
const text = stripFrontmatter(rawText);
const previewTitle = parseTitle(text) || identifier || "Note Preview";
title.textContent = previewTitle;
subtitle.textContent = identifier ? `Identifier: ${identifier}` : "Note content";
contentEl.textContent = text.slice(0, 1400).trim();
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
@@ -0,0 +1,254 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory MCP UI Search</title>
<style>
:root {
color-scheme: light;
--bg: #f6f0f2;
--panel: #ffffff;
--ink: #1a1a1d;
--muted: #6b6770;
--accent: #b91c1c;
--accent-soft: #fee2e2;
--border: #ead7dc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "IBM Plex Sans", "Space Grotesk", system-ui, sans-serif;
background: radial-gradient(circle at top, #fdf7f8 0%, #f7eef1 60%, #f2e7ea 100%);
color: var(--ink);
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.badge {
font-size: 11px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--accent-soft);
color: var(--accent);
padding: 4px 10px;
}
#subtitle {
font-size: 12px;
color: var(--muted);
margin-bottom: 14px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
tr:hover {
background: #fff5f5;
}
.title {
font-weight: 600;
}
.path {
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.empty {
text-align: center;
padding: 18px;
color: var(--muted);
}
.footer {
margin-top: 12px;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<div class="top">
<h1>Search Results</h1>
<div class="badge">mcp-ui style</div>
</div>
<div id="subtitle">Waiting for results...</div>
<div class="panel">
<div id="table"></div>
<div id="empty" class="empty" style="display: none;">No results to show.</div>
</div>
<div id="footer" class="footer"></div>
<script>
(function () {
const subtitle = document.getElementById("subtitle");
const tableContainer = document.getElementById("table");
const emptyState = document.getElementById("empty");
const footer = document.getElementById("footer");
let hasData = false;
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
function extractStructured(toolOutput) {
if (!toolOutput) return null;
const structured =
toolOutput.structuredContent ||
toolOutput.structured_content ||
toolOutput.structured ||
null;
if (structured) return structured;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) {
const parsed = safeJsonParse(textBlock.text);
return parsed || { text: textBlock.text };
}
}
if (typeof toolOutput === "string") {
return safeJsonParse(toolOutput) || { text: toolOutput };
}
return toolOutput;
}
function getResults(structured) {
if (!structured) return [];
if (Array.isArray(structured.results)) return structured.results;
if (structured.result && Array.isArray(structured.result.results)) {
return structured.result.results;
}
return [];
}
function renderTable(results) {
if (!results.length) {
tableContainer.innerHTML = "";
emptyState.style.display = "block";
return;
}
emptyState.style.display = "none";
const rows = results
.map((result) => {
return `
<tr>
<td>
<div class=\"title\">${result.title || "Untitled"}</div>
<div class=\"path\">${result.permalink || result.file_path || ""}</div>
</td>
<td>${result.type || ""}</td>
<td>${Number(result.score || 0).toFixed(2)}</td>
</tr>
`;
})
.join("");
tableContainer.innerHTML = `
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Score</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const query = renderData.toolInput && renderData.toolInput.query;
const structured = extractStructured(renderData.toolOutput);
const results = getResults(structured);
subtitle.textContent = query ? `Query: ${query}` : "Search results";
footer.textContent = `Results: ${results.length}`;
renderTable(results);
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,280 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Basic Memory Search Results</title>
<style>
:root {
color-scheme: light;
--bg: #f7f4ef;
--panel: #ffffff;
--ink: #1f1f1f;
--muted: #5b5b5b;
--accent: #0f766e;
--accent-2: #0a4f4b;
--border: #e3dbd2;
--row: #fbf9f5;
--row-hover: #f2ece3;
--tag: #f3efe7;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
background: radial-gradient(circle at top, #faf6ef 0%, #f2ece2 45%, #efe7da 100%);
color: var(--ink);
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.3px;
}
#subtitle {
font-size: 13px;
color: var(--muted);
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
tr:nth-child(even) {
background: var(--row);
}
tr:hover {
background: var(--row-hover);
}
.title {
font-weight: 600;
color: var(--accent-2);
}
.meta {
font-size: 12px;
color: var(--muted);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
font-size: 11px;
padding: 2px 8px;
background: var(--tag);
border-radius: 999px;
border: 1px solid var(--border);
}
.empty {
padding: 24px;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.footer {
margin-top: 12px;
font-size: 12px;
color: var(--muted);
}
</style>
</head>
<body>
<header>
<h1>Search Results</h1>
<div id="subtitle">Waiting for results...</div>
</header>
<div class="panel">
<div id="table"></div>
<div id="empty" class="empty" style="display: none;">No results to show.</div>
</div>
<div class="footer" id="footer"></div>
<script>
(function () {
const subtitle = document.getElementById("subtitle");
const tableContainer = document.getElementById("table");
const emptyState = document.getElementById("empty");
const footer = document.getElementById("footer");
let hasData = false;
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
function extractStructured(toolOutput) {
if (!toolOutput) return null;
const structured =
toolOutput.structuredContent ||
toolOutput.structured_content ||
toolOutput.structured ||
null;
if (structured) return structured;
const content = toolOutput.content;
if (Array.isArray(content)) {
const textBlock = content.find(
(block) => block && block.type === "text" && typeof block.text === "string"
);
if (textBlock) {
const parsed = safeJsonParse(textBlock.text);
return parsed || { text: textBlock.text };
}
}
if (typeof toolOutput === "string") {
return safeJsonParse(toolOutput) || { text: toolOutput };
}
return toolOutput;
}
function getResults(structured) {
if (!structured) return [];
if (Array.isArray(structured.results)) return structured.results;
if (structured.result && Array.isArray(structured.result.results)) {
return structured.result.results;
}
return [];
}
function renderTable(results) {
if (!results.length) {
tableContainer.innerHTML = "";
emptyState.style.display = "block";
return;
}
emptyState.style.display = "none";
const rows = results
.map((result) => {
const tags =
(result.metadata && result.metadata.tags) ||
result.tags ||
result.metadata?.tag ||
[];
const tagsMarkup = Array.isArray(tags)
? tags.map((tag) => `<span class=\"tag\">${tag}</span>`).join("")
: "";
return `
<tr>
<td>
<div class=\"title\">${result.title || "Untitled"}</div>
<div class=\"meta\">${result.permalink || result.file_path || ""}</div>
</td>
<td>${result.type || ""}</td>
<td>${Number(result.score || 0).toFixed(2)}</td>
<td>
<div class=\"tags\">${tagsMarkup}</div>
</td>
</tr>
`;
})
.join("");
tableContainer.innerHTML = `
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Score</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
function updateFromRenderData(renderData) {
if (!renderData) return;
const query = renderData.toolInput && renderData.toolInput.query;
const structured = extractStructured(renderData.toolOutput);
const results = getResults(structured);
subtitle.textContent = query ? `Query: ${query}` : "Search results";
footer.textContent = `Results: ${results.length}`;
renderTable(results);
}
function handleMessage(event) {
const message = event.data || {};
if (message.type === "ui-lifecycle-iframe-render-data") {
hasData = true;
updateFromRenderData(message.payload && message.payload.renderData);
}
}
window.addEventListener("message", handleMessage);
window.parent?.postMessage({ type: "ui-lifecycle-iframe-ready" }, "*");
setTimeout(() => {
if (!hasData) {
window.parent?.postMessage({ type: "ui-request-render-data" }, "*");
}
}, 200);
})();
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
"""Helpers for embedded MCP-UI resources (Python SDK)."""
from __future__ import annotations
import importlib
from typing import Any
from basic_memory.mcp.ui import load_html
try: # Optional dependency for MCP-UI embedded resources
mcp_ui_server = importlib.import_module("mcp_ui_server")
UIMetadataKey = mcp_ui_server.UIMetadataKey
create_ui_resource = mcp_ui_server.create_ui_resource
except ImportError: # pragma: no cover - handled by callers
UIMetadataKey = None
create_ui_resource = None
class MissingMCPUIServerError(RuntimeError):
"""Raised when the MCP-UI server SDK is not available."""
def _ensure_sdk() -> tuple[Any, Any]:
if create_ui_resource is None or UIMetadataKey is None:
raise MissingMCPUIServerError(
"mcp-ui-server is not installed. "
"Install it with `uv pip install -e /Users/phernandez/dev/mcp-ui/sdks/python/server` "
"or `pip install mcp-ui-server`."
)
return create_ui_resource, UIMetadataKey
def build_embedded_ui_resource(
*,
uri: str,
html_filename: str,
render_data: dict[str, Any],
preferred_frame_size: list[str],
metadata: dict[str, Any] | None = None,
):
"""Create an embedded UI resource using the MCP-UI Python SDK."""
create_resource, metadata_keys = _ensure_sdk()
html = load_html(html_filename)
return create_resource(
{
"uri": uri,
"content": {"type": "rawHtml", "htmlString": html},
"encoding": "text",
"uiMetadata": {
metadata_keys.PREFERRED_FRAME_SIZE: preferred_frame_size,
metadata_keys.INITIAL_RENDER_DATA: render_data,
},
"metadata": metadata or {},
}
)
+27
View File
@@ -0,0 +1,27 @@
"""Helpers for serving MCP UI HTML resources."""
from __future__ import annotations
import os
from pathlib import Path
DEFAULT_VARIANT = "vanilla"
SUPPORTED_VARIANTS = {"vanilla", "tool-ui", "mcp-ui"}
def get_ui_variant() -> str:
"""Return the active UI variant from environment settings."""
value = os.getenv("BASIC_MEMORY_MCP_UI_VARIANT", DEFAULT_VARIANT).strip().lower()
return value if value in SUPPORTED_VARIANTS else DEFAULT_VARIANT
def load_html(filename: str) -> str:
"""Load a UI HTML template from disk."""
path = Path(__file__).parent / "html" / filename
return path.read_text(encoding="utf-8")
def load_variant_html(base_name: str) -> str:
"""Load a UI template for the current variant."""
variant = get_ui_variant()
return load_html(f"{base_name}-{variant}.html")