Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
8 changes: 5 additions & 3 deletions src/mcp-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions src/mcp-core/greennode/mcp_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Product servers (src/<product>-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
Expand All @@ -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

Expand All @@ -26,6 +26,7 @@
"DiscoveryCache",
"ProfileSettings",
"load_profile",
"resolve_config_dir",
"BaseClient",
"validate_id",
"__version__",
Expand Down
28 changes: 27 additions & 1 deletion src/mcp-core/greennode/mcp_core/config.py
Original file line number Diff line number Diff line change
@@ -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).
"""
Expand All @@ -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."""
Expand Down
16 changes: 16 additions & 0 deletions src/mcp-core/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ProfileSettings,
TokenManager,
load_profile,
resolve_config_dir,
validate_id,
)

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
8 changes: 4 additions & 4 deletions src/vks-mcp-server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions src/vks-mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 6 additions & 4 deletions src/vks-mcp-server/greennode/vks_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/vks-mcp-server/scripts/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion templates/new-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down