Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/holoviz_mcp/holoviews_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
)


@mcp.tool
@mcp.tool()
async def list_elements(ctx: Context) -> list[str]:
"""
List all available HoloViews visualization elements.
Expand All @@ -50,7 +50,7 @@ async def list_elements(ctx: Context) -> list[str]:
return sorted(elements_list)


@mcp.tool
@mcp.tool()
async def get_docstring(ctx: Context, element: str, backend: Literal["matplotlib", "bokeh", "plotly"] = "bokeh") -> str:
"""
Get the hvPlot docstring for a specific plot type, including available options and usage details.
Expand Down
52 changes: 35 additions & 17 deletions src/holoviz_mcp/holoviz_mcp/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -1258,25 +1258,43 @@ def _clone_or_update_repo_sync(self, repo_name: str, repo_config: "GitRepository

try:
if repo_path.exists():
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo = git.Repo(repo_path)
repo.remotes.origin.pull()
else:
logger.info(f"Cloning {repo_name} repository to {repo_path}...")
clone_kwargs: dict[str, Any] = {"depth": 1}

if repo_config.branch:
clone_kwargs["branch"] = repo_config.branch
elif repo_config.tag:
clone_kwargs["branch"] = repo_config.tag
elif repo_config.commit:
git.Repo.clone_from(str(repo_config.url), repo_path, **clone_kwargs)
repo = git.Repo(repo_path)
repo.git.checkout(repo_config.commit)
expected_ref = repo_config.branch or repo_config.tag

if expected_ref:
try:
current_ref = repo.active_branch.name
except TypeError:
current_ref = None # Detached HEAD (e.g. tag/commit checkout)

if current_ref != expected_ref:
logger.info(f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. " "Deleting and re-cloning...")
shutil.rmtree(repo_path)
# Fall through to clone below
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using repo.active_branch to determine the current ref breaks for tag-based clones: cloning a tag via --branch <tag> typically leaves the repo in a detached HEAD where active_branch raises and current_ref becomes None, causing unnecessary re-clones (or always re-cloning) even when the correct tag is already checked out. Consider handling repo_config.tag separately by comparing the checked-out commit to the tag’s commit (or using an exact-match tag check), and avoid relying on active_branch for tags.

Suggested change
expected_ref = repo_config.branch or repo_config.tag
if expected_ref:
try:
current_ref = repo.active_branch.name
except TypeError:
current_ref = None # Detached HEAD (e.g. tag/commit checkout)
if current_ref != expected_ref:
logger.info(f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. " "Deleting and re-cloning...")
shutil.rmtree(repo_path)
# Fall through to clone below
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
# If a tag is specified, avoid relying on active_branch (HEAD will be detached).
if repo_config.tag:
tag_name = repo_config.tag
tag_ref = next((t for t in repo.tags if t.name == tag_name), None)
if tag_ref is not None and repo.head.commit == tag_ref.commit:
# Already at the expected tag commit; nothing to update for immutable tags.
logger.info(
f"Repository {repo_name} at {repo_path} already checked out at tag '{tag_name}'."
)
return repo_path
logger.info(
f"Stale or mismatched tag checkout for {repo_name}: expected tag '{tag_name}'. "
"Deleting and re-cloning..."
)
shutil.rmtree(repo_path)
# Fall through to clone below
else:
# Branch-based (or default) checkout: use active_branch when available.
expected_ref = repo_config.branch
if expected_ref:
try:
current_ref = repo.active_branch.name
except Exception:
# Detached HEAD or other issue; treat as unknown branch.
current_ref = None
if current_ref != expected_ref:
logger.info(
f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. "
"Deleting and re-cloning..."
)
shutil.rmtree(repo_path)
# Fall through to clone below
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path

Copilot uses AI. Check for mistakes.

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected_ref only considers branch/tag, so a repo configured with a specific commit will be treated as having no expected ref when it already exists and will just pull(), potentially moving HEAD away from the configured commit. If repo_config.commit is meant to pin the checkout, add a commit check for existing repos (e.g., compare repo.head.commit.hexsha and reset/re-clone when it differs).

Suggested change
expected_ref = repo_config.branch or repo_config.tag
if expected_ref:
try:
current_ref = repo.active_branch.name
except TypeError:
current_ref = None # Detached HEAD (e.g. tag/commit checkout)
if current_ref != expected_ref:
logger.info(f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. " "Deleting and re-cloning...")
shutil.rmtree(repo_path)
# Fall through to clone below
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
# If a specific commit is configured, treat it as a pinned checkout.
if repo_config.commit:
current_commit = repo.head.commit.hexsha
expected_commit = repo_config.commit
# Allow expected_commit to be a short SHA prefix.
if not current_commit.startswith(expected_commit):
logger.info(
f"Stale checkout for {repo_name}: on commit '{current_commit}', "
f"expected '{expected_commit}'. Deleting and re-cloning..."
)
shutil.rmtree(repo_path)
# Fall through to clone below
else:
logger.info(
f"Repository {repo_name} already at desired commit '{expected_commit}'."
)
return repo_path
else:
expected_ref = repo_config.branch or repo_config.tag
if expected_ref:
try:
current_ref = repo.active_branch.name
except TypeError:
current_ref = None # Detached HEAD (e.g. tag/commit checkout)
if current_ref != expected_ref:
logger.info(
f"Stale checkout for {repo_name}: on '{current_ref}', expected '{expected_ref}'. "
"Deleting and re-cloning..."
)
shutil.rmtree(repo_path)
# Fall through to clone below
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path
else:
logger.info(f"Updating {repo_name} repository at {repo_path}...")
repo.remotes.origin.pull()
return repo_path

Copilot uses AI. Check for mistakes.

# Repo does not exist (fresh or was just deleted above)
logger.info(f"Cloning {repo_name} repository to {repo_path}...")
clone_kwargs: dict[str, Any] = {"depth": 1}

if repo_config.branch:
clone_kwargs["branch"] = repo_config.branch
elif repo_config.tag:
clone_kwargs["branch"] = repo_config.tag
elif repo_config.commit:
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:
logger.warning(f"Failed to clone/update {repo_name}: {e}")
Expand Down Expand Up @@ -1343,7 +1361,7 @@ def _extract_docs_from_repo_sync(self, repo_path: Path, project: str, old_hashes
reference_count = sum(1 for doc in docs if doc["is_reference"])
regular_count = len(docs) - reference_count
if skipped_count:
logger.info(f" {project}: {len(docs)} changed + {skipped_count} unchanged " f"({regular_count} regular, {reference_count} reference guides)")
logger.info(f" {project}: {len(docs)} changed + {skipped_count} unchanged ({regular_count} regular, {reference_count} reference guides)")
else:
logger.info(f" {project}: {len(docs)} total documents ({regular_count} regular, {reference_count} reference guides)")
return {"docs": docs, "skipped_hashes": skipped_hashes}
Expand Down Expand Up @@ -1676,7 +1694,7 @@ async def index_documentation(

clone_extract_elapsed = time.monotonic() - overall_t0
await log_info(
f"Clone + extract completed in {clone_extract_elapsed:.1f}s " f"({len(all_docs)} to index, {len(all_skipped_hashes)} unchanged)",
f"Clone + extract completed in {clone_extract_elapsed:.1f}s ({len(all_docs)} to index, {len(all_skipped_hashes)} unchanged)",
ctx,
)

Expand All @@ -1702,7 +1720,7 @@ async def index_documentation(

docs_to_index = all_docs # all are new or changed (unchanged were skipped by workers)
await log_info(
f"Incremental: {len(new_docs)} new, {len(changed_docs)} changed, " f"{len(all_skipped_hashes)} unchanged, {len(deleted_doc_ids)} deleted",
f"Incremental: {len(new_docs)} new, {len(changed_docs)} changed, {len(all_skipped_hashes)} unchanged, {len(deleted_doc_ids)} deleted",
ctx,
)

Expand Down
16 changes: 8 additions & 8 deletions src/holoviz_mcp/holoviz_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ async def app_lifespan(server: FastMCP):
)


@mcp.tool
@mcp.tool()
def get_skill(name: str) -> str:
"""Get the specified skill for usage with LLMs.

Expand All @@ -181,7 +181,7 @@ def get_skill(name: str) -> str:
return _get_skill(name)


@mcp.tool
@mcp.tool()
def list_skills() -> list[str]:
"""List all available skills.

Expand All @@ -195,7 +195,7 @@ def list_skills() -> list[str]:
return _list_skills()


@mcp.tool
@mcp.tool()
async def get_reference_guide(
component: str,
project: str | None = None,
Expand Down Expand Up @@ -237,7 +237,7 @@ async def get_reference_guide(
return await indexer.search_get_reference_guide(component, project, content, ctx=ctx)


@mcp.tool
@mcp.tool()
async def list_projects() -> list[str]:
"""List all HoloViz and user-defined projects with indexed documentation.

Expand All @@ -253,7 +253,7 @@ async def list_projects() -> list[str]:
return await indexer.list_projects()


@mcp.tool
@mcp.tool()
async def get_document(path: str, project: str, ctx: Context) -> Document:
"""Retrieve a specific document by path and project.

Expand All @@ -271,7 +271,7 @@ async def get_document(path: str, project: str, ctx: Context) -> Document:
return await indexer.get_document(path, project, ctx=ctx)


@mcp.tool
@mcp.tool()
async def search(
query: str,
project: str | None = None,
Expand Down Expand Up @@ -349,7 +349,7 @@ async def search(
return await indexer.search(query, project, content, max_results, max_content_chars, ctx=ctx)


@mcp.tool(enabled=False)
# @mcp.tool(enabled=False)
async def update_index(ctx: Context) -> str:
"""Update the documentation index by re-cloning repositories and re-indexing content.

Expand Down Expand Up @@ -380,7 +380,7 @@ async def update_index(ctx: Context) -> str:
return error_msg


@mcp.tool
@mcp.tool()
async def display(
code: str,
name: str = "",
Expand Down
6 changes: 3 additions & 3 deletions src/holoviz_mcp/hvplot_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def _help(
return doc, sig


@mcp.tool
@mcp.tool()
async def list_plot_types(ctx: Context) -> list[str]:
"""
List all available hvPlot plot types supported in the current environment.
Expand Down Expand Up @@ -75,7 +75,7 @@ async def list_plot_types(ctx: Context) -> list[str]:
return sorted(HoloViewsConverter._kind_mapping)


@mcp.tool
@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:
Expand Down Expand Up @@ -111,7 +111,7 @@ async def get_docstring(
return doc


@mcp.tool
@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.
Expand Down
10 changes: 5 additions & 5 deletions src/holoviz_mcp/panel_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async def _get_all_components(ctx: Context) -> list[ComponentDetails]:
return COMPONENTS


@mcp.tool
@mcp.tool()
async def list_packages(ctx: Context) -> list[str]:
"""
List all installed packages that provide Panel UI components.
Expand Down Expand Up @@ -158,7 +158,7 @@ async def list_packages(ctx: Context) -> list[str]:
return sorted(set(component.package for component in await _get_all_components(ctx)))


@mcp.tool
@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.
Expand Down Expand Up @@ -265,7 +265,7 @@ async def _get_component(ctx: Context, name: str | None = None, module_path: str
return components_list


@mcp.tool
@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.
Expand Down Expand Up @@ -322,7 +322,7 @@ async def list_components(ctx: Context, name: str | None = None, module_path: st
return components_list


@mcp.tool
@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.
Expand Down Expand Up @@ -383,7 +383,7 @@ async def get_component(ctx: Context, name: str | None = None, module_path: str
return component


@mcp.tool
@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.
Expand Down
Loading
Loading