Skip to content

API Reference

This section provides detailed API documentation for HoloViz MCP.

Core Modules

holoviz_mcp

Accessible imports for the holoviz_mcp package.

mcp = FastMCP(name='holoviz', instructions="\n [his MCP server provides comprehensive tools, resources and prompts for working with the HoloViz ecosystem following best practices.\n\n HoloViz provides a set of core Python packages that make visualization easier, more accurate, and more powerful:\n\n - [Panel](https://panel.holoviz.org): for making apps and dashboards for your plots from any supported plotting library.\n - [hvPlot](https://hvplot.holoviz.org): to quickly generate interactive plots from your data.\n - [HoloViews](https://holoviews.org): to help you make all of your data instantly visualizable.\n - [GeoViews](https://geoviews.org): to extend HoloViews for geographic data.\n - [Datashader](https://datashader.org): for rendering even the largest datasets.\n - [Lumen](https://lumen.holoviz.org): to build data-driven dashboards from a simple YAML specification that's well suited to modern AI tools like LLMs.\n - [Param](https://param.holoviz.org): to create declarative user-configurable objects.\n - [Colorcet](https://colorcet.holoviz.org): for perceptually uniform colormaps.\n\n The server is composed of multiple sub-servers that provide various functionalities:\n\n - Documentation: Search and access HoloViz documentation and reference guides\n - Panel: Tools, resources and prompts for using Panel and Panel Extension packages\n - hvPlot: Tools, resources and prompts for using hvPlot to develop quick, interactive plots in Python\n ") module-attribute

Server

HoloViz MCP Server.

This MCP server provides comprehensive tools, resources and prompts for working with the HoloViz ecosystem, including Panel and hvPlot following best practices.

The server is composed of multiple sub-servers that provide various functionalities:

  • Documentation: Search and access HoloViz documentation as context
  • hvPlot: Tools, resources and prompts for using hvPlot to develop quick, interactive plots in Python
  • Panel: Tools, resources and prompts for using Panel Material UI

logger = logging.getLogger(__name__) module-attribute

mcp = FastMCP(name='holoviz', instructions="\n [his MCP server provides comprehensive tools, resources and prompts for working with the HoloViz ecosystem following best practices.\n\n HoloViz provides a set of core Python packages that make visualization easier, more accurate, and more powerful:\n\n - [Panel](https://panel.holoviz.org): for making apps and dashboards for your plots from any supported plotting library.\n - [hvPlot](https://hvplot.holoviz.org): to quickly generate interactive plots from your data.\n - [HoloViews](https://holoviews.org): to help you make all of your data instantly visualizable.\n - [GeoViews](https://geoviews.org): to extend HoloViews for geographic data.\n - [Datashader](https://datashader.org): for rendering even the largest datasets.\n - [Lumen](https://lumen.holoviz.org): to build data-driven dashboards from a simple YAML specification that's well suited to modern AI tools like LLMs.\n - [Param](https://param.holoviz.org): to create declarative user-configurable objects.\n - [Colorcet](https://colorcet.holoviz.org): for perceptually uniform colormaps.\n\n The server is composed of multiple sub-servers that provide various functionalities:\n\n - Documentation: Search and access HoloViz documentation and reference guides\n - Panel: Tools, resources and prompts for using Panel and Panel Extension packages\n - hvPlot: Tools, resources and prompts for using hvPlot to develop quick, interactive plots in Python\n ") module-attribute

main()

Set up and run the composed MCP server.

Source code in src/holoviz_mcp/server.py
def main() -> None:
    """Set up and run the composed MCP server."""
    pid = f"Process ID: {os.getpid()}"
    print(pid)  # noqa: T201

    async def setup_and_run() -> None:
        await setup_composed_server()
        config = get_config()

        # Pass host and port for HTTP transport
        if config.server.transport == "http":
            await mcp.run_async(
                transport=config.server.transport,
                host=config.server.host,
                port=config.server.port,
            )
        else:
            await mcp.run_async(transport=config.server.transport)

    asyncio.run(setup_and_run())

setup_composed_server() async

Set up the composed server by importing all sub-servers with prefixes.

This uses static composition (import_server), which copies components from sub-servers into the main server with appropriate prefixes.

Source code in src/holoviz_mcp/server.py
async def setup_composed_server() -> None:
    """Set up the composed server by importing all sub-servers with prefixes.

    This uses static composition (import_server), which copies components
    from sub-servers into the main server with appropriate prefixes.
    """
    await mcp.import_server(holoviz_mcp, prefix="holoviz")
    await mcp.import_server(hvplot_mcp, prefix="hvplot")
    await mcp.import_server(panel_mcp, prefix="panel")

Panel MCP

Panel MCP Server Package.

This package provides Model Context Protocol (MCP) tools for working with Panel, the Python library for creating interactive web applications and dashboards.

The package includes: - Component discovery and introspection tools - Parameter information extraction - URL proxying utilities for remote environments - Data models for component metadata

Main modules: - server: MCP server implementation with Panel-specific tools - data: Component metadata collection and utilities - models: Pydantic models for component information

Server

Panel MCP Server.

This MCP server provides tools, resources and prompts for using Panel to develop quick, interactive applications, tools and dashboards in Python using best practices.

Use this server to access:

  • Panel Best Practices: Learn how to use Panel effectively.
  • Panel Components: Get information about specific Panel components like widgets (input), panes (output) and layouts.

COMPONENTS = [] module-attribute

mcp = FastMCP(name='panel', instructions='\n [Panel](https://panel.holoviz.org/) MCP Server.\n\n This MCP server provides tools, resources and prompts for using Panel to develop quick, interactive\n applications, tools and dashboards in Python using best practices.\n\n DO use this server to search for specific Panel components and access detailed information including docstrings and parameter information.\n ') module-attribute

get_component(ctx, name=None, module_path=None, package=None) async

Get complete details about a single Panel component including docstring and parameters.

Use this tool when you need full information about a specific Panel component, including its docstring, parameter specifications, and initialization signature. This is the most comprehensive tool for component information.

IMPORTANT: This tool returns exactly one component. If your criteria match multiple components, you'll get an error asking you to be more specific.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required
name str

Component name to match (case-insensitive). If None, must specify other criteria. Examples: "Button", "TextInput", "Slider"

None
module_path str

Full module path to match. If None, uses name and package to find component. Examples: "panel.widgets.Button", "panel_material_ui.Button"

None
package str

Package name to filter by. If None, searches all packages. Examples: "panel" or "panel_material_ui"

None

Returns:

Type Description
ComponentDetails

Complete component information including docstring, parameters, and initialization signature.

Raises:

Type Description
ValueError

If no components match the criteria or if multiple components match (be more specific).

Examples:

Get Panel's Button component:

>>> get_component(name="Button", package="panel")
ComponentDetails(name="Button", package="panel", docstring="A clickable button...", parameters={...})

Get Material UI Button component:

>>> get_component(name="Button", package="panel_material_ui")
ComponentDetails(name="Button", package="panel_material_ui", ...)

Get component by exact module path:

>>> get_component(module_path="panel.widgets.button.Button")
ComponentDetails(name="Button", module_path="panel.widgets.button.Button", ...)
Source code in src/holoviz_mcp/panel_mcp/server.py
@mcp.tool
async def get_component(ctx: Context, name: str | None = None, module_path: str | None = None, package: str | None = None) -> ComponentDetails:
    """
    Get complete details about a single Panel component including docstring and parameters.

    Use this tool when you need full information about a specific Panel component, including
    its docstring, parameter specifications, and initialization signature. This is the most
    comprehensive tool for component information.

    IMPORTANT: This tool returns exactly one component. If your criteria match multiple components,
    you'll get an error asking you to be more specific.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).
    name : str, optional
        Component name to match (case-insensitive). If None, must specify other criteria.
        Examples: "Button", "TextInput", "Slider"
    module_path : str, optional
        Full module path to match. If None, uses name and package to find component.
        Examples: "panel.widgets.Button", "panel_material_ui.Button"
    package : str, optional
        Package name to filter by. If None, searches all packages.
        Examples: "panel" or "panel_material_ui"

    Returns
    -------
    ComponentDetails
        Complete component information including docstring, parameters, and initialization signature.

    Raises
    ------
    ValueError
        If no components match the criteria or if multiple components match (be more specific).

    Examples
    --------
    Get Panel's Button component:
    >>> get_component(name="Button", package="panel")
    ComponentDetails(name="Button", package="panel", docstring="A clickable button...", parameters={...})

    Get Material UI Button component:
    >>> get_component(name="Button", package="panel_material_ui")
    ComponentDetails(name="Button", package="panel_material_ui", ...)

    Get component by exact module path:
    >>> get_component(module_path="panel.widgets.button.Button")
    ComponentDetails(name="Button", module_path="panel.widgets.button.Button", ...)
    """
    components_list = await _get_component(ctx, name, module_path, package)

    if not components_list:
        raise ValueError(f"No components found matching criteria: '{name}', '{module_path}', '{package}'. Please check your inputs.")
    if len(components_list) > 1:
        module_paths = "'" + "','".join([component.module_path for component in components_list]) + "'"
        raise ValueError(f"Multiple components found matching criteria: {module_paths}. Please refine your search.")
    component = components_list[0]
    return component

get_component_parameters(ctx, name=None, module_path=None, package=None) async

Get detailed parameter information for a single Panel component.

Use this tool when you need to understand the parameters of a specific Panel component, including their types, default values, documentation, and constraints. This is useful for understanding how to properly initialize and configure a component.

IMPORTANT: This tool returns parameters for exactly one component. If your criteria match multiple components, you'll get an error asking you to be more specific.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required
name str

Component name to match (case-insensitive). If None, must specify other criteria. Examples: "Button", "TextInput", "Slider"

None
module_path str

Full module path to match. If None, uses name and package to find component. Examples: "panel.widgets.Button", "panel_material_ui.Button"

None
package str

Package name to filter by. If None, searches all packages. Examples: "hvplot", "panel" or "panel_material_ui"

None

Returns:

Type Description
dict[str, ParameterInfo]

Dictionary mapping parameter names to their detailed information, including: - type: Parameter type (e.g., 'String', 'Number', 'Boolean') - default: Default value - doc: Parameter documentation - bounds: Value constraints for numeric parameters - objects: Available options for selector parameters

Raises:

Type Description
ValueError

If no components match the criteria or if multiple components match (be more specific).

Examples:

Get Button parameters:

>>> get_component_parameters(name="Button", package="panel")
{"name": ParameterInfo(type="String", default="Button", doc="The text displayed on the button"), ...}

Get TextInput parameters:

>>> get_component_parameters(name="TextInput", package="panel")
{"value": ParameterInfo(type="String", default="", doc="The current text value"), ...}

Get parameters by exact module path:

>>> get_component_parameters(module_path="panel.widgets.Slider")
{"start": ParameterInfo(type="Number", default=0, bounds=(0, 100)), ...}
Source code in src/holoviz_mcp/panel_mcp/server.py
@mcp.tool
async def get_component_parameters(ctx: Context, name: str | None = None, module_path: str | None = None, package: str | None = None) -> dict[str, ParameterInfo]:
    """
    Get detailed parameter information for a single Panel component.

    Use this tool when you need to understand the parameters of a specific Panel component,
    including their types, default values, documentation, and constraints. This is useful
    for understanding how to properly initialize and configure a component.

    IMPORTANT: This tool returns parameters for exactly one component. If your criteria
    match multiple components, you'll get an error asking you to be more specific.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).
    name : str, optional
        Component name to match (case-insensitive). If None, must specify other criteria.
        Examples: "Button", "TextInput", "Slider"
    module_path : str, optional
        Full module path to match. If None, uses name and package to find component.
        Examples: "panel.widgets.Button", "panel_material_ui.Button"
    package : str, optional
        Package name to filter by. If None, searches all packages.
        Examples: "hvplot", "panel" or "panel_material_ui"

    Returns
    -------
    dict[str, ParameterInfo]
        Dictionary mapping parameter names to their detailed information, including:
        - type: Parameter type (e.g., 'String', 'Number', 'Boolean')
        - default: Default value
        - doc: Parameter documentation
        - bounds: Value constraints for numeric parameters
        - objects: Available options for selector parameters

    Raises
    ------
    ValueError
        If no components match the criteria or if multiple components match (be more specific).

    Examples
    --------
    Get Button parameters:
    >>> get_component_parameters(name="Button", package="panel")
    {"name": ParameterInfo(type="String", default="Button", doc="The text displayed on the button"), ...}

    Get TextInput parameters:
    >>> get_component_parameters(name="TextInput", package="panel")
    {"value": ParameterInfo(type="String", default="", doc="The current text value"), ...}

    Get parameters by exact module path:
    >>> get_component_parameters(module_path="panel.widgets.Slider")
    {"start": ParameterInfo(type="Number", default=0, bounds=(0, 100)), ...}
    """
    components_list = await _get_component(ctx, name, module_path, package)

    if not components_list:
        raise ValueError(f"No components found matching criteria: '{name}', '{module_path}', '{package}'. Please check your inputs.")
    if len(components_list) > 1:
        module_paths = "'" + "','".join([component.module_path for component in components_list]) + "'"
        raise ValueError(f"Multiple components found matching criteria: {module_paths}. Please refine your search.")

    component = components_list[0]
    return component.parameters

list_components(ctx, name=None, module_path=None, package=None) async

Get a summary list of Panel components without detailed docstring and parameter information.

Use this tool to get an overview of available Panel components when you want to browse or discover components without needing full parameter details. This is faster than get_component and provides just the essential information.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required
name str

Component name to filter by (case-insensitive). If None, returns all components. Examples: "Button", "TextInput", "Slider"

None
module_path str

Module path prefix to filter by. If None, returns all components. Examples: "panel.widgets" to get all widgets, "panel.pane" to get all panes

None
package str

Package name to filter by. If None, returns all components. Examples: "hvplot", "panel" or "panel_material_ui"

None

Returns:

Type Description
list[ComponentSummary]

List of component summaries containing name, package, description, and module path. No parameter details are included for faster responses.

Examples:

Get all available components:

>>> list_components()
[ComponentSummary(name="Button", package="panel", description="A clickable button widget", ...)]

Get all Material UI components:

>>> list_components(package="panel_material_ui")
[ComponentSummary(name="Button", package="panel_material_ui", ...)]

Get all Button components from all packages:

>>> list_components(name="Button")
[ComponentSummary(name="Button", package="panel", ...), ComponentSummary(name="Button", package="panel_material_ui", ...)]
Source code in src/holoviz_mcp/panel_mcp/server.py
@mcp.tool
async def list_components(ctx: Context, name: str | None = None, module_path: str | None = None, package: str | None = None) -> list[ComponentSummary]:
    """
    Get a summary list of Panel components without detailed docstring and parameter information.

    Use this tool to get an overview of available Panel components when you want to browse
    or discover components without needing full parameter details. This is faster than
    get_component and provides just the essential information.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).
    name : str, optional
        Component name to filter by (case-insensitive). If None, returns all components.
        Examples: "Button", "TextInput", "Slider"
    module_path : str, optional
        Module path prefix to filter by. If None, returns all components.
        Examples: "panel.widgets" to get all widgets, "panel.pane" to get all panes
    package : str, optional
        Package name to filter by. If None, returns all components.
        Examples: "hvplot", "panel" or "panel_material_ui"

    Returns
    -------
    list[ComponentSummary]
        List of component summaries containing name, package, description, and module path.
        No parameter details are included for faster responses.

    Examples
    --------
    Get all available components:
    >>> list_components()
    [ComponentSummary(name="Button", package="panel", description="A clickable button widget", ...)]

    Get all Material UI components:
    >>> list_components(package="panel_material_ui")
    [ComponentSummary(name="Button", package="panel_material_ui", ...)]

    Get all Button components from all packages:
    >>> list_components(name="Button")
    [ComponentSummary(name="Button", package="panel", ...), ComponentSummary(name="Button", package="panel_material_ui", ...)]
    """
    components_list = []

    for component in await _get_all_components(ctx=ctx):
        if name and component.name.lower() != name.lower():
            continue
        if package and component.package != package:
            continue
        if module_path and not component.module_path.startswith(module_path):
            continue
        components_list.append(component.to_base())

    return components_list

list_packages(ctx) async

List all installed packages that provide Panel UI components.

Use this tool to discover what Panel-related packages are available in your environment. This helps you understand which packages you can use in the 'package' parameter of other tools.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required

Returns:

Type Description
list[str]

List of package names that provide Panel components, sorted alphabetically. Examples: ["panel"] or ["panel", "panel_material_ui"]

Examples:

Use this tool to see available packages:

>>> list_packages()
["panel", "panel_material_ui"]

Then use those package names in other tools:

>>> list_components(package="panel_material_ui")
>>> search("button", package="panel")
Source code in src/holoviz_mcp/panel_mcp/server.py
@mcp.tool
async def list_packages(ctx: Context) -> list[str]:
    """
    List all installed packages that provide Panel UI components.

    Use this tool to discover what Panel-related packages are available in your environment.
    This helps you understand which packages you can use in the 'package' parameter of other tools.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).

    Returns
    -------
    list[str]
        List of package names that provide Panel components, sorted alphabetically.
        Examples: ["panel"] or ["panel", "panel_material_ui"]

    Examples
    --------
    Use this tool to see available packages:
    >>> list_packages()
    ["panel", "panel_material_ui"]

    Then use those package names in other tools:
    >>> list_components(package="panel_material_ui")
    >>> search("button", package="panel")
    """
    return sorted(set(component.package for component in await _get_all_components(ctx)))

search_components(ctx, query, package=None, limit=10) async

Search for Panel components by search query and optional package filter.

Use this tool to find components when you don't know the exact name but have keywords. The search looks through component names, module paths, and documentation to find matches.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required
query str

Search term to look for. Can be component names, functionality keywords, or descriptions. Examples: "button", "input", "text", "chart", "plot", "slider", "select"

required
package str

Package name to filter results. If None, searches all packages. Examples: "hvplot", "panel", or "panel_material_ui"

None
limit int

Maximum number of results to return. Default is 10.

10

Returns:

Type Description
list[ComponentSummarySearchResult]

List of matching components with relevance scores (0-100, where 100 is exact match). Results are sorted by relevance score in descending order.

Examples:

Search for button components:

>>> search_components("button")
[ComponentSummarySearchResult(name="Button", package="panel", relevance_score=80, ...)]

Search within a specific package:

>>> search_components("input", package="panel_material_ui")
[ComponentSummarySearchResult(name="TextInput", package="panel_material_ui", ...)]

Find chart components with limited results:

>>> search_components("chart", limit=5)
[ComponentSummarySearchResult(name="Bokeh", package="panel", ...)]
Source code in src/holoviz_mcp/panel_mcp/server.py
@mcp.tool
async def search_components(ctx: Context, query: str, package: str | None = None, limit: int = 10) -> list[ComponentSummarySearchResult]:
    """
    Search for Panel components by search query and optional package filter.

    Use this tool to find components when you don't know the exact name but have keywords.
    The search looks through component names, module paths, and documentation to find matches.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).
    query : str
        Search term to look for. Can be component names, functionality keywords, or descriptions.
        Examples: "button", "input", "text", "chart", "plot", "slider", "select"
    package : str, optional
        Package name to filter results. If None, searches all packages.
        Examples: "hvplot", "panel", or "panel_material_ui"
    limit : int, optional
        Maximum number of results to return. Default is 10.

    Returns
    -------
    list[ComponentSummarySearchResult]
        List of matching components with relevance scores (0-100, where 100 is exact match).
        Results are sorted by relevance score in descending order.

    Examples
    --------
    Search for button components:
    >>> search_components("button")
    [ComponentSummarySearchResult(name="Button", package="panel", relevance_score=80, ...)]

    Search within a specific package:
    >>> search_components("input", package="panel_material_ui")
    [ComponentSummarySearchResult(name="TextInput", package="panel_material_ui", ...)]

    Find chart components with limited results:
    >>> search_components("chart", limit=5)
    [ComponentSummarySearchResult(name="Bokeh", package="panel", ...)]
    """
    query_lower = query.lower()

    matches = []
    for component in await _get_all_components(ctx=ctx):
        score = 0
        if package and component.package.lower() != package.lower():
            continue

        if component.name.lower() == query_lower or component.module_path.lower() == query_lower:
            score = 100
        elif query_lower in component.name.lower():
            score = 80
        elif query_lower in component.module_path.lower():
            score = 60
        elif query_lower in component.docstring.lower():
            score = 40
        elif any(word in component.docstring.lower() for word in query_lower.split()):
            score = 20

        if score > 0:
            matches.append(ComponentSummarySearchResult.from_component(component=component, relevance_score=score))

    matches.sort(key=lambda x: x.relevance_score, reverse=True)
    if len(matches) > limit:
        matches = matches[:limit]

    return matches

Models

Pydantic models for Panel component metadata collection.

