diff --git a/.env.example b/.env.example index 500f59a..1496fea 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me SESSION_SECRET=change-me-session-secret MCP_TOKEN=change-me-mcp-token -MCP_AUTH_MODE=token +MCP_AUTH_MODE=oauth MCP_RESOURCE_URL=http://localhost:8001/mcp MCP_OAUTH_ISSUER= MCP_OAUTH_AUDIENCE= diff --git a/README.md b/README.md index b24e289..de0a8d9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - `api`: FastAPI, REST API, HTML-админка, healthcheck. - `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`. -- `mcp`: HTTP MCP endpoint со статическим bearer token или OAuth/OIDC access token. +- `mcp`: HTTP MCP endpoint с OAuth/OIDC access token для внешних агентов или legacy static token для локального режима. - `postgres`: основная БД. Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, JSON-снапшот и сжатый HTML-снапшот. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`. @@ -27,8 +27,8 @@ cp .env.example .env - `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска. - `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки. - `SESSION_SECRET`: секрет подписи cookie. -- `MCP_TOKEN`: статический bearer token для `/mcp`. -- `MCP_AUTH_MODE`: режим авторизации MCP: `token`, `oauth` или `oauth_or_token`. +- `MCP_TOKEN`: статический bearer token для legacy/local режима `MCP_AUTH_MODE=token`. +- `MCP_AUTH_MODE`: режим авторизации MCP: `oauth` для внешних агентов или `token` для локальной отладки. - `MCP_RESOURCE_URL`: публичный URL MCP endpoint, например `https://example.com/mcp`. - `MCP_OAUTH_ISSUER`: issuer внешнего OIDC-провайдера. - `MCP_OAUTH_AUDIENCE`: ожидаемый `aud` в OAuth access token. @@ -90,7 +90,7 @@ curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=. Endpoint: `POST /mcp`, авторизация `Authorization: Bearer `. -По умолчанию используется статический токен из `MCP_TOKEN`: +Для внешних ИИ-агентов используйте `MCP_AUTH_MODE=oauth`. В этом режиме статический `MCP_TOKEN` не принимается: клиент должен передать OAuth/OIDC access token с нужным scope. Поддерживаемые tools: @@ -100,7 +100,7 @@ Endpoint: `POST /mcp`, авторизация `Authorization: Bearer `. - `list_employee_courses(profile_id_or_url)` - `get_crawl_status()` -Пример: +Пример локального legacy-режима со статическим токеном: ```bash curl http://localhost:8001/mcp \ @@ -109,10 +109,10 @@ curl http://localhost:8001/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` -Для OAuth/OIDC настройте внешний authorization server и включите режим `oauth` или `oauth_or_token`: +Для production OAuth/OIDC настройте внешний authorization server и включите режим `oauth`: ```env -MCP_AUTH_MODE=oauth_or_token +MCP_AUTH_MODE=oauth MCP_RESOURCE_URL=https://example.com/mcp MCP_OAUTH_ISSUER=https://auth.example.com MCP_OAUTH_AUDIENCE=miem-mcp diff --git a/app/config.py b/app/config.py index 6be9d8e..09a5019 100644 --- a/app/config.py +++ b/app/config.py @@ -20,7 +20,7 @@ class Settings(BaseSettings): admin_password: str = "admin" session_secret: str = Field(default="dev-session-secret", min_length=8) mcp_token: str = "dev-mcp-token" - mcp_auth_mode: Literal["token", "oauth", "oauth_or_token"] = "token" + mcp_auth_mode: Literal["token", "oauth"] = "oauth" mcp_resource_url: str = "http://localhost:8001/mcp" mcp_oauth_issuer: str = "" mcp_oauth_audience: str = "" diff --git a/app/security.py b/app/security.py index 09032b9..70012fd 100644 --- a/app/security.py +++ b/app/security.py @@ -79,11 +79,11 @@ def mcp_protected_resource_metadata(settings: Settings) -> dict: def _mcp_static_token_allowed(settings: Settings) -> bool: - return settings.mcp_auth_mode in {"token", "oauth_or_token"} + return settings.mcp_auth_mode == "token" def _mcp_oauth_allowed(settings: Settings) -> bool: - return settings.mcp_auth_mode in {"oauth", "oauth_or_token"} + return settings.mcp_auth_mode == "oauth" def _validate_mcp_oauth_token(token: str, settings: Settings) -> None: diff --git a/tests/test_api_mcp.py b/tests/test_api_mcp.py index 883dee0..5842d73 100644 --- a/tests/test_api_mcp.py +++ b/tests/test_api_mcp.py @@ -43,7 +43,9 @@ def test_mcp_requires_token_and_lists_tools(): session.close() app.dependency_overrides[get_db] = override_db - app.dependency_overrides[get_settings] = lambda: Settings(mcp_token="secret", session_secret="session-secret") + app.dependency_overrides[get_settings] = lambda: Settings( + mcp_auth_mode="token", mcp_token="secret", session_secret="session-secret" + ) client = TestClient(app) unauthorized = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) @@ -93,7 +95,9 @@ def test_mcp_search_employees_returns_matching_employee(): db.close() app.dependency_overrides[get_db] = override_db - app.dependency_overrides[get_settings] = lambda: Settings(mcp_token="secret", session_secret="session-secret") + app.dependency_overrides[get_settings] = lambda: Settings( + mcp_auth_mode="token", mcp_token="secret", session_secret="session-secret" + ) client = TestClient(app) response = client.post( @@ -113,7 +117,7 @@ def test_mcp_search_employees_returns_matching_employee(): app.dependency_overrides.clear() -def test_mcp_oauth_or_token_keeps_static_token_fallback(): +def test_mcp_oauth_rejects_static_token(): engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, @@ -130,7 +134,7 @@ def test_mcp_oauth_or_token_keeps_static_token_fallback(): session.close() settings = Settings( - mcp_auth_mode="oauth_or_token", + mcp_auth_mode="oauth", mcp_token="secret", session_secret="session-secret", mcp_oauth_issuer="https://auth.example.com", @@ -147,8 +151,10 @@ def test_mcp_oauth_or_token_keeps_static_token_fallback(): json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, ) - assert response.status_code == 200 - assert response.json()["result"]["tools"][0]["name"] == "search_employees" + assert response.status_code == 401 + assert response.headers["www-authenticate"] == ( + 'Bearer resource_metadata="http://localhost:8001/.well-known/oauth-protected-resource"' + ) app.dependency_overrides.clear() diff --git a/tests/test_config.py b/tests/test_config.py index 9d9aeb6..5b615af 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from app.config import Settings @@ -11,3 +14,8 @@ def test_numeric_crawl_limit_is_parsed(): settings = Settings(crawl_limit="25") assert settings.crawl_limit == 25 + + +def test_mcp_auth_mode_rejects_oauth_or_token_fallback(): + with pytest.raises(ValidationError): + Settings(mcp_auth_mode="oauth_or_token")