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=
|
DATA_GO_KR_API_KEY=
|
||||||
VWORLD_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`.
|
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
|
## Environment Variables
|
||||||
|
|
||||||
```env
|
```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
|
- `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
|
- `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
|
## Client Connection
|
||||||
|
|
||||||
### Claude Desktop
|
### 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/`.
|
- 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.
|
- 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.
|
- 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
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from pydantic import BaseModel, Field
|
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
|
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):
|
class Settings(BaseModel):
|
||||||
app_name: str = "civilplan_mcp"
|
app_name: str = "civilplan_mcp"
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.0"
|
||||||
@@ -19,13 +35,22 @@ class Settings(BaseModel):
|
|||||||
db_path: Path = Field(default_factory=lambda: BASE_DIR / "civilplan.db")
|
db_path: Path = Field(default_factory=lambda: BASE_DIR / "civilplan.db")
|
||||||
output_dir: Path = Field(default_factory=lambda: BASE_DIR / "output")
|
output_dir: Path = Field(default_factory=lambda: BASE_DIR / "output")
|
||||||
data_dir: Path = Field(default_factory=lambda: BASE_DIR / "civilplan_mcp" / "data")
|
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", ""))
|
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", ""))
|
vworld_api_key: str = Field(default_factory=lambda: os.getenv("VWORLD_API_KEY", ""))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
load_local_env()
|
||||||
settings = Settings()
|
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)
|
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return settings
|
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()
|
missing_keys = check_api_keys()
|
||||||
for key in missing_keys:
|
for key in missing_keys:
|
||||||
logger.warning("API key missing: %s", key)
|
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)
|
scheduler = build_scheduler(start=True)
|
||||||
try:
|
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",
|
"aiosqlite>=0.20.0",
|
||||||
"httpx>=0.27.0",
|
"httpx>=0.27.0",
|
||||||
"apscheduler>=3.10.0",
|
"apscheduler>=3.10.0",
|
||||||
|
"python-dotenv>=1.0.1",
|
||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
civilplan-setup-keys = "civilplan_mcp.setup_keys:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["civilplan_mcp*"]
|
include = ["civilplan_mcp*"]
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ ezdxf>=1.3.0
|
|||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
apscheduler>=3.10.0
|
apscheduler>=3.10.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
python-dateutil>=2.9.0
|
python-dateutil>=2.9.0
|
||||||
pytest>=8.0.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