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
28 changes: 0 additions & 28 deletions .github/workflows/plugin-api-v2.yml

This file was deleted.

67 changes: 67 additions & 0 deletions .github/workflows/plugin-api-v3.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: plugin-api-v3

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: akashic-plugins/plugin-contracts
ref: 4dd69dd621e029e51e99aa428443fa3a4ec1f6cf
path: .plugin-contracts
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Check Plugin API v3
env:
PYTHONPATH: .plugin-contracts
run: python -m akashic_plugin_contracts check plugin.py

plugin-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: kachofugetsu09/akashic-agent
ref: 3005f838bcd96e2cbc58616aede46e4f39df4523
path: .akashic-core
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: |
.akashic-core/requirements.txt
mcp/requirements.txt
- name: Stage exact Steam runtime
run: |
python -m venv mcp/.venv
mcp/.venv/bin/python -m pip install \
-r .akashic-core/requirements.txt \
-r .akashic-core/requirements-dev.txt \
-r mcp/requirements.txt
- name: Verify Steam v3 composition and migration
env:
AKASHIC_AGENT_ROOT: .akashic-core
PYTHONPATH: .akashic-core:mcp:mcp/.venv/lib/python3.13/site-packages
run: mcp/.venv/bin/python -m pytest -q mcp/tests tests
- name: Check changed v3 sources
env:
PYTHONPATH: .akashic-core:mcp
run: mcp/.venv/bin/pyright plugin.py mcp/runtime_config.py mcp/run_mcp.py scripts tests
- name: Compile Python sources
run: python -m compileall -q plugin.py mcp scripts tests
- name: Check diff formatting
run: git diff --check
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Restart Akashic after install.
Runtime data lives in:

```text
~/.akashic-plugin/data/steam-<marketplace>/
<workspace>/plugin-data/steam-<marketplace>/
```

Common files:
Expand All @@ -43,4 +43,22 @@ Create `steam_mcp_config.json` in the plugin data directory:
`get_steam_context` 每次读取实时在线状态,并在历史游戏时长快照超过
`snapshot_interval_seconds` 时自动刷新。空的最近游玩列表也会记录快照批次,避免重复刷新。

When migrating from the old workspace MCP, the plugin copies the old config and cache files automatically on first startup.
## v2 data migration

v3 不会在插件加载时隐式复制正式数据。停止 Akashic 后显式执行:

```bash
PYTHONPATH=/path/to/akashic-agent \
python scripts/migrate_v2_data.py \
--workspace /path/to/workspace \
--marketplace github
```

迁移保留 `mcp/steam-mcp` 原文件,在
`plugin-data/steam-<marketplace>/.steam-v2-migration.json` 写入 hash 与 SQLite
完整性证据。进程内失败会回滚本次新增文件;进程崩溃后重跑会清理 staging,
并只接纳已经发布且内容完全相同的文件。

候选验证使用无凭证、无外网、无数据库的 recording backend;正式 MCP
只从自己的 `plugin-data` 读取 `steam_mcp_config.json`,不读取 ambient
`STEAM_API_KEY` 或 `STEAM_ID`。
24 changes: 24 additions & 0 deletions akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
schema_version = 1
name = "steam"
version = "3.0.0"
api_version = 3
entrypoint = "plugin.py"

[[python]]
requirements = "mcp/requirements.txt"

[validation]
exclude_data_paths = [
"steam_mcp_config.json",
"steam_user_cache.json",
"steam_app_cache.json",
"steam_proactive.sqlite3",
".steam-v2-migration.json",
]

[[mcp]]
name = "steam"
command = ["python", "mcp/run_mcp.py"]
required_tools = ["get_steam_context"]
candidate_read_only_tools = ["get_steam_context"]
candidate_env = {STEAM_BACKEND = "recording"}
5 changes: 0 additions & 5 deletions mcp/http_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass
import os
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
Expand Down Expand Up @@ -65,10 +64,6 @@ def get(
raise SteamApiError(f"Steam API request failed: {exc.reason}") from exc

def _load_api_key(self) -> str:
env_api_key = os.environ.get("STEAM_API_KEY", "").strip()
if env_api_key:
return env_api_key

if not self.config_path.exists():
raise SteamApiError(
f"Steam API key is required. Create `{self.config_path}` and set `steam_api_key`."
Expand Down
9 changes: 9 additions & 0 deletions mcp/run_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ def main() -> None:
if str(script_dir) not in sys.path:
sys.path.insert(0, str(script_dir))

backend = os.environ.get("STEAM_BACKEND", "formal").strip().lower()
if backend not in {"formal", "recording"}:
raise RuntimeError(f"未知 STEAM_BACKEND: {backend}")
if backend == "formal":
from runtime_config import load_runtime_config

data_root = Path(os.environ["AKA_PLUGIN_DATA_DIR"]).resolve()
_ = load_runtime_config(data_root / "steam_mcp_config.json")

from steam_mcp import mcp

mcp.run(transport="stdio")
Expand Down
38 changes: 38 additions & 0 deletions mcp/runtime_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True, slots=True)
class SteamRuntimeConfig:
steam_api_key: str
steam_id: str
snapshot_interval_seconds: int


def load_runtime_config(path: Path) -> SteamRuntimeConfig:
"""读取并校验 formal Steam runtime 配置。"""

# 1. 配置只来自 formal plugin-data,不接受 ambient secret
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise RuntimeError("Steam formal runtime 缺少 steam_mcp_config.json") from error
except json.JSONDecodeError as error:
raise RuntimeError("steam_mcp_config.json 不是合法 JSON") from error
if not isinstance(raw, dict):
raise RuntimeError("steam_mcp_config.json 根节点必须是 object")

# 2. 启动前建立完整 credential 与用户身份不变量
api_key = raw.get("steam_api_key")
steam_id = raw.get("steam_id")
if not isinstance(api_key, str) or not api_key.strip():
raise RuntimeError("steam_mcp_config.json 缺少 steam_api_key")
if not isinstance(steam_id, str) or not steam_id.strip():
raise RuntimeError("steam_mcp_config.json 缺少 steam_id")
interval = raw.get("snapshot_interval_seconds", 6 * 3600)
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 300:
raise RuntimeError("snapshot_interval_seconds 必须是大于等于 300 的整数")
return SteamRuntimeConfig(api_key.strip(), steam_id.strip(), interval)
19 changes: 18 additions & 1 deletion mcp/steam_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
mcp = FastMCP("steam-web-api")
RUNTIME_DIR = Path(os.environ.get("AKA_PLUGIN_DATA_DIR", "").strip() or Path.cwd())
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
BACKEND = os.environ.get("STEAM_BACKEND", "formal").strip().lower()
http_client = HttpClient(config_path=str(RUNTIME_DIR / "steam_mcp_config.json"))
SUPPORTED_FORMATS = {"json", "xml", "vdf"}
MAX_STEAM_IDS_PER_REQUEST = 100
Expand Down Expand Up @@ -633,14 +634,30 @@ def get_steam_context() -> dict:
"""获取用户 Steam 游戏活动的持久上下文,供 proactive engine 注入 background_context。
返回近两周游戏时长、历史对比、当前在线状态等结构化数据。
"""
if BACKEND == "recording":
return {
"items": [
{
"presence": "unknown",
"interruptibility": 0.4,
"confidence": 0.0,
"transition": "",
"recording": True,
}
]
}
import steam_proactive
return steam_proactive.get_context()

return {"items": [steam_proactive.get_context()]}


@mcp.tool()
def take_steam_snapshot() -> dict:
"""拉取并存储一次 Steam 游戏数据快照。通常由定时任务调用,也可手动触发。"""
if BACKEND == "recording":
raise RuntimeError("recording backend 禁止写 Steam snapshot")
import steam_proactive

return steam_proactive.take_snapshot()


Expand Down
6 changes: 0 additions & 6 deletions mcp/steam_proactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@ def _load_config() -> dict:
raise ValueError("steam_mcp_config.json 根节点必须是 object")
else:
loaded = {}
steam_api_key = os.environ.get("STEAM_API_KEY", "").strip()
steam_id = os.environ.get("STEAM_ID", "").strip()
if steam_api_key:
loaded["steam_api_key"] = steam_api_key
if steam_id:
loaded["steam_id"] = steam_id
return loaded


Expand Down
75 changes: 75 additions & 0 deletions mcp/tests/test_v3_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import importlib
import json
import sys

import pytest

from runtime_config import load_runtime_config


def test_formal_runtime_config_requires_plugin_data_credentials(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "steam_mcp_config.json"
config_path.write_text(
json.dumps({"steam_id": "user", "snapshot_interval_seconds": 3600}),
encoding="utf-8",
)
monkeypatch.setenv("STEAM_API_KEY", "ambient-secret")

with pytest.raises(RuntimeError, match="缺少 steam_api_key"):
load_runtime_config(config_path)


def test_formal_runtime_config_accepts_complete_file(tmp_path) -> None:
config_path = tmp_path / "steam_mcp_config.json"
config_path.write_text(
json.dumps(
{
"steam_api_key": "formal-secret",
"steam_id": "user",
"snapshot_interval_seconds": 3600,
}
),
encoding="utf-8",
)

config = load_runtime_config(config_path)

assert config.steam_api_key == "formal-secret"
assert config.steam_id == "user"
assert config.snapshot_interval_seconds == 3600


def test_recording_context_never_reads_formal_config_or_creates_database(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
data_root = tmp_path / "candidate-data"
monkeypatch.setenv("AKA_PLUGIN_DATA_DIR", str(data_root))
monkeypatch.setenv("STEAM_BACKEND", "recording")
monkeypatch.setenv("STEAM_API_KEY", "ambient-secret")
monkeypatch.setenv("STEAM_ID", "ambient-user")
sys.modules.pop("steam_mcp", None)
sys.modules.pop("steam_proactive", None)

module = importlib.import_module("steam_mcp")
result = module.get_steam_context()

assert result == {
"items": [
{
"presence": "unknown",
"interruptibility": 0.4,
"confidence": 0.0,
"transition": "",
"recording": True,
}
]
}
assert "steam_proactive" not in sys.modules
assert not (data_root / "steam_mcp_config.json").exists()
assert not (data_root / "steam_proactive.sqlite3").exists()
Loading
Loading