diff --git a/CLAUDE.md b/CLAUDE.md index 41aca66..bfb8cc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,8 +70,11 @@ GitHub Actions live in `.github/workflows/`: ## Configuration -All servers read `~/.greenode/credentials` and `~/.greenode/config` (INI -format, shared with greennode-cli) via `mcp_core.config.load_profile`. +All servers read `~/.greennode/credentials` and `~/.greennode/config` (INI +format, shared with greennode-cli) via `mcp_core.config.load_profile`. Resolve +the directory with `mcp_core.config.resolve_config_dir()` — it prefers +`~/.greennode` and falls back to the pre-rename `~/.greenode` when only the +legacy directory exists (mirrors greennode-cli). **Environment variable overrides** (highest priority): diff --git a/src/mcp-core/README.md b/src/mcp-core/README.md index fb7dc9a..d26f308 100644 --- a/src/mcp-core/README.md +++ b/src/mcp-core/README.md @@ -5,7 +5,7 @@ Shared core for GreenNode MCP servers. Product servers | Module | Provides | |--------|----------| -| `config` | `load_profile()` — `~/.greenode` credentials/config INI + `GRN_*` env overrides | +| `config` | `load_profile()` — `~/.greennode` credentials/config INI + `GRN_*` env overrides; `resolve_config_dir()` picks `~/.greennode` (legacy `~/.greenode` fallback) | | `auth` | `TokenManager` — GreenNode IAM client-credentials token, auto-refresh (camelCase API) | | `http` | `BaseClient` — retry on 5xx/timeout (1s→2s→4s), auto-refresh on 401, 30s timeout; `user_token_var`/`current_identity` for per-request token passthrough + cache isolation | | `validators` | `validate_id()` — safe resource-ID check before URL construction | @@ -14,9 +14,11 @@ Shared core for GreenNode MCP servers. Product servers ## Usage in a product server ```python -from greennode.mcp_core import BaseClient, DiscoveryCache, TokenManager, load_profile +from greennode.mcp_core import ( + BaseClient, DiscoveryCache, TokenManager, load_profile, resolve_config_dir, +) -profile = load_profile(Path.home() / ".greenode") # credentials + region +profile = load_profile(resolve_config_dir()) # ~/.greennode (+legacy) credentials + region config = MyProductConfig(..., profile) # adds region -> base URLs class MyClient(BaseClient): diff --git a/src/mcp-core/greennode/mcp_core/__init__.py b/src/mcp-core/greennode/mcp_core/__init__.py index ffb81bb..bf86ed6 100644 --- a/src/mcp-core/greennode/mcp_core/__init__.py +++ b/src/mcp-core/greennode/mcp_core/__init__.py @@ -3,7 +3,7 @@ Product servers (src/-mcp-server) import from here instead of copying config/auth/HTTP plumbing: -- config: load_profile() — ~/.greenode credentials/config + GRN_* env overrides +- config: load_profile() — ~/.greennode credentials/config + GRN_* env overrides - auth: TokenManager — GreenNode IAM client-credentials token with auto-refresh - http: BaseClient — retry on 5xx/timeout, auto-refresh on 401 - validators: validate_id — safe resource-ID validation for URL construction @@ -12,7 +12,7 @@ from greennode.mcp_core.auth import IAM_TOKEN_URL, TokenManager from greennode.mcp_core.cache import DEFAULT_MAXSIZE, DiscoveryCache -from greennode.mcp_core.config import ProfileSettings, load_profile +from greennode.mcp_core.config import ProfileSettings, load_profile, resolve_config_dir from greennode.mcp_core.http import BaseClient from greennode.mcp_core.validators import validate_id @@ -26,6 +26,7 @@ "DiscoveryCache", "ProfileSettings", "load_profile", + "resolve_config_dir", "BaseClient", "validate_id", "__version__", diff --git a/src/mcp-core/greennode/mcp_core/config.py b/src/mcp-core/greennode/mcp_core/config.py index 7ef9449..4bdc28e 100644 --- a/src/mcp-core/greennode/mcp_core/config.py +++ b/src/mcp-core/greennode/mcp_core/config.py @@ -1,7 +1,7 @@ """Profile/credential loading shared by all GreenNode MCP servers. Reads the ``credentials`` and ``config`` INI files under a config directory -(``~/.greenode``, shared with greennode-cli) with ``GRN_*`` environment +(``~/.greennode``, shared with greennode-cli) with ``GRN_*`` environment variable overrides. Product servers wrap :class:`ProfileSettings` in their own config dataclass and add product endpoints (region → base URLs). """ @@ -14,6 +14,32 @@ from pathlib import Path +#: Preferred config directory name — where ``grn configure`` writes. +DEFAULT_CONFIG_DIR_NAME = ".greennode" +#: Pre-rename config directory name, kept as a read-only fallback. +LEGACY_CONFIG_DIR_NAME = ".greenode" + + +def resolve_config_dir(home: Path | None = None) -> Path: + """Return the config directory to READ credentials/config from. + + Prefers ``~/.greennode``; falls back to the pre-rename ``~/.greenode`` only + when the preferred directory is absent but the legacy one exists — so + installs made before the greennode-cli rename keep working until the user + re-runs ``grn configure``. When neither exists, returns the preferred + ``~/.greennode`` (its non-existence surfaces as a normal "no credentials" + error). Mirrors greennode-cli's ``effectiveConfigDir``. + """ + base = home or Path.home() + preferred = base / DEFAULT_CONFIG_DIR_NAME + if preferred.exists(): + return preferred + legacy = base / LEGACY_CONFIG_DIR_NAME + if legacy.exists(): + return legacy + return preferred + + @dataclass class ProfileSettings: """Credentials and defaults resolved from env + profile files.""" diff --git a/src/mcp-core/tests/test_core.py b/src/mcp-core/tests/test_core.py index d88ada5..0a5e86b 100644 --- a/src/mcp-core/tests/test_core.py +++ b/src/mcp-core/tests/test_core.py @@ -12,6 +12,7 @@ ProfileSettings, TokenManager, load_profile, + resolve_config_dir, validate_id, ) @@ -56,6 +57,21 @@ def test_load_profile_missing_credentials(tmp_path): load_profile(tmp_path / "nowhere") +def test_resolve_config_dir_prefers_greennode(tmp_path): + (tmp_path / ".greennode").mkdir() + (tmp_path / ".greenode").mkdir() # legacy also present — must be ignored + assert resolve_config_dir(tmp_path) == tmp_path / ".greennode" + + +def test_resolve_config_dir_falls_back_to_legacy(tmp_path): + (tmp_path / ".greenode").mkdir() # only the legacy dir exists + assert resolve_config_dir(tmp_path) == tmp_path / ".greenode" + + +def test_resolve_config_dir_defaults_to_greennode_when_neither(tmp_path): + assert resolve_config_dir(tmp_path) == tmp_path / ".greennode" + + # --------------------------------------------------------------------------- # auth + http # --------------------------------------------------------------------------- diff --git a/src/vks-mcp-server/CLAUDE.md b/src/vks-mcp-server/CLAUDE.md index 596a155..d67a5bc 100644 --- a/src/vks-mcp-server/CLAUDE.md +++ b/src/vks-mcp-server/CLAUDE.md @@ -35,7 +35,7 @@ reverted for exactly this reason — see #46/#47.) - **Pagination is 0-based**: page 0 = first page - **API returns 202** for most successful operations (not 200) -- **The greennode-cli is the source of truth for the current API** — the bundled `~/.greenode/mcp-specs/vks.json` OpenAPI file is stale +- **The greennode-cli is the source of truth for the current API** — the bundled `~/.greennode/mcp-specs/vks.json` OpenAPI file is stale - Discovery (vpc/subnet/flavor/sshkey/secgroup/volume-type/placement-group) goes to the **vServer API** (token-only auth); `project_id` is auto-discovered from `GET /v1/projects` when unset - **vServer list pagination is effectively a no-op**: `page`/`size` query params are ignored and every list endpoint returns the full set in one response (envelope reports `page=0 / pageSize=0 / totalPage=0`, `len(listData) == totalItem`). Discovery fetchers go through `_fetch_all_items`, which uses that single-call fast path but pages explicitly as a safety net if a response ever reports `totalItem > len(listData)` — so results never truncate silently as an account grows. - **VKS list pagination IS enforced** (opposite of vServer): server-side default `pageSize=10` silently truncates bare calls. `list_clusters` / `list_nodegroups` / `list_nodes` go through `paging.fetch_all_vks_items` and never expose paging params. @@ -67,7 +67,7 @@ Per-request upstream identity, no flags: an IAM bearer token in `Authorization` (forwarded by the AgentBase Gateway) → every VKS/vServer call runs as that caller (per-user projects; caches isolated per caller identity; a rejected user token never falls back to the service account). No token → the shared service -account (`~/.greenode` / `GRN_CLIENT_ID`+`GRN_CLIENT_SECRET`). Neither → 401. +account (`~/.greennode` / `GRN_CLIENT_ID`+`GRN_CLIENT_SECRET`). Neither → 401. The server boots credential-less on HTTP (passthrough-only); stdio requires service-account credentials. `/health` is always open. `--auth-debug` (env `GRN_MCP_AUTH_DEBUG=1`) is an opt-in, redacted, HTTP-only diagnostic: logs a summary of inbound request auth and exposes `GET /whoami`. Never verifies signatures, never logs the full token; off by default; not for production. @@ -117,11 +117,11 @@ cd src/vks-mcp-server && uv run pytest tests/ -v ``` - Tests cover all handlers (incl. tool-schema introspection and outputSchema assertions); `respx` mocks all HTTP — no credentials needed -- **Manual testing** (real API, credentials from `~/.greenode/`): MCP Inspector over stdio or piped JSON-RPC — full walkthrough in `README.md` → Development. Do NOT use `uv run mcp dev` (FastMCP is built inside `create_server()`/`main()`, no module-level `mcp` object). Verify auth first with the `get_access_token` tool. +- **Manual testing** (real API, credentials from `~/.greennode/`): MCP Inspector over stdio or piped JSON-RPC — full walkthrough in `README.md` → Development. Do NOT use `uv run mcp dev` (FastMCP is built inside `create_server()`/`main()`, no module-level `mcp` object). Verify auth first with the `get_access_token` tool. ## Relationship with greennode-cli -Both projects share config files (`~/.greenode/`), the REGIONS endpoints, the +Both projects share config files (`~/.greennode/`, legacy `~/.greenode` fallback), the REGIONS endpoints, the IAM auth flow, and the `GRN_*` env var names. Tool names map 1:1 to CLI command names. Key differences: the MCP server is **async** (CLI is sync), returns **structured JSON** for data tools, and adds **K8s resource diff --git a/src/vks-mcp-server/README.md b/src/vks-mcp-server/README.md index dc39aef..b2f4a76 100644 --- a/src/vks-mcp-server/README.md +++ b/src/vks-mcp-server/README.md @@ -28,8 +28,9 @@ uv sync ## Configuration -Credentials and region are read from `~/.greenode/credentials` and -`~/.greenode/config` (INI format, shared with greennode-cli). +Credentials and region are read from `~/.greennode/credentials` and +`~/.greennode/config` (INI format, shared with greennode-cli). The pre-rename +`~/.greenode` is still read as a fallback when only it exists. Environment variables override the config files (highest priority): @@ -94,7 +95,7 @@ One behavior, no flags — the upstream identity is resolved per request: service account. All caches (discovery, kubernetes clients, project_id) are isolated per caller. 2. No token, but service-account credentials are configured - (`~/.greenode` or `GRN_CLIENT_ID`/`GRN_CLIENT_SECRET`) → the shared + (`~/.greennode` or `GRN_CLIENT_ID`/`GRN_CLIENT_SECRET`) → the shared service account. 3. Neither → **401** + `WWW-Authenticate`. @@ -216,7 +217,7 @@ adding new tools. ### Manual testing with MCP Inspector (stdio) Interactive UI to browse tools/prompts, inspect input/output schemas, and call -tools against the **real** VKS API (credentials are read from `~/.greenode/`, +tools against the **real** VKS API (credentials are read from `~/.greennode/`, same as the CLI — run `grn configure` once if you haven't): ```bash @@ -243,7 +244,7 @@ Notes: - `uv run mcp dev` does **not** work here: the FastMCP instance is built by `create_server()` inside `main()`, so there is no module-level `mcp` object. Use the Inspector command above instead. -- To test another account/region without touching `~/.greenode/`, prefix env +- To test another account/region without touching `~/.greennode/`, prefix env overrides: `GRN_DEFAULT_REGION=HAN GRN_PROFILE=staging npx ...` ### Scripted smoke test over stdio (no UI) diff --git a/src/vks-mcp-server/greennode/vks_mcp_server/prompts_handler.py b/src/vks-mcp-server/greennode/vks_mcp_server/prompts_handler.py index 7962cd0..f2a2cb9 100644 --- a/src/vks-mcp-server/greennode/vks_mcp_server/prompts_handler.py +++ b/src/vks-mcp-server/greennode/vks_mcp_server/prompts_handler.py @@ -25,8 +25,8 @@ 1. MCP server `greennode-mcp` đã cấu hình trong client. Thao tác đọc chạy mặc định; tạo/sửa/xoá/scale cần chạy server với `--allow-write` (nếu write lỗi vì read-only, báo người dùng khởi động lại với `--allow-write`). -2. Xác thực qua `~/.greenode/` (GreenNode IAM bearer token). Cấu hình bằng - `grn configure` (ghi `~/.greenode/credentials` + `config`). Thứ tự ưu tiên: +2. Xác thực qua `~/.greennode/` (GreenNode IAM bearer token). Cấu hình bằng + `grn configure` (ghi `~/.greennode/credentials` + `config`). Thứ tự ưu tiên: env (`GRN_CLIENT_ID`, `GRN_CLIENT_SECRET`, `GRN_PROJECT_ID`, `GRN_DEFAULT_REGION`) → file profile (`GRN_PROFILE`, mặc định `default`). `project_id` cần cho discovery; `grn configure` tự dò. Kiểm tra xác thực bằng tool `get_access_token`. diff --git a/src/vks-mcp-server/greennode/vks_mcp_server/server.py b/src/vks-mcp-server/greennode/vks_mcp_server/server.py index 4a4b82e..c336063 100644 --- a/src/vks-mcp-server/greennode/vks_mcp_server/server.py +++ b/src/vks-mcp-server/greennode/vks_mcp_server/server.py @@ -7,6 +7,7 @@ import json import os import sys +from greennode.mcp_core.config import resolve_config_dir from greennode.mcp_core.http import user_token_var from greennode.vks_mcp_server.auth import TokenManager from greennode.vks_mcp_server.auth_debug import summarize_request @@ -21,13 +22,14 @@ from greennode.vks_mcp_server.prompts_handler import PromptsHandler from greennode.vks_mcp_server.version_handler import VersionHandler from mcp.server.fastmcp import FastMCP -from pathlib import Path from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response -CONFIG_PATH = Path.home() / ".greenode" +# Prefer ~/.greennode; fall back to the legacy ~/.greenode when only it exists +# (mirrors greennode-cli after the rename). +CONFIG_PATH = resolve_config_dir() SERVER_INSTRUCTIONS = """ # GreenNode MCP Server @@ -115,7 +117,7 @@ class UpstreamIdentityMiddleware(BaseHTTPMiddleware): CALLER (token scoped to this request via a contextvar; a rejected user token never falls back to the service account). 2. No token, but service-account credentials configured -> the shared - service account (GRN_CLIENT_ID / GRN_CLIENT_SECRET or ~/.greenode). + service account (GRN_CLIENT_ID / GRN_CLIENT_SECRET or ~/.greennode). 3. Neither -> 401. """ @@ -294,7 +296,7 @@ def main() -> None: # stdio has no headers, so it cannot run without credentials. if args.transport != "streamable-http": raise SystemExit( - "No credentials found (~/.greenode or GRN_CLIENT_ID/" + "No credentials found (~/.greennode or GRN_CLIENT_ID/" "GRN_CLIENT_SECRET). stdio transport requires service-account " "credentials; the HTTP transport can run token-passthrough-only." ) from None diff --git a/src/vks-mcp-server/scripts/smoke_test.py b/src/vks-mcp-server/scripts/smoke_test.py index e0df502..48368ca 100644 --- a/src/vks-mcp-server/scripts/smoke_test.py +++ b/src/vks-mcp-server/scripts/smoke_test.py @@ -4,7 +4,7 @@ Starts a LOCAL server in read-only mode (no --allow-write), drives the real MCP protocol over streamable-http (initialize -> initialized -> tools/list -> tools/call), exercises the read-only tools against the live VKS API using the -credentials in ~/.greenode, and prints a PASS/FAIL summary. +credentials in ~/.greennode, and prints a PASS/FAIL summary. Safe: never calls write tools, never prints access tokens or kubeconfig content. diff --git a/templates/new-server/README.md b/templates/new-server/README.md index 570722e..a31ab33 100644 --- a/templates/new-server/README.md +++ b/templates/new-server/README.md @@ -8,7 +8,7 @@ An MCP (Model Context Protocol) server for **{{PRODUCT}}** on VNG Cloud. ## Configuration -Credentials are read from `~/.greenode/credentials` and `~/.greenode/config` +Credentials are read from `~/.greennode/credentials` and `~/.greennode/config` (INI format, shared with greennode-cli; `GRN_*` env vars override — see the repo-root CLAUDE.md). diff --git a/templates/new-server/greennode/__product_snake___mcp_server/server.py b/templates/new-server/greennode/__product_snake___mcp_server/server.py index e76077f..dcced7c 100644 --- a/templates/new-server/greennode/__product_snake___mcp_server/server.py +++ b/templates/new-server/greennode/__product_snake___mcp_server/server.py @@ -3,9 +3,9 @@ from __future__ import annotations import argparse -from pathlib import Path from greennode.mcp_core.auth import TokenManager +from greennode.mcp_core.config import resolve_config_dir from greennode.{{product_snake}}_mcp_server.client import {{Product}}Client from greennode.{{product_snake}}_mcp_server.config import load_config from greennode.{{product_snake}}_mcp_server.example_handler import ExampleHandler @@ -14,7 +14,8 @@ from starlette.responses import JSONResponse, Response -CONFIG_PATH = Path.home() / ".greenode" +# Prefer ~/.greennode; fall back to the legacy ~/.greenode when only it exists. +CONFIG_PATH = resolve_config_dir() SERVER_INSTRUCTIONS = """\ GreenNode {{PRODUCT}} MCP Server.