Skip to content

Commit 190271e

Browse files
nickrociclaude
andcommitted
feat(plugin): kick provisioning from MCP server start — install-time in practice
The plugin spec has no install-time script, but Claude Code starts the plugin's MCP server right after /plugin install + /reload-plugins — the earliest code the plugin gets to run. `ultan mcp` now fires the background installer when the runtime is missing, with plugin.json passing CLAUDE_PLUGIN_ROOT/CLAUDE_PLUGIN_DATA into its env (documented mcpServers expansion); missing vars degrade to a no-op. Provisioning now starts at the earliest of: MCP server start (~install time), SessionStart (fresh session), or first prompt (mid-session install) — all funneled through ensure-ultan.sh's atomic lock, so concurrent triggers collapse into one install. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 89ac7ec commit 190271e

4 files changed

Lines changed: 119 additions & 10 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
"git+https://github.com/nickroci/ultan@main",
1414
"ultan",
1515
"mcp"
16-
]
16+
],
17+
"env": {
18+
"CLAUDE_PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
19+
"CLAUDE_PLUGIN_DATA": "${CLAUDE_PLUGIN_DATA}"
20+
}
1721
}
1822
}
1923
}

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ don't need one).
5656
> healthy / idle), priming latency, and capture freshness — at any stage, even
5757
> mid-install.
5858
59-
On first use the plugin provisions that retrieval stack into its private storage
60-
**in the background** — triggered by `SessionStart` in a fresh session, or by your
61-
first prompt when you installed mid-session (`/reload-plugins` alone doesn't fire
62-
`SessionStart`, so the prompt hook covers that path). Until it finishes, priming
63-
falls back to a fast lexical scan; after that the daemon **lazy-starts on demand**.
64-
Models download anonymously from HuggingFace — see *First-start expectations* below.
59+
The plugin provisions that retrieval stack into its private storage **in the
60+
background**, triggered by whichever happens first: the plugin's MCP server starting
61+
(right after install/reload — the spec has no install-time script, so this is the
62+
earliest the plugin gets to run), a fresh session's `SessionStart`, or your next
63+
prompt. All three funnel into one lock-guarded installer, so they never race. Until
64+
it finishes, priming falls back to a fast lexical scan; after that the daemon
65+
**lazy-starts on demand**. Models download anonymously from HuggingFace — see
66+
*First-start expectations* below.
6567

6668
You now have:
6769

tests/test_mcp_provision.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""The MCP server start is the plugin's earliest code execution after
2+
/plugin install — _kick_provisioner must fire the background installer
3+
exactly when the runtime is missing, and never otherwise."""
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from ultan import _mcp
10+
11+
12+
@pytest.fixture()
13+
def spawn_spy(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
14+
calls: list[list[str]] = []
15+
16+
class _FakeProc:
17+
pass
18+
19+
def _fake_popen(cmd: list[str], **kwargs: object) -> _FakeProc:
20+
calls.append(cmd)
21+
return _FakeProc()
22+
23+
monkeypatch.setattr(_mcp.subprocess, "Popen", _fake_popen)
24+
return calls
25+
26+
27+
def _plugin_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
28+
root = tmp_path / "plugin-root"
29+
(root / "scripts").mkdir(parents=True)
30+
(root / "scripts" / "ensure-ultan.sh").write_text("#!/bin/bash\n", encoding="utf-8")
31+
data = tmp_path / "plugin-data"
32+
data.mkdir()
33+
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(root))
34+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(data))
35+
return root, data
36+
37+
38+
def test_kick_fires_installer_when_unprovisioned(
39+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
40+
) -> None:
41+
root, _data = _plugin_dirs(tmp_path, monkeypatch)
42+
_mcp._kick_provisioner()
43+
assert spawn_spy == [["bash", str(root / "scripts" / "ensure-ultan.sh")]]
44+
45+
46+
def test_kick_noop_when_already_provisioned(
47+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
48+
) -> None:
49+
_root, data = _plugin_dirs(tmp_path, monkeypatch)
50+
(data / "bin").mkdir()
51+
(data / "bin" / "ultan").write_text("#!/bin/bash\n", encoding="utf-8")
52+
_mcp._kick_provisioner()
53+
assert spawn_spy == []
54+
55+
56+
def test_kick_noop_without_plugin_root(
57+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spawn_spy: list[list[str]]
58+
) -> None:
59+
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
60+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path / "nope"))
61+
_mcp._kick_provisioner()
62+
assert spawn_spy == []

ultan/_mcp.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,51 @@
1414

1515
from __future__ import annotations
1616

17+
import os
18+
import subprocess
19+
from pathlib import Path
1720
from typing import TYPE_CHECKING
1821

1922
if TYPE_CHECKING:
2023
from mcp.server.fastmcp import FastMCP
2124

2225

26+
def _kick_provisioner() -> None:
27+
"""Fire the plugin's background installer if the runtime isn't provisioned.
28+
29+
The plugin spec has no install-time script, but Claude Code starts this
30+
MCP server right after `/plugin install` + `/reload-plugins` — the
31+
earliest code the plugin gets to run — so kicking here makes provisioning
32+
start at install time in practice. Idempotent and cheap: ensure-ultan.sh
33+
returns immediately and holds an atomic install lock, so overlapping
34+
kicks from the MCP server, SessionStart, and the first-prompt fallback
35+
all collapse into one install. plugin.json passes CLAUDE_PLUGIN_ROOT /
36+
CLAUDE_PLUGIN_DATA into our env; missing vars degrade to a no-op (the
37+
other triggers still cover provisioning)."""
38+
data = os.environ.get("CLAUDE_PLUGIN_DATA") or str(
39+
Path.home() / ".claude" / "plugins" / "data" / "ultan-ultan"
40+
)
41+
if (Path(data) / "bin" / "ultan").exists():
42+
return # already provisioned
43+
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
44+
if not root:
45+
return
46+
script = Path(root) / "scripts" / "ensure-ultan.sh"
47+
if not script.exists():
48+
return
49+
try:
50+
subprocess.Popen(
51+
["bash", str(script)],
52+
env={**os.environ, "CLAUDE_PLUGIN_DATA": data},
53+
stdout=subprocess.DEVNULL, # the installer logs to install.log itself
54+
stderr=subprocess.DEVNULL,
55+
stdin=subprocess.DEVNULL,
56+
start_new_session=True,
57+
)
58+
except OSError:
59+
pass
60+
61+
2362
def build_server() -> "FastMCP":
2463
"""Construct the FastMCP server and register tools. Pure construction — no
2564
daemon spawn, no I/O — so it's unit-testable."""
@@ -48,11 +87,13 @@ def ultan_recall(query: str) -> str: # pyright: ignore[reportUnusedFunction] #
4887

4988

5089
def serve() -> int:
51-
"""Run the MCP server over stdio (what Claude Code launches). Async-spawns
52-
the daemon first so memory comes up in the background without blocking the
53-
MCP handshake or hitting Claude Code's server-startup timeout."""
90+
"""Run the MCP server over stdio (what Claude Code launches). Kicks the
91+
plugin provisioner if the runtime is missing (closest thing to an
92+
install-time hook — see _kick_provisioner), then async-spawns the daemon;
93+
neither blocks the MCP handshake or Claude Code's server-startup timeout."""
5494
from . import _daemon
5595

96+
_kick_provisioner()
5697
_daemon.ensure_running()
5798
build_server().run()
5899
return 0

0 commit comments

Comments
 (0)