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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ TopicEye 是给内容创作者用的选题雷达:持续抓取 25+ 信源(RSS
播客 / newsletter / 趋势榜),用可解释的六维引擎给每条内容打分,挑出今天值得写的题目。

对外有两条 agent 入口:REST(`AGENT_API.md` 文档化的 skill/scoring 端点)与 MCP
server(`app/mcp/`,挂载在 `/mcp`,Bearer token 认证,官方 `mcp` SDK 实现 2026-07-28
无状态协议)。
server(`app/mcp/`,挂载在 `/mcp`,Bearer token 认证——静态个人 token 或动态
OAuth 2.1(授权码 + PKCE + DCR,AS 端点挂站点根路径)两可,官方 `mcp` SDK 实现
2026-07-28 无状态协议)。

两件不看代码就会踩空的事:

Expand Down Expand Up @@ -64,7 +65,9 @@ scripts/check_layering.py`,CI 作业 `backend-layering`)只作用于 `app/ap
`select/insert/update/delete` 构造与 `db.execute/db.add/db.scalars/db.scalar`。上表其余禁止项
它查不出——`from app.models import <ORMModel>` 与 api 层合法要用的 Enum、依赖注入同模块,
自动判定会误报,只能人工看。`app/mcp/` 是与 `api/v1` 平级的入口层,不在机器检查范围内,
按上表 api 行的纪律人工把关(当前只调 services 与 `DailyReportRepository`)。
按上表 api 行的纪律人工把关(当前只调 services 与 `DailyReportRepository`;OAuth
provider 的业务编排在 `app/services/mcp_oauth_service.py`,同意页 REST 端点在
`app/api/v1/mcp_oauth.py`)。

### 2.3 迁移与高风险变更

Expand Down
34 changes: 28 additions & 6 deletions AGENT_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ The full machine-readable spec is available at `/openapi.json` (or browse intera

TopicEye also speaks the **Model Context Protocol** natively: a streamable-http MCP server is mounted at **`/mcp`** (2026-07-28 stateless protocol; the same server auto-negotiates older protocol revisions for legacy clients).

**Auth is the same Bearer token as the REST API** (see Authentication above) — create a personal API token, then configure your MCP client to send it as an `Authorization: Bearer <token>` header.
**Two auth paths are supported:**

1. **Dynamic OAuth 2.1 (browser pop-up login)** — the standard MCP client flow. Point your MCP client at `https://<your-host>/mcp` with no token; the client discovers the authorization server via RFC 9728 metadata, registers itself (RFC 7591 dynamic client registration), and opens a browser for you to log in and approve. Access tokens live 60 min, refresh tokens 30 days with rotation (a replayed refresh token revokes its whole family).
2. **Static Bearer token** (see Authentication above) — create a personal API token and configure your MCP client to send it as an `Authorization: Bearer <token>` header. Works with every MCP client, including ones without OAuth support.

### Tools (no destructive writes)

Expand All @@ -179,7 +182,7 @@ All tools mirror the REST agent endpoints. Note `get_daily_report` without `date

### Client config examples

ZCode / Claude Code style (header-authenticated streamable-http):
ZCode / Claude Code style (header-authenticated streamable-http, static token):

```json
{
Expand All @@ -192,9 +195,28 @@ ZCode / Claude Code style (header-authenticated streamable-http):
}
```

OAuth mode needs no `headers` — just the URL. The client will pop up a browser on first connect; after you log in and click 授权 (approve) on the consent page, it stores the tokens itself.

### Dynamic OAuth endpoints

The authorization server (AS) lives at the site root, with `issuer = SITE_BASE_URL`:

| Endpoint | Purpose |
|---|---|
| `GET /.well-known/oauth-authorization-server` | RFC 8414 AS metadata |
| `GET /.well-known/oauth-protected-resource/mcp` | RFC 9728 resource metadata (points clients at the AS) |
| `GET/POST /authorize` | Authorization endpoint (PKCE S256 mandatory) → redirects to the consent page. Requires a logged-in browser session: anonymous GET is redirected to the login page with a `returnTo` bounce-back; anonymous POST gets 400. The initiating user is bound to the request — only the initiator can view/approve/deny it on the consent page |
| `POST /token` | Token endpoint (authorization_code + refresh_token grants). A replayed (already-consumed) authorization code is rejected and logged as an exfiltration signal |
| `POST /register` | RFC 7591 dynamic client registration |
| `POST /revoke` | RFC 7009 revocation (family-wide; note: the SDK requires a `client_secret` form field to be present — public clients send an empty value) |

The consent page itself (`/mcp/authorize` on the frontend) talks to three session-authenticated REST endpoints: `GET /api/v1/mcp-oauth/authorize-info`, `POST /api/v1/mcp-oauth/approve`, `POST /api/v1/mcp-oauth/deny` — all three initiator-only. Authorization codes are single-use and expire in 10 minutes. The consent page marks every DCR-registered client as an unvetted third-party app (client names are self-declared).

Tool-level scopes are enforced by a central fail-closed gate (`TOOL_REQUIRED_SCOPES` in `app/mcp/scopes.py`); all four current tools are read-only and require no scopes. Adding any new tool (especially the first write tool) must register it there — a CI assertion keeps the tool set and the gate registry in lockstep, and the full scope model is the tracked precondition for the first write tool (issue #118).

Operational notes:

- `MCP_ENABLED=false` disables the server (no route mounted; startup logs state it explicitly).
- Rate limit: 60 req/min per IP on `/mcp`.
- Production requires `SITE_BASE_URL` to be set (enables Host/Origin validation against DNS-rebinding; otherwise a startup warning is logged and the check stays off).
- Status of dynamic OAuth authorization (browser pop-up login): not yet implemented — the advertised issuer points back at the site, and clients should use pre-provisioned API tokens. Static Bearer tokens work with every MCP client; adding a real authorization server is a follow-up option (the verifier is swappable behind the SDK's `TokenVerifier`).
- `MCP_ENABLED=false` disables the whole MCP surface (no route mounted; startup logs state it explicitly). `MCP_OAUTH_ENABLED=false` disables only the OAuth endpoints (static tokens keep working).
- Rate limits: 60 req/min per IP on `/mcp`; 30/min on `/authorize` and `/token`; 10/min on `/register`.
- Production requires `SITE_BASE_URL` to be set (enables Host/Origin validation against DNS-rebinding; otherwise a startup warning is logged and the check stays off). The OAuth issuer must be HTTPS (loopback hosts excepted) — with an invalid issuer the AS routes are not mounted, an explicit error is logged, and static tokens keep working.
- Single-origin deployments behind the Next.js proxy: `/authorize`, `/token`, `/register`, `/revoke`, and `/.well-known/*` are proxied to the backend by `next.config.js` rewrites.
1 change: 1 addition & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import app.models.favorite # noqa: F401, E402
import app.models.feedback # noqa: F401, E402
import app.models.llm_model # noqa: F401, E402
import app.models.mcp_oauth # noqa: F401, E402
import app.models.model_catalog # noqa: F401, E402
import app.models.monthly_digest # noqa: F401, E402
import app.models.mother_topic # noqa: F401, E402
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""MCP 动态 OAuth 授权服务器存储:三张表

Revision ID: c7a4e9f21b86
Revises: b4e2c8a01f73
Create Date: 2026-10-17

#116 起 MCP server 支持标准 OAuth 2.1 浏览器弹窗登录(授权码 + PKCE +
DCR),需要授权服务器侧的持久化:

- mcp_oauth_clients:RFC 7591 动态注册的客户端(secret 为 Fernet 密文,
同 oauth_providers 先例,依赖 INTEGRATION_SECRET_KEY 已配置)
- mcp_oauth_authorizations:/authorize 落的 pending 授权请求,用户批准后
同一行盖 code_hash 变一次性授权码(pending/approved/denied/consumed)
- mcp_oauth_tokens:access / refresh 共表,库存 sha256(raw 只回传一次),
family_id 串起轮换链供重放检测整族撤销

新表全部为增量(无数据回填、不改既有表),downgrade 直接删表;若生产
已有用户完成过 OAuth 授权,回滚前先备份 token 行。
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "c7a4e9f21b86"
down_revision: str | None = "b4e2c8a01f73"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"mcp_oauth_clients",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("client_id", sa.String(length=64), nullable=False, comment="SDK 注册时生成的 uuid4"),
sa.Column("client_name", sa.String(length=200), nullable=True, comment="同意页展示的应用名"),
sa.Column(
"client_secret_enc",
sa.Text(),
nullable=True,
comment="Fernet 密文(enc:v1:);公共客户端(method=none)为 NULL",
),
sa.Column(
"metadata_json",
sa.Text(),
nullable=False,
comment="OAuthClientInformationFull dump(不含 secret)",
),
sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_mcp_oauth_clients_client_id", "mcp_oauth_clients", ["client_id"], unique=True)

op.create_table(
"mcp_oauth_authorizations",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("request_id", sa.String(length=36), nullable=False, comment="uuid4,发给前端同意页"),
sa.Column(
"status",
sa.String(length=12),
nullable=False,
server_default="pending",
comment="pending / approved / denied / consumed",
),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column(
"initiator_user_id",
sa.Integer(),
nullable=True,
comment="发起授权请求的用户(绑定校验)",
),
sa.Column("code_hash", sa.String(length=64), nullable=True, comment="批准时生成的授权码 sha256"),
sa.Column("scopes", sa.Text(), nullable=True, comment="JSON 数组"),
sa.Column("state", sa.Text(), nullable=True, comment="客户端 state 原样带回"),
sa.Column("code_challenge", sa.String(length=128), nullable=False, comment="PKCE S256 challenge"),
sa.Column("redirect_uri", sa.String(length=512), nullable=False),
sa.Column("redirect_uri_provided_explicitly", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("resource", sa.String(length=512), nullable=True, comment="RFC 8707 资源指示符"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["initiator_user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_mcp_oauth_authorizations_request_id", "mcp_oauth_authorizations", ["request_id"], unique=True)
op.create_index("ix_mcp_oauth_authorizations_code_hash", "mcp_oauth_authorizations", ["code_hash"], unique=True)
op.create_index("ix_mcp_oauth_authorizations_client_status", "mcp_oauth_authorizations", ["client_id", "status"])

op.create_table(
"mcp_oauth_tokens",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("token_hash", sa.String(length=64), nullable=False, comment="sha256 hex"),
sa.Column("token_type", sa.String(length=10), nullable=False, comment="access / refresh"),
sa.Column("client_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("family_id", sa.String(length=36), nullable=False, comment="授权码发放起的轮换链标识"),
sa.Column("scopes", sa.Text(), nullable=True, comment="JSON 数组"),
sa.Column("resource", sa.String(length=512), nullable=True, comment="RFC 8707 资源指示符"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_mcp_oauth_tokens_token_hash", "mcp_oauth_tokens", ["token_hash"], unique=True)
op.create_index("ix_mcp_oauth_tokens_family", "mcp_oauth_tokens", ["family_id"])
op.create_index("ix_mcp_oauth_tokens_client_type", "mcp_oauth_tokens", ["client_id", "token_type"])


def downgrade() -> None:
# 回滚丢弃 OAuth 客户端注册与 token(存量静态 token 不受影响);
# 需要保留授权历史时先 pg_dump 这三张表
op.drop_table("mcp_oauth_tokens")
op.drop_table("mcp_oauth_authorizations")
op.drop_table("mcp_oauth_clients")
84 changes: 84 additions & 0 deletions backend/app/api/v1/mcp_oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""MCP OAuth 同意页交互端点(cookie 会话认证)。

浏览器弹窗登录流程中,前端 ``/mcp/authorize`` 同意页与后端的交互面:
- GET /mcp-oauth/authorize-info — 展示待授权客户端信息(client_name 等)
- POST /mcp-oauth/approve — 用户批准,返回带 code 的客户端回调地址
- POST /mcp-oauth/deny — 用户拒绝,返回 error=access_denied 回调地址

授权码签发、TTL、单次性语义在 ``app/services/mcp_oauth_service.py``;
本层只做会话校验(get_current_user)与 response shaping。三个端点都
要求操作者是授权请求的**发起者**(service 层校验 initiator_user_id,
非发起者一律 404 不暴露存在性)——发起者绑定由 ``app/mcp/authorize_route.py``
的 /authorize 包装路由落库,request_id 是 uuid4(≈122 bit 熵)、
10 分钟过期且一次性。
"""

from __future__ import annotations

import logging

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.v1.auth import get_current_user
from app.core.database import get_db
from app.models.user import User
from app.services import mcp_oauth_service

router = APIRouter(
prefix="/mcp-oauth",
tags=["mcp-oauth"],
dependencies=[Depends(get_current_user)],
)
logger = logging.getLogger(__name__)

_GONE = "授权请求不存在、已过期或已被处理"


class ConsentDecisionRequest(BaseModel):
request_id: str = Field(..., min_length=1, max_length=36, description="/authorize 下发的待授权请求 id")


@router.get("/authorize-info")
async def get_authorize_info(
request_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""同意页展示信息:应用名、主页、请求的 scope 与回调目标主机(仅发起者)。"""
info = await mcp_oauth_service.get_authorization_info(db, request_id, viewer_user_id=current_user.id)
if info is None:
raise HTTPException(status_code=404, detail=_GONE)
return info


@router.post("/approve")
async def approve_authorization(
req: ConsentDecisionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""批准授权:绑定当前用户并签发一次性授权码,返回客户端回调地址。"""
result = await mcp_oauth_service.approve_authorization(db, req.request_id, current_user.id)
if result is None:
raise HTTPException(status_code=404, detail=_GONE)
_code, redirect_to = result
await db.commit()
logger.info("MCP OAuth approved: user_id=%d client=%s", current_user.id, req.request_id)
# redirect_to 已含一次性 code;前端 window.location 跳转即可
return {"redirect_to": redirect_to}


@router.post("/deny")
async def deny_authorization(
req: ConsentDecisionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""拒绝授权:按 RFC 6749 以 error=access_denied 回调客户端(仅发起者)。"""
redirect_to = await mcp_oauth_service.deny_authorization(db, req.request_id, acting_user_id=current_user.id)
if redirect_to is None:
raise HTTPException(status_code=404, detail=_GONE)
await db.commit()
return {"redirect_to": redirect_to}
2 changes: 2 additions & 0 deletions backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from app.api.v1.integrations import router as integrations_router
from app.api.v1.llm_evaluations import router as llm_evaluations_router
from app.api.v1.llm_models import router as llm_models_router
from app.api.v1.mcp_oauth import router as mcp_oauth_router
from app.api.v1.metrics import router as metrics_router
from app.api.v1.model_catalog import router as model_catalog_router
from app.api.v1.monthly_digests import router as monthly_digests_router
Expand Down Expand Up @@ -50,6 +51,7 @@
router.include_router(auth_router)
router.include_router(oauth_router)
router.include_router(user_api_tokens_router)
router.include_router(mcp_oauth_router)
router.include_router(plans_router)
router.include_router(integrations_router)
router.include_router(scoring_router)
Expand Down
12 changes: 12 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ class Settings(BaseSettings):
# (个人 API token / session token,与 REST API 同一套)。关闭时不挂载
# 路由、不启动 session manager(启动日志显式记录,不静默跳过)。
MCP_ENABLED: bool = True
# MCP 动态 OAuth 授权服务器(/authorize /token /register /revoke + RFC 8414/9728
# 元数据,挂在站点根路径)。issuer = SITE_BASE_URL;非法 issuer(如局域网
# http,SDK 强制 https、loopback 除外)时启动显式报错并跳过挂载,静态
# token 用法不受影响。
MCP_OAUTH_ENABLED: bool = True
# 授权码 / pending 授权请求 TTL(秒)
MCP_OAUTH_CODE_TTL_SECONDS: int = 600
# access token TTL(秒);refresh 轮换时旧对整体撤销
MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS: int = 3600
MCP_OAUTH_REFRESH_TOKEN_DAYS: int = 30
# 同意页在前端的路径(与 SITE_BASE_URL 拼出完整回调地址)
MCP_OAUTH_CONSENT_PATH: str = "/mcp/authorize"
READ_CACHE_TTL_SECONDS: float = 60.0
# 整体同步超时(秒)。包含 fetch + classify + persist 三阶段。
# 120s 对 arXiv/HN/36氪 等条目多的信源太短(classify 阶段 LLM 调用慢),
Expand Down
Loading
Loading