From a88930ce69e6a597b37f576777c43705f8056ac2 Mon Sep 17 00:00:00 2001 From: HumbleTurtle Date: Tue, 21 Jul 2026 01:51:38 -0300 Subject: [PATCH] fix(auth): reload attached credentials in live MCP --- resources/scripts/napseer_mcp_server.py | 20 ++++++ tests/test_live_auth_state_reload.py | 92 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/test_live_auth_state_reload.py diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index f13233c..b1f8b50 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -5719,6 +5719,12 @@ def agent_scoped_folder(args): def request_json(method, path, payload=None, token_required=True, retry_auth=True, extra_headers=None): + if token_required: + # `nap project attach` may run in a separate process while this MCP + # server stays alive. Reload the project auth file before building + # each authenticated request so the long-running server observes the + # replacement access and refresh tokens without a restart. + refresh_public_auth_state() if token_required and not TOKEN: raise RuntimeError("NAPSEER_TOKEN is required") body = None if payload is None else json.dumps(payload).encode("utf-8") @@ -5727,6 +5733,7 @@ def request_json(method, path, payload=None, token_required=True, retry_auth=Tru headers["Authorization"] = f"Bearer {TOKEN}" if extra_headers: headers.update(extra_headers) + request_token = TOKEN request = urllib.request.Request(f"{BASE_URL}{path}", data=body, headers=headers, method=method) try: with api_urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response: @@ -5738,6 +5745,19 @@ def request_json(method, path, payload=None, token_required=True, retry_auth=Tru body_text = exc.read().decode("utf-8") if exc.code == 401 and token_required and retry_auth: try: + # The attach flow may have completed after this request was + # sent. Prefer its new access token over attempting to renew + # the stale in-memory refresh token that just failed. + refresh_public_auth_state() + if TOKEN and TOKEN != request_token: + return request_json( + method, + path, + payload, + token_required=True, + retry_auth=False, + extra_headers=extra_headers, + ) renew_auth() except Exception as renew_exc: raise RuntimeError( diff --git a/tests/test_live_auth_state_reload.py b/tests/test_live_auth_state_reload.py new file mode 100644 index 0000000..b8b55fe --- /dev/null +++ b/tests/test_live_auth_state_reload.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Regression tests for auth.json replacement in a running MCP wrapper.""" + +import importlib.util +import io +import pathlib +import sys +import urllib.error + + +def load_module(): + script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py" + sys.path.insert(0, str(script_path.parent)) + spec = importlib.util.spec_from_file_location("napseer_mcp_server_live_auth_test", script_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return b'{"ok":true}' + + +def auth(token, refresh_token): + return { + "base_url": "https://api.example.test", + "project_id": "project-1", + "account_mode": "operator_project", + "oauth_client_id": "nap-cli", + "token": token, + "refresh_token": refresh_token, + } + + +def run(): + mod = load_module() + current = auth("fresh-access", "fresh-refresh") + requests = [] + mod.load_public_auth_file = lambda: dict(current) + mod.gateway_is_unlocked = lambda: False + + def open_success(request, timeout=None): + requests.append(request) + return Response() + + mod.api_urlopen = open_success + mod.TOKEN = "stale-access" + mod.REFRESH_TOKEN = "stale-refresh" + result = mod.request_json("GET", "/v1/projects/project-1") + assert result == {"ok": True} + assert requests[-1].get_header("Authorization") == "Bearer fresh-access" + assert mod.REFRESH_TOKEN == "fresh-refresh" + + current = auth("stale-access", "stale-refresh") + requests.clear() + renew_calls = [] + mod.renew_auth = lambda: renew_calls.append(True) + + def attach_during_request(request, timeout=None): + requests.append(request) + if len(requests) == 1: + current.update(auth("attached-access", "attached-refresh")) + raise urllib.error.HTTPError( + request.full_url, + 401, + "Unauthorized", + {}, + io.BytesIO(b'{"error":{"code":"unauthorized"}}'), + ) + return Response() + + mod.api_urlopen = attach_during_request + result = mod.request_json("GET", "/v1/projects/project-1") + assert result == {"ok": True} + assert len(requests) == 2 + assert requests[0].get_header("Authorization") == "Bearer stale-access" + assert requests[1].get_header("Authorization") == "Bearer attached-access" + assert renew_calls == [] + + print("ok: running MCP wrapper reloads replaced auth state") + + +if __name__ == "__main__": + run()