This module defines the data models used to represent Panel UI component information, including parameter details, component summaries, and search results.

ComponentDetails

Bases: ComponentSummary

Complete information about a Panel UI component.

This model includes all available information about a component: summary information, initialization signature, full docstring, and detailed parameter specifications.

Source code in src/holoviz_mcp/panel_mcp/models.py
class ComponentDetails(ComponentSummary):
    """
    Complete information about a Panel UI component.

    This model includes all available information about a component:
    summary information, initialization signature, full docstring,
    and detailed parameter specifications.

    """

    init_signature: str = Field(description="Signature of the component's __init__ method.")
    docstring: str = Field(description="Docstring of the component, providing detailed information about its usage.")
    parameters: dict[str, ParameterInfo] = Field(
        description="Dictionary of parameters for the component, where keys are parameter names and values are ParameterInfo objects."
    )

    def to_base(self) -> ComponentSummary:
        """
        Convert to a basic component summary.

        Strips away detailed information to create a lightweight
        summary suitable for listings and overviews.

        Returns
        -------
        ComponentSummary
            A summary version of this component.
        """
        return ComponentSummary(
            module_path=self.module_path,
            name=self.name,
            package=self.package,
            description=self.description,
        )
docstring = Field(description='Docstring of the component, providing detailed information about its usage.') class-attribute instance-attribute
init_signature = Field(description="Signature of the component's __init__ method.") class-attribute instance-attribute
parameters = Field(description='Dictionary of parameters for the component, where keys are parameter names and values are ParameterInfo objects.') class-attribute instance-attribute
to_base()

Convert to a basic component summary.

Strips away detailed information to create a lightweight summary suitable for listings and overviews.

Returns:

Type Description
ComponentSummary

A summary version of this component.

Source code in src/holoviz_mcp/panel_mcp/models.py
def to_base(self) -> ComponentSummary:
    """
    Convert to a basic component summary.

    Strips away detailed information to create a lightweight
    summary suitable for listings and overviews.

    Returns
    -------
    ComponentSummary
        A summary version of this component.
    """
    return ComponentSummary(
        module_path=self.module_path,
        name=self.name,
        package=self.package,
        description=self.description,
    )

ComponentSummary

Bases: BaseModel

High-level information about a Panel UI component.

This model provides a compact representation of a component without detailed parameter information or docstrings. Used for listings and quick overviews.

Source code in src/holoviz_mcp/panel_mcp/models.py
class ComponentSummary(BaseModel):
    """
    High-level information about a Panel UI component.

    This model provides a compact representation of a component without
    detailed parameter information or docstrings. Used for listings and
    quick overviews.
    """

    module_path: str = Field(description="Full module path of the component, e.g., 'panel.widgets.Button' or 'panel_material_ui.Button'.")
    name: str = Field(description="Name of the component, e.g., 'Button' or 'TextInput'.")
    package: str = Field(description="Package name of the component, e.g., 'panel' or 'panel_material_ui'.")
    description: str = Field(description="Short description of the component's purpose and functionality.")
description = Field(description="Short description of the component's purpose and functionality.") class-attribute instance-attribute
module_path = Field(description="Full module path of the component, e.g., 'panel.widgets.Button' or 'panel_material_ui.Button'.") class-attribute instance-attribute
name = Field(description="Name of the component, e.g., 'Button' or 'TextInput'.") class-attribute instance-attribute
package = Field(description="Package name of the component, e.g., 'panel' or 'panel_material_ui'.") class-attribute instance-attribute

ComponentSummarySearchResult

Bases: ComponentSummary

Component summary with search relevance scoring.

Extends ComponentSummary with a relevance score for search results, allowing proper ranking and filtering of search matches.

Source code in src/holoviz_mcp/panel_mcp/models.py
class ComponentSummarySearchResult(ComponentSummary):
    """
    Component summary with search relevance scoring.

    Extends ComponentSummary with a relevance score for search results,
    allowing proper ranking and filtering of search matches.

    """

    relevance_score: int = Field(default=0, description="Relevance score for search results")

    @classmethod
    def from_component(cls, component: ComponentDetails, relevance_score: int) -> ComponentSummarySearchResult:
        """
        Create a search result from a component and relevance score.

        Parameters
        ----------
        component : ComponentDetails
            The component to create a search result from.
        relevance_score : int
            The relevance score (0-100) for this search result.

        Returns
        -------
        ComponentSummarySearchResult
            A search result summary of the component.
        """
        return cls(
            module_path=component.module_path, name=component.name, package=component.package, description=component.description, relevance_score=relevance_score
        )
relevance_score = Field(default=0, description='Relevance score for search results') class-attribute instance-attribute
from_component(component, relevance_score) classmethod

Create a search result from a component and relevance score.

Parameters:

Name Type Description Default
component ComponentDetails

The component to create a search result from.

required
relevance_score int

The relevance score (0-100) for this search result.

required

Returns:

Type Description
ComponentSummarySearchResult

A search result summary of the component.

Source code in src/holoviz_mcp/panel_mcp/models.py
@classmethod
def from_component(cls, component: ComponentDetails, relevance_score: int) -> ComponentSummarySearchResult:
    """
    Create a search result from a component and relevance score.

    Parameters
    ----------
    component : ComponentDetails
        The component to create a search result from.
    relevance_score : int
        The relevance score (0-100) for this search result.

    Returns
    -------
    ComponentSummarySearchResult
        A search result summary of the component.
    """
    return cls(
        module_path=component.module_path, name=component.name, package=component.package, description=component.description, relevance_score=relevance_score
    )

ParameterInfo

Bases: BaseModel

Information about a Panel component parameter.

This model captures parameter metadata including type, default value, documentation, and type-specific attributes like bounds or options.

Source code in src/holoviz_mcp/panel_mcp/models.py
class ParameterInfo(BaseModel):
    """
    Information about a Panel component parameter.

    This model captures parameter metadata including type, default value,
    documentation, and type-specific attributes like bounds or options.
    """

    model_config = ConfigDict(extra="allow")  # Allow additional fields we don't know about

    # Common attributes that most parameters have
    type: str = Field(description="The type of the parameter, e.g., 'Parameter', 'Number', 'Selector'.")
    default: Optional[Any] = Field(default=None, description="The default value for the parameter.")
    doc: Optional[str] = Field(default=None, description="Documentation string for the parameter.")
    # Optional attributes that may not be present
    allow_None: Optional[bool] = Field(default=None, description="Whether the parameter accepts None values.")
    constant: Optional[bool] = Field(default=None, description="Whether the parameter is constant (cannot be changed after initialization).")
    readonly: Optional[bool] = Field(default=None, description="Whether the parameter is read-only.")
    per_instance: Optional[bool] = Field(default=None, description="Whether the parameter is per-instance or shared across instances.")

    # Type-specific attributes (will be present only for relevant parameter types)
    objects: Optional[Any] = Field(default=None, description="Available options for Selector-type parameters.")
    bounds: Optional[Any] = Field(default=None, description="Value bounds for Number-type parameters.")
    regex: Optional[str] = Field(default=None, description="Regular expression pattern for String-type parameters.")
allow_None = Field(default=None, description='Whether the parameter accepts None values.') class-attribute instance-attribute
bounds = Field(default=None, description='Value bounds for Number-type parameters.') class-attribute instance-attribute
constant = Field(default=None, description='Whether the parameter is constant (cannot be changed after initialization).') class-attribute instance-attribute
default = Field(default=None, description='The default value for the parameter.') class-attribute instance-attribute
doc = Field(default=None, description='Documentation string for the parameter.') class-attribute instance-attribute
model_config = ConfigDict(extra='allow') class-attribute instance-attribute
objects = Field(default=None, description='Available options for Selector-type parameters.') class-attribute instance-attribute
per_instance = Field(default=None, description='Whether the parameter is per-instance or shared across instances.') class-attribute instance-attribute
readonly = Field(default=None, description='Whether the parameter is read-only.') class-attribute instance-attribute
regex = Field(default=None, description='Regular expression pattern for String-type parameters.') class-attribute instance-attribute
type = Field(description="The type of the parameter, e.g., 'Parameter', 'Number', 'Selector'.") class-attribute instance-attribute

Data

Data collection module for Panel component metadata.

This module provides functionality to collect metadata about Panel UI components, including their documentation, parameter schema, and module information. It supports collecting information from panel.viewable.Viewable subclasses across different Panel-related packages.

collect_component_info(cls)

Collect comprehensive information about a Panel component class.

Extracts metadata including docstring, parameter information, method signatures, and other relevant details from a Panel component class. Handles parameter introspection safely, converting non-serializable values appropriately.

Parameters:

Name Type Description Default
cls type

The Panel component class to analyze.

required

Returns:

Type Description
ComponentDetails

A complete model containing all collected component information.

Source code in src/holoviz_mcp/panel_mcp/data.py
def collect_component_info(cls: type) -> ComponentDetails:
    """
    Collect comprehensive information about a Panel component class.

    Extracts metadata including docstring, parameter information, method signatures,
    and other relevant details from a Panel component class. Handles parameter
    introspection safely, converting non-serializable values appropriately.

    Parameters
    ----------
    cls : type
        The Panel component class to analyze.

    Returns
    -------
    ComponentDetails
        A complete model containing all collected component information.
    """
    # Extract docstring
    docstring = cls.__doc__ if cls.__doc__ else ""

    # Extract description (first sentence from docstring)
    description = ""
    if docstring:
        # Clean the docstring and get first sentence
        cleaned_docstring = docstring.strip()
        if cleaned_docstring:
            # Find first sentence ending with period, exclamation, or question mark
            import re

            sentences = re.split(r"[.!?]", cleaned_docstring)
            if sentences:
                description = sentences[0].strip()
                # Remove leading/trailing whitespace and normalize spaces
                description = " ".join(description.split())

    # Extract parameters information
    parameters = {}
    if hasattr(cls, "param"):
        for param_name in sorted(cls.param):
            # Skip private parameters
            if param_name.startswith("_"):
                continue

            param_obj = cls.param[param_name]
            param_data = {}

            # Get common parameter attributes (skip private ones)
            for attr in ["default", "doc", "allow_None", "constant", "readonly", "per_instance"]:
                if hasattr(param_obj, attr) and getattr(param_obj, attr):
                    value = getattr(param_obj, attr)
                    if isinstance(value, str):
                        value = dedent(value).strip()
                    # Handle non-JSON serializable values
                    try:
                        json.dumps(value)
                        param_data[attr] = value
                    except (TypeError, ValueError):
                        param_data[attr] = "NON_JSON_SERIALIZABLE_VALUE"

            # Get type-specific attributes
            param_type = type(param_obj).__name__
            param_data["type"] = param_type

            # For Selector parameters, get options
            if hasattr(param_obj, "objects") and param_obj.objects:
                try:
                    json.dumps(param_obj.objects)
                    param_data["objects"] = param_obj.objects
                except (TypeError, ValueError):
                    param_data["objects"] = "NON_JSON_SERIALIZABLE_VALUE"

            # For Number parameters, get bounds
            if hasattr(param_obj, "bounds") and param_obj.bounds:
                try:
                    json.dumps(param_obj.bounds)
                    param_data["bounds"] = param_obj.bounds
                except (TypeError, ValueError):
                    param_data["bounds"] = "NON_JSON_SERIALIZABLE_VALUE"

            # For String parameters, get regex
            if hasattr(param_obj, "regex") and param_obj.regex:
                try:
                    json.dumps(param_obj.regex)
                    param_data["regex"] = param_obj.regex
                except (TypeError, ValueError):
                    param_data["regex"] = "NON_JSON_SERIALIZABLE_VALUE"

            # Create ParameterInfo model
            parameters[param_name] = ParameterInfo(**param_data)

    # Get __init__ method signature
    init_signature = ""
    if hasattr(cls, "__init__"):
        try:
            import inspect

            sig = inspect.signature(cls.__init__)  # type: ignore[misc]
            init_signature = str(sig)
        except Exception as e:
            init_signature = f"Error getting signature: {e}"

    # Read reference guide content
    # Create and return ComponentInfo model
    return ComponentDetails(
        name=cls.__name__,
        description=description,
        package=cls.__module__.split(".")[0],
        module_path=f"{cls.__module__}.{cls.__name__}",
        init_signature=init_signature,
        docstring=docstring,
        parameters=parameters,
    )

find_all_subclasses(cls)

Recursively find all subclasses of a given class.

This function performs a depth-first search through the class hierarchy to find all classes that inherit from the given base class, either directly or through inheritance chains.

Parameters:

Name Type Description Default
cls type

The base class to find subclasses for.

required

Returns:

Type Description
set[type]

Set of all subclasses found recursively, not including the base class itself.

Source code in src/holoviz_mcp/panel_mcp/data.py
def find_all_subclasses(cls: type) -> set[type]:
    """
    Recursively find all subclasses of a given class.

    This function performs a depth-first search through the class hierarchy
    to find all classes that inherit from the given base class, either directly
    or through inheritance chains.

    Parameters
    ----------
    cls : type
        The base class to find subclasses for.

    Returns
    -------
    set[type]
        Set of all subclasses found recursively, not including the base class itself.
    """
    subclasses = set()
    for subclass in cls.__subclasses__():
        subclasses.add(subclass)
        subclasses.update(find_all_subclasses(subclass))
    return subclasses

get_components(parent=Viewable)

Get detailed information about all Panel component subclasses.

Discovers all subclasses of the specified parent class (typically Viewable), filters out private classes, and collects comprehensive metadata for each. Results are sorted alphabetically by module path for consistency.

Parameters:

Name Type Description Default
parent type

The parent class to search for subclasses. Defaults to panel.viewable.Viewable.

Viewable

Returns:

Type Description
list[ComponentDetails]

List of detailed component information models, sorted by module path.

Source code in src/holoviz_mcp/panel_mcp/data.py
def get_components(parent=Viewable) -> list[ComponentDetails]:
    """
    Get detailed information about all Panel component subclasses.

    Discovers all subclasses of the specified parent class (typically Viewable),
    filters out private classes, and collects comprehensive metadata for each.
    Results are sorted alphabetically by module path for consistency.

    Parameters
    ----------
    parent : type, optional
        The parent class to search for subclasses. Defaults to panel.viewable.Viewable.

    Returns
    -------
    list[ComponentDetails]
        List of detailed component information models, sorted by module path.
    """
    all_subclasses = find_all_subclasses(parent)

    # Filter to only those in panel_material_ui package and exclude private classes
    subclasses = [cls for cls in all_subclasses if not cls.__name__.startswith("_")]

    # Collect component information
    component_data = [collect_component_info(cls) for cls in subclasses]

    # Sort by module_path for consistent ordering
    component_data.sort(key=lambda x: x.module_path)
    return component_data

load_components(filepath)

Load component data from a JSON file.

Reads and deserializes component data that was previously saved using save_components(). Validates the file exists before attempting to load.

Parameters:

Name Type Description Default
filepath str

Path to the saved component data JSON file.

required

Returns:

Type Description
list[ComponentDetails]

Loaded component data as Pydantic model instances.

Raises:

Type Description
FileNotFoundError

If the specified file does not exist.

Source code in src/holoviz_mcp/panel_mcp/data.py
def load_components(filepath: str) -> list[ComponentDetails]:
    """
    Load component data from a JSON file.

    Reads and deserializes component data that was previously saved using
    save_components(). Validates the file exists before attempting to load.

    Parameters
    ----------
    filepath : str
        Path to the saved component data JSON file.

    Returns
    -------
    list[ComponentDetails]
        Loaded component data as Pydantic model instances.

    Raises
    ------
    FileNotFoundError
        If the specified file does not exist.
    """
    file_path = Path(filepath)

    if not file_path.exists():
        raise FileNotFoundError(f"File not found: {filepath}")

    with open(file_path, "r", encoding="utf-8") as f:
        json_data = json.load(f)

    # Convert JSON data back to Pydantic models
    return [ComponentDetails(**item) for item in json_data]

save_components(data, filename)

Save component data to a JSON file.

Serializes a list of ComponentDetails objects to JSON format for persistence. The JSON is formatted with indentation for human readability.

Parameters:

Name Type Description Default
data list[ComponentDetails]

Component data to save, typically from get_components().

required
filename str

Path where the JSON file should be created.

required

Returns:

Type Description
str

Absolute path to the created file.

Source code in src/holoviz_mcp/panel_mcp/data.py
def save_components(data: list[ComponentDetails], filename: str) -> str:
    """
    Save component data to a JSON file.

    Serializes a list of ComponentDetails objects to JSON format for persistence.
    The JSON is formatted with indentation for human readability.

    Parameters
    ----------
    data : list[ComponentDetails]
        Component data to save, typically from get_components().
    filename : str
        Path where the JSON file should be created.

    Returns
    -------
    str
        Absolute path to the created file.
    """
    filepath = Path(filename)

    # Convert Pydantic models to dict for JSON serialization
    json_data = [component.model_dump() for component in data]

    with open(filepath, "w", encoding="utf-8") as f:
        json.dump(json_data, f, indent=2, ensure_ascii=False)

    return str(filepath)

to_proxy_url(url, jupyter_server_proxy_url='')

Convert localhost URLs to Jupyter server proxy URLs when applicable.

This function handles URL conversion for environments where localhost access needs to be proxied (like JupyterHub, Binder, etc.). It supports both 'localhost' and '127.0.0.1' addresses and preserves paths and query parameters.

Parameters:

Name Type Description Default
url str

The original URL to potentially convert. Can be any URL, but only localhost and 127.0.0.1 URLs will be converted.

required
jupyter_server_proxy_url str

Base URL for the Jupyter server proxy. If None or empty, no conversion is performed. Defaults to the configured proxy URL.

''

Returns:

Type Description
str

The converted proxy URL if applicable, otherwise the original URL. Proxy URLs maintain the original port, path, and query parameters.

Examples:

>>> to_proxy_url("http://localhost:5007/app")
"https://hub.example.com/user/alice/proxy/5007/app"
>>> to_proxy_url("https://external.com/page")
"https://external.com/page"  # No conversion for external URLs
Source code in src/holoviz_mcp/panel_mcp/data.py
def to_proxy_url(url: str, jupyter_server_proxy_url: str = "") -> str:
    """
    Convert localhost URLs to Jupyter server proxy URLs when applicable.

    This function handles URL conversion for environments where localhost access
    needs to be proxied (like JupyterHub, Binder, etc.). It supports both
    'localhost' and '127.0.0.1' addresses and preserves paths and query parameters.

    Parameters
    ----------
    url : str
        The original URL to potentially convert. Can be any URL, but only
        localhost and 127.0.0.1 URLs will be converted.
    jupyter_server_proxy_url : str, optional
        Base URL for the Jupyter server proxy. If None or empty, no conversion
        is performed. Defaults to the configured proxy URL.

    Returns
    -------
    str
        The converted proxy URL if applicable, otherwise the original URL.
        Proxy URLs maintain the original port, path, and query parameters.

    Examples
    --------
    >>> to_proxy_url("http://localhost:5007/app")
    "https://hub.example.com/user/alice/proxy/5007/app"

    >>> to_proxy_url("https://external.com/page")
    "https://external.com/page"  # No conversion for external URLs
    """
    if jupyter_server_proxy_url and jupyter_server_proxy_url.strip():
        # Check if this is a localhost or 127.0.0.1 URL
        if url.startswith("http://localhost:"):
            # Parse the URL to extract port, path, and query
            url_parts = url.replace("http://localhost:", "")
        elif url.startswith("http://127.0.0.1:"):
            # Parse the URL to extract port, path, and query
            url_parts = url.replace("http://127.0.0.1:", "")
        else:
            # Not a local URL, return original
            proxy_url = url
            return proxy_url

        # Find the port (everything before the first slash or end of string)
        if "/" in url_parts:
            port = url_parts.split("/", 1)[0]
            path_and_query = "/" + url_parts.split("/", 1)[1]
        else:
            port = url_parts
            path_and_query = "/"

        # Validate that port is a valid number
        if port and port.isdigit() and 1 <= int(port) <= 65535:
            # Build the proxy URL
            proxy_url = f"{jupyter_server_proxy_url}{port}{path_and_query}"
        else:
            # Invalid port, return original URL
            proxy_url = url
    else:
        proxy_url = url
    return proxy_url

hvPlot MCP

hvPlot MCP Server.

Server

hvPlot MCP Server.

This MCP server provides tools, resources, and prompts for using hvPlot to develop quick, interactive plots in Python using best practices.

Use this server to: - List available hvPlot plot types (e.g., 'line', 'scatter', 'bar', ...) - Get docstrings and function signatures for hvPlot plot types - Access hvPlot documentation and best practices

mcp = FastMCP(name='hvplot', instructions='\n [hvPlot](https://hvplot.holoviz.org/) MCP Server.\n\n This MCP server provides tools, resources, and prompts for using hvPlot to develop quick, interactive plots\n in Python using best practices. Use this server to:\n\n - List available hvPlot plot types\n - Get docstrings and function signatures for hvPlot plot types\n - Access hvPlot documentation and best practices') module-attribute

