Merge pull request 'feat: requires OAuth-only auth mode for MCP agents' (#12) from feature/mcp-oauth-oidc into main
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
@@ -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=
|
||||
|
||||
14
README.md
14
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 <token>`.
|
||||
|
||||
По умолчанию используется статический токен из `MCP_TOKEN`:
|
||||
Для внешних ИИ-агентов используйте `MCP_AUTH_MODE=oauth`. В этом режиме статический `MCP_TOKEN` не принимается: клиент должен передать OAuth/OIDC access token с нужным scope.
|
||||
|
||||
Поддерживаемые tools:
|
||||
|
||||
@@ -100,7 +100,7 @@ Endpoint: `POST /mcp`, авторизация `Authorization: Bearer <token>`.
|
||||
- `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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user