Skip to content

Commit 9128902

Browse files
authored
Merge pull request #5 from utensils/develop
MCP Lifecycle fixes
2 parents 5e7931f + a3aca2b commit 9128902

15 files changed

+1060
-72
lines changed

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ MCP-NixOS provides MCP resources and tools for NixOS packages, system options, H
1414

1515
Official repository: [https://github.com/utensils/mcp-nixos](https://github.com/utensils/mcp-nixos)
1616

17+
## Branch Management
18+
19+
- Default development branch is `develop`
20+
- Main release branch is `main`
21+
- Branch protection rules are enforced:
22+
- `main`: Requires PR review (1 approval), admin enforcement, no deletion, no force push
23+
- `develop`: Protected from deletion but allows force push
24+
- PRs follow the pattern: commit to `develop` → open PR to `main` → merge once approved
25+
- Branch deletion on merge is disabled to preserve branch history
26+
1727
## Architecture
1828

1929
### Core Components

flake.nix

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,16 @@
250250
{
251251
name = "lint";
252252
category = "development";
253-
help = "Lint code with Black (check) and Flake8";
253+
help = "Format with Black and then lint code with Flake8 (only checks format in CI)";
254254
command = ''
255-
echo "--- Checking formatting with Black ---"
256-
black --check mcp_nixos/ tests/
255+
# Check if running in CI environment
256+
if [ "$(printenv CI 2>/dev/null)" != "" ] || [ "$(printenv GITHUB_ACTIONS 2>/dev/null)" != "" ]; then
257+
echo "--- CI detected: Checking formatting with Black ---"
258+
black --check mcp_nixos/ tests/
259+
else
260+
echo "--- Formatting code with Black ---"
261+
black mcp_nixos/ tests/
262+
fi
257263
echo "--- Running Flake8 linter ---"
258264
flake8 mcp_nixos/ tests/
259265
'';

mcp_nixos/server.py

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,8 @@
8080
search_programs_resource,
8181
)
8282
from mcp_nixos.tools.darwin.darwin_tools import register_darwin_tools
83-
from mcp_nixos.tools.home_manager_tools import ( # noqa: F401
84-
home_manager_info,
85-
home_manager_search,
86-
home_manager_stats,
87-
register_home_manager_tools,
88-
)
89-
from mcp_nixos.tools.nixos_tools import nixos_info, nixos_search, nixos_stats, register_nixos_tools # noqa: F401
83+
from mcp_nixos.tools.home_manager_tools import register_home_manager_tools
84+
from mcp_nixos.tools.nixos_tools import register_nixos_tools
9085
from mcp_nixos.utils.helpers import create_wildcard_query # noqa: F401
9186

9287
# Load environment variables from .env file
@@ -206,6 +201,44 @@ def run_precache():
206201
async def app_lifespan(mcp_server: FastMCP):
207202
logger.info("Initializing MCP-NixOS server components")
208203

204+
# Import state persistence
205+
from mcp_nixos.utils.state_persistence import get_state_persistence
206+
207+
# Create state tracking with initial value
208+
state_persistence = get_state_persistence()
209+
state_persistence.load_state()
210+
211+
# Track connection count across reconnections
212+
connection_count = state_persistence.increment_counter("connection_count")
213+
logger.info(f"This is connection #{connection_count} since server installation")
214+
215+
# Create synchronization for MCP protocol initialization
216+
protocol_initialized = asyncio.Event()
217+
app_ready = asyncio.Event()
218+
219+
# Track initialization state in context
220+
lifespan_context = {
221+
"nixos_context": nixos_context,
222+
"home_manager_context": home_manager_context,
223+
"darwin_context": darwin_context,
224+
"is_ready": False,
225+
"initialization_time": time.time(),
226+
"connection_count": connection_count,
227+
}
228+
229+
# Handle MCP protocol handshake
230+
# FastMCP doesn't expose a public API for modifying initialize behavior,
231+
# but it handles the initialize/initialized protocol automatically.
232+
# We'll use protocol_initialized.set() when we detect the first connection.
233+
234+
# We'll mark the initialization as complete as soon as app is ready
235+
logger.info("Setting protocol initialization events")
236+
protocol_initialized.set()
237+
238+
# This will trigger waiting for connection
239+
logger.info("App is ready for requests")
240+
lifespan_context["is_ready"] = True
241+
209242
# Start loading Home Manager data in background thread
210243
# This way the server can start up immediately without blocking
211244
logger.info("Starting background loading of Home Manager data...")
@@ -228,6 +261,20 @@ async def app_lifespan(mcp_server: FastMCP):
228261
# Don't wait for the data to be fully loaded
229262
logger.info("Server will continue startup while Home Manager and Darwin data loads in background")
230263

264+
# Mark app as ready for requests
265+
logger.info("App is ready for requests, waiting for MCP protocol initialization")
266+
app_ready.set()
267+
268+
# Wait for MCP protocol initialization (with timeout)
269+
try:
270+
await asyncio.wait_for(protocol_initialized.wait(), timeout=5.0)
271+
logger.info("MCP protocol initialization complete")
272+
lifespan_context["is_ready"] = True
273+
except asyncio.TimeoutError:
274+
logger.warning("Timeout waiting for MCP initialize request. Server will proceed anyway.")
275+
# Still mark as ready to avoid hanging
276+
lifespan_context["is_ready"] = True
277+
231278
# Add prompt to guide assistants on using the MCP tools
232279
@mcp_server.prompt()
233280
def mcp_nixos_prompt():
@@ -652,12 +699,15 @@ def mcp_nixos_prompt():
652699
"""
653700

654701
try:
702+
# Save the final state before yielding control to server
703+
from mcp_nixos.utils.state_persistence import get_state_persistence
704+
705+
state_persistence = get_state_persistence()
706+
state_persistence.set_state("last_startup_time", time.time())
707+
state_persistence.save_state()
708+
655709
# We yield our contexts that will be accessible in all handlers
656-
yield {
657-
"nixos_context": nixos_context,
658-
"home_manager_context": home_manager_context,
659-
"darwin_context": darwin_context,
660-
}
710+
yield lifespan_context
661711
except Exception as e:
662712
logger.error(f"Error in server lifespan: {e}")
663713
raise
@@ -668,6 +718,25 @@ def mcp_nixos_prompt():
668718
# Track start time for overall shutdown duration
669719
shutdown_start = time.time()
670720

721+
# Save final state before shutdown
722+
try:
723+
from mcp_nixos.utils.state_persistence import get_state_persistence
724+
725+
state_persistence = get_state_persistence()
726+
state_persistence.set_state("last_shutdown_time", time.time())
727+
state_persistence.set_state("shutdown_reason", "normal")
728+
729+
# Calculate uptime if we have an initialization time
730+
if lifespan_context.get("initialization_time"):
731+
uptime = time.time() - lifespan_context["initialization_time"]
732+
state_persistence.set_state("last_uptime", uptime)
733+
logger.info(f"Server uptime: {uptime:.2f}s")
734+
735+
# Save state to disk
736+
state_persistence.save_state()
737+
except Exception as e:
738+
logger.error(f"Error saving state during shutdown: {e}")
739+
671740
# Create coroutines for shutdown operations
672741
shutdown_coroutines = []
673742

@@ -710,8 +779,22 @@ def mcp_nixos_prompt():
710779
logger.debug("All context shutdowns completed")
711780
except asyncio.TimeoutError:
712781
logger.warning("Some shutdown operations timed out and were terminated")
782+
# Record abnormal shutdown in state
783+
try:
784+
state_persistence = get_state_persistence()
785+
state_persistence.set_state("shutdown_reason", "timeout")
786+
state_persistence.save_state()
787+
except Exception:
788+
pass # Avoid cascading errors
713789
except Exception as e:
714790
logger.error(f"Error during concurrent shutdown operations: {e}")
791+
# Record error in state
792+
try:
793+
state_persistence = get_state_persistence()
794+
state_persistence.set_state("shutdown_reason", f"error: {str(e)}")
795+
state_persistence.save_state()
796+
except Exception:
797+
pass # Avoid cascading errors
715798

716799
# Log shutdown duration
717800
shutdown_duration = time.time() - shutdown_start

0 commit comments

Comments
 (0)