get_docstring(ctx, plot_type, docstring=True, generic=True, style=True) async

Get the hvPlot docstring for a specific plot type, including available options and usage details.

Use this tool to retrieve the full docstring for a plot type, including generic and style options. Equivalent to hvplot.help(plot_type) in the hvPlot API.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required
plot_type str

The type of plot to provide help for (e.g., 'line', 'scatter').

required
docstring bool

Whether to include the docstring in the output.

True
generic bool

Whether to include generic plotting options shared by all plot types.

True
style str or bool

Plotting backend to use for style options. If True, automatically infers the backend.

True

Returns:

Type Description
str

The docstring for the specified plot type, including all relevant options and usage information.

Examples:

>>> get_docstring(plot_type='line')
Source code in src/holoviz_mcp/hvplot_mcp/server.py
@mcp.tool
async def get_docstring(
    ctx: Context, plot_type: str, docstring: bool = True, generic: bool = True, style: Union[Literal["matplotlib", "bokeh", "plotly"], bool] = True
) -> str:
    """
    Get the hvPlot docstring for a specific plot type, including available options and usage details.

    Use this tool to retrieve the full docstring for a plot type, including generic and style options.
    Equivalent to `hvplot.help(plot_type)` in the hvPlot API.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).
    plot_type : str
        The type of plot to provide help for (e.g., 'line', 'scatter').
    docstring : bool, default=True
        Whether to include the docstring in the output.
    generic : bool, default=True
        Whether to include generic plotting options shared by all plot types.
    style : str or bool, default=True
        Plotting backend to use for style options. If True, automatically infers the backend.

    Returns
    -------
    str
        The docstring for the specified plot type, including all relevant options and usage information.

    Examples
    --------
    >>> get_docstring(plot_type='line')
    """
    doc, _ = _help(plot_type=plot_type, docstring=docstring, generic=generic, style=style)
    return doc

get_signature(ctx, plot_type, style=True) async

Get the function signature for a specific hvPlot plot type.

Use this tool to retrieve the Python function signature for a plot type, showing all accepted arguments and their defaults.

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required
plot_type str

The type of plot to provide help for (e.g., 'line', 'scatter').

required
style str or bool

Plotting backend to use for style options. If True, automatically infers the backend (ignored here).

True

Returns:

Type Description
str

The function signature for the specified plot type.

Examples:

>>> get_signature(plot_type='line')
Source code in src/holoviz_mcp/hvplot_mcp/server.py
@mcp.tool
async def get_signature(ctx: Context, plot_type: str, style: Union[Literal["matplotlib", "bokeh", "plotly"], bool] = True) -> str:
    """
    Get the function signature for a specific hvPlot plot type.

    Use this tool to retrieve the Python function signature for a plot type, showing all accepted arguments and their defaults.

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).
    plot_type : str
        The type of plot to provide help for (e.g., 'line', 'scatter').
    style : str or bool, default=True
        Plotting backend to use for style options. If True, automatically infers the backend (ignored here).

    Returns
    -------
    str
        The function signature for the specified plot type.

    Examples
    --------
    >>> get_signature(plot_type='line')
    """
    _, sig = _help(plot_type=plot_type, docstring=True, generic=True, style=style)
    return str(sig)

list_plot_types(ctx) async

List all available hvPlot plot types supported in the current environment.

Use this tool to discover what plot types you can generate with hvPlot.

Note: The plot types are also called "kinds".

Parameters:

Name Type Description Default
ctx Context

FastMCP context (automatically provided by the MCP framework).

required

Returns:

Type Description
list[str]

Sorted list of all plot type names (e.g., 'line', 'scatter', 'bar', ...).

Examples:

>>> list_plot_types()
['area', 'bar', 'box', 'contour', ...]
Source code in src/holoviz_mcp/hvplot_mcp/server.py
@mcp.tool
async def list_plot_types(ctx: Context) -> list[str]:
    """
    List all available hvPlot plot types supported in the current environment.

    Use this tool to discover what plot types you can generate with hvPlot.

    Note: The plot types are also called "kinds".

    Parameters
    ----------
    ctx : Context
        FastMCP context (automatically provided by the MCP framework).

    Returns
    -------
    list[str]
        Sorted list of all plot type names (e.g., 'line', 'scatter', 'bar', ...).

    Examples
    --------
    >>> list_plot_types()
    ['area', 'bar', 'box', 'contour', ...]
    """
    from hvplot.converter import HoloViewsConverter

    return sorted(HoloViewsConverter._kind_mapping)

Documentation MCP

docs_mcp package.

Server

HoloViz Documentation MCP Server.

This server provides tools, resources and prompts for accessing documentation related to the HoloViz ecosystems.

Use this server to search and access documentation for HoloViz libraries, including Panel and hvPlot.

config = get_config() module-attribute

logger = logging.getLogger(__name__) module-attribute

mcp = FastMCP(name='documentation', instructions='\n [HoloViz](https://holoviz.org/) Documentation MCP Server.\n\n This server provides tools, resources and prompts for accessing documentation related to the HoloViz ecosystems.\n\n Use this server to search and access documentation for HoloViz libraries, including Panel and hvPlot.\n ') module-attribute

get_best_practices(project)

Get best practices for using a project with LLMs.

DO Always use this tool to get best practices for using a project with LLMs before using it!

Args: project (str): The name of the project to get best practices for. For example, "panel", "panel-material-ui", etc.

Returns:

Type Description
str: A string containing the best practices for the project in Markdown format.
Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool
def get_best_practices(project: str) -> str:
    """Get best practices for using a project with LLMs.

    DO Always use this tool to get best practices for using a project with LLMs before using it!

    Args:
        project (str): The name of the project to get best practices for. For example, "panel", "panel-material-ui", etc.

    Returns
    -------
        str: A string containing the best practices for the project in Markdown format.
    """
    return _get_best_practices(project)

get_document(path, project, ctx) async

Retrieve a specific document by path and project.

Use this tool to look up a specific document within a project.

Args: path: The relative path to the source document (e.g., "index.md", "how_to/customize.md") project: the name of the project (e.g., "panel", "panel-material-ui", "hvplot")

Returns:

Type Description
The markdown content of the specified document.
Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool
async def get_document(path: str, project: str, ctx: Context) -> Document:
    """Retrieve a specific document by path and project.

    Use this tool to look up a specific document within a project.

    Args:
        path: The relative path to the source document (e.g., "index.md", "how_to/customize.md")
        project: the name of the project (e.g., "panel", "panel-material-ui", "hvplot")

    Returns
    -------
        The markdown content of the specified document.
    """
    indexer = get_indexer()
    return await indexer.get_document(path, project, ctx=ctx)

get_indexer()

Get or create the global DocumentationIndexer instance.

Source code in src/holoviz_mcp/holoviz_mcp/server.py
def get_indexer() -> DocumentationIndexer:
    """Get or create the global DocumentationIndexer instance."""
    global _indexer
    if _indexer is None:
        _indexer = DocumentationIndexer()
    return _indexer

get_reference_guide(component, project=None, content=True, ctx=None) async

Find reference guides for specific HoloViz components.

Reference guides are a subset of all documents that focus on specific UI components or plot types, such as:

  • panel: "Button", "TextInput", ...
  • hvplot: "bar", "scatter", ...
  • ...

DO use this tool to easily find reference guides for specific components in HoloViz libraries.

Args: component (str): Name of the component (e.g., "Button", "TextInput", "bar", "scatter") project (str, optional): Project name. Defaults to None (searches all projects). Options: "panel", "panel-material-ui", "hvplot", "param", "holoviews" content (bool, optional): Whether to include full content. Defaults to True. Set to False to only return metadata for faster responses.

Returns:

Type Description
list[Document]: A list of reference guides for the component.

Examples:

>>> get_reference_guide("Button")  # Find Button component guide across all projects
>>> get_reference_guide("Button", "panel")  # Find Panel Button component guide specifically
>>> get_reference_guide("TextInput", "panel-material-ui")  # Find Material UI TextInput guide
>>> get_reference_guide("bar", "hvplot")  # Find hvplot bar chart reference
>>> get_reference_guide("scatter", "hvplot")  # Find hvplot scatter plot reference
>>> get_reference_guide("Audio", content=False)  # Don't include Markdown content for faster response
Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool
async def get_reference_guide(component: str, project: str | None = None, content: bool = True, ctx: Context | None = None) -> list[Document]:
    """Find reference guides for specific HoloViz components.

    Reference guides are a subset of all documents that focus on specific UI components
    or plot types, such as:

    - `panel`: "Button", "TextInput", ...
    - `hvplot`: "bar", "scatter", ...
    - ...

    DO use this tool to easily find reference guides for specific components in HoloViz libraries.

    Args:
        component (str): Name of the component (e.g., "Button", "TextInput", "bar", "scatter")
        project (str, optional): Project name. Defaults to None (searches all projects).
            Options: "panel", "panel-material-ui", "hvplot", "param", "holoviews"
        content (bool, optional): Whether to include full content. Defaults to True.
            Set to False to only return metadata for faster responses.

    Returns
    -------
        list[Document]: A list of reference guides for the component.

    Examples
    --------
    >>> get_reference_guide("Button")  # Find Button component guide across all projects
    >>> get_reference_guide("Button", "panel")  # Find Panel Button component guide specifically
    >>> get_reference_guide("TextInput", "panel-material-ui")  # Find Material UI TextInput guide
    >>> get_reference_guide("bar", "hvplot")  # Find hvplot bar chart reference
    >>> get_reference_guide("scatter", "hvplot")  # Find hvplot scatter plot reference
    >>> get_reference_guide("Audio", content=False)  # Don't include Markdown content for faster response
    """
    indexer = get_indexer()
    return await indexer.search_get_reference_guide(component, project, content, ctx=ctx)

list_best_practices()

List all available best practices projects.

This tool discovers available best practices from both user and default directories, with user resources taking precedence over default ones.

Returns:

Type Description
list[str]: A list of project names that have best practices available.

Names are returned in hyphenated format (e.g., "panel-material-ui").

Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool
def list_best_practices() -> list[str]:
    """List all available best practices projects.

    This tool discovers available best practices from both user and default directories,
    with user resources taking precedence over default ones.

    Returns
    -------
        list[str]: A list of project names that have best practices available.
                   Names are returned in hyphenated format (e.g., "panel-material-ui").
    """
    return _list_best_practices()

list_projects() async

List all available projects with documentation.

This tool discovers all projects that have documentation available in the index, including both core HoloViz libraries and any additional user-defined projects.

Returns:

Type Description
list[str]: A list of project names that have documentation available.

Names are returned in hyphenated format (e.g., "panel-material-ui").

Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool
async def list_projects() -> list[str]:
    """List all available projects with documentation.

    This tool discovers all projects that have documentation available in the index,
    including both core HoloViz libraries and any additional user-defined projects.

    Returns
    -------
        list[str]: A list of project names that have documentation available.
                   Names are returned in hyphenated format (e.g., "panel-material-ui").
    """
    indexer = get_indexer()
    return await indexer.list_projects()

search(query, project=None, content=True, max_results=5, ctx=None) async

Search HoloViz documentation using semantic similarity.

Optimized for finding relevant documentation based on natural language queries.

DO use this tool to find answers to questions about HoloViz libraries, such as Panel and hvPlot.

Args: query (str): Search query using natural language. For example "How to style Material UI components?" or "interactive plotting with widgets" project (str, optional): Optional project filter. Defaults to None. Options: "panel", "panel-material-ui", "hvplot", "param", "holoviews" content (bool, optional): Whether to include full content. Defaults to True. Set to False to only return metadata for faster responses. max_results (int, optional): Maximum number of results to return. Defaults to 5.

Returns:

Type Description
list[Document]: A list of relevant documents ordered by relevance.

Examples:

>>> search("How to style Material UI components?", "panel-material-ui")  # Semantic search in specific project
>>> search("interactive plotting with widgets", "hvplot")  # Find hvplot interactive guides
>>> search("dashboard layout best practices")  # Search across all projects
>>> search("custom widgets", project="panel", max_results=3)  # Limit results
>>> search("parameter handling", content=False)  # Get metadata only for overview
Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool
async def search(
    query: str,
    project: str | None = None,
    content: bool = True,
    max_results: int = 5,
    ctx: Context | None = None,
) -> list[Document]:
    """Search HoloViz documentation using semantic similarity.

    Optimized for finding relevant documentation based on natural language queries.

    DO use this tool to find answers to questions about HoloViz libraries, such as Panel and hvPlot.

    Args:
        query (str): Search query using natural language.
            For example "How to style Material UI components?" or "interactive plotting with widgets"
        project (str, optional): Optional project filter. Defaults to None.
            Options: "panel", "panel-material-ui", "hvplot", "param", "holoviews"
        content (bool, optional): Whether to include full content. Defaults to True.
            Set to False to only return metadata for faster responses.
        max_results (int, optional): Maximum number of results to return. Defaults to 5.

    Returns
    -------
        list[Document]: A list of relevant documents ordered by relevance.

    Examples
    --------
    >>> search("How to style Material UI components?", "panel-material-ui")  # Semantic search in specific project
    >>> search("interactive plotting with widgets", "hvplot")  # Find hvplot interactive guides
    >>> search("dashboard layout best practices")  # Search across all projects
    >>> search("custom widgets", project="panel", max_results=3)  # Limit results
    >>> search("parameter handling", content=False)  # Get metadata only for overview
    """
    indexer = get_indexer()
    return await indexer.search(query, project, content, max_results, ctx=ctx)

update_index(ctx) async

Update the documentation index by re-cloning repositories and re-indexing content.

DO use this tool periodically (weekly) to ensure the documentation index is up-to-date with the latest changes in the HoloViz ecosystem.

Warning: This operation can take a long time (up to 5 minutes) depending on the number of repositories and their size!

Returns:

Type Description
str: Status message indicating the result of the update operation.

Examples:

>>> update_index()  # Updates all documentation repositories and rebuilds index
Source code in src/holoviz_mcp/holoviz_mcp/server.py
@mcp.tool(enabled=False)
async def update_index(ctx: Context) -> str:
    """Update the documentation index by re-cloning repositories and re-indexing content.

    DO use this tool periodically (weekly) to ensure the documentation index is up-to-date
    with the latest changes in the HoloViz ecosystem.

    Warning: This operation can take a long time (up to 5 minutes) depending on the number of
    repositories and their size!

    Returns
    -------
        str: Status message indicating the result of the update operation.

    Examples
    --------
    >>> update_index()  # Updates all documentation repositories and rebuilds index
    """
    try:
        indexer = get_indexer()

        # Use True as ctx to enable print statements for user feedback
        await indexer.index_documentation(ctx=ctx)

        return "Documentation index updated successfully."
    except Exception as e:
        logger.error(f"Failed to update documentation index: {e}")
        error_msg = f"Failed to update documentation index: {str(e)}"
        return error_msg

Models

Data models for the HoloViz Documentation MCP server.

Document

Bases: BaseModel

Represents a document.

Source code in src/holoviz_mcp/holoviz_mcp/models.py
class Document(BaseModel):
    """Represents a document."""

    title: str = Field(..., description="The title of the document.")
    url: HttpUrl = Field(..., description="The URL of the rendered, target document.")
    project: str = Field(..., description="The project to which the document belongs.")
    source_path: str = Field(..., description="The path to the document within the project.")
    source_url: HttpUrl = Field(..., description="The URL to the source document.")
    is_reference: bool = Field(..., description="Indicates if the document is a reference guide.")
    description: Optional[str] = Field(default=None, description="A brief description of the document.")
    content: Optional[str] = Field(default=None, description="The content of the documentation, if available. In Markdown format if possible.")
    relevance_score: Optional[float] = Field(default=None, description="Relevance score of the document, where 1 is the highest score indicating an exact match.")
content = Field(default=None, description='The content of the documentation, if available. In Markdown format if possible.') class-attribute instance-attribute
description = Field(default=None, description='A brief description of the document.') class-attribute instance-attribute
is_reference = Field(..., description='Indicates if the document is a reference guide.') class-attribute instance-attribute
project = Field(..., description='The project to which the document belongs.') class-attribute instance-attribute
relevance_score = Field(default=None, description='Relevance score of the document, where 1 is the highest score indicating an exact match.') class-attribute instance-attribute
source_path = Field(..., description='The path to the document within the project.') class-attribute instance-attribute
source_url = Field(..., description='The URL to the source document.') class-attribute instance-attribute
title = Field(..., description='The title of the document.') class-attribute instance-attribute
url = Field(..., description='The URL of the rendered, target document.') class-attribute instance-attribute

Data

Data handling for the HoloViz Documentation MCP server.

logger = logging.getLogger(__name__) module-attribute

DocumentationIndexer

Handles cloning, processing, and indexing of documentation.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
class DocumentationIndexer:
    """Handles cloning, processing, and indexing of documentation."""

    def __init__(self, *, data_dir: Optional[Path] = None, repos_dir: Optional[Path] = None, vector_dir: Optional[Path] = None):
        """Initialize the DocumentationIndexer.

        Args:
            data_dir: Directory to store index data. Defaults to user config directory.
            repos_dir: Directory to store cloned repositories. Defaults to HOLOVIZ_MCP_REPOS_DIR.
            vector_dir: Directory to store vector database. Defaults to config.vector_dir
        """
        # Use unified config for default paths
        config = self._holoviz_mcp_config = get_config()

        self.data_dir = data_dir or config.user_dir
        self.data_dir.mkdir(parents=True, exist_ok=True)

        # Use configurable repos directory for repository downloads
        self.repos_dir = repos_dir or config.repos_dir
        self.repos_dir.mkdir(parents=True, exist_ok=True)

        # Use configurable directory for vector database path
        vector_db_path = vector_dir or config.server.vector_db_path
        vector_db_path.parent.mkdir(parents=True, exist_ok=True)

        # Disable ChromaDB telemetry based on config
        if not config.server.anonymized_telemetry:
            os.environ["ANONYMIZED_TELEMETRY"] = "False"

        # Initialize ChromaDB
        self.chroma_client = chromadb.PersistentClient(path=str(vector_db_path))
        self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)

        # Initialize notebook converter
        self.nb_exporter = MarkdownExporter()

        # Load documentation config from the centralized config system
        self.config = get_config().docs

    def is_indexed(self) -> bool:
        """Check if documentation index exists and is valid."""
        try:
            count = self.collection.count()
            return count > 0
        except Exception:
            return False

    async def ensure_indexed(self, ctx: Context | None = None):
        """Ensure documentation is indexed, creating if necessary."""
        if not self.is_indexed():
            await log_info("Documentation index not found. Creating initial index...", ctx)
            await self.index_documentation()

    async def clone_or_update_repo(self, repo_name: str, repo_config: "GitRepository", ctx: Context | None = None) -> Optional[Path]:
        """Clone or update a single repository."""
        repo_path = self.repos_dir / repo_name

        try:
            if repo_path.exists():
                # Update existing repository
                await log_info(f"Updating {repo_name} repository at {repo_path}...", ctx)
                repo = git.Repo(repo_path)
                repo.remotes.origin.pull()
            else:
                # Clone new repository
                await log_info(f"Cloning {repo_name} repository to {repo_path}...", ctx)
                clone_kwargs: dict[str, Any] = {"depth": 1}  # Shallow clone for efficiency

                # Add branch, tag, or commit if specified
                if repo_config.branch:
                    clone_kwargs["branch"] = repo_config.branch
                elif repo_config.tag:
                    clone_kwargs["branch"] = repo_config.tag
                elif repo_config.commit:
                    # For specific commits, we need to clone and then checkout
                    git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs)
                    repo = git.Repo(repo_path)
                    repo.git.checkout(repo_config.commit)
                    return repo_path

                git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs)

            return repo_path
        except Exception as e:
            msg = f"Failed to clone/update {repo_name}: {e}"
            await log_warning(msg, ctx)  # Changed from log_exception to log_warning so it doesn't raise
            return None

    def _is_reference_document(self, file_path: Path, project: str, folder_name: str = "") -> bool:
        """Check if the document is a reference document using configurable patterns.

        Args:
            file_path: Full path to the file
            project: Project name
            folder_name: Name of the folder this file belongs to

        Returns
        -------
            bool: True if this is a reference document
        """
        repo_config = self.config.repositories[project]
        repo_path = self.repos_dir / project

        try:
            relative_path = file_path.relative_to(repo_path)

            # Check against configured reference patterns
            for pattern in repo_config.reference_patterns:
                if relative_path.match(pattern):
                    return True

            # Fallback to simple "reference" in path check
            return "reference" in relative_path.parts
        except (ValueError, KeyError):
            # If we can't determine relative path or no patterns configured, use simple fallback
            return "reference" in file_path.parts

    def _generate_doc_id(self, project: str, path: Path) -> str:
        """Generate a unique document ID from project and path."""
        readable_path = str(path).replace("/", "___").replace(".", "_")
        readable_id = f"{project}___{readable_path}"

        return readable_id

    def _generate_doc_url(self, project: str, path: Path, folder_name: str = "") -> str:
        """Generate documentation URL for a file.

        This method creates the final URL where the documentation can be accessed online.
        It handles folder URL mapping to ensure proper URL structure for different documentation layouts.

        Args:
            project: Name of the project/repository (e.g., "panel", "hvplot")
            path: Relative path to the file within the repository
            folder_name: Name of the folder containing the file (e.g., "examples/reference", "doc")
                       Used for URL path mapping when folders have custom URL structures

        Returns
        -------
            Complete URL to the documentation file

        Examples
        --------
            For Panel reference guides:
            - Input: project="panel", path="examples/reference/widgets/Button.ipynb", folder_name="examples/reference"
            - Output: "https://panel.holoviz.org/reference/widgets/Button.html"

            For regular documentation:
            - Input: project="panel", path="doc/getting_started.md", folder_name="doc"
            - Output: "https://panel.holoviz.org/getting_started.html"
        """
        repo_config = self.config.repositories[project]
        base_url = str(repo_config.base_url).rstrip("/")

        # Get the URL path mapping for this folder
        folder_url_path = repo_config.get_folder_url_path(folder_name)

        # If there's a folder URL mapping, we need to adjust the path
        if folder_url_path and folder_name:
            # Remove the folder name from the beginning of the path
            path_str = str(path)

            # Check if path starts with the folder name
            if path_str.startswith(folder_name + "/"):
                # Remove the folder prefix and leading slash
                remaining_path = path_str[len(folder_name) + 1 :]
                adjusted_path = Path(remaining_path) if remaining_path else Path(".")
            elif path_str == folder_name:
                # The path is exactly the folder name
                adjusted_path = Path(".")
            else:
                # Fallback: try to remove folder parts from the beginning
                path_parts = list(path.parts)
                folder_parts = folder_name.split("/")
                for folder_part in folder_parts:
                    if path_parts and path_parts[0] == folder_part:
                        path_parts = path_parts[1:]
                adjusted_path = Path(*path_parts) if path_parts else Path(".")

            # Don't remove first part since we already adjusted the path
            doc_path = convert_path_to_url(adjusted_path, remove_first_part=False, url_transform=repo_config.url_transform)
        else:
            # Convert file path to URL format normally (remove first part for legacy compatibility)
            doc_path = convert_path_to_url(path, remove_first_part=True, url_transform=repo_config.url_transform)

        # Combine base URL, folder URL path, and document path
        if folder_url_path:
            full_url = f"{base_url}{folder_url_path}/{doc_path}"
        else:
            full_url = f"{base_url}/{doc_path}"

        return full_url.replace("//", "/").replace(":/", "://")  # Fix double slashes

    @staticmethod
    def _to_title(fallback_filename: str = "") -> str:
        """Extract title from a filename or return a default title."""
        title = Path(fallback_filename).stem
        if "_" in title and title.split("_")[0].isdigit():
            title = title.split("_", 1)[-1]
        title = title.replace("_", " ").replace("-", " ").title()
        return title

    @classmethod
    def _extract_title_from_markdown(cls, content: str, fallback_filename: str = "") -> str:
        """Extract title from markdown content, with filename fallback."""
        lines = content.split("\n")
        for line in lines:
            line = line.strip()
            if line.startswith("# "):
                # Return just the title text without the "# " prefix
                return line[2:].strip()
            if line.startswith("##"):
                break

        if fallback_filename:
            return cls._to_title(fallback_filename)

        return "No Title"

    @staticmethod
    def _extract_description_from_markdown(content: str, max_length=200) -> str:
        """Extract description from markdown content."""
        content = content.strip()

        # Plotly documents start with --- ... --- section. Skip the section
        if content.startswith("---"):
            content = content.split("---", 2)[-1].strip()

        lines = content.split("\n")
        clean_lines = []
        in_code_block = False

        for line in lines:
            if line.strip().startswith("```"):
                in_code_block = not in_code_block
                continue

            if in_code_block or line.startswith(("#", "    ", "\t", "---", "___")):
                continue

            clean_lines.append(line)

        # Join lines and clean up
        clean_content = "\n".join(clean_lines).strip()

        # Remove extra whitespace and limit length
        clean_content = " ".join(clean_content.split())

        if len(clean_content) > max_length:
            clean_content = clean_content[:max_length].rsplit(" ", 1)[0]
        if not clean_content.endswith("."):
            clean_content += " ..."

        return clean_content

    def convert_notebook_to_markdown(self, notebook_path: Path) -> str:
        """Convert a Jupyter notebook to markdown."""
        try:
            with open(notebook_path, "r", encoding="utf-8") as f:
                notebook = nbread(f, as_version=4)

            (body, resources) = self.nb_exporter.from_notebook_node(notebook)
            return body
        except Exception as e:
            logger.error(f"Failed to convert notebook {notebook_path}: {e}")
            return str(e)

    @staticmethod
    def _to_source_url(file_path: Path, repo_config: GitRepository, raw: bool = False) -> str:
        """Generate source URL for a file based on repository configuration."""
        url = str(repo_config.url)
        branch = repo_config.branch or "main"
        if url.startswith("https://github.com") and url.endswith(".git"):
            url = url.replace("https://github.com/", "").replace(".git", "")
            project, repository = url.split("/")
            if raw:
                return f"https://raw.githubusercontent.com/{project}/{repository}/refs/heads/{branch}/{file_path}"

            return f"https://github.com/{project}/{repository}/blob/{branch}/{file_path}"
        if "dev.azure.com" in url:
            organisation = url.split("/")[3].split("@")[0]
            project = url.split("/")[-3]
            repo_name = url.split("/")[-1]
            if raw:
                return f"https://dev.azure.com/{organisation}/{project}/_apis/sourceProviders/TfsGit/filecontents?repository={repo_name}&path=/{file_path}&commitOrBranch={branch}&api-version=7.0"

            return f"https://dev.azure.com/{organisation}/{project}/_git/{repo_name}?path=/{file_path}&version=GB{branch}"

        raise ValueError(f"Unsupported repository URL format: {url}. Please provide a valid GitHub or Azure DevOps URL.")

    def process_file(self, file_path: Path, project: str, repo_config: GitRepository, folder_name: str = "") -> Optional[dict[str, Any]]:
        """Process a file and extract metadata."""
        try:
            if file_path.suffix == ".ipynb":
                content = self.convert_notebook_to_markdown(file_path)
            elif file_path.suffix in [".md", ".rst", ".txt"]:
                with open(file_path, "r", encoding="utf-8") as f:
                    content = f.read()
            else:
                logger.debug(f"Skipping unsupported file type: {file_path}")
                return None

            title = self._extract_title_from_markdown(content, file_path.name)
            if not title:
                title = file_path.stem.replace("_", " ").title()

            description = self._extract_description_from_markdown(content)

            repo_path = self.repos_dir / project
            relative_path = file_path.relative_to(repo_path)

            doc_id = self._generate_doc_id(project, relative_path)

            is_reference = self._is_reference_document(file_path, project, folder_name)

            source_url = self._to_source_url(relative_path, repo_config)

            return {
                "id": doc_id,
                "title": title,
                "url": self._generate_doc_url(project, relative_path, folder_name),
                "project": project,
                "source_path": str(relative_path),
                "source_path_stem": file_path.stem,
                "source_url": source_url,
                "description": description,
                "content": content,
                "is_reference": is_reference,
            }
        except Exception as e:
            logger.error(f"Failed to process file {file_path}: {e}")
            return None

    async def extract_docs_from_repo(self, repo_path: Path, project: str, ctx: Context | None = None) -> list[dict[str, Any]]:
        """Extract documentation files from a repository."""
        docs = []
        repo_config = self.config.repositories[project]

        # Use the new folder structure with URL path mapping
        if isinstance(repo_config.folders, dict):
            folders = repo_config.folders
        else:
            # Convert list to dict with default FolderConfig
            folders = {name: FolderConfig() for name in repo_config.folders}

        files: set = set()
        await log_info(f"Processing {project} documentation files in {','.join(folders.keys())}", ctx)

        for folder_name in folders.keys():
            docs_folder: Path = repo_path / folder_name
            if docs_folder.exists():
                # Use index patterns from config
                for pattern in self.config.index_patterns:
                    files.update(docs_folder.glob(pattern))

        for file in files:
            if file.exists() and not file.is_dir():
                # Determine which folder this file belongs to
                folder_name = ""
                for fname in folders.keys():
                    folder_path = repo_path / fname
                    try:
                        file.relative_to(folder_path)
                        folder_name = fname
                        break
                    except ValueError:
                        continue

                doc_data = self.process_file(file, project, repo_config, folder_name)
                if doc_data:
                    docs.append(doc_data)

        # Count reference vs regular documents
        reference_count = sum(1 for doc in docs if doc["is_reference"])
        regular_count = len(docs) - reference_count

        await log_info(f"  📄 {project}: {len(docs)} total documents ({regular_count} regular, {reference_count} reference guides)", ctx)
        return docs

    async def index_documentation(self, ctx: Context | None = None):
        """Indexes all documentation."""
        await log_info("Starting documentation indexing...", ctx)

        all_docs = []

        # Clone/update repositories and extract documentation
        for repo_name, repo_config in self.config.repositories.items():
            await log_info(f"Processing {repo_name}...", ctx)
            repo_path = await self.clone_or_update_repo(repo_name, repo_config)
            if repo_path:
                docs = await self.extract_docs_from_repo(repo_path, repo_name, ctx)
                all_docs.extend(docs)

        if not all_docs:
            await log_warning("No documentation found to index", ctx)
            return

        # Validate for duplicate IDs and log details
        await self._validate_unique_ids(all_docs)

        # Clear existing collection
        await log_info("Clearing existing index...", ctx)

        # Only delete if collection has data
        try:
            count = self.collection.count()
            if count > 0:
                # Delete all documents by getting all IDs first
                results = self.collection.get()
                if results["ids"]:
                    self.collection.delete(ids=results["ids"])
        except Exception as e:
            logger.warning(f"Failed to clear existing collection: {e}")
            # If clearing fails, recreate the collection
            try:
                self.chroma_client.delete_collection("holoviz_docs")
                self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
            except Exception as e2:
                await log_exception(f"Failed to recreate collection: {e2}", ctx)
                raise

        # Add documents to ChromaDB
        await log_info(f"Adding {len(all_docs)} documents to index...", ctx)

        self.collection.add(
            documents=[doc["content"] for doc in all_docs],
            metadatas=[
                {
                    "title": doc["title"],
                    "url": doc["url"],
                    "project": doc["project"],
                    "source_path": doc["source_path"],
                    "source_path_stem": doc["source_path_stem"],
                    "source_url": doc["source_url"],
                    "description": doc["description"],
                    "is_reference": doc["is_reference"],
                }
                for doc in all_docs
            ],
            ids=[doc["id"] for doc in all_docs],
        )

        await log_info(f"✅ Successfully indexed {len(all_docs)} documents", ctx)
        await log_info(f"📊 Vector database stored at: {self.data_dir / 'chroma'}", ctx)
        await log_info(f"🔍 Index contains {self.collection.count()} total documents", ctx)

        # Show detailed summary table
        await self._log_summary_table(ctx)

    async def _validate_unique_ids(self, all_docs: list[dict[str, Any]], ctx: Context | None = None) -> None:
        """Validate that all document IDs are unique and log duplicates."""
        seen_ids: dict = {}
        duplicates = []

        for doc in all_docs:
            doc_id = doc["id"]
            if doc_id in seen_ids:
                duplicates.append(
                    {
                        "id": doc_id,
                        "first_doc": seen_ids[doc_id],
                        "duplicate_doc": {"project": doc["project"], "source_path": doc["source_path"], "title": doc["title"]},
                    }
                )

                await log_warning(f"DUPLICATE ID FOUND: {doc_id}", ctx)
                await log_warning(f"  First document: {seen_ids[doc_id]['project']}/{seen_ids[doc_id]['path']} - {seen_ids[doc_id]['title']}", ctx)
                await log_warning(f"  Duplicate document: {doc['project']}/{doc['path']} - {doc['title']}", ctx)
            else:
                seen_ids[doc_id] = {"project": doc["project"], "source_path": doc["source_path"], "title": doc["title"]}

        if duplicates:
            error_msg = f"Found {len(duplicates)} duplicate document IDs"
            await log_exception(error_msg, ctx)

            # Log all duplicates for debugging
            for dup in duplicates:
                await log_exception(
                    f"Duplicate ID '{dup['id']}': {dup['first_doc']['project']}/{dup['first_doc']['path']} vs {dup['duplicate_doc']['project']}/{dup['duplicate_doc']['path']}",  # noqa: D401, E501
                    ctx,
                )

            raise ValueError(f"Document ID collision detected. {len(duplicates)} duplicate IDs found. Check logs for details.")

    async def search_get_reference_guide(self, component: str, project: Optional[str] = None, content: bool = True, ctx: Context | None = None) -> list[Document]:
        """Search for reference guides for a specific component."""
        await self.ensure_indexed()

        # Build search strategies
        filters: list[dict[str, Any]] = []
        if project:
            filters.append({"project": str(project)})
        filters.append({"source_path_stem": str(component)})
        filters.append({"is_reference": True})
        where_clause: dict[str, Any] = {"$and": filters} if len(filters) > 1 else filters[0]

        all_results = []

        filename_results = self.collection.query(query_texts=[component], n_results=1000, where=where_clause)
        if filename_results["ids"] and filename_results["ids"][0]:
            for i, _ in enumerate(filename_results["ids"][0]):
                if filename_results["metadatas"] and filename_results["metadatas"][0]:
                    metadata = filename_results["metadatas"][0][i]
                    # Include content if requested
                    content_text = filename_results["documents"][0][i] if (content and filename_results["documents"]) else None

                    # Safe URL construction
                    url_value = metadata.get("url", "https://example.com")
                    if not url_value or url_value == "None" or not isinstance(url_value, str):
                        url_value = "https://example.com"

                    # Give exact filename matches a high relevance score
                    relevance_score = 1.0  # Highest priority for exact filename matches

                    document = Document(
                        title=str(metadata["title"]),
                        url=HttpUrl(url_value),
                        project=str(metadata["project"]),
                        source_path=str(metadata["source_path"]),
                        source_url=HttpUrl(str(metadata.get("source_url", ""))),
                        description=str(metadata["description"]),
                        is_reference=bool(metadata["is_reference"]),
                        content=content_text,
                        relevance_score=relevance_score,
                    )

                    if project and document.project != project:
                        await log_exception(f"Project mismatch for component '{component}': expected '{project}', got '{document.project}'", ctx)
                    elif metadata["source_path_stem"] != component:
                        await log_exception(f"Path stem mismatch for component '{component}': expected '{component}', got '{metadata['source_path_stem']}'", ctx)
                    else:
                        all_results.append(document)
        return all_results

    async def search(self, query: str, project: Optional[str] = None, content: bool = True, max_results: int = 5, ctx: Context | None = None) -> list[Document]:
        """Search the documentation using semantic similarity."""
        await self.ensure_indexed(ctx=ctx)

        # Build where clause for filtering
        where_clause = {"project": str(project)} if project else None

        try:
            # Perform vector similarity search
            results = self.collection.query(query_texts=[query], n_results=max_results, where=where_clause)  # type: ignore[arg-type]

            documents = []
            if results["ids"] and results["ids"][0]:
                for i, _ in enumerate(results["ids"][0]):
                    if results["metadatas"] and results["metadatas"][0]:
                        metadata = results["metadatas"][0][i]

                        # Include content if requested
                        content_text = results["documents"][0][i] if (content and results["documents"]) else None

                        # Safe URL construction
                        url_value = metadata.get("url", "https://example.com")
                        if not url_value or url_value == "None" or not isinstance(url_value, str):
                            url_value = "https://example.com"

                        # Safe relevance score calculation
                        relevance_score = None
                        if (
                            results.get("distances")
                            and isinstance(results["distances"], list)
                            and len(results["distances"]) > 0
                            and isinstance(results["distances"][0], list)
                            and len(results["distances"][0]) > i
                        ):
                            try:
                                relevance_score = (2.0 - float(results["distances"][0][i])) / 2.0
                            except (ValueError, TypeError):
                                relevance_score = None

                        document = Document(
                            title=str(metadata["title"]),
                            url=HttpUrl(url_value),
                            project=str(metadata["project"]),
                            source_path=str(metadata["source_path"]),
                            source_url=HttpUrl(str(metadata.get("source_url", ""))),
                            description=str(metadata["description"]),
                            is_reference=bool(metadata["is_reference"]),
                            content=content_text,
                            relevance_score=relevance_score,
                        )
                        documents.append(document)
            return documents
        except Exception as e:
            raise e

    async def get_document(self, path: str, project: str, ctx: Context | None = None) -> Document:
        """Get a specific document."""
        await self.ensure_indexed(ctx=ctx)

        # Build where clause for filtering
        filters: list[dict[str, str]] = [{"project": str(project)}, {"source_path": str(path)}]
        where_clause: dict[str, Any] = {"$and": filters}

        # Perform vector similarity search
        results = self.collection.query(query_texts=[""], n_results=3, where=where_clause)

        documents = []
        if results["ids"] and results["ids"][0]:
            for i, _ in enumerate(results["ids"][0]):
                if results["metadatas"] and results["metadatas"][0]:
                    metadata = results["metadatas"][0][i]

                    # Include content if requested
                    content_text = results["documents"][0][i] if results["documents"] else None

                    # Safe URL construction
                    url_value = metadata.get("url", "https://example.com")
                    if not url_value or url_value == "None" or not isinstance(url_value, str):
                        url_value = "https://example.com"

                    # Safe relevance score calculation
                    relevance_score = None
                    if (
                        results.get("distances")
                        and isinstance(results["distances"], list)
                        and len(results["distances"]) > 0
                        and isinstance(results["distances"][0], list)
                        and len(results["distances"][0]) > i
                    ):
                        try:
                            relevance_score = 1.0 - float(results["distances"][0][i])
                        except (ValueError, TypeError):
                            relevance_score = None

                    document = Document(
                        title=str(metadata["title"]),
                        url=HttpUrl(url_value),
                        project=str(metadata["project"]),
                        source_path=str(metadata["source_path"]),
                        source_url=HttpUrl(str(metadata.get("source_url", ""))),
                        description=str(metadata["description"]),
                        is_reference=bool(metadata["is_reference"]),
                        content=content_text,
                        relevance_score=relevance_score,
                    )
                    documents.append(document)

        if len(documents) > 1:
            raise ValueError(f"Multiple documents found for path '{path}' in project '{project}'. Please ensure unique paths.")
        elif len(documents) == 0:
            raise ValueError(f"No document found for path '{path}' in project '{project}'.")
        return documents[0]

    async def list_projects(self) -> list[str]:
        """List all available projects with documentation in the index.

        Returns
        -------
        list[str]: A list of project names that have documentation available.
                   Names are returned in hyphenated format (e.g., "panel-material-ui").
        """
        await self.ensure_indexed()

        try:
            # Get all documents from the collection to extract unique project names
            results = self.collection.get()

            if not results["metadatas"]:
                return []

            # Extract unique project names
            projects = set()
            for metadata in results["metadatas"]:
                project = metadata.get("project")
                if project:
                    # Convert underscored names to hyphenated format for consistency
                    project_name = str(project).replace("_", "-")
                    projects.add(project_name)

            # Return sorted list
            return sorted(projects)

        except Exception as e:
            logger.error(f"Failed to list projects: {e}")
            return []

    async def _log_summary_table(self, ctx: Context | None = None):
        """Log a summary table showing document counts by repository."""
        try:
            # Get all documents from the collection
            results = self.collection.get()

            if not results["metadatas"]:
                await log_info("No documents found in index", ctx)
                return

            # Count documents by project and type
            project_stats: dict[str, dict[str, int]] = {}
            for metadata in results["metadatas"]:
                project = str(metadata.get("project", "unknown"))
                is_reference = metadata.get("is_reference", False)

                if project not in project_stats:
                    project_stats[project] = {"total": 0, "regular": 0, "reference": 0}

                project_stats[project]["total"] += 1
                if is_reference:
                    project_stats[project]["reference"] += 1
                else:
                    project_stats[project]["regular"] += 1

            # Log summary table
            await log_info("", ctx)
            await log_info("📊 Document Summary by Repository:", ctx)
            await log_info("=" * 60, ctx)
            await log_info(f"{'Repository':<20} {'Total':<8} {'Regular':<8} {'Reference':<10}", ctx)
            await log_info("-" * 60, ctx)

            total_docs = 0
            total_regular = 0
            total_reference = 0

            for project in sorted(project_stats.keys()):
                stats = project_stats[project]
                await log_info(f"{project:<20} {stats['total']:<8} {stats['regular']:<8} {stats['reference']:<10}", ctx)
                total_docs += stats["total"]
                total_regular += stats["regular"]
                total_reference += stats["reference"]

            await log_info("-" * 60, ctx)
            await log_info(f"{'TOTAL':<20} {total_docs:<8} {total_regular:<8} {total_reference:<10}", ctx)
            await log_info("=" * 60, ctx)

        except Exception as e:
            await log_warning(f"Failed to generate summary table: {e}", ctx)

    def run(self):
        """Update the DocumentationIndexer."""
        # Configure logging for the CLI
        logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[logging.StreamHandler()])

        logger.info("🚀 HoloViz MCP Documentation Indexer")
        logger.info("=" * 50)

        async def run_indexer(indexer=self):
            logger.info(f"📦 Default config: {indexer._holoviz_mcp_config.config_file_path(location='default')}")
            logger.info(f"🏠 User config: {indexer._holoviz_mcp_config.config_file_path(location='user')}")
            logger.info(f"📁 Repository directory: {indexer.repos_dir}")
            logger.info(f"💾 Vector database: {indexer.data_dir / 'chroma'}")
            logger.info(f"🔧 Configured repositories: {len(indexer.config.repositories)}")
            logger.info("")

            await indexer.index_documentation()

            # Final summary
            count = indexer.collection.count()
            logger.info("")
            logger.info("=" * 50)
            logger.info("✅ Indexing completed successfully!")
            logger.info(f"📊 Total documents in database: {count}")
            logger.info("=" * 50)

        asyncio.run(run_indexer())
chroma_client = chromadb.PersistentClient(path=(str(vector_db_path))) instance-attribute
collection = self.chroma_client.get_or_create_collection('holoviz_docs', configuration=_CROMA_CONFIGURATION) instance-attribute
config = get_config().docs instance-attribute
data_dir = data_dir or config.user_dir instance-attribute
nb_exporter = MarkdownExporter() instance-attribute
repos_dir = repos_dir or config.repos_dir instance-attribute
clone_or_update_repo(repo_name, repo_config, ctx=None) async

Clone or update a single repository.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def clone_or_update_repo(self, repo_name: str, repo_config: "GitRepository", ctx: Context | None = None) -> Optional[Path]:
    """Clone or update a single repository."""
    repo_path = self.repos_dir / repo_name

    try:
        if repo_path.exists():
            # Update existing repository
            await log_info(f"Updating {repo_name} repository at {repo_path}...", ctx)
            repo = git.Repo(repo_path)
            repo.remotes.origin.pull()
        else:
            # Clone new repository
            await log_info(f"Cloning {repo_name} repository to {repo_path}...", ctx)
            clone_kwargs: dict[str, Any] = {"depth": 1}  # Shallow clone for efficiency

            # Add branch, tag, or commit if specified
            if repo_config.branch:
                clone_kwargs["branch"] = repo_config.branch
            elif repo_config.tag:
                clone_kwargs["branch"] = repo_config.tag
            elif repo_config.commit:
                # For specific commits, we need to clone and then checkout
                git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs)
                repo = git.Repo(repo_path)
                repo.git.checkout(repo_config.commit)
                return repo_path

            git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs)

        return repo_path
    except Exception as e:
        msg = f"Failed to clone/update {repo_name}: {e}"
        await log_warning(msg, ctx)  # Changed from log_exception to log_warning so it doesn't raise
        return None
convert_notebook_to_markdown(notebook_path)

Convert a Jupyter notebook to markdown.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def convert_notebook_to_markdown(self, notebook_path: Path) -> str:
    """Convert a Jupyter notebook to markdown."""
    try:
        with open(notebook_path, "r", encoding="utf-8") as f:
            notebook = nbread(f, as_version=4)

        (body, resources) = self.nb_exporter.from_notebook_node(notebook)
        return body
    except Exception as e:
        logger.error(f"Failed to convert notebook {notebook_path}: {e}")
        return str(e)
ensure_indexed(ctx=None) async

Ensure documentation is indexed, creating if necessary.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def ensure_indexed(self, ctx: Context | None = None):
    """Ensure documentation is indexed, creating if necessary."""
    if not self.is_indexed():
        await log_info("Documentation index not found. Creating initial index...", ctx)
        await self.index_documentation()
extract_docs_from_repo(repo_path, project, ctx=None) async

Extract documentation files from a repository.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def extract_docs_from_repo(self, repo_path: Path, project: str, ctx: Context | None = None) -> list[dict[str, Any]]:
    """Extract documentation files from a repository."""
    docs = []
    repo_config = self.config.repositories[project]

    # Use the new folder structure with URL path mapping
    if isinstance(repo_config.folders, dict):
        folders = repo_config.folders
    else:
        # Convert list to dict with default FolderConfig
        folders = {name: FolderConfig() for name in repo_config.folders}

    files: set = set()
    await log_info(f"Processing {project} documentation files in {','.join(folders.keys())}", ctx)

    for folder_name in folders.keys():
        docs_folder: Path = repo_path / folder_name
        if docs_folder.exists():
            # Use index patterns from config
            for pattern in self.config.index_patterns:
                files.update(docs_folder.glob(pattern))

    for file in files:
        if file.exists() and not file.is_dir():
            # Determine which folder this file belongs to
            folder_name = ""
            for fname in folders.keys():
                folder_path = repo_path / fname
                try:
                    file.relative_to(folder_path)
                    folder_name = fname
                    break
                except ValueError:
                    continue

            doc_data = self.process_file(file, project, repo_config, folder_name)
            if doc_data:
                docs.append(doc_data)

    # Count reference vs regular documents
    reference_count = sum(1 for doc in docs if doc["is_reference"])
    regular_count = len(docs) - reference_count

    await log_info(f"  📄 {project}: {len(docs)} total documents ({regular_count} regular, {reference_count} reference guides)", ctx)
    return docs
get_document(path, project, ctx=None) async

Get a specific document.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def get_document(self, path: str, project: str, ctx: Context | None = None) -> Document:
    """Get a specific document."""
    await self.ensure_indexed(ctx=ctx)

    # Build where clause for filtering
    filters: list[dict[str, str]] = [{"project": str(project)}, {"source_path": str(path)}]
    where_clause: dict[str, Any] = {"$and": filters}

    # Perform vector similarity search
    results = self.collection.query(query_texts=[""], n_results=3, where=where_clause)

    documents = []
    if results["ids"] and results["ids"][0]:
        for i, _ in enumerate(results["ids"][0]):
            if results["metadatas"] and results["metadatas"][0]:
                metadata = results["metadatas"][0][i]

                # Include content if requested
                content_text = results["documents"][0][i] if results["documents"] else None

                # Safe URL construction
                url_value = metadata.get("url", "https://example.com")
                if not url_value or url_value == "None" or not isinstance(url_value, str):
                    url_value = "https://example.com"

                # Safe relevance score calculation
                relevance_score = None
                if (
                    results.get("distances")
                    and isinstance(results["distances"], list)
                    and len(results["distances"]) > 0
                    and isinstance(results["distances"][0], list)
                    and len(results["distances"][0]) > i
                ):
                    try:
                        relevance_score = 1.0 - float(results["distances"][0][i])
                    except (ValueError, TypeError):
                        relevance_score = None

                document = Document(
                    title=str(metadata["title"]),
                    url=HttpUrl(url_value),
                    project=str(metadata["project"]),
                    source_path=str(metadata["source_path"]),
                    source_url=HttpUrl(str(metadata.get("source_url", ""))),
                    description=str(metadata["description"]),
                    is_reference=bool(metadata["is_reference"]),
                    content=content_text,
                    relevance_score=relevance_score,
                )
                documents.append(document)

    if len(documents) > 1:
        raise ValueError(f"Multiple documents found for path '{path}' in project '{project}'. Please ensure unique paths.")
    elif len(documents) == 0:
        raise ValueError(f"No document found for path '{path}' in project '{project}'.")
    return documents[0]
index_documentation(ctx=None) async

Indexes all documentation.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def index_documentation(self, ctx: Context | None = None):
    """Indexes all documentation."""
    await log_info("Starting documentation indexing...", ctx)

    all_docs = []

    # Clone/update repositories and extract documentation
    for repo_name, repo_config in self.config.repositories.items():
        await log_info(f"Processing {repo_name}...", ctx)
        repo_path = await self.clone_or_update_repo(repo_name, repo_config)
        if repo_path:
            docs = await self.extract_docs_from_repo(repo_path, repo_name, ctx)
            all_docs.extend(docs)

    if not all_docs:
        await log_warning("No documentation found to index", ctx)
        return

    # Validate for duplicate IDs and log details
    await self._validate_unique_ids(all_docs)

    # Clear existing collection
    await log_info("Clearing existing index...", ctx)

    # Only delete if collection has data
    try:
        count = self.collection.count()
        if count > 0:
            # Delete all documents by getting all IDs first
            results = self.collection.get()
            if results["ids"]:
                self.collection.delete(ids=results["ids"])
    except Exception as e:
        logger.warning(f"Failed to clear existing collection: {e}")
        # If clearing fails, recreate the collection
        try:
            self.chroma_client.delete_collection("holoviz_docs")
            self.collection = self.chroma_client.get_or_create_collection("holoviz_docs", configuration=_CROMA_CONFIGURATION)
        except Exception as e2:
            await log_exception(f"Failed to recreate collection: {e2}", ctx)
            raise

    # Add documents to ChromaDB
    await log_info(f"Adding {len(all_docs)} documents to index...", ctx)

    self.collection.add(
        documents=[doc["content"] for doc in all_docs],
        metadatas=[
            {
                "title": doc["title"],
                "url": doc["url"],
                "project": doc["project"],
                "source_path": doc["source_path"],
                "source_path_stem": doc["source_path_stem"],
                "source_url": doc["source_url"],
                "description": doc["description"],
                "is_reference": doc["is_reference"],
            }
            for doc in all_docs
        ],
        ids=[doc["id"] for doc in all_docs],
    )

    await log_info(f"✅ Successfully indexed {len(all_docs)} documents", ctx)
    await log_info(f"📊 Vector database stored at: {self.data_dir / 'chroma'}", ctx)
    await log_info(f"🔍 Index contains {self.collection.count()} total documents", ctx)

    # Show detailed summary table
    await self._log_summary_table(ctx)
is_indexed()

Check if documentation index exists and is valid.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def is_indexed(self) -> bool:
    """Check if documentation index exists and is valid."""
    try:
        count = self.collection.count()
        return count > 0
    except Exception:
        return False
list_projects() async

List all available projects with documentation in the index.

Returns:

Type Description
list[str]: A list of project names that have documentation available.

Names are returned in hyphenated format (e.g., "panel-material-ui").

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def list_projects(self) -> list[str]:
    """List all available projects with documentation in the index.

    Returns
    -------
    list[str]: A list of project names that have documentation available.
               Names are returned in hyphenated format (e.g., "panel-material-ui").
    """
    await self.ensure_indexed()

    try:
        # Get all documents from the collection to extract unique project names
        results = self.collection.get()

        if not results["metadatas"]:
            return []

        # Extract unique project names
        projects = set()
        for metadata in results["metadatas"]:
            project = metadata.get("project")
            if project:
                # Convert underscored names to hyphenated format for consistency
                project_name = str(project).replace("_", "-")
                projects.add(project_name)

        # Return sorted list
        return sorted(projects)

    except Exception as e:
        logger.error(f"Failed to list projects: {e}")
        return []
process_file(file_path, project, repo_config, folder_name='')

Process a file and extract metadata.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def process_file(self, file_path: Path, project: str, repo_config: GitRepository, folder_name: str = "") -> Optional[dict[str, Any]]:
    """Process a file and extract metadata."""
    try:
        if file_path.suffix == ".ipynb":
            content = self.convert_notebook_to_markdown(file_path)
        elif file_path.suffix in [".md", ".rst", ".txt"]:
            with open(file_path, "r", encoding="utf-8") as f:
                content = f.read()
        else:
            logger.debug(f"Skipping unsupported file type: {file_path}")
            return None

        title = self._extract_title_from_markdown(content, file_path.name)
        if not title:
            title = file_path.stem.replace("_", " ").title()

        description = self._extract_description_from_markdown(content)

        repo_path = self.repos_dir / project
        relative_path = file_path.relative_to(repo_path)

        doc_id = self._generate_doc_id(project, relative_path)

        is_reference = self._is_reference_document(file_path, project, folder_name)

        source_url = self._to_source_url(relative_path, repo_config)

        return {
            "id": doc_id,
            "title": title,
            "url": self._generate_doc_url(project, relative_path, folder_name),
            "project": project,
            "source_path": str(relative_path),
            "source_path_stem": file_path.stem,
            "source_url": source_url,
            "description": description,
            "content": content,
            "is_reference": is_reference,
        }
    except Exception as e:
        logger.error(f"Failed to process file {file_path}: {e}")
        return None
run()

Update the DocumentationIndexer.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def run(self):
    """Update the DocumentationIndexer."""
    # Configure logging for the CLI
    logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[logging.StreamHandler()])

    logger.info("🚀 HoloViz MCP Documentation Indexer")
    logger.info("=" * 50)

    async def run_indexer(indexer=self):
        logger.info(f"📦 Default config: {indexer._holoviz_mcp_config.config_file_path(location='default')}")
        logger.info(f"🏠 User config: {indexer._holoviz_mcp_config.config_file_path(location='user')}")
        logger.info(f"📁 Repository directory: {indexer.repos_dir}")
        logger.info(f"💾 Vector database: {indexer.data_dir / 'chroma'}")
        logger.info(f"🔧 Configured repositories: {len(indexer.config.repositories)}")
        logger.info("")

        await indexer.index_documentation()

        # Final summary
        count = indexer.collection.count()
        logger.info("")
        logger.info("=" * 50)
        logger.info("✅ Indexing completed successfully!")
        logger.info(f"📊 Total documents in database: {count}")
        logger.info("=" * 50)

    asyncio.run(run_indexer())
search(query, project=None, content=True, max_results=5, ctx=None) async

Search the documentation using semantic similarity.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def search(self, query: str, project: Optional[str] = None, content: bool = True, max_results: int = 5, ctx: Context | None = None) -> list[Document]:
    """Search the documentation using semantic similarity."""
    await self.ensure_indexed(ctx=ctx)

    # Build where clause for filtering
    where_clause = {"project": str(project)} if project else None

    try:
        # Perform vector similarity search
        results = self.collection.query(query_texts=[query], n_results=max_results, where=where_clause)  # type: ignore[arg-type]

        documents = []
        if results["ids"] and results["ids"][0]:
            for i, _ in enumerate(results["ids"][0]):
                if results["metadatas"] and results["metadatas"][0]:
                    metadata = results["metadatas"][0][i]

                    # Include content if requested
                    content_text = results["documents"][0][i] if (content and results["documents"]) else None

                    # Safe URL construction
                    url_value = metadata.get("url", "https://example.com")
                    if not url_value or url_value == "None" or not isinstance(url_value, str):
                        url_value = "https://example.com"

                    # Safe relevance score calculation
                    relevance_score = None
                    if (
                        results.get("distances")
                        and isinstance(results["distances"], list)
                        and len(results["distances"]) > 0
                        and isinstance(results["distances"][0], list)
                        and len(results["distances"][0]) > i
                    ):
                        try:
                            relevance_score = (2.0 - float(results["distances"][0][i])) / 2.0
                        except (ValueError, TypeError):
                            relevance_score = None

                    document = Document(
                        title=str(metadata["title"]),
                        url=HttpUrl(url_value),
                        project=str(metadata["project"]),
                        source_path=str(metadata["source_path"]),
                        source_url=HttpUrl(str(metadata.get("source_url", ""))),
                        description=str(metadata["description"]),
                        is_reference=bool(metadata["is_reference"]),
                        content=content_text,
                        relevance_score=relevance_score,
                    )
                    documents.append(document)
        return documents
    except Exception as e:
        raise e
search_get_reference_guide(component, project=None, content=True, ctx=None) async

Search for reference guides for a specific component.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def search_get_reference_guide(self, component: str, project: Optional[str] = None, content: bool = True, ctx: Context | None = None) -> list[Document]:
    """Search for reference guides for a specific component."""
    await self.ensure_indexed()

    # Build search strategies
    filters: list[dict[str, Any]] = []
    if project:
        filters.append({"project": str(project)})
    filters.append({"source_path_stem": str(component)})
    filters.append({"is_reference": True})
    where_clause: dict[str, Any] = {"$and": filters} if len(filters) > 1 else filters[0]

    all_results = []

    filename_results = self.collection.query(query_texts=[component], n_results=1000, where=where_clause)
    if filename_results["ids"] and filename_results["ids"][0]:
        for i, _ in enumerate(filename_results["ids"][0]):
            if filename_results["metadatas"] and filename_results["metadatas"][0]:
                metadata = filename_results["metadatas"][0][i]
                # Include content if requested
                content_text = filename_results["documents"][0][i] if (content and filename_results["documents"]) else None

                # Safe URL construction
                url_value = metadata.get("url", "https://example.com")
                if not url_value or url_value == "None" or not isinstance(url_value, str):
                    url_value = "https://example.com"

                # Give exact filename matches a high relevance score
                relevance_score = 1.0  # Highest priority for exact filename matches

                document = Document(
                    title=str(metadata["title"]),
                    url=HttpUrl(url_value),
                    project=str(metadata["project"]),
                    source_path=str(metadata["source_path"]),
                    source_url=HttpUrl(str(metadata.get("source_url", ""))),
                    description=str(metadata["description"]),
                    is_reference=bool(metadata["is_reference"]),
                    content=content_text,
                    relevance_score=relevance_score,
                )

                if project and document.project != project:
                    await log_exception(f"Project mismatch for component '{component}': expected '{project}', got '{document.project}'", ctx)
                elif metadata["source_path_stem"] != component:
                    await log_exception(f"Path stem mismatch for component '{component}': expected '{component}', got '{metadata['source_path_stem']}'", ctx)
                else:
                    all_results.append(document)
    return all_results

convert_path_to_url(path, remove_first_part=True, url_transform='holoviz')

Convert a relative file path to a URL path.

Converts file paths to web URLs by replacing file extensions with .html and optionally removing the first path component for legacy compatibility.

Args: path: The file path to convert remove_first_part: Whether to remove the first path component (legacy compatibility) url_transform: How to transform the file path into a URL:

    - "holoviz": Replace file extension with .html (default)
    - "plotly": Replace file extension with / (e.g., filename.md -> filename/)
    - "datashader": Remove leading index and replace file extension with .html (e.g., 01_filename.md -> filename.html)

Returns:

Type Description
URL path with .html extension

Examples:

>>> convert_path_to_url(Path("doc/getting_started.md"))
"getting_started.html"
>>> convert_path_to_url(Path("examples/reference/Button.ipynb"), False)
"examples/reference/Button.html"
>>> convert_path_to_url(Path("/doc/python/3d-axes.md"), False, "plotly")
"/doc/python/3d-axes/"
>>> convert_path_to_url(Path("/examples/user_guide/10_Performance.ipynb"), False, "datashader")
"/examples/user_guide/Performance.html"
Source code in src/holoviz_mcp/holoviz_mcp/data.py
def convert_path_to_url(path: Path, remove_first_part: bool = True, url_transform: Literal["holoviz", "plotly", "datashader"] = "holoviz") -> str:
    """Convert a relative file path to a URL path.

    Converts file paths to web URLs by replacing file extensions with .html
    and optionally removing the first path component for legacy compatibility.

    Args:
        path: The file path to convert
        remove_first_part: Whether to remove the first path component (legacy compatibility)
        url_transform: How to transform the file path into a URL:

            - "holoviz": Replace file extension with .html (default)
            - "plotly": Replace file extension with / (e.g., filename.md -> filename/)
            - "datashader": Remove leading index and replace file extension with .html (e.g., 01_filename.md -> filename.html)

    Returns
    -------
        URL path with .html extension

    Examples
    --------
        >>> convert_path_to_url(Path("doc/getting_started.md"))
        "getting_started.html"
        >>> convert_path_to_url(Path("examples/reference/Button.ipynb"), False)
        "examples/reference/Button.html"
        >>> convert_path_to_url(Path("/doc/python/3d-axes.md"), False, "plotly")
        "/doc/python/3d-axes/"
        >>> convert_path_to_url(Path("/examples/user_guide/10_Performance.ipynb"), False, "datashader")
        "/examples/user_guide/Performance.html"
    """
    if url_transform in ["holoviz", "datashader"]:
        path = remove_leading_number_sep_from_path(path)

    # Convert path to URL format
    parts = list(path.parts)

    # Only remove first part if requested (for legacy compatibility)
    if remove_first_part and parts:
        parts.pop(0)

    # Reconstruct path and convert to string
    if parts:
        url_path = str(Path(*parts))
    else:
        url_path = ""

    # Replace file extensions with suffix
    if url_path:
        path_obj = Path(url_path)
        if url_transform == "plotly":
            url_path = str(path_obj.with_suffix(suffix="")) + "/"
            if url_path.endswith("index/"):
                url_path = url_path[: -len("index/")] + "/"
        else:
            url_path = str(path_obj.with_suffix(suffix=".html"))

    return url_path

get_best_practices(project)

Get best practices for using a project with LLMs.

This function searches for best practices resources in user and default directories, with user resources taking precedence over default ones.

Args: project (str): The name of the project to get best practices for. Both hyphenated (e.g., "panel-material-ui") and underscored (e.g., "panel_material_ui") names are supported.

Returns:

Type Description
str: A string containing the best practices for the project in Markdown format.

Raises:

Type Description
FileNotFoundError: If no best practices file is found for the project.
Source code in src/holoviz_mcp/holoviz_mcp/data.py
def get_best_practices(project: str) -> str:
    """Get best practices for using a project with LLMs.

    This function searches for best practices resources in user and default directories,
    with user resources taking precedence over default ones.

    Args:
        project (str): The name of the project to get best practices for.
                      Both hyphenated (e.g., "panel-material-ui") and underscored
                      (e.g., "panel_material_ui") names are supported.

    Returns
    -------
        str: A string containing the best practices for the project in Markdown format.

    Raises
    ------
        FileNotFoundError: If no best practices file is found for the project.
    """
    config = get_config()

    # Convert underscored names to hyphenated for file lookup
    project_filename = project.replace("_", "-")

    # Search in user directory first, then default directory
    search_paths = [
        config.best_practices_dir("user"),
        config.best_practices_dir("default"),
    ]

    for search_dir in search_paths:
        best_practices_file = search_dir / f"{project_filename}.md"
        if best_practices_file.exists():
            return best_practices_file.read_text(encoding="utf-8")

    # If not found, raise error with helpful message
    available_files = []
    for search_dir in search_paths:
        if search_dir.exists():
            available_files.extend([f.stem for f in search_dir.glob("*.md")])

    available_str = ", ".join(set(available_files)) if available_files else "None"
    raise FileNotFoundError(
        f"Best practices file for project '{project}' not found. " f"Available projects: {available_str}. " f"Searched in: {[str(p) for p in search_paths]}"
    )

list_best_practices()

List all available best practices projects.

This function discovers available best practices from both user and default directories, with user resources taking precedence over default ones.

Returns:

Type Description
list[str]: A list of project names that have best practices available.

Names are returned in hyphenated format (e.g., "panel-material-ui").

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def list_best_practices() -> list[str]:
    """List all available best practices projects.

    This function discovers available best practices from both user and default directories,
    with user resources taking precedence over default ones.

    Returns
    -------
        list[str]: A list of project names that have best practices available.
                   Names are returned in hyphenated format (e.g., "panel-material-ui").
    """
    config = get_config()

    # Collect available projects from both directories
    available_projects = set()

    search_paths = [
        config.best_practices_dir("user"),
        config.best_practices_dir("default"),
    ]

    for search_dir in search_paths:
        if search_dir.exists():
            for md_file in search_dir.glob("*.md"):
                available_projects.add(md_file.stem)

    return sorted(list(available_projects))

log_exception(message, ctx=None) async

Log an error message to the context or logger.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def log_exception(message: str, ctx: Context | None = None):
    """Log an error message to the context or logger."""
    if ctx:
        await ctx.error(message)
    else:
        logger.error(message)
        raise Exception(message)

log_info(message, ctx=None) async

Log an info message to the context or logger.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def log_info(message: str, ctx: Context | None = None):
    """Log an info message to the context or logger."""
    if ctx:
        await ctx.info(message)
    else:
        logger.info(message)

log_warning(message, ctx=None) async

Log a warning message to the context or logger.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
async def log_warning(message: str, ctx: Context | None = None):
    """Log a warning message to the context or logger."""
    if ctx:
        await ctx.warning(message)
    else:
        logger.warning(message)

main()

Run the documentation indexer.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def main():
    """Run the documentation indexer."""
    DocumentationIndexer().run()

remove_leading_number_sep_from_path(p)

Remove a leading number + underscore or hyphen from the last path component.

Source code in src/holoviz_mcp/holoviz_mcp/data.py
def remove_leading_number_sep_from_path(p: Path) -> Path:
    """Remove a leading number + underscore or hyphen from the last path component."""
    new_name = re.sub(r"^\d+[_-]", "", p.name)
    return p.with_name(new_name)

Configuration

Configuration package for HoloViz MCP server.

ConfigLoader

Loads and manages HoloViz MCP configuration.

Source code in src/holoviz_mcp/config/loader.py
class ConfigLoader:
    """Loads and manages HoloViz MCP configuration."""

    def __init__(self, config: Optional[HoloVizMCPConfig] = None):
        """Initialize configuration loader.

        Args:
            config: Pre-configured HoloVizMCPConfig with environment paths.
                   If None, loads paths from environment. Configuration will
                   still be loaded from files even if this is provided.
        """
        self._env_config = config
        self._loaded_config: Optional[HoloVizMCPConfig] = None

    def load_config(self) -> HoloVizMCPConfig:
        """Load configuration from files and environment.

        Returns
        -------
            Loaded configuration.

        Raises
        ------
            ConfigurationError: If configuration cannot be loaded or is invalid.
        """
        if self._loaded_config is not None:
            return self._loaded_config

        # Get environment config (from parameter or environment)
        if self._env_config is not None:
            env_config = self._env_config
        else:
            env_config = HoloVizMCPConfig()

        # Start with default configuration dict
        config_dict = self._get_default_config()

        # Load from default config file if it exists
        default_config_file = env_config.default_dir / "config.yaml"
        if default_config_file.exists():
            try:
                default_config = self._load_yaml_file(default_config_file)
                config_dict = self._merge_configs(config_dict, default_config)
                logger.info(f"Loaded default configuration from {default_config_file}")
            except Exception as e:
                logger.warning(f"Failed to load default config from {default_config_file}: {e}")

        # Load from user config file if it exists
        user_config_file = env_config.config_file_path()
        if user_config_file.exists():
            user_config = self._load_yaml_file(user_config_file)
            # Filter out any unknown fields to prevent validation errors
            user_config = self._filter_known_fields(user_config)
            config_dict = self._merge_configs(config_dict, user_config)
            logger.info(f"Loaded user configuration from {user_config_file}")

        # Apply environment variable overrides
        config_dict = self._apply_env_overrides(config_dict)

        # Add the environment paths to the config dict
        config_dict.update(
            {
                "user_dir": env_config.user_dir,
                "default_dir": env_config.default_dir,
                "repos_dir": env_config.repos_dir,
            }
        )

        # Create the final configuration
        try:
            self._loaded_config = HoloVizMCPConfig(**config_dict)
        except ValidationError as e:
            raise ConfigurationError(f"Invalid configuration: {e}") from e

        return self._loaded_config

    def _filter_known_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
        """Filter out unknown fields that aren't part of the HoloVizMCPConfig schema.

        This prevents validation errors when loading user config files that might
        contain extra fields.
        """
        known_fields = {"server", "docs", "resources", "prompts", "user_dir", "default_dir", "repos_dir"}
        return {k: v for k, v in config_dict.items() if k in known_fields}

    def _get_default_config(self) -> dict[str, Any]:
        """Get default configuration dictionary."""
        return {
            "server": {
                "name": "holoviz-mcp",
                "version": "1.0.0",
                "description": "Model Context Protocol server for HoloViz ecosystem",
                "log_level": "INFO",
            },
            "docs": {
                "repositories": {},  # No more Python-side defaults!
                "index_patterns": ["**/*.md", "**/*.rst", "**/*.txt"],
                "exclude_patterns": ["**/node_modules/**", "**/.git/**", "**/build/**"],
                "max_file_size": 1024 * 1024,  # 1MB
                "update_interval": 86400,  # 24 hours
            },
            "resources": {"search_paths": []},
            "prompts": {"search_paths": []},
        }

    def _load_yaml_file(self, file_path: Path) -> dict[str, Any]:
        """Load YAML file safely.

        Args:
            file_path: Path to YAML file.

        Returns
        -------
            Parsed YAML content.

        Raises
        ------
            ConfigurationError: If file cannot be loaded or parsed.
        """
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                content = yaml.safe_load(f)
                if content is None:
                    return {}
                if not isinstance(content, dict):
                    raise ConfigurationError(f"Configuration file must contain a YAML dictionary: {file_path}")
                return content
        except yaml.YAMLError as e:
            raise ConfigurationError(f"Invalid YAML in {file_path}: {e}") from e
        except Exception as e:
            raise ConfigurationError(f"Failed to load {file_path}: {e}") from e

    def _merge_configs(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
        """Merge two configuration dictionaries recursively.

        Args:
            base: Base configuration.
            override: Override configuration.

        Returns
        -------
            Merged configuration.
        """
        result = base.copy()

        for key, value in override.items():
            if key in result and isinstance(result[key], dict) and isinstance(value, dict):
                result[key] = self._merge_configs(result[key], value)
            else:
                result[key] = value

        return result

    def _apply_env_overrides(self, config: dict[str, Any]) -> dict[str, Any]:
        """Apply environment variable overrides to configuration.

        Args:
            config: Configuration dictionary.

        Returns
        -------
            Configuration with environment overrides applied.
        """
        # Log level override
        if "HOLOVIZ_MCP_LOG_LEVEL" in os.environ:
            config.setdefault("server", {})["log_level"] = os.environ["HOLOVIZ_MCP_LOG_LEVEL"]

        # Server name override
        if "HOLOVIZ_MCP_SERVER_NAME" in os.environ:
            config.setdefault("server", {})["name"] = os.environ["HOLOVIZ_MCP_SERVER_NAME"]

        # Transport override
        if "HOLOVIZ_MCP_TRANSPORT" in os.environ:
            config.setdefault("server", {})["transport"] = os.environ["HOLOVIZ_MCP_TRANSPORT"]

        # Host override (for HTTP transport)
        if "HOLOVIZ_MCP_HOST" in os.environ:
            config.setdefault("server", {})["host"] = os.environ["HOLOVIZ_MCP_HOST"]

        # Port override (for HTTP transport)
        if "HOLOVIZ_MCP_PORT" in os.environ:
            port_str = os.environ["HOLOVIZ_MCP_PORT"]
            try:
                port = int(port_str)
                if not (1 <= port <= 65535):
                    raise ValueError(f"Port must be between 1 and 65535, got {port}")
                config.setdefault("server", {})["port"] = port
            except ValueError as e:
                raise ConfigurationError(f"Invalid HOLOVIZ_MCP_PORT: {port_str}") from e

        # Telemetry override
        if "ANONYMIZED_TELEMETRY" in os.environ:
            config.setdefault("server", {})["anonymized_telemetry"] = os.environ["ANONYMIZED_TELEMETRY"].lower() in ("true", "1", "yes", "on")

        # Jupyter proxy URL override
        if "JUPYTER_SERVER_PROXY_URL" in os.environ:
            config.setdefault("server", {})["jupyter_server_proxy_url"] = os.environ["JUPYTER_SERVER_PROXY_URL"]

        return config

    def get_repos_dir(self) -> Path:
        """Get the repository download directory."""
        config = self.load_config()
        return config.repos_dir

    def get_resources_dir(self) -> Path:
        """Get the resources directory."""
        config = self.load_config()
        return config.resources_dir()

    def get_prompts_dir(self) -> Path:
        """Get the prompts directory."""
        config = self.load_config()
        return config.prompts_dir()

    def get_best_practices_dir(self) -> Path:
        """Get the best practices directory."""
        config = self.load_config()
        return config.best_practices_dir()

    def create_default_user_config(self) -> None:
        """Create a default user configuration file."""
        config = self.load_config()
        config_file = config.config_file_path()

        # Create directories if they don't exist
        config_file.parent.mkdir(parents=True, exist_ok=True)

        # Don't overwrite existing config
        if config_file.exists():
            logger.info(f"Configuration file already exists: {config_file}")
            return

        # Create default configuration
        template = {
            "server": {
                "name": "holoviz-mcp",
                "log_level": "INFO",
            },
            "docs": {
                "repositories": {
                    "example-repo": {
                        "url": "https://github.com/example/repo.git",
                        "branch": "main",
                        "folders": {"doc": {"url_path": ""}},
                        "base_url": "https://example.readthedocs.io",
                        "reference_patterns": ["doc/reference/**/*.md", "examples/reference/**/*.ipynb"],
                    }
                }
            },
            "resources": {"search_paths": []},
            "prompts": {"search_paths": []},
        }

        with open(config_file, "w", encoding="utf-8") as f:
            yaml.dump(template, f, default_flow_style=False, sort_keys=False)

        logger.info(f"Created default user configuration file: {config_file}")

    def reload_config(self) -> HoloVizMCPConfig:
        """Reload configuration from files.

        Returns
        -------
            Reloaded configuration.
        """
        self._loaded_config = None
        return self.load_config()

    def clear_cache(self) -> None:
        """Clear the cached configuration to force reload on next access."""
        self._loaded_config = None

clear_cache()

Clear the cached configuration to force reload on next access.

Source code in src/holoviz_mcp/config/loader.py
def clear_cache(self) -> None:
    """Clear the cached configuration to force reload on next access."""
    self._loaded_config = None

create_default_user_config()

Create a default user configuration file.

Source code in src/holoviz_mcp/config/loader.py
def create_default_user_config(self) -> None:
    """Create a default user configuration file."""
    config = self.load_config()
    config_file = config.config_file_path()

    # Create directories if they don't exist
    config_file.parent.mkdir(parents=True, exist_ok=True)

    # Don't overwrite existing config
    if config_file.exists():
        logger.info(f"Configuration file already exists: {config_file}")
        return

    # Create default configuration
    template = {
        "server": {
            "name": "holoviz-mcp",
            "log_level": "INFO",
        },
        "docs": {
            "repositories": {
                "example-repo": {
                    "url": "https://github.com/example/repo.git",
                    "branch": "main",
                    "folders": {"doc": {"url_path": ""}},
                    "base_url": "https://example.readthedocs.io",
                    "reference_patterns": ["doc/reference/**/*.md", "examples/reference/**/*.ipynb"],
                }
            }
        },
        "resources": {"search_paths": []},
        "prompts": {"search_paths": []},
    }

    with open(config_file, "w", encoding="utf-8") as f:
        yaml.dump(template, f, default_flow_style=False, sort_keys=False)

    logger.info(f"Created default user configuration file: {config_file}")

get_best_practices_dir()

Get the best practices directory.

Source code in src/holoviz_mcp/config/loader.py
def get_best_practices_dir(self) -> Path:
    """Get the best practices directory."""
    config = self.load_config()
    return config.best_practices_dir()

get_prompts_dir()

Get the prompts directory.

Source code in src/holoviz_mcp/config/loader.py
def get_prompts_dir(self) -> Path:
    """Get the prompts directory."""
    config = self.load_config()
    return config.prompts_dir()

get_repos_dir()

Get the repository download directory.

Source code in src/holoviz_mcp/config/loader.py
def get_repos_dir(self) -> Path:
    """Get the repository download directory."""
    config = self.load_config()
    return config.repos_dir

get_resources_dir()

Get the resources directory.

Source code in src/holoviz_mcp/config/loader.py
def get_resources_dir(self) -> Path:
    """Get the resources directory."""
    config = self.load_config()
    return config.resources_dir()

load_config()

Load configuration from files and environment.

Returns:

Type Description
Loaded configuration.

Raises:

Type Description
ConfigurationError: If configuration cannot be loaded or is invalid.
Source code in src/holoviz_mcp/config/loader.py
def load_config(self) -> HoloVizMCPConfig:
    """Load configuration from files and environment.

    Returns
    -------
        Loaded configuration.

    Raises
    ------
        ConfigurationError: If configuration cannot be loaded or is invalid.
    """
    if self._loaded_config is not None:
        return self._loaded_config

    # Get environment config (from parameter or environment)
    if self._env_config is not None:
        env_config = self._env_config
    else:
        env_config = HoloVizMCPConfig()

    # Start with default configuration dict
    config_dict = self._get_default_config()

    # Load from default config file if it exists
    default_config_file = env_config.default_dir / "config.yaml"
    if default_config_file.exists():
        try:
            default_config = self._load_yaml_file(default_config_file)
            config_dict = self._merge_configs(config_dict, default_config)
            logger.info(f"Loaded default configuration from {default_config_file}")
        except Exception as e:
            logger.warning(f"Failed to load default config from {default_config_file}: {e}")

    # Load from user config file if it exists
    user_config_file = env_config.config_file_path()
    if user_config_file.exists():
        user_config = self._load_yaml_file(user_config_file)
        # Filter out any unknown fields to prevent validation errors
        user_config = self._filter_known_fields(user_config)
        config_dict = self._merge_configs(config_dict, user_config)
        logger.info(f"Loaded user configuration from {user_config_file}")

    # Apply environment variable overrides
    config_dict = self._apply_env_overrides(config_dict)

    # Add the environment paths to the config dict
    config_dict.update(
        {
            "user_dir": env_config.user_dir,
            "default_dir": env_config.default_dir,
            "repos_dir": env_config.repos_dir,
        }
    )

    # Create the final configuration
    try:
        self._loaded_config = HoloVizMCPConfig(**config_dict)
    except ValidationError as e:
        raise ConfigurationError(f"Invalid configuration: {e}") from e

    return self._loaded_config

reload_config()

Reload configuration from files.

Returns:

Type Description
Reloaded configuration.
Source code in src/holoviz_mcp/config/loader.py
def reload_config(self) -> HoloVizMCPConfig:
    """Reload configuration from files.

    Returns
    -------
        Reloaded configuration.
    """
    self._loaded_config = None
    return self.load_config()

ConfigurationError

Bases: Exception

Raised when configuration cannot be loaded or is invalid.

Source code in src/holoviz_mcp/config/loader.py
class ConfigurationError(Exception):
    """Raised when configuration cannot be loaded or is invalid."""

DocsConfig

Bases: BaseModel

Configuration for documentation repositories.

Source code in src/holoviz_mcp/config/models.py
class DocsConfig(BaseModel):
    """Configuration for documentation repositories."""

    repositories: dict[str, GitRepository] = Field(default_factory=dict, description="Dictionary mapping package names to repository configurations")
    index_patterns: list[str] = Field(
        default_factory=lambda: ["**/*.md", "**/*.rst", "**/*.txt"], description="File patterns to include when indexing documentation"
    )
    exclude_patterns: list[str] = Field(
        default_factory=lambda: ["**/node_modules/**", "**/.git/**", "**/build/**"], description="File patterns to exclude when indexing documentation"
    )
    max_file_size: PositiveInt = Field(
        default=1024 * 1024,  # 1MB
        description="Maximum file size in bytes to index",
    )
    update_interval: PositiveInt = Field(
        default=86400,  # 24 hours
        description="How often to check for updates in seconds",
    )

exclude_patterns = Field(default_factory=(lambda: ['**/node_modules/**', '**/.git/**', '**/build/**']), description='File patterns to exclude when indexing documentation') class-attribute instance-attribute

index_patterns = Field(default_factory=(lambda: ['**/*.md', '**/*.rst', '**/*.txt']), description='File patterns to include when indexing documentation') class-attribute instance-attribute

max_file_size = Field(default=(1024 * 1024), description='Maximum file size in bytes to index') class-attribute instance-attribute

repositories = Field(default_factory=dict, description='Dictionary mapping package names to repository configurations') class-attribute instance-attribute

update_interval = Field(default=86400, description='How often to check for updates in seconds') class-attribute instance-attribute

GitRepository

Bases: BaseModel

Configuration for a Git repository.

Source code in src/holoviz_mcp/config/models.py
class GitRepository(BaseModel):
    """Configuration for a Git repository."""

    url: AnyHttpUrl = Field(..., description="Git repository URL")
    branch: Optional[str] = Field(default=None, description="Git branch to use")
    tag: Optional[str] = Field(default=None, description="Git tag to use (e.g., '1.7.2')")
    commit: Optional[str] = Field(default=None, description="Git commit hash to use")
    folders: Union[list[str], dict[str, FolderConfig]] = Field(
        default_factory=lambda: {"doc": FolderConfig()},
        description="Folders to index within the repository. Can be a list of folder names or a dict mapping folder names to FolderConfig objects",
    )
    base_url: AnyHttpUrl = Field(..., description="Base URL for documentation links")
    url_transform: Literal["holoviz", "plotly", "datashader"] = Field(
        default="holoviz",
        description="""How to transform file path into URL:

        - holoViz transform suffix to .html: filename.md -> filename.html
        - plotly transform suffix to /: filename.md -> filename/
        - datashader removes leading index and transform suffix to .html: 01_filename.md -> filename.html
        """,
    )
    reference_patterns: list[str] = Field(
        default_factory=lambda: ["examples/reference/**/*.md", "examples/reference/**/*.ipynb"], description="Glob patterns for reference documentation files"
    )

    @field_validator("tag")
    @classmethod
    def validate_tag(cls, v):
        """Validate git tag format, allowing both 'v1.2.3' and '1.2.3' formats."""
        if v is not None and v.startswith("v"):
            # Allow tags like 'v1.7.2' but also suggest plain version numbers
            pass
        return v

    @field_validator("folders")
    @classmethod
    def validate_folders(cls, v):
        """Validate and normalize folders configuration."""
        if isinstance(v, list):
            # Convert list to dict format for backward compatibility
            return {folder: FolderConfig() for folder in v}
        elif isinstance(v, dict):
            # Ensure all values are FolderConfig objects
            result = {}
            for folder, config in v.items():
                if isinstance(config, dict):
                    result[folder] = FolderConfig(**config)
                elif isinstance(config, FolderConfig):
                    result[folder] = config
                else:
                    raise ValueError(f"Invalid folder config for '{folder}': must be dict or FolderConfig")
            return result
        else:
            raise ValueError("folders must be a list or dict")

    def get_folder_names(self) -> list[str]:
        """Get list of folder names for backward compatibility."""
        if isinstance(self.folders, dict):
            return list(self.folders.keys())
        return self.folders

    def get_folder_url_path(self, folder_name: str) -> str:
        """Get URL path for a specific folder."""
        if isinstance(self.folders, dict):
            folder_config = self.folders.get(folder_name)
            if folder_config:
                return folder_config.url_path
        return ""

base_url = Field(..., description='Base URL for documentation links') class-attribute instance-attribute

branch = Field(default=None, description='Git branch to use') class-attribute instance-attribute

commit = Field(default=None, description='Git commit hash to use') class-attribute instance-attribute

folders = Field(default_factory=(lambda: {'doc': FolderConfig()}), description='Folders to index within the repository. Can be a list of folder names or a dict mapping folder names to FolderConfig objects') class-attribute instance-attribute

reference_patterns = Field(default_factory=(lambda: ['examples/reference/**/*.md', 'examples/reference/**/*.ipynb']), description='Glob patterns for reference documentation files') class-attribute instance-attribute

tag = Field(default=None, description="Git tag to use (e.g., '1.7.2')") class-attribute instance-attribute

url = Field(..., description='Git repository URL') class-attribute instance-attribute

url_transform = Field(default='holoviz', description='How to transform file path into URL:\n\n - holoViz transform suffix to .html: filename.md -> filename.html\n - plotly transform suffix to /: filename.md -> filename/\n - datashader removes leading index and transform suffix to .html: 01_filename.md -> filename.html\n ') class-attribute instance-attribute

get_folder_names()

Get list of folder names for backward compatibility.

Source code in src/holoviz_mcp/config/models.py
def get_folder_names(self) -> list[str]:
    """Get list of folder names for backward compatibility."""
    if isinstance(self.folders, dict):
        return list(self.folders.keys())
    return self.folders

get_folder_url_path(folder_name)

Get URL path for a specific folder.

Source code in src/holoviz_mcp/config/models.py
def get_folder_url_path(self, folder_name: str) -> str:
    """Get URL path for a specific folder."""
    if isinstance(self.folders, dict):
        folder_config = self.folders.get(folder_name)
        if folder_config:
            return folder_config.url_path
    return ""

validate_folders(v) classmethod

Validate and normalize folders configuration.

Source code in src/holoviz_mcp/config/models.py
@field_validator("folders")
@classmethod
def validate_folders(cls, v):
    """Validate and normalize folders configuration."""
    if isinstance(v, list):
        # Convert list to dict format for backward compatibility
        return {folder: FolderConfig() for folder in v}
    elif isinstance(v, dict):
        # Ensure all values are FolderConfig objects
        result = {}
        for folder, config in v.items():
            if isinstance(config, dict):
                result[folder] = FolderConfig(**config)
            elif isinstance(config, FolderConfig):
                result[folder] = config
            else:
                raise ValueError(f"Invalid folder config for '{folder}': must be dict or FolderConfig")
        return result
    else:
        raise ValueError("folders must be a list or dict")

validate_tag(v) classmethod

Validate git tag format, allowing both 'v1.2.3' and '1.2.3' formats.

Source code in src/holoviz_mcp/config/models.py
@field_validator("tag")
@classmethod
def validate_tag(cls, v):
    """Validate git tag format, allowing both 'v1.2.3' and '1.2.3' formats."""
    if v is not None and v.startswith("v"):
        # Allow tags like 'v1.7.2' but also suggest plain version numbers
        pass
    return v

HoloVizMCPConfig

Bases: BaseModel

Main configuration for HoloViz MCP server.

Source code in src/holoviz_mcp/config/models.py
class HoloVizMCPConfig(BaseModel):
    """Main configuration for HoloViz MCP server."""

    server: ServerConfig = Field(default_factory=ServerConfig)
    docs: DocsConfig = Field(default_factory=DocsConfig)
    resources: ResourceConfig = Field(default_factory=ResourceConfig)
    prompts: PromptConfig = Field(default_factory=PromptConfig)

    # Environment paths - merged from EnvironmentConfig with defaults
    user_dir: Path = Field(default_factory=_holoviz_mcp_user_dir, description="User configuration directory")
    default_dir: Path = Field(default_factory=_holoviz_mcp_default_dir, description="Default configuration directory")
    repos_dir: Path = Field(default_factory=lambda: _holoviz_mcp_user_dir() / "repos", description="Repository download directory")

    model_config = ConfigDict(extra="forbid", validate_assignment=True)

    def config_file_path(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the configuration file.

        Args:
            location: Whether to get user or default config file path
        """
        if location == "user":
            return self.user_dir / "config.yaml"
        else:
            return self.default_dir / "config.yaml"

    def resources_dir(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the resources directory.

        Args:
            location: Whether to get user or default resources directory
        """
        if location == "user":
            return self.user_dir / "config" / "resources"
        else:
            return self.default_dir / "resources"

    def prompts_dir(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the prompts directory.

        Args:
            location: Whether to get user or default prompts directory
        """
        if location == "user":
            return self.user_dir / "config" / "prompts"
        else:
            return self.default_dir / "prompts"

    def best_practices_dir(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the best practices directory.

        Args:
            location: Whether to get user or default best practices directory
        """
        return self.resources_dir(location) / "best-practices"

default_dir = Field(default_factory=_holoviz_mcp_default_dir, description='Default configuration directory') class-attribute instance-attribute

docs = Field(default_factory=DocsConfig) class-attribute instance-attribute

model_config = ConfigDict(extra='forbid', validate_assignment=True) class-attribute instance-attribute

prompts = Field(default_factory=PromptConfig) class-attribute instance-attribute

repos_dir = Field(default_factory=(lambda: _holoviz_mcp_user_dir() / 'repos'), description='Repository download directory') class-attribute instance-attribute

resources = Field(default_factory=ResourceConfig) class-attribute instance-attribute

server = Field(default_factory=ServerConfig) class-attribute instance-attribute

user_dir = Field(default_factory=_holoviz_mcp_user_dir, description='User configuration directory') class-attribute instance-attribute

best_practices_dir(location='user')

Get the path to the best practices directory.

Args: location: Whether to get user or default best practices directory

Source code in src/holoviz_mcp/config/models.py
def best_practices_dir(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the best practices directory.

    Args:
        location: Whether to get user or default best practices directory
    """
    return self.resources_dir(location) / "best-practices"

config_file_path(location='user')

Get the path to the configuration file.

Args: location: Whether to get user or default config file path

Source code in src/holoviz_mcp/config/models.py
def config_file_path(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the configuration file.

    Args:
        location: Whether to get user or default config file path
    """
    if location == "user":
        return self.user_dir / "config.yaml"
    else:
        return self.default_dir / "config.yaml"

prompts_dir(location='user')

Get the path to the prompts directory.

Args: location: Whether to get user or default prompts directory

Source code in src/holoviz_mcp/config/models.py
def prompts_dir(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the prompts directory.

    Args:
        location: Whether to get user or default prompts directory
    """
    if location == "user":
        return self.user_dir / "config" / "prompts"
    else:
        return self.default_dir / "prompts"

resources_dir(location='user')

Get the path to the resources directory.

Args: location: Whether to get user or default resources directory

Source code in src/holoviz_mcp/config/models.py
def resources_dir(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the resources directory.

    Args:
        location: Whether to get user or default resources directory
    """
    if location == "user":
        return self.user_dir / "config" / "resources"
    else:
        return self.default_dir / "resources"

PromptConfig

Bases: BaseModel

Configuration for prompts.

Source code in src/holoviz_mcp/config/models.py
class PromptConfig(BaseModel):
    """Configuration for prompts."""

    search_paths: list[Path] = Field(default_factory=list, description="Additional paths to search for prompts")

search_paths = Field(default_factory=list, description='Additional paths to search for prompts') class-attribute instance-attribute

ResourceConfig

Bases: BaseModel

Configuration for resources (best practices, etc.).

Source code in src/holoviz_mcp/config/models.py
class ResourceConfig(BaseModel):
    """Configuration for resources (best practices, etc.)."""

    search_paths: list[Path] = Field(default_factory=list, description="Additional paths to search for resources")

search_paths = Field(default_factory=list, description='Additional paths to search for resources') class-attribute instance-attribute

ServerConfig

Bases: BaseModel

Configuration for the MCP server.

Source code in src/holoviz_mcp/config/models.py
class ServerConfig(BaseModel):
    """Configuration for the MCP server."""

    name: str = Field(default="holoviz-mcp", description="Server name")
    version: str = Field(default="1.0.0", description="Server version")
    description: str = Field(default="Model Context Protocol server for HoloViz ecosystem", description="Server description")
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(default="INFO", description="Logging level")
    transport: Literal["stdio", "http"] = Field(default="stdio", description="Transport protocol for MCP communication")
    host: str = Field(default="127.0.0.1", description="Host address to bind to when using HTTP transport (use 0.0.0.0 for Docker)")
    port: int = Field(default=8000, description="Port to bind to when using HTTP transport")
    anonymized_telemetry: bool = Field(default=False, description="Enable anonymized telemetry")
    jupyter_server_proxy_url: str = Field(default="", description="Jupyter server proxy URL for Panel app integration")
    vector_db_path: Path = Field(
        default_factory=lambda: (_holoviz_mcp_user_dir() / "vector_db" / "chroma").expanduser(), description="Path to the Chroma vector database."
    )

anonymized_telemetry = Field(default=False, description='Enable anonymized telemetry') class-attribute instance-attribute

description = Field(default='Model Context Protocol server for HoloViz ecosystem', description='Server description') class-attribute instance-attribute

host = Field(default='127.0.0.1', description='Host address to bind to when using HTTP transport (use 0.0.0.0 for Docker)') class-attribute instance-attribute

jupyter_server_proxy_url = Field(default='', description='Jupyter server proxy URL for Panel app integration') class-attribute instance-attribute

log_level = Field(default='INFO', description='Logging level') class-attribute instance-attribute

name = Field(default='holoviz-mcp', description='Server name') class-attribute instance-attribute

port = Field(default=8000, description='Port to bind to when using HTTP transport') class-attribute instance-attribute

transport = Field(default='stdio', description='Transport protocol for MCP communication') class-attribute instance-attribute

vector_db_path = Field(default_factory=(lambda: (_holoviz_mcp_user_dir() / 'vector_db' / 'chroma').expanduser()), description='Path to the Chroma vector database.') class-attribute instance-attribute

version = Field(default='1.0.0', description='Server version') class-attribute instance-attribute

get_config()

Get the current configuration.

Source code in src/holoviz_mcp/config/loader.py
def get_config() -> HoloVizMCPConfig:
    """Get the current configuration."""
    return get_config_loader().load_config()

get_config_loader()

Get the global configuration loader instance.

Source code in src/holoviz_mcp/config/loader.py
def get_config_loader() -> ConfigLoader:
    """Get the global configuration loader instance."""
    global _config_loader
    if _config_loader is None:
        _config_loader = ConfigLoader()
    return _config_loader

reload_config()

Reload configuration from files.

Source code in src/holoviz_mcp/config/loader.py
def reload_config() -> HoloVizMCPConfig:
    """Reload configuration from files."""
    return get_config_loader().reload_config()

Loader

Configuration loader for HoloViz MCP server.

logger = logging.getLogger(__name__) module-attribute

ConfigLoader

Loads and manages HoloViz MCP configuration.

Source code in src/holoviz_mcp/config/loader.py
class ConfigLoader:
    """Loads and manages HoloViz MCP configuration."""

    def __init__(self, config: Optional[HoloVizMCPConfig] = None):
        """Initialize configuration loader.

        Args:
            config: Pre-configured HoloVizMCPConfig with environment paths.
                   If None, loads paths from environment. Configuration will
                   still be loaded from files even if this is provided.
        """
        self._env_config = config
        self._loaded_config: Optional[HoloVizMCPConfig] = None

    def load_config(self) -> HoloVizMCPConfig:
        """Load configuration from files and environment.

        Returns
        -------
            Loaded configuration.

        Raises
        ------
            ConfigurationError: If configuration cannot be loaded or is invalid.
        """
        if self._loaded_config is not None:
            return self._loaded_config

        # Get environment config (from parameter or environment)
        if self._env_config is not None:
            env_config = self._env_config
        else:
            env_config = HoloVizMCPConfig()

        # Start with default configuration dict
        config_dict = self._get_default_config()

        # Load from default config file if it exists
        default_config_file = env_config.default_dir / "config.yaml"
        if default_config_file.exists():
            try:
                default_config = self._load_yaml_file(default_config_file)
                config_dict = self._merge_configs(config_dict, default_config)
                logger.info(f"Loaded default configuration from {default_config_file}")
            except Exception as e:
                logger.warning(f"Failed to load default config from {default_config_file}: {e}")

        # Load from user config file if it exists
        user_config_file = env_config.config_file_path()
        if user_config_file.exists():
            user_config = self._load_yaml_file(user_config_file)
            # Filter out any unknown fields to prevent validation errors
            user_config = self._filter_known_fields(user_config)
            config_dict = self._merge_configs(config_dict, user_config)
            logger.info(f"Loaded user configuration from {user_config_file}")

        # Apply environment variable overrides
        config_dict = self._apply_env_overrides(config_dict)

        # Add the environment paths to the config dict
        config_dict.update(
            {
                "user_dir": env_config.user_dir,
                "default_dir": env_config.default_dir,
                "repos_dir": env_config.repos_dir,
            }
        )

        # Create the final configuration
        try:
            self._loaded_config = HoloVizMCPConfig(**config_dict)
        except ValidationError as e:
            raise ConfigurationError(f"Invalid configuration: {e}") from e

        return self._loaded_config

    def _filter_known_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
        """Filter out unknown fields that aren't part of the HoloVizMCPConfig schema.

        This prevents validation errors when loading user config files that might
        contain extra fields.
        """
        known_fields = {"server", "docs", "resources", "prompts", "user_dir", "default_dir", "repos_dir"}
        return {k: v for k, v in config_dict.items() if k in known_fields}

    def _get_default_config(self) -> dict[str, Any]:
        """Get default configuration dictionary."""
        return {
            "server": {
                "name": "holoviz-mcp",
                "version": "1.0.0",
                "description": "Model Context Protocol server for HoloViz ecosystem",
                "log_level": "INFO",
            },
            "docs": {
                "repositories": {},  # No more Python-side defaults!
                "index_patterns": ["**/*.md", "**/*.rst", "**/*.txt"],
                "exclude_patterns": ["**/node_modules/**", "**/.git/**", "**/build/**"],
                "max_file_size": 1024 * 1024,  # 1MB
                "update_interval": 86400,  # 24 hours
            },
            "resources": {"search_paths": []},
            "prompts": {"search_paths": []},
        }

    def _load_yaml_file(self, file_path: Path) -> dict[str, Any]:
        """Load YAML file safely.

        Args:
            file_path: Path to YAML file.

        Returns
        -------
            Parsed YAML content.

        Raises
        ------
            ConfigurationError: If file cannot be loaded or parsed.
        """
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                content = yaml.safe_load(f)
                if content is None:
                    return {}
                if not isinstance(content, dict):
                    raise ConfigurationError(f"Configuration file must contain a YAML dictionary: {file_path}")
                return content
        except yaml.YAMLError as e:
            raise ConfigurationError(f"Invalid YAML in {file_path}: {e}") from e
        except Exception as e:
            raise ConfigurationError(f"Failed to load {file_path}: {e}") from e

    def _merge_configs(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
        """Merge two configuration dictionaries recursively.

        Args:
            base: Base configuration.
            override: Override configuration.

        Returns
        -------
            Merged configuration.
        """
        result = base.copy()

        for key, value in override.items():
            if key in result and isinstance(result[key], dict) and isinstance(value, dict):
                result[key] = self._merge_configs(result[key], value)
            else:
                result[key] = value

        return result

    def _apply_env_overrides(self, config: dict[str, Any]) -> dict[str, Any]:
        """Apply environment variable overrides to configuration.

        Args:
            config: Configuration dictionary.

        Returns
        -------
            Configuration with environment overrides applied.
        """
        # Log level override
        if "HOLOVIZ_MCP_LOG_LEVEL" in os.environ:
            config.setdefault("server", {})["log_level"] = os.environ["HOLOVIZ_MCP_LOG_LEVEL"]

        # Server name override
        if "HOLOVIZ_MCP_SERVER_NAME" in os.environ:
            config.setdefault("server", {})["name"] = os.environ["HOLOVIZ_MCP_SERVER_NAME"]

        # Transport override
        if "HOLOVIZ_MCP_TRANSPORT" in os.environ:
            config.setdefault("server", {})["transport"] = os.environ["HOLOVIZ_MCP_TRANSPORT"]

        # Host override (for HTTP transport)
        if "HOLOVIZ_MCP_HOST" in os.environ:
            config.setdefault("server", {})["host"] = os.environ["HOLOVIZ_MCP_HOST"]

        # Port override (for HTTP transport)
        if "HOLOVIZ_MCP_PORT" in os.environ:
            port_str = os.environ["HOLOVIZ_MCP_PORT"]
            try:
                port = int(port_str)
                if not (1 <= port <= 65535):
                    raise ValueError(f"Port must be between 1 and 65535, got {port}")
                config.setdefault("server", {})["port"] = port
            except ValueError as e:
                raise ConfigurationError(f"Invalid HOLOVIZ_MCP_PORT: {port_str}") from e

        # Telemetry override
        if "ANONYMIZED_TELEMETRY" in os.environ:
            config.setdefault("server", {})["anonymized_telemetry"] = os.environ["ANONYMIZED_TELEMETRY"].lower() in ("true", "1", "yes", "on")

        # Jupyter proxy URL override
        if "JUPYTER_SERVER_PROXY_URL" in os.environ:
            config.setdefault("server", {})["jupyter_server_proxy_url"] = os.environ["JUPYTER_SERVER_PROXY_URL"]

        return config

    def get_repos_dir(self) -> Path:
        """Get the repository download directory."""
        config = self.load_config()
        return config.repos_dir

    def get_resources_dir(self) -> Path:
        """Get the resources directory."""
        config = self.load_config()
        return config.resources_dir()

    def get_prompts_dir(self) -> Path:
        """Get the prompts directory."""
        config = self.load_config()
        return config.prompts_dir()

    def get_best_practices_dir(self) -> Path:
        """Get the best practices directory."""
        config = self.load_config()
        return config.best_practices_dir()

    def create_default_user_config(self) -> None:
        """Create a default user configuration file."""
        config = self.load_config()
        config_file = config.config_file_path()

        # Create directories if they don't exist
        config_file.parent.mkdir(parents=True, exist_ok=True)

        # Don't overwrite existing config
        if config_file.exists():
            logger.info(f"Configuration file already exists: {config_file}")
            return

        # Create default configuration
        template = {
            "server": {
                "name": "holoviz-mcp",
                "log_level": "INFO",
            },
            "docs": {
                "repositories": {
                    "example-repo": {
                        "url": "https://github.com/example/repo.git",
                        "branch": "main",
                        "folders": {"doc": {"url_path": ""}},
                        "base_url": "https://example.readthedocs.io",
                        "reference_patterns": ["doc/reference/**/*.md", "examples/reference/**/*.ipynb"],
                    }
                }
            },
            "resources": {"search_paths": []},
            "prompts": {"search_paths": []},
        }

        with open(config_file, "w", encoding="utf-8") as f:
            yaml.dump(template, f, default_flow_style=False, sort_keys=False)

        logger.info(f"Created default user configuration file: {config_file}")

    def reload_config(self) -> HoloVizMCPConfig:
        """Reload configuration from files.

        Returns
        -------
            Reloaded configuration.
        """
        self._loaded_config = None
        return self.load_config()

    def clear_cache(self) -> None:
        """Clear the cached configuration to force reload on next access."""
        self._loaded_config = None
clear_cache()

Clear the cached configuration to force reload on next access.

Source code in src/holoviz_mcp/config/loader.py
def clear_cache(self) -> None:
    """Clear the cached configuration to force reload on next access."""
    self._loaded_config = None
create_default_user_config()

Create a default user configuration file.

Source code in src/holoviz_mcp/config/loader.py
def create_default_user_config(self) -> None:
    """Create a default user configuration file."""
    config = self.load_config()
    config_file = config.config_file_path()

    # Create directories if they don't exist
    config_file.parent.mkdir(parents=True, exist_ok=True)

    # Don't overwrite existing config
    if config_file.exists():
        logger.info(f"Configuration file already exists: {config_file}")
        return

    # Create default configuration
    template = {
        "server": {
            "name": "holoviz-mcp",
            "log_level": "INFO",
        },
        "docs": {
            "repositories": {
                "example-repo": {
                    "url": "https://github.com/example/repo.git",
                    "branch": "main",
                    "folders": {"doc": {"url_path": ""}},
                    "base_url": "https://example.readthedocs.io",
                    "reference_patterns": ["doc/reference/**/*.md", "examples/reference/**/*.ipynb"],
                }
            }
        },
        "resources": {"search_paths": []},
        "prompts": {"search_paths": []},
    }

    with open(config_file, "w", encoding="utf-8") as f:
        yaml.dump(template, f, default_flow_style=False, sort_keys=False)

    logger.info(f"Created default user configuration file: {config_file}")
get_best_practices_dir()

Get the best practices directory.

Source code in src/holoviz_mcp/config/loader.py
def get_best_practices_dir(self) -> Path:
    """Get the best practices directory."""
    config = self.load_config()
    return config.best_practices_dir()
get_prompts_dir()

Get the prompts directory.

Source code in src/holoviz_mcp/config/loader.py
def get_prompts_dir(self) -> Path:
    """Get the prompts directory."""
    config = self.load_config()
    return config.prompts_dir()
get_repos_dir()

Get the repository download directory.

Source code in src/holoviz_mcp/config/loader.py
def get_repos_dir(self) -> Path:
    """Get the repository download directory."""
    config = self.load_config()
    return config.repos_dir
get_resources_dir()

Get the resources directory.

Source code in src/holoviz_mcp/config/loader.py
def get_resources_dir(self) -> Path:
    """Get the resources directory."""
    config = self.load_config()
    return config.resources_dir()
load_config()

Load configuration from files and environment.

Returns:

Type Description
Loaded configuration.

Raises:

Type Description
ConfigurationError: If configuration cannot be loaded or is invalid.
Source code in src/holoviz_mcp/config/loader.py
def load_config(self) -> HoloVizMCPConfig:
    """Load configuration from files and environment.

    Returns
    -------
        Loaded configuration.

    Raises
    ------
        ConfigurationError: If configuration cannot be loaded or is invalid.
    """
    if self._loaded_config is not None:
        return self._loaded_config

    # Get environment config (from parameter or environment)
    if self._env_config is not None:
        env_config = self._env_config
    else:
        env_config = HoloVizMCPConfig()

    # Start with default configuration dict
    config_dict = self._get_default_config()

    # Load from default config file if it exists
    default_config_file = env_config.default_dir / "config.yaml"
    if default_config_file.exists():
        try:
            default_config = self._load_yaml_file(default_config_file)
            config_dict = self._merge_configs(config_dict, default_config)
            logger.info(f"Loaded default configuration from {default_config_file}")
        except Exception as e:
            logger.warning(f"Failed to load default config from {default_config_file}: {e}")

    # Load from user config file if it exists
    user_config_file = env_config.config_file_path()
    if user_config_file.exists():
        user_config = self._load_yaml_file(user_config_file)
        # Filter out any unknown fields to prevent validation errors
        user_config = self._filter_known_fields(user_config)
        config_dict = self._merge_configs(config_dict, user_config)
        logger.info(f"Loaded user configuration from {user_config_file}")

    # Apply environment variable overrides
    config_dict = self._apply_env_overrides(config_dict)

    # Add the environment paths to the config dict
    config_dict.update(
        {
            "user_dir": env_config.user_dir,
            "default_dir": env_config.default_dir,
            "repos_dir": env_config.repos_dir,
        }
    )

    # Create the final configuration
    try:
        self._loaded_config = HoloVizMCPConfig(**config_dict)
    except ValidationError as e:
        raise ConfigurationError(f"Invalid configuration: {e}") from e

    return self._loaded_config
reload_config()

Reload configuration from files.

Returns:

Type Description
Reloaded configuration.
Source code in src/holoviz_mcp/config/loader.py
def reload_config(self) -> HoloVizMCPConfig:
    """Reload configuration from files.

    Returns
    -------
        Reloaded configuration.
    """
    self._loaded_config = None
    return self.load_config()

ConfigurationError

Bases: Exception

Raised when configuration cannot be loaded or is invalid.

Source code in src/holoviz_mcp/config/loader.py
class ConfigurationError(Exception):
    """Raised when configuration cannot be loaded or is invalid."""

get_config()

Get the current configuration.

Source code in src/holoviz_mcp/config/loader.py
def get_config() -> HoloVizMCPConfig:
    """Get the current configuration."""
    return get_config_loader().load_config()

get_config_loader()

Get the global configuration loader instance.

Source code in src/holoviz_mcp/config/loader.py
def get_config_loader() -> ConfigLoader:
    """Get the global configuration loader instance."""
    global _config_loader
    if _config_loader is None:
        _config_loader = ConfigLoader()
    return _config_loader

reload_config()

Reload configuration from files.

Source code in src/holoviz_mcp/config/loader.py
def reload_config() -> HoloVizMCPConfig:
    """Reload configuration from files."""
    return get_config_loader().reload_config()

Models

Configuration models for HoloViz MCP server.

DocsConfig

Bases: BaseModel

Configuration for documentation repositories.

Source code in src/holoviz_mcp/config/models.py
class DocsConfig(BaseModel):
    """Configuration for documentation repositories."""

    repositories: dict[str, GitRepository] = Field(default_factory=dict, description="Dictionary mapping package names to repository configurations")
    index_patterns: list[str] = Field(
        default_factory=lambda: ["**/*.md", "**/*.rst", "**/*.txt"], description="File patterns to include when indexing documentation"
    )
    exclude_patterns: list[str] = Field(
        default_factory=lambda: ["**/node_modules/**", "**/.git/**", "**/build/**"], description="File patterns to exclude when indexing documentation"
    )
    max_file_size: PositiveInt = Field(
        default=1024 * 1024,  # 1MB
        description="Maximum file size in bytes to index",
    )
    update_interval: PositiveInt = Field(
        default=86400,  # 24 hours
        description="How often to check for updates in seconds",
    )
exclude_patterns = Field(default_factory=(lambda: ['**/node_modules/**', '**/.git/**', '**/build/**']), description='File patterns to exclude when indexing documentation') class-attribute instance-attribute
index_patterns = Field(default_factory=(lambda: ['**/*.md', '**/*.rst', '**/*.txt']), description='File patterns to include when indexing documentation') class-attribute instance-attribute
max_file_size = Field(default=(1024 * 1024), description='Maximum file size in bytes to index') class-attribute instance-attribute
repositories = Field(default_factory=dict, description='Dictionary mapping package names to repository configurations') class-attribute instance-attribute
update_interval = Field(default=86400, description='How often to check for updates in seconds') class-attribute instance-attribute

FolderConfig

Bases: BaseModel

Configuration for a folder within a repository.

Source code in src/holoviz_mcp/config/models.py
class FolderConfig(BaseModel):
    """Configuration for a folder within a repository."""

    url_path: str = Field(default="", description="URL path mapping for this folder (e.g., '' for root, '/reference' for reference docs)")
url_path = Field(default='', description="URL path mapping for this folder (e.g., '' for root, '/reference' for reference docs)") class-attribute instance-attribute

GitRepository

Bases: BaseModel

Configuration for a Git repository.

Source code in src/holoviz_mcp/config/models.py
class GitRepository(BaseModel):
    """Configuration for a Git repository."""

    url: AnyHttpUrl = Field(..., description="Git repository URL")
    branch: Optional[str] = Field(default=None, description="Git branch to use")
    tag: Optional[str] = Field(default=None, description="Git tag to use (e.g., '1.7.2')")
    commit: Optional[str] = Field(default=None, description="Git commit hash to use")
    folders: Union[list[str], dict[str, FolderConfig]] = Field(
        default_factory=lambda: {"doc": FolderConfig()},
        description="Folders to index within the repository. Can be a list of folder names or a dict mapping folder names to FolderConfig objects",
    )
    base_url: AnyHttpUrl = Field(..., description="Base URL for documentation links")
    url_transform: Literal["holoviz", "plotly", "datashader"] = Field(
        default="holoviz",
        description="""How to transform file path into URL:

        - holoViz transform suffix to .html: filename.md -> filename.html
        - plotly transform suffix to /: filename.md -> filename/
        - datashader removes leading index and transform suffix to .html: 01_filename.md -> filename.html
        """,
    )
    reference_patterns: list[str] = Field(
        default_factory=lambda: ["examples/reference/**/*.md", "examples/reference/**/*.ipynb"], description="Glob patterns for reference documentation files"
    )

    @field_validator("tag")
    @classmethod
    def validate_tag(cls, v):
        """Validate git tag format, allowing both 'v1.2.3' and '1.2.3' formats."""
        if v is not None and v.startswith("v"):
            # Allow tags like 'v1.7.2' but also suggest plain version numbers
            pass
        return v

    @field_validator("folders")
    @classmethod
    def validate_folders(cls, v):
        """Validate and normalize folders configuration."""
        if isinstance(v, list):
            # Convert list to dict format for backward compatibility
            return {folder: FolderConfig() for folder in v}
        elif isinstance(v, dict):
            # Ensure all values are FolderConfig objects
            result = {}
            for folder, config in v.items():
                if isinstance(config, dict):
                    result[folder] = FolderConfig(**config)
                elif isinstance(config, FolderConfig):
                    result[folder] = config
                else:
                    raise ValueError(f"Invalid folder config for '{folder}': must be dict or FolderConfig")
            return result
        else:
            raise ValueError("folders must be a list or dict")

    def get_folder_names(self) -> list[str]:
        """Get list of folder names for backward compatibility."""
        if isinstance(self.folders, dict):
            return list(self.folders.keys())
        return self.folders

    def get_folder_url_path(self, folder_name: str) -> str:
        """Get URL path for a specific folder."""
        if isinstance(self.folders, dict):
            folder_config = self.folders.get(folder_name)
            if folder_config:
                return folder_config.url_path
        return ""
base_url = Field(..., description='Base URL for documentation links') class-attribute instance-attribute
branch = Field(default=None, description='Git branch to use') class-attribute instance-attribute
commit = Field(default=None, description='Git commit hash to use') class-attribute instance-attribute
folders = Field(default_factory=(lambda: {'doc': FolderConfig()}), description='Folders to index within the repository. Can be a list of folder names or a dict mapping folder names to FolderConfig objects') class-attribute instance-attribute
reference_patterns = Field(default_factory=(lambda: ['examples/reference/**/*.md', 'examples/reference/**/*.ipynb']), description='Glob patterns for reference documentation files') class-attribute instance-attribute
tag = Field(default=None, description="Git tag to use (e.g., '1.7.2')") class-attribute instance-attribute
url = Field(..., description='Git repository URL') class-attribute instance-attribute
url_transform = Field(default='holoviz', description='How to transform file path into URL:\n\n - holoViz transform suffix to .html: filename.md -> filename.html\n - plotly transform suffix to /: filename.md -> filename/\n - datashader removes leading index and transform suffix to .html: 01_filename.md -> filename.html\n ') class-attribute instance-attribute
get_folder_names()

Get list of folder names for backward compatibility.

Source code in src/holoviz_mcp/config/models.py
def get_folder_names(self) -> list[str]:
    """Get list of folder names for backward compatibility."""
    if isinstance(self.folders, dict):
        return list(self.folders.keys())
    return self.folders
get_folder_url_path(folder_name)

Get URL path for a specific folder.

Source code in src/holoviz_mcp/config/models.py
def get_folder_url_path(self, folder_name: str) -> str:
    """Get URL path for a specific folder."""
    if isinstance(self.folders, dict):
        folder_config = self.folders.get(folder_name)
        if folder_config:
            return folder_config.url_path
    return ""
validate_folders(v) classmethod

Validate and normalize folders configuration.

Source code in src/holoviz_mcp/config/models.py
@field_validator("folders")
@classmethod
def validate_folders(cls, v):
    """Validate and normalize folders configuration."""
    if isinstance(v, list):
        # Convert list to dict format for backward compatibility
        return {folder: FolderConfig() for folder in v}
    elif isinstance(v, dict):
        # Ensure all values are FolderConfig objects
        result = {}
        for folder, config in v.items():
            if isinstance(config, dict):
                result[folder] = FolderConfig(**config)
            elif isinstance(config, FolderConfig):
                result[folder] = config
            else:
                raise ValueError(f"Invalid folder config for '{folder}': must be dict or FolderConfig")
        return result
    else:
        raise ValueError("folders must be a list or dict")
validate_tag(v) classmethod

Validate git tag format, allowing both 'v1.2.3' and '1.2.3' formats.

Source code in src/holoviz_mcp/config/models.py
@field_validator("tag")
@classmethod
def validate_tag(cls, v):
    """Validate git tag format, allowing both 'v1.2.3' and '1.2.3' formats."""
    if v is not None and v.startswith("v"):
        # Allow tags like 'v1.7.2' but also suggest plain version numbers
        pass
    return v

HoloVizMCPConfig

Bases: BaseModel

Main configuration for HoloViz MCP server.

Source code in src/holoviz_mcp/config/models.py
class HoloVizMCPConfig(BaseModel):
    """Main configuration for HoloViz MCP server."""

    server: ServerConfig = Field(default_factory=ServerConfig)
    docs: DocsConfig = Field(default_factory=DocsConfig)
    resources: ResourceConfig = Field(default_factory=ResourceConfig)
    prompts: PromptConfig = Field(default_factory=PromptConfig)

    # Environment paths - merged from EnvironmentConfig with defaults
    user_dir: Path = Field(default_factory=_holoviz_mcp_user_dir, description="User configuration directory")
    default_dir: Path = Field(default_factory=_holoviz_mcp_default_dir, description="Default configuration directory")
    repos_dir: Path = Field(default_factory=lambda: _holoviz_mcp_user_dir() / "repos", description="Repository download directory")

    model_config = ConfigDict(extra="forbid", validate_assignment=True)

    def config_file_path(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the configuration file.

        Args:
            location: Whether to get user or default config file path
        """
        if location == "user":
            return self.user_dir / "config.yaml"
        else:
            return self.default_dir / "config.yaml"

    def resources_dir(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the resources directory.

        Args:
            location: Whether to get user or default resources directory
        """
        if location == "user":
            return self.user_dir / "config" / "resources"
        else:
            return self.default_dir / "resources"

    def prompts_dir(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the prompts directory.

        Args:
            location: Whether to get user or default prompts directory
        """
        if location == "user":
            return self.user_dir / "config" / "prompts"
        else:
            return self.default_dir / "prompts"

    def best_practices_dir(self, location: Literal["user", "default"] = "user") -> Path:
        """Get the path to the best practices directory.

        Args:
            location: Whether to get user or default best practices directory
        """
        return self.resources_dir(location) / "best-practices"
default_dir = Field(default_factory=_holoviz_mcp_default_dir, description='Default configuration directory') class-attribute instance-attribute
docs = Field(default_factory=DocsConfig) class-attribute instance-attribute
model_config = ConfigDict(extra='forbid', validate_assignment=True) class-attribute instance-attribute
prompts = Field(default_factory=PromptConfig) class-attribute instance-attribute
repos_dir = Field(default_factory=(lambda: _holoviz_mcp_user_dir() / 'repos'), description='Repository download directory') class-attribute instance-attribute
resources = Field(default_factory=ResourceConfig) class-attribute instance-attribute
server = Field(default_factory=ServerConfig) class-attribute instance-attribute
user_dir = Field(default_factory=_holoviz_mcp_user_dir, description='User configuration directory') class-attribute instance-attribute
best_practices_dir(location='user')

Get the path to the best practices directory.

Args: location: Whether to get user or default best practices directory

Source code in src/holoviz_mcp/config/models.py
def best_practices_dir(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the best practices directory.

    Args:
        location: Whether to get user or default best practices directory
    """
    return self.resources_dir(location) / "best-practices"
config_file_path(location='user')

Get the path to the configuration file.

Args: location: Whether to get user or default config file path

Source code in src/holoviz_mcp/config/models.py
def config_file_path(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the configuration file.

    Args:
        location: Whether to get user or default config file path
    """
    if location == "user":
        return self.user_dir / "config.yaml"
    else:
        return self.default_dir / "config.yaml"
prompts_dir(location='user')

Get the path to the prompts directory.

Args: location: Whether to get user or default prompts directory

Source code in src/holoviz_mcp/config/models.py
def prompts_dir(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the prompts directory.

    Args:
        location: Whether to get user or default prompts directory
    """
    if location == "user":
        return self.user_dir / "config" / "prompts"
    else:
        return self.default_dir / "prompts"
resources_dir(location='user')

Get the path to the resources directory.

Args: location: Whether to get user or default resources directory

Source code in src/holoviz_mcp/config/models.py
def resources_dir(self, location: Literal["user", "default"] = "user") -> Path:
    """Get the path to the resources directory.

    Args:
        location: Whether to get user or default resources directory
    """
    if location == "user":
        return self.user_dir / "config" / "resources"
    else:
        return self.default_dir / "resources"

PromptConfig

Bases: BaseModel

Configuration for prompts.

Source code in src/holoviz_mcp/config/models.py
class PromptConfig(BaseModel):
    """Configuration for prompts."""

    search_paths: list[Path] = Field(default_factory=list, description="Additional paths to search for prompts")
search_paths = Field(default_factory=list, description='Additional paths to search for prompts') class-attribute instance-attribute

ResourceConfig

Bases: BaseModel

Configuration for resources (best practices, etc.).

Source code in src/holoviz_mcp/config/models.py
class ResourceConfig(BaseModel):
    """Configuration for resources (best practices, etc.)."""

    search_paths: list[Path] = Field(default_factory=list, description="Additional paths to search for resources")
search_paths = Field(default_factory=list, description='Additional paths to search for resources') class-attribute instance-attribute

ServerConfig

Bases: BaseModel

Configuration for the MCP server.

Source code in src/holoviz_mcp/config/models.py
class ServerConfig(BaseModel):
    """Configuration for the MCP server."""

    name: str = Field(default="holoviz-mcp", description="Server name")
    version: str = Field(default="1.0.0", description="Server version")
    description: str = Field(default="Model Context Protocol server for HoloViz ecosystem", description="Server description")
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(default="INFO", description="Logging level")
    transport: Literal["stdio", "http"] = Field(default="stdio", description="Transport protocol for MCP communication")
    host: str = Field(default="127.0.0.1", description="Host address to bind to when using HTTP transport (use 0.0.0.0 for Docker)")
    port: int = Field(default=8000, description="Port to bind to when using HTTP transport")
    anonymized_telemetry: bool = Field(default=False, description="Enable anonymized telemetry")
    jupyter_server_proxy_url: str = Field(default="", description="Jupyter server proxy URL for Panel app integration")
    vector_db_path: Path = Field(
        default_factory=lambda: (_holoviz_mcp_user_dir() / "vector_db" / "chroma").expanduser(), description="Path to the Chroma vector database."
    )
anonymized_telemetry = Field(default=False, description='Enable anonymized telemetry') class-attribute instance-attribute
description = Field(default='Model Context Protocol server for HoloViz ecosystem', description='Server description') class-attribute instance-attribute
host = Field(default='127.0.0.1', description='Host address to bind to when using HTTP transport (use 0.0.0.0 for Docker)') class-attribute instance-attribute
jupyter_server_proxy_url = Field(default='', description='Jupyter server proxy URL for Panel app integration') class-attribute instance-attribute
log_level = Field(default='INFO', description='Logging level') class-attribute instance-attribute
name = Field(default='holoviz-mcp', description='Server name') class-attribute instance-attribute
port = Field(default=8000, description='Port to bind to when using HTTP transport') class-attribute instance-attribute
transport = Field(default='stdio', description='Transport protocol for MCP communication') class-attribute instance-attribute
vector_db_path = Field(default_factory=(lambda: (_holoviz_mcp_user_dir() / 'vector_db' / 'chroma').expanduser()), description='Path to the Chroma vector database.') class-attribute instance-attribute
version = Field(default='1.0.0', description='Server version') class-attribute instance-attribute

Applications

Here for technical reasons.