mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
0b335476d6
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
532 lines
21 KiB
Python
532 lines
21 KiB
Python
"""Schema tools for Basic Memory MCP server.
|
|
|
|
Provides tools for schema validation, inference, and drift detection through the MCP protocol.
|
|
These tools call the schema API endpoints via the typed SchemaClient.
|
|
"""
|
|
|
|
from typing import Literal, Optional
|
|
|
|
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.schemas.schema import DriftReport, InferenceReport, ValidationReport
|
|
|
|
|
|
def _format_validation_report(report: ValidationReport) -> str:
|
|
"""Render a ValidationReport as readable markdown.
|
|
|
|
Produces output the LLM can display directly instead of trying to
|
|
interpret raw JSON, which leads to "undefined — invalid" rendering.
|
|
"""
|
|
lines: list[str] = []
|
|
|
|
# --- Header ---
|
|
type_label = report.note_type or "all"
|
|
lines.append(f"# Schema Validation: {type_label}")
|
|
lines.append("")
|
|
lines.append(
|
|
f"Notes: {report.total_notes} | Valid: {report.valid_count} "
|
|
f"| Warnings: {report.warning_count} | Errors: {report.error_count}"
|
|
)
|
|
lines.append("")
|
|
|
|
# --- Per-note results ---
|
|
for r in report.results:
|
|
status = "valid" if r.passed else "INVALID"
|
|
lines.append(f"- **{r.note_identifier}** — {status}")
|
|
for w in r.warnings:
|
|
lines.append(f" - warning: {w}")
|
|
for e in r.errors:
|
|
lines.append(f" - error: {e}")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_inference_report(report: InferenceReport) -> str:
|
|
"""Render an InferenceReport as readable markdown.
|
|
|
|
Without this formatter the LLM receives raw JSON and renders
|
|
field names as "undefined".
|
|
"""
|
|
lines: list[str] = []
|
|
|
|
# --- Header ---
|
|
lines.append(f"# Schema Inference: {report.note_type}")
|
|
lines.append("")
|
|
lines.append(f"Notes analyzed: {report.notes_analyzed}")
|
|
lines.append("")
|
|
|
|
# --- Suggested schema YAML ---
|
|
if report.suggested_schema:
|
|
lines.append("## Suggested Schema")
|
|
lines.append("")
|
|
lines.append("```yaml")
|
|
lines.append("---")
|
|
lines.append(f"title: {report.note_type.title()}")
|
|
lines.append("type: schema")
|
|
lines.append(f"entity: {report.note_type}")
|
|
lines.append("version: 1")
|
|
lines.append("schema:")
|
|
for field_name, field_def in report.suggested_schema.items():
|
|
lines.append(f" {field_name}: {field_def}")
|
|
lines.append("---")
|
|
lines.append("```")
|
|
lines.append("")
|
|
|
|
# --- Field frequency table ---
|
|
if report.field_frequencies:
|
|
lines.append("## Field Frequencies")
|
|
lines.append("")
|
|
for f in report.field_frequencies:
|
|
pct = f"{f.percentage:.0%}"
|
|
req_marker = "required" if f.name in report.suggested_required else "optional"
|
|
samples = ", ".join(f.sample_values[:3]) if f.sample_values else ""
|
|
sample_str = f" (e.g. {samples})" if samples else ""
|
|
lines.append(
|
|
f"- **{f.name}** ({f.source}) — {pct} ({f.count}/{f.total}) "
|
|
f"[{req_marker}]{sample_str}"
|
|
)
|
|
lines.append("")
|
|
|
|
# --- Excluded fields ---
|
|
if report.excluded:
|
|
lines.append("## Excluded (below threshold)")
|
|
lines.append("")
|
|
for name in report.excluded:
|
|
lines.append(f"- {name}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_drift_report(report: DriftReport) -> str:
|
|
"""Render a DriftReport as readable markdown.
|
|
|
|
Without this formatter the LLM receives raw JSON and renders
|
|
field names as "undefined".
|
|
"""
|
|
lines: list[str] = []
|
|
|
|
# --- Header ---
|
|
lines.append(f"# Schema Drift: {report.note_type}")
|
|
lines.append("")
|
|
|
|
has_drift = report.new_fields or report.dropped_fields or report.cardinality_changes
|
|
|
|
if not has_drift:
|
|
lines.append("No drift detected — schema matches actual usage.")
|
|
return "\n".join(lines)
|
|
|
|
# --- New fields ---
|
|
if report.new_fields:
|
|
lines.append("## New Fields (in notes but not in schema)")
|
|
lines.append("")
|
|
for f in report.new_fields:
|
|
pct = f"{f.percentage:.0%}"
|
|
lines.append(f"- **{f.name}** ({f.source}) — {pct} ({f.count}/{f.total})")
|
|
lines.append("")
|
|
|
|
# --- Dropped fields ---
|
|
if report.dropped_fields:
|
|
lines.append("## Dropped Fields (in schema but rare in notes)")
|
|
lines.append("")
|
|
for f in report.dropped_fields:
|
|
pct = f"{f.percentage:.0%}"
|
|
lines.append(f"- **{f.name}** ({f.source}) — {pct} ({f.count}/{f.total})")
|
|
lines.append("")
|
|
|
|
# --- Cardinality changes ---
|
|
if report.cardinality_changes:
|
|
lines.append("## Cardinality Changes")
|
|
lines.append("")
|
|
for change in report.cardinality_changes:
|
|
lines.append(f"- {change}")
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _no_notes_guidance(note_type: str, tool_name: str) -> str:
|
|
"""Build guidance string when no notes of a given type exist.
|
|
|
|
Used by schema_validate when the project has zero notes of the
|
|
requested type — a different situation from "notes exist but no schema".
|
|
"""
|
|
return (
|
|
f"# No Notes Found of Type '{note_type}'\n\n"
|
|
f"`{tool_name}` found no notes with type '{note_type}' in the project.\n\n"
|
|
f"## Next Steps\n\n"
|
|
f"1. **Create notes of this type** — use `write_note` with "
|
|
f'`note_type="{note_type}"` to create notes\n'
|
|
f"2. **Check existing types** — use `search_notes` with `note_types` "
|
|
f"filter to see what types exist\n"
|
|
f"3. **Browse content** — use `list_directory` or `recent_activity` to "
|
|
f"see what's in the project\n"
|
|
)
|
|
|
|
|
|
def _no_schema_guidance(note_type: str, tool_name: str) -> str:
|
|
"""Build guidance string when no schema exists for a note type.
|
|
|
|
Used by schema_validate and schema_diff to explain what happened
|
|
and how to create a schema.
|
|
"""
|
|
return (
|
|
f"# No Schema Found for '{note_type}'\n\n"
|
|
f"`{tool_name}` requires a schema note to exist for type '{note_type}'.\n\n"
|
|
f"## How to Create a Schema\n\n"
|
|
f'1. **Infer from existing notes** — run `schema_infer("{note_type}")` to '
|
|
f"analyze your notes and get a suggested schema\n"
|
|
f"2. **Create a schema note** — write a markdown file with this frontmatter:\n\n"
|
|
f"```yaml\n"
|
|
f"---\n"
|
|
f"title: {note_type.title()}\n"
|
|
f"type: schema\n"
|
|
f"entity: {note_type}\n"
|
|
f"version: 1\n"
|
|
f"schema:\n"
|
|
f" name: string, full name\n"
|
|
f" role?: string, job title\n"
|
|
f"settings:\n"
|
|
f" validation: warn\n"
|
|
f"---\n"
|
|
f"```\n\n"
|
|
f"Schema fields use Picoschema notation:\n"
|
|
f"- `field_name: type, description` — required field\n"
|
|
f"- `field_name?: type, description` — optional field\n"
|
|
f"- Supported types: `string`, `number`, `boolean`, `string[]`\n\n"
|
|
f"3. **Sync** — run `basic-memory sync` or wait for auto-sync to pick up "
|
|
f"the new schema note\n"
|
|
f'4. **Re-run** — call `{tool_name}("{note_type}")` again\n'
|
|
)
|
|
|
|
|
|
@mcp.tool(
|
|
description="Validate notes against their Picoschema definitions.",
|
|
annotations={"readOnlyHint": True, "openWorldHint": False},
|
|
)
|
|
async def schema_validate(
|
|
note_type: Optional[str] = None,
|
|
identifier: Optional[str] = None,
|
|
project: Optional[str] = None,
|
|
project_id: Optional[str] = None,
|
|
output_format: Literal["text", "json"] = "text",
|
|
context: Context | None = None,
|
|
) -> ValidationReport | str | dict:
|
|
"""Validate notes against their resolved schema.
|
|
|
|
Validates a specific note (by identifier) or all notes of a given type.
|
|
Returns warnings/errors based on the schema's validation mode.
|
|
|
|
Schemas are resolved in priority order:
|
|
1. Inline schema (dict in frontmatter)
|
|
2. Explicit reference (string in frontmatter)
|
|
3. Implicit by type (type field matches schema note's entity field)
|
|
4. No schema (no validation)
|
|
|
|
Project Resolution:
|
|
Server resolves projects in this order: Single Project Mode -> project parameter -> default.
|
|
If project unknown, use list_memory_projects() first.
|
|
|
|
Args:
|
|
note_type: Note type to batch-validate (e.g., "person", "meeting").
|
|
If provided, validates all notes of this type.
|
|
identifier: Specific note to validate (permalink, title, or path).
|
|
If provided, validates only this note.
|
|
project: Project name. Optional -- server will resolve.
|
|
project_id: Project external_id (UUID). Prefer this over `project` when known —
|
|
it routes to the exact project regardless of name collisions across cloud
|
|
workspaces. Takes precedence over `project`. Get from list_memory_projects().
|
|
context: Optional FastMCP context for performance caching.
|
|
|
|
Returns:
|
|
ValidationReport with per-note results, or error guidance string
|
|
|
|
Examples:
|
|
# Validate all person notes
|
|
schema_validate(note_type="person")
|
|
|
|
# Validate a specific note
|
|
schema_validate(identifier="people/paul-graham")
|
|
|
|
# Validate in a specific project
|
|
schema_validate(note_type="person", project="my-research")
|
|
"""
|
|
async with get_project_client(project, context=context, project_id=project_id) as (
|
|
client,
|
|
active_project,
|
|
):
|
|
logger.info(
|
|
f"MCP tool call tool=schema_validate project={active_project.name} "
|
|
f"note_type={note_type} identifier={identifier}"
|
|
)
|
|
|
|
try:
|
|
from basic_memory.mcp.clients.schema import SchemaClient
|
|
|
|
schema_client = SchemaClient(client, active_project.external_id)
|
|
result = await schema_client.validate(
|
|
note_type=note_type,
|
|
identifier=identifier,
|
|
)
|
|
|
|
logger.info(
|
|
f"MCP tool response: tool=schema_validate project={active_project.name} "
|
|
f"total={result.total_notes} valid={result.valid_count} "
|
|
f"warnings={result.warning_count} errors={result.error_count}"
|
|
)
|
|
|
|
# --- No notes guard ---
|
|
# Trigger: no entities of this type exist in the project
|
|
# Why: can't validate notes that don't exist yet
|
|
# Outcome: return guidance on creating notes of this type
|
|
effective_type = note_type or result.note_type or "unknown"
|
|
if result.total_entities == 0:
|
|
if output_format == "json":
|
|
return {"error": f"No notes found of type '{effective_type}'"}
|
|
return _no_notes_guidance(effective_type, "schema_validate")
|
|
|
|
# --- No schema guard ---
|
|
# Trigger: entities exist but none were validated (no schema found)
|
|
# Why: notes of this type exist but no schema was found, so none were validated
|
|
# Outcome: return guidance on how to create a schema
|
|
if result.total_notes == 0:
|
|
if output_format == "json":
|
|
return {"error": f"No schema found for type '{effective_type}'"}
|
|
return _no_schema_guidance(effective_type, "schema_validate")
|
|
|
|
if output_format == "json":
|
|
return result.model_dump(mode="json", exclude_none=True)
|
|
|
|
return _format_validation_report(result)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Schema validation failed: {e}, project: {active_project.name}")
|
|
if output_format == "json":
|
|
return {"error": f"Schema validation failed: {e}"}
|
|
return (
|
|
f"# Schema Validation Failed\n\n"
|
|
f"Error validating schemas: {e}\n\n"
|
|
f"## Troubleshooting\n"
|
|
f"1. Ensure schema notes exist (type: schema) for the target note type\n"
|
|
f"2. Check that notes have the correct type in frontmatter\n"
|
|
f"3. Verify the project has been synced: `basic-memory status`\n"
|
|
)
|
|
|
|
|
|
@mcp.tool(
|
|
description="Analyze existing notes and suggest a Picoschema definition.",
|
|
annotations={"readOnlyHint": True, "openWorldHint": False},
|
|
)
|
|
async def schema_infer(
|
|
note_type: str,
|
|
threshold: float = 0.25,
|
|
project: Optional[str] = None,
|
|
project_id: Optional[str] = None,
|
|
output_format: Literal["text", "json"] = "text",
|
|
context: Context | None = None,
|
|
) -> str | dict:
|
|
"""Analyze existing notes and suggest a schema definition.
|
|
|
|
Examines observation categories and relation types across all notes
|
|
of the given type. Returns frequency analysis and suggested Picoschema
|
|
YAML that can be saved as a schema note.
|
|
|
|
Frequency thresholds:
|
|
- 95%+ present -> required field
|
|
- threshold+ present -> optional field
|
|
- Below threshold -> excluded (but noted)
|
|
|
|
Project Resolution:
|
|
Server resolves projects in this order: Single Project Mode -> project parameter -> default.
|
|
If project unknown, use list_memory_projects() first.
|
|
|
|
Args:
|
|
note_type: The note type to analyze (e.g., "person", "meeting").
|
|
threshold: Minimum frequency (0-1) for a field to be suggested as optional.
|
|
Default 0.25 (25%). Fields above 95% become required.
|
|
project: Project name. Optional -- server will resolve.
|
|
project_id: Project external_id (UUID). Prefer this over `project` when known —
|
|
it routes to the exact project regardless of name collisions across cloud
|
|
workspaces. Takes precedence over `project`. Get from list_memory_projects().
|
|
context: Optional FastMCP context for performance caching.
|
|
|
|
Returns:
|
|
InferenceReport with frequency data and suggested schema, or error string
|
|
|
|
Examples:
|
|
# Infer schema for person notes
|
|
schema_infer("person")
|
|
|
|
# Use a higher threshold (50% minimum)
|
|
schema_infer("meeting", threshold=0.5)
|
|
|
|
# Infer in a specific project
|
|
schema_infer("person", project="my-research")
|
|
"""
|
|
async with get_project_client(project, context=context, project_id=project_id) as (
|
|
client,
|
|
active_project,
|
|
):
|
|
logger.info(
|
|
f"MCP tool call tool=schema_infer project={active_project.name} "
|
|
f"note_type={note_type} threshold={threshold}"
|
|
)
|
|
|
|
try:
|
|
from basic_memory.mcp.clients.schema import SchemaClient
|
|
|
|
schema_client = SchemaClient(client, active_project.external_id)
|
|
result = await schema_client.infer(note_type, threshold=threshold)
|
|
|
|
logger.info(
|
|
f"MCP tool response: tool=schema_infer project={active_project.name} "
|
|
f"note_type={note_type} notes_analyzed={result.notes_analyzed} "
|
|
f"required={len(result.suggested_required)} "
|
|
f"optional={len(result.suggested_optional)}"
|
|
)
|
|
|
|
# --- Empty schema guard ---
|
|
# Trigger: notes were analyzed but no fields met the threshold
|
|
# Why: returning hundreds of excluded fields overwhelms the LLM context
|
|
# Outcome: return actionable guidance instead of a massive empty result
|
|
if result.notes_analyzed > 0 and not result.suggested_schema:
|
|
if output_format == "json":
|
|
return {
|
|
"error": (
|
|
f"No schema pattern found for '{note_type}' "
|
|
f"(threshold: {threshold:.0%})"
|
|
)
|
|
}
|
|
return (
|
|
f"# No Schema Pattern Found\n\n"
|
|
f"Analyzed {result.notes_analyzed} notes of type '{note_type}', "
|
|
f"but no observation or relation appeared in enough notes to suggest "
|
|
f"a schema (threshold: {threshold:.0%}).\n\n"
|
|
f"This usually means '{note_type}' is too broad — the notes don't "
|
|
f"share a consistent structure.\n\n"
|
|
f"## Suggestions\n"
|
|
f"1. **Use a more specific type** — try `search_notes` with "
|
|
f"`note_types` filter to see what types exist\n"
|
|
f"2. **Lower the threshold** — "
|
|
f'`schema_infer("{note_type}", threshold=0.1)` to include '
|
|
f"rarer fields\n"
|
|
f"3. **Create typed notes** — use `write_note` with a specific "
|
|
f'`note_type` (e.g., "person", "meeting") to build consistent '
|
|
f"structure\n"
|
|
)
|
|
|
|
if output_format == "json":
|
|
return result.model_dump(mode="json", exclude_none=True)
|
|
|
|
return _format_inference_report(result)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Schema inference failed: {e}, project: {active_project.name}")
|
|
if output_format == "json":
|
|
return {"error": f"Schema inference failed: {e}"}
|
|
return (
|
|
f"# Schema Inference Failed\n\n"
|
|
f"Error inferring schema for type '{note_type}': {e}\n\n"
|
|
f"## Troubleshooting\n"
|
|
f"1. Ensure notes of type '{note_type}' exist in the project\n"
|
|
f'2. Try searching: `search_notes("{note_type}", note_types=["{note_type}"])`\n'
|
|
f"3. Verify the project has been synced: `basic-memory status`\n"
|
|
)
|
|
|
|
|
|
@mcp.tool(
|
|
description="Detect drift between a schema definition and actual note usage.",
|
|
annotations={"readOnlyHint": True, "openWorldHint": False},
|
|
)
|
|
async def schema_diff(
|
|
note_type: str,
|
|
project: Optional[str] = None,
|
|
project_id: Optional[str] = None,
|
|
output_format: Literal["text", "json"] = "text",
|
|
context: Context | None = None,
|
|
) -> str | dict:
|
|
"""Detect drift between a schema definition and actual note usage.
|
|
|
|
Compares the existing schema for a note type against how notes of
|
|
that type are actually structured. Identifies new fields that have
|
|
appeared, declared fields that are rarely used, and cardinality changes
|
|
(single-value vs array).
|
|
|
|
Useful for evolving schemas as your knowledge base grows -- run
|
|
periodically to see if your schema still matches reality.
|
|
|
|
Project Resolution:
|
|
Server resolves projects in this order: Single Project Mode -> project parameter -> default.
|
|
If project unknown, use list_memory_projects() first.
|
|
|
|
Args:
|
|
note_type: The note type to check for drift (e.g., "person").
|
|
project: Project name. Optional -- server will resolve.
|
|
project_id: Project external_id (UUID). Prefer this over `project` when known —
|
|
it routes to the exact project regardless of name collisions across cloud
|
|
workspaces. Takes precedence over `project`. Get from list_memory_projects().
|
|
context: Optional FastMCP context for performance caching.
|
|
|
|
Returns:
|
|
DriftReport with new fields, dropped fields, and cardinality changes,
|
|
or error guidance string
|
|
|
|
Examples:
|
|
# Check drift for person schema
|
|
schema_diff("person")
|
|
|
|
# Check drift in a specific project
|
|
schema_diff("person", project="my-research")
|
|
"""
|
|
async with get_project_client(project, context=context, project_id=project_id) as (
|
|
client,
|
|
active_project,
|
|
):
|
|
logger.info(
|
|
f"MCP tool call tool=schema_diff project={active_project.name} note_type={note_type}"
|
|
)
|
|
|
|
try:
|
|
from basic_memory.mcp.clients.schema import SchemaClient
|
|
|
|
schema_client = SchemaClient(client, active_project.external_id)
|
|
result = await schema_client.diff(note_type)
|
|
|
|
logger.info(
|
|
f"MCP tool response: tool=schema_diff project={active_project.name} "
|
|
f"note_type={note_type} schema_found={result.schema_found} "
|
|
f"new_fields={len(result.new_fields)} "
|
|
f"dropped_fields={len(result.dropped_fields)} "
|
|
f"cardinality_changes={len(result.cardinality_changes)}"
|
|
)
|
|
|
|
# --- No schema guard ---
|
|
# Trigger: API reports no schema was found for this type
|
|
# Why: diff requires a schema to compare against
|
|
# Outcome: return guidance on how to create a schema
|
|
if not result.schema_found:
|
|
if output_format == "json":
|
|
return {"error": f"No schema found for type '{note_type}'"}
|
|
return _no_schema_guidance(note_type, "schema_diff")
|
|
|
|
if output_format == "json":
|
|
return result.model_dump(mode="json", exclude_none=True)
|
|
|
|
return _format_drift_report(result)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Schema diff failed: {e}, project: {active_project.name}")
|
|
if output_format == "json":
|
|
return {"error": f"Schema diff failed: {e}"}
|
|
return (
|
|
f"# Schema Diff Failed\n\n"
|
|
f"Error detecting drift for type '{note_type}': {e}\n\n"
|
|
f"## Troubleshooting\n"
|
|
f"1. Ensure a schema note exists for type '{note_type}'\n"
|
|
f"2. Ensure notes of type '{note_type}' exist in the project\n"
|
|
f"3. Verify the project has been synced: `basic-memory status`\n"
|
|
)
|