Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。

## [Unreleased]
- 内置 MCP 端点 GET/DELETE 漏了 `status == "active"` 判定,连接器实例停用后仍可经 SSE 长连接通道继续收发消息;与 POST 对齐,停用实例一律返回 CONNECTOR_NOT_FOUND
- Linux 桌面客户端无边框窗口支持拖拽边缘与角落调整大小。

### 新增
Expand Down
4 changes: 2 additions & 2 deletions src/octop/api/routers/internal_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async def internal_mcp_get(
) -> Response:
"""Streamable HTTP clients open a long-lived GET SSE channel after initialize."""
inst = server.services.repos.connector_repo.get(instance_id)
if inst is None or inst.kind != kind:
if inst is None or inst.kind != kind or inst.status != "active":
raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, "instance not found")

svc = _service(server)
Expand Down Expand Up @@ -142,7 +142,7 @@ async def internal_mcp_delete(
) -> Response:
"""Streamable HTTP clients may DELETE to end a session."""
inst = server.services.repos.connector_repo.get(instance_id)
if inst is None or inst.kind != kind:
if inst is None or inst.kind != kind or inst.status != "active":
raise OctopError(ErrorCode.CONNECTOR_NOT_FOUND, "instance not found")
svc = _service(server)
if svc.verify_internal_token(instance_id, token) is None:
Expand Down
106 changes: 106 additions & 0 deletions tests/unit/api/test_internal_mcp_active_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""tests/unit/api/test_internal_mcp_active_only.py

The internal MCP endpoints are opened by an Octop-hosted connector gateway with a
long-lived internal token. Disabling the connector instance is the user's off
switch, so every verb on the session must stop answering, not just POST.
"""

from __future__ import annotations

from types import SimpleNamespace

import pytest
from fastapi.responses import JSONResponse, Response, StreamingResponse

from octop.api.routers import internal_mcp
from octop.infra.errors import ErrorCode, OctopError

INSTANCE_ID = "01INSTANCE"
KIND = "generic"
TOKEN = "internal-token"


class _Repo:
def __init__(self, inst: SimpleNamespace | None) -> None:
self._inst = inst

def get(self, instance_id: str) -> SimpleNamespace | None:
return self._inst if instance_id == INSTANCE_ID else None


class _Svc:
def verify_internal_token(self, instance_id: str, token: str) -> dict[str, str] | None:
if instance_id != INSTANCE_ID or token != TOKEN:
return None
return {"internal_token": token}


class _Request:
def __init__(self, headers: dict[str, str] | None = None) -> None:
self.headers = headers or {}


def _server(monkeypatch: pytest.MonkeyPatch, status: str | None) -> SimpleNamespace:
"""A server whose connector instance has `status` (None = the row is gone)."""
monkeypatch.setattr(internal_mcp, "_service", lambda srv: _Svc())
inst = None if status is None else SimpleNamespace(kind=KIND, status=status)
repos = SimpleNamespace(connector_repo=_Repo(inst))
return SimpleNamespace(services=SimpleNamespace(repos=repos))


async def _get(server: SimpleNamespace, **headers: str) -> object:
return await internal_mcp.internal_mcp_get(
KIND, INSTANCE_ID, _Request(headers or None), TOKEN, server
)


async def test_get_of_disabled_instance_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
server = _server(monkeypatch, "disabled")
with pytest.raises(OctopError) as excinfo:
await _get(server, **{internal_mcp.MCP_SESSION_HEADER: "s1"})
assert excinfo.value.code is ErrorCode.CONNECTOR_NOT_FOUND


async def test_delete_of_disabled_instance_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
server = _server(monkeypatch, "disabled")
with pytest.raises(OctopError) as excinfo:
await internal_mcp.internal_mcp_delete(KIND, INSTANCE_ID, TOKEN, server)
assert excinfo.value.code is ErrorCode.CONNECTOR_NOT_FOUND


async def test_post_of_disabled_instance_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
server = _server(monkeypatch, "disabled")
with pytest.raises(OctopError) as excinfo:
await internal_mcp.internal_mcp_post(KIND, INSTANCE_ID, _Request(), TOKEN, server)
assert excinfo.value.code is ErrorCode.CONNECTOR_NOT_FOUND


async def test_get_with_wrong_token_is_auth_failed(monkeypatch: pytest.MonkeyPatch) -> None:
server = _server(monkeypatch, "active")
with pytest.raises(OctopError) as excinfo:
await internal_mcp.internal_mcp_get(KIND, INSTANCE_ID, _Request(), "nope", server)
assert excinfo.value.code is ErrorCode.AUTH_FAILED


async def test_get_of_active_instance_opens_the_stream(monkeypatch: pytest.MonkeyPatch) -> None:
resp = await _get(_server(monkeypatch, "active"), **{internal_mcp.MCP_SESSION_HEADER: "s1"})
assert isinstance(resp, StreamingResponse)


async def test_delete_of_active_instance_ends_the_session(monkeypatch: pytest.MonkeyPatch) -> None:
server = _server(monkeypatch, "active")
resp = await internal_mcp.internal_mcp_delete(KIND, INSTANCE_ID, TOKEN, server)
assert isinstance(resp, Response)
assert resp.status_code == 204


async def test_get_of_missing_instance_is_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(OctopError) as excinfo:
await _get(_server(monkeypatch, None))
assert excinfo.value.code is ErrorCode.CONNECTOR_NOT_FOUND


async def test_get_without_session_header_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
resp = await _get(_server(monkeypatch, "active"))
assert isinstance(resp, JSONResponse)
assert resp.status_code == 405
Loading