Add local API key onboarding
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
# Copy this file to .env for local use, or import it with:
|
||||
# python setup_keys.py --from-env-file .env
|
||||
DATA_GO_KR_API_KEY=
|
||||
VWORLD_API_KEY=
|
||||
|
||||
29
README.md
29
README.md
@@ -54,6 +54,32 @@ python server.py
|
||||
|
||||
The server starts on `127.0.0.1:8765` and exposes the MCP endpoint at `/mcp`.
|
||||
|
||||
## API Key Setup
|
||||
|
||||
CivilPlan supports two local-only key flows:
|
||||
|
||||
### Option A: `.env`
|
||||
|
||||
1. Copy `.env.example` to `.env`
|
||||
2. Fill in your own keys
|
||||
3. Start the server
|
||||
|
||||
`.env` is ignored by git and loaded automatically at startup.
|
||||
|
||||
### Option B: encrypted local storage
|
||||
|
||||
```bash
|
||||
python setup_keys.py
|
||||
```
|
||||
|
||||
Or import an existing `.env` file into encrypted storage:
|
||||
|
||||
```bash
|
||||
python setup_keys.py --from-env-file .env
|
||||
```
|
||||
|
||||
On Windows, this stores the keys with DPAPI under your local user profile so the same machine and user account are required to decrypt them.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
@@ -64,6 +90,8 @@ VWORLD_API_KEY=
|
||||
- `DATA_GO_KR_API_KEY`: public data portal API key used for benchmark probing and future official integrations
|
||||
- `VWORLD_API_KEY`: VWorld API key used for address-to-PNU lookup and cadastral queries
|
||||
|
||||
Live keys are intentionally not committed to the public repository.
|
||||
|
||||
## Client Connection
|
||||
|
||||
### Claude Desktop
|
||||
@@ -120,6 +148,7 @@ If parsing fails, the server creates `.update_required_*` flag files and emits s
|
||||
- Land-price lookup requires manually downloaded source files under `civilplan_mcp/data/land_prices/`.
|
||||
- Nara benchmark validation currently probes API availability and falls back to local heuristics when the public endpoint is unavailable.
|
||||
- Updater fetchers are conservative and may request manual review when source pages change.
|
||||
- Public repository builds do not include live API credentials. Users should set their own keys through `.env` or `setup_keys.py`.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -4,12 +4,28 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from civilplan_mcp.secure_store import default_key_store_path, load_api_keys
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load_local_env() -> None:
|
||||
env_path = BASE_DIR / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path, override=False)
|
||||
|
||||
|
||||
def _load_secure_api_keys(path: Path) -> dict[str, str]:
|
||||
try:
|
||||
return load_api_keys(path=path)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
app_name: str = "civilplan_mcp"
|
||||
version: str = "1.0.0"
|
||||
@@ -19,13 +35,22 @@ class Settings(BaseModel):
|
||||
db_path: Path = Field(default_factory=lambda: BASE_DIR / "civilplan.db")
|
||||
output_dir: Path = Field(default_factory=lambda: BASE_DIR / "output")
|
||||
data_dir: Path = Field(default_factory=lambda: BASE_DIR / "civilplan_mcp" / "data")
|
||||
key_store_path: Path = Field(default_factory=default_key_store_path)
|
||||
data_go_kr_api_key: str = Field(default_factory=lambda: os.getenv("DATA_GO_KR_API_KEY", ""))
|
||||
vworld_api_key: str = Field(default_factory=lambda: os.getenv("VWORLD_API_KEY", ""))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
load_local_env()
|
||||
settings = Settings()
|
||||
secure_keys = _load_secure_api_keys(settings.key_store_path)
|
||||
|
||||
if not settings.data_go_kr_api_key:
|
||||
settings.data_go_kr_api_key = secure_keys.get("DATA_GO_KR_API_KEY", "")
|
||||
if not settings.vworld_api_key:
|
||||
settings.vworld_api_key = secure_keys.get("VWORLD_API_KEY", "")
|
||||
|
||||
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return settings
|
||||
|
||||
|
||||
128
civilplan_mcp/secure_store.py
Normal file
128
civilplan_mcp/secure_store.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_KEY_FILE_NAME = "api-keys.dpapi.json"
|
||||
|
||||
|
||||
class DATA_BLOB(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbData", wintypes.DWORD),
|
||||
("pbData", ctypes.POINTER(ctypes.c_char)),
|
||||
]
|
||||
|
||||
|
||||
def default_key_store_path() -> Path:
|
||||
key_root = Path(os.getenv("CIVILPLAN_KEY_DIR", Path.home() / "key"))
|
||||
return key_root / "civilplan-mcp" / DEFAULT_KEY_FILE_NAME
|
||||
|
||||
|
||||
def _require_windows() -> None:
|
||||
if sys.platform != "win32":
|
||||
raise RuntimeError("Windows DPAPI storage is only available on Windows.")
|
||||
|
||||
|
||||
def _blob_from_bytes(data: bytes) -> tuple[DATA_BLOB, ctypes.Array[ctypes.c_char]]:
|
||||
raw_buffer = ctypes.create_string_buffer(data, len(data))
|
||||
blob = DATA_BLOB(
|
||||
cbData=len(data),
|
||||
pbData=ctypes.cast(raw_buffer, ctypes.POINTER(ctypes.c_char)),
|
||||
)
|
||||
return blob, raw_buffer
|
||||
|
||||
|
||||
def _bytes_from_blob(blob: DATA_BLOB) -> bytes:
|
||||
return ctypes.string_at(blob.pbData, blob.cbData)
|
||||
|
||||
|
||||
def _protect_bytes(raw: bytes) -> bytes:
|
||||
_require_windows()
|
||||
crypt32 = ctypes.windll.crypt32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
input_blob, input_buffer = _blob_from_bytes(raw)
|
||||
output_blob = DATA_BLOB()
|
||||
|
||||
success = crypt32.CryptProtectData(
|
||||
ctypes.byref(input_blob),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
ctypes.byref(output_blob),
|
||||
)
|
||||
if not success:
|
||||
raise ctypes.WinError()
|
||||
|
||||
try:
|
||||
return _bytes_from_blob(output_blob)
|
||||
finally:
|
||||
if output_blob.pbData:
|
||||
kernel32.LocalFree(output_blob.pbData)
|
||||
del input_buffer
|
||||
|
||||
|
||||
def _unprotect_bytes(raw: bytes) -> bytes:
|
||||
_require_windows()
|
||||
crypt32 = ctypes.windll.crypt32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
input_blob, input_buffer = _blob_from_bytes(raw)
|
||||
output_blob = DATA_BLOB()
|
||||
|
||||
success = crypt32.CryptUnprotectData(
|
||||
ctypes.byref(input_blob),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
ctypes.byref(output_blob),
|
||||
)
|
||||
if not success:
|
||||
raise ctypes.WinError()
|
||||
|
||||
try:
|
||||
return _bytes_from_blob(output_blob)
|
||||
finally:
|
||||
if output_blob.pbData:
|
||||
kernel32.LocalFree(output_blob.pbData)
|
||||
del input_buffer
|
||||
|
||||
|
||||
def save_api_keys(keys: dict[str, str], path: Path | None = None) -> Path:
|
||||
target = path or default_key_store_path()
|
||||
payload = {
|
||||
"version": 1,
|
||||
"keys": {key: value for key, value in keys.items() if value},
|
||||
}
|
||||
protected = _protect_bytes(json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
||||
envelope = {
|
||||
"scheme": "windows-dpapi-base64",
|
||||
"ciphertext": base64.b64encode(protected).decode("ascii"),
|
||||
}
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return target
|
||||
|
||||
|
||||
def load_api_keys(path: Path | None = None) -> dict[str, str]:
|
||||
target = path or default_key_store_path()
|
||||
if not target.exists():
|
||||
return {}
|
||||
|
||||
envelope = json.loads(target.read_text(encoding="utf-8"))
|
||||
ciphertext = base64.b64decode(envelope["ciphertext"])
|
||||
payload = json.loads(_unprotect_bytes(ciphertext).decode("utf-8"))
|
||||
keys: dict[str, Any] = payload.get("keys", {})
|
||||
return {str(key): str(value) for key, value in keys.items() if value}
|
||||
@@ -64,6 +64,8 @@ async def civilplan_lifespan(_: FastMCP) -> AsyncIterator[dict[str, object]]:
|
||||
missing_keys = check_api_keys()
|
||||
for key in missing_keys:
|
||||
logger.warning("API key missing: %s", key)
|
||||
if missing_keys:
|
||||
logger.warning("Provide keys in .env or run `python setup_keys.py` for encrypted local storage.")
|
||||
|
||||
scheduler = build_scheduler(start=True)
|
||||
try:
|
||||
|
||||
56
civilplan_mcp/setup_keys.py
Normal file
56
civilplan_mcp/setup_keys.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from civilplan_mcp.secure_store import default_key_store_path, save_api_keys
|
||||
|
||||
|
||||
def _parse_env_file(path: Path) -> dict[str, str]:
|
||||
parsed: dict[str, str] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
parsed[key.strip()] = value.strip().strip("'").strip('"')
|
||||
return parsed
|
||||
|
||||
|
||||
def _prompt_value(name: str, current: str = "") -> str:
|
||||
prompt = f"{name}"
|
||||
if current:
|
||||
prompt += " [press Enter to keep imported value]"
|
||||
prompt += ": "
|
||||
entered = getpass(prompt).strip()
|
||||
return entered or current
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Store CivilPlan API keys in Windows DPAPI encrypted local storage.")
|
||||
parser.add_argument(
|
||||
"--from-env-file",
|
||||
type=Path,
|
||||
help="Import API keys from an existing .env file before prompting.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
imported: dict[str, str] = {}
|
||||
if args.from_env_file:
|
||||
imported = _parse_env_file(args.from_env_file)
|
||||
|
||||
data_go_kr_api_key = _prompt_value("DATA_GO_KR_API_KEY", imported.get("DATA_GO_KR_API_KEY", ""))
|
||||
vworld_api_key = _prompt_value("VWORLD_API_KEY", imported.get("VWORLD_API_KEY", ""))
|
||||
|
||||
target = save_api_keys(
|
||||
{
|
||||
"DATA_GO_KR_API_KEY": data_go_kr_api_key,
|
||||
"VWORLD_API_KEY": vworld_api_key,
|
||||
}
|
||||
)
|
||||
|
||||
print("CivilPlan API keys were saved to encrypted local storage.")
|
||||
print(f"Path: {target}")
|
||||
print("The file is protected with Windows DPAPI and can only be read by the same Windows user on this machine.")
|
||||
return 0
|
||||
@@ -22,9 +22,13 @@ dependencies = [
|
||||
"aiosqlite>=0.20.0",
|
||||
"httpx>=0.27.0",
|
||||
"apscheduler>=3.10.0",
|
||||
"python-dotenv>=1.0.1",
|
||||
"python-dateutil>=2.9.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
civilplan-setup-keys = "civilplan_mcp.setup_keys:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["civilplan_mcp*"]
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ ezdxf>=1.3.0
|
||||
aiosqlite>=0.20.0
|
||||
httpx>=0.27.0
|
||||
apscheduler>=3.10.0
|
||||
python-dotenv>=1.0.1
|
||||
python-dateutil>=2.9.0
|
||||
pytest>=8.0.0
|
||||
|
||||
5
setup_keys.py
Normal file
5
setup_keys.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from civilplan_mcp.setup_keys import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
76
tests/test_config_and_secure_store.py
Normal file
76
tests/test_config_and_secure_store.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from civilplan_mcp import config, secure_store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_settings_cache() -> None:
|
||||
config.get_settings.cache_clear()
|
||||
yield
|
||||
config.get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_secure_store_round_trip_with_stub_crypto(tmp_path: Path, monkeypatch) -> None:
|
||||
target = tmp_path / "api-keys.dpapi.json"
|
||||
|
||||
monkeypatch.setattr(secure_store, "_protect_bytes", lambda raw: raw[::-1])
|
||||
monkeypatch.setattr(secure_store, "_unprotect_bytes", lambda raw: raw[::-1])
|
||||
|
||||
secure_store.save_api_keys(
|
||||
{
|
||||
"DATA_GO_KR_API_KEY": "data-key",
|
||||
"VWORLD_API_KEY": "vworld-key",
|
||||
},
|
||||
path=target,
|
||||
)
|
||||
|
||||
loaded = secure_store.load_api_keys(path=target)
|
||||
|
||||
assert loaded == {
|
||||
"DATA_GO_KR_API_KEY": "data-key",
|
||||
"VWORLD_API_KEY": "vworld-key",
|
||||
}
|
||||
|
||||
|
||||
def test_get_settings_uses_secure_store_when_env_missing(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(config, "BASE_DIR", tmp_path)
|
||||
monkeypatch.setattr(config, "load_local_env", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"_load_secure_api_keys",
|
||||
lambda path: {
|
||||
"DATA_GO_KR_API_KEY": "secure-data-key",
|
||||
"VWORLD_API_KEY": "secure-vworld-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
|
||||
|
||||
settings = config.get_settings()
|
||||
|
||||
assert settings.data_go_kr_api_key == "secure-data-key"
|
||||
assert settings.vworld_api_key == "secure-vworld-key"
|
||||
|
||||
|
||||
def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(config, "BASE_DIR", tmp_path)
|
||||
monkeypatch.setattr(config, "load_local_env", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"_load_secure_api_keys",
|
||||
lambda path: {
|
||||
"DATA_GO_KR_API_KEY": "secure-data-key",
|
||||
"VWORLD_API_KEY": "secure-vworld-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.setenv("DATA_GO_KR_API_KEY", "env-data-key")
|
||||
monkeypatch.setenv("VWORLD_API_KEY", "env-vworld-key")
|
||||
|
||||
settings = config.get_settings()
|
||||
|
||||
assert settings.data_go_kr_api_key == "env-data-key"
|
||||
assert settings.vworld_api_key == "env-vworld-key"
|
||||
Reference in New Issue
Block a user