"""Rich viewers for MCP classification results.
Purpose:
Render :class:`~oas2mcp.models.mcp.McpBundle`,
:class:`~oas2mcp.models.mcp.McpCandidate`, and compact agent-context
previews in a readable terminal format using Rich.
Design:
- Keep classification output separate from raw OpenAPI catalog viewers.
- Provide both a compact bundle summary and a detailed candidate view.
- Optimize for readability when names and URIs are long.
- Surface key agent-prep metadata such as auth, safety, prompts, notes,
request refs, response refs, and resolved security details.
Examples:
.. code-block:: python
from rich.console import Console
from oas2mcp.viewers.classification import render_mcp_bundle_summary
render_mcp_bundle_summary(bundle, console=Console())
"""
from __future__ import annotations
from collections import Counter
from rich.box import SIMPLE_HEAVY
from rich.console import Console, Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from oas2mcp.models.mcp import McpBundle, McpCandidate
from oas2mcp.models.normalized import ApiCatalog, ApiOperation
from oas2mcp.utils.lookup import get_security_scheme
from oas2mcp.utils.refs import (
collect_request_schema_refs,
collect_response_schema_refs,
)
[docs]
def render_mcp_bundle_summary(
bundle: McpBundle,
*,
console: Console | None = None,
max_candidates: int = 15,
) -> None:
"""Render a Rich summary for an MCP candidate bundle.
Args:
bundle: The MCP candidate bundle to display.
console: Optional Rich console instance.
max_candidates: Maximum number of candidates to display in the compact
summary table.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
from rich.console import Console
render_mcp_bundle_summary(
bundle,
console=Console(),
)
"""
resolved_console = console or Console()
resolved_console.print(build_bundle_overview_panel(bundle))
resolved_console.print(build_bundle_counts_table(bundle))
resolved_console.print(
build_candidate_summary_table(bundle, max_candidates=max_candidates)
)
[docs]
def render_mcp_candidate_detail(
candidate: McpCandidate,
*,
console: Console | None = None,
) -> None:
"""Render a detailed Rich view for one MCP candidate.
Args:
candidate: The MCP candidate to display.
console: Optional Rich console instance.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
from rich.console import Console
render_mcp_candidate_detail(
bundle.candidates[0],
console=Console(),
)
"""
resolved_console = console or Console()
resolved_console.print(build_candidate_overview_panel(candidate))
resolved_console.print(build_candidate_metadata_table(candidate))
resolved_console.print(build_candidate_prompts_table(candidate))
resolved_console.print(build_candidate_notes_panel(candidate))
[docs]
def render_operation_agent_context_preview(
*,
catalog: ApiCatalog,
operation: ApiOperation,
candidate: McpCandidate,
console: Console | None = None,
) -> None:
"""Render a compact preview of the agent-facing operation context.
Args:
catalog: The normalized API catalog.
operation: The normalized API operation.
candidate: The first-pass MCP candidate derived from the operation.
console: Optional Rich console instance.
Returns:
None.
Raises:
None.
Examples:
.. code-block:: python
render_operation_agent_context_preview(
catalog=catalog,
operation=operation,
candidate=candidate,
console=Console(),
)
"""
resolved_console = console or Console()
resolved_console.print(
build_agent_context_overview_panel(
catalog=catalog,
operation=operation,
candidate=candidate,
)
)
resolved_console.print(build_agent_context_refs_table(operation=operation))
resolved_console.print(
build_agent_context_security_table(
catalog=catalog,
candidate=candidate,
)
)
resolved_console.print(
build_agent_context_rationale_panel(
operation=operation,
candidate=candidate,
)
)
[docs]
def build_bundle_overview_panel(bundle: McpBundle) -> Panel:
"""Build a Rich overview panel for an MCP bundle.
Args:
bundle: The MCP bundle.
Returns:
A Rich panel.
Raises:
None.
Examples:
.. code-block:: python
panel = build_bundle_overview_panel(bundle)
"""
lines = Group(
Text(f"Catalog: {bundle.catalog_name}", style="bold"),
Text(f"Catalog Slug: {bundle.catalog_slug}", style="cyan"),
Text(f"Candidates: {len(bundle.candidates)}"),
)
return Panel(lines, title="MCP Classification Overview", border_style="green")
[docs]
def build_bundle_counts_table(bundle: McpBundle) -> Table:
"""Build a Rich table of candidate counts by kind and safety level.
Args:
bundle: The MCP bundle.
Returns:
A Rich table.
Raises:
None.
Examples:
.. code-block:: python
table = build_bundle_counts_table(bundle)
"""
kind_counts = Counter(candidate.kind for candidate in bundle.candidates)
safety_counts = Counter(candidate.safety_level for candidate in bundle.candidates)
table = Table(title="Candidate Counts", box=SIMPLE_HEAVY)
table.add_column("Bucket", style="cyan")
table.add_column("Name", style="magenta")
table.add_column("Count", justify="right")
if not bundle.candidates:
table.add_row("-", "-", "0")
return table
for kind, count in sorted(kind_counts.items()):
table.add_row("kind", kind, str(count))
for safety, count in sorted(safety_counts.items()):
table.add_row("safety", safety, str(count))
return table
[docs]
def build_candidate_summary_table(
bundle: McpBundle,
*,
max_candidates: int,
) -> Table:
"""Build a compact candidate summary table.
Args:
bundle: The MCP bundle.
max_candidates: Maximum number of candidates to display.
Returns:
A Rich table.
Raises:
None.
Examples:
.. code-block:: python
table = build_candidate_summary_table(bundle, max_candidates=10)
"""
table = Table(
title=f"Candidates (showing up to {max_candidates})",
box=SIMPLE_HEAVY,
)
table.add_column("Kind", style="magenta", width=9)
table.add_column("Slug", style="cyan", overflow="fold", max_width=20)
table.add_column("Title", overflow="fold", max_width=28)
table.add_column("Safety", width=12)
table.add_column("Confirm", width=8)
table.add_column("Auth", overflow="fold", max_width=20)
table.add_column("Prompts", justify="right", width=7)
table.add_column("Tool / Resource", overflow="fold", max_width=34)
if not bundle.candidates:
table.add_row("-", "-", "-", "-", "-", "-", "-", "-")
return table
for candidate in bundle.candidates[:max_candidates]:
auth_text = (
", ".join(candidate.auth_scheme_names)
if candidate.auth_scheme_names
else "-"
)
target = candidate.tool_name or candidate.resource_uri or "-"
table.add_row(
candidate.kind,
candidate.operation_slug,
candidate.title,
candidate.safety_level,
"yes" if candidate.requires_confirmation else "no",
auth_text,
str(len(candidate.prompt_templates)),
target,
)
return table
[docs]
def build_candidate_overview_panel(candidate: McpCandidate) -> Panel:
"""Build an overview panel for one MCP candidate.
Args:
candidate: The MCP candidate.
Returns:
A Rich panel.
Raises:
None.
Examples:
.. code-block:: python
panel = build_candidate_overview_panel(candidate)
"""
lines = Group(
Text(f"{candidate.kind.upper()} · {candidate.title}", style="bold cyan"),
Text(f"Operation Key: {candidate.operation_key}"),
Text(f"Operation Slug: {candidate.operation_slug}"),
Text(f"Safety: {candidate.safety_level}"),
Text(
f"Requires Confirmation: {'yes' if candidate.requires_confirmation else 'no'}"
),
Text(f"Description: {candidate.description}"),
)
return Panel(lines, title="MCP Candidate Detail", border_style="blue")
[docs]
def build_candidate_prompts_table(candidate: McpCandidate) -> Table:
"""Build a table of suggested prompt templates for a candidate.
Args:
candidate: The MCP candidate.
Returns:
A Rich table.
Raises:
None.
Examples:
.. code-block:: python
table = build_candidate_prompts_table(candidate)
"""
table = Table(title="Prompt Templates", box=SIMPLE_HEAVY)
table.add_column("Name", style="magenta", overflow="fold")
table.add_column("Title", overflow="fold")
table.add_column("Arguments", overflow="fold")
table.add_column("Description", overflow="fold")
if not candidate.prompt_templates:
table.add_row("-", "-", "-", "-")
return table
for prompt in candidate.prompt_templates:
table.add_row(
prompt.name,
prompt.title,
", ".join(prompt.arguments) if prompt.arguments else "-",
prompt.description,
)
return table
[docs]
def build_candidate_notes_panel(candidate: McpCandidate) -> Panel:
"""Build a notes panel for one MCP candidate.
Args:
candidate: The MCP candidate.
Returns:
A Rich panel.
Raises:
None.
Examples:
.. code-block:: python
panel = build_candidate_notes_panel(candidate)
"""
if not candidate.notes:
content = Text("No notes.", style="dim")
else:
content = Group(*[Text(f"- {note}") for note in candidate.notes])
return Panel(content, title="Notes", border_style="yellow")
[docs]
def build_agent_context_overview_panel(
*,
catalog: ApiCatalog,
operation: ApiOperation,
candidate: McpCandidate,
) -> Panel:
"""Build an overview panel for the agent-facing operation context.
Args:
catalog: The normalized API catalog.
operation: The normalized API operation.
candidate: The MCP candidate derived from the operation.
Returns:
A Rich panel.
Raises:
None.
Examples:
.. code-block:: python
panel = build_agent_context_overview_panel(
catalog=catalog,
operation=operation,
candidate=candidate,
)
"""
lines = Group(
Text(f"Catalog: {catalog.name}", style="bold"),
Text(f"Operation: {operation.key}", style="cyan"),
Text(f"operationId: {operation.operation_id or '-'}"),
Text(f"Candidate kind: {candidate.kind}"),
Text(f"Safety: {candidate.safety_level}"),
Text(f"Tool name: {candidate.tool_name or '-'}"),
Text(f"Resource URI: {candidate.resource_uri or '-'}"),
)
return Panel(lines, title="Agent Context Preview", border_style="green")
[docs]
def build_agent_context_refs_table(*, operation: ApiOperation) -> Table:
"""Build a request-vs-response schema ref table for an operation.
Args:
operation: The normalized API operation.
Returns:
A Rich table.
Raises:
None.
Examples:
.. code-block:: python
table = build_agent_context_refs_table(operation=operation)
"""
request_refs = collect_request_schema_refs(operation)
response_refs = collect_response_schema_refs(operation)
table = Table(title="Schema Reference Split", box=SIMPLE_HEAVY)
table.add_column("Bucket", style="cyan")
table.add_column("Refs", overflow="fold")
table.add_row("request", ", ".join(request_refs) if request_refs else "-")
table.add_row("response", ", ".join(response_refs) if response_refs else "-")
return table
[docs]
def build_agent_context_security_table(
*,
catalog: ApiCatalog,
candidate: McpCandidate,
) -> Table:
"""Build a table of resolved security scheme details for a candidate.
Args:
catalog: The normalized API catalog.
candidate: The MCP candidate.
Returns:
A Rich table.
Raises:
None.
Examples:
.. code-block:: python
table = build_agent_context_security_table(
catalog=catalog,
candidate=candidate,
)
"""
table = Table(title="Resolved Security Schemes", box=SIMPLE_HEAVY)
table.add_column("Scheme", style="magenta")
table.add_column("Type", width=12)
table.add_column("Location", width=12)
table.add_column("Parameter", overflow="fold")
table.add_column("Details", overflow="fold")
if not candidate.auth_scheme_names:
table.add_row("-", "-", "-", "-", "No security schemes referenced.")
return table
for scheme_name in candidate.auth_scheme_names:
scheme = get_security_scheme(catalog, name=scheme_name)
if scheme is None:
table.add_row(scheme_name, "-", "-", "-", "Scheme not found in catalog.")
continue
details: list[str] = []
if scheme.scheme:
details.append(f"scheme={scheme.scheme}")
if scheme.bearer_format:
details.append(f"bearer={scheme.bearer_format}")
if scheme.open_id_connect_url:
details.append(f"openid={scheme.open_id_connect_url}")
if scheme.flows:
details.append(f"flows={', '.join(scheme.flows.keys())}")
table.add_row(
scheme.name,
scheme.type,
scheme.location or "-",
scheme.parameter_name or "-",
", ".join(details) if details else "-",
)
return table
[docs]
def build_agent_context_rationale_panel(
*,
operation: ApiOperation,
candidate: McpCandidate,
) -> Panel:
"""Build a small deterministic rationale panel for one classification.
Args:
operation: The normalized API operation.
candidate: The MCP candidate derived from the operation.
Returns:
A Rich panel.
Raises:
None.
Examples:
.. code-block:: python
panel = build_agent_context_rationale_panel(
operation=operation,
candidate=candidate,
)
"""
reasons: list[str] = []
if operation.method in {"GET", "HEAD"}:
reasons.append(f"{operation.method} is treated as read-oriented by default.")
else:
reasons.append(f"{operation.method} is treated as action-oriented by default.")
if operation.is_mutating:
reasons.append("Mutating methods default to tool classification.")
reasons.append("Mutating operations default to confirmation enabled.")
if operation.method == "DELETE":
reasons.append("DELETE defaults to destructive safety level.")
if operation.request_body is not None:
reasons.append("Request body present, which usually favors tool execution.")
if candidate.auth_scheme_names:
reasons.append("Security requirements were attached to the candidate.")
if not reasons:
reasons.append("No special classification rules were triggered.")
return Panel(
Group(*[Text(f"- {reason}") for reason in reasons]),
title="Classification Rationale",
border_style="yellow",
)