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
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ services:
FEEDBACK_GITHUB_API_URL: ${FEEDBACK_GITHUB_API_URL:-https://api.github.com}
FEEDBACK_PUBLIC_BASE_URL: ${FEEDBACK_PUBLIC_BASE_URL:-}
MCP_OAUTH_PUBLIC_BASE_URL: ${MCP_OAUTH_PUBLIC_BASE_URL:-}
MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK: ${MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK:-false}
DESKTOP_MODEL_SYNC_SCOPE: ${DESKTOP_MODEL_SYNC_SCOPE:-}
STORAGE_TYPE: ${STORAGE_TYPE:-local}
STORAGE_PATH: /app/storage
Expand Down
1 change: 1 addition & 0 deletions document/en/deployment/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ These variables are deployment-level fallbacks for the GitHub binding under `/ad
|---|---|---|---|
| `MCP_HOST` | `mcp` (compose default) | MCP container hostname; set `127.0.0.1` for local debugging | CE |
| `MCP_MARKET_REVALIDATE_INTERVAL` | `21600` | Remote MCP marketplace snapshot revalidation interval in seconds (minimum `300`) | CE |
| `MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK` | `false` | Allow users to add MCP endpoints on loopback, RFC1918, or IPv6 ULA addresses; enable only in trusted private deployments. Cloud-metadata/link-local ranges stay blocked, and users cannot submit private endpoints to the marketplace | CE |
| `INTERNET_SEARCH_ENGINE` | `tavily` (compose default) | Internet search engine: `tavily` / `baidu` / `langsearch` | CE |
| `TAVILY_API_KEY` | (empty) | Tavily Search API key; required when Tavily is selected | CE |
| `BAIDU_API_KEY` | (empty) | Baidu search API key; required when Baidu is selected | CE |
Expand Down
13 changes: 13 additions & 0 deletions document/en/modules/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ counts to the agent's answer context.
Multi-endpoint workflows remain concurrent and preserve partial success under
a 25,000-character business-data limit.

The industry-chain Canvas reads stable node IDs preserved by
`get_chain_information`. Clicking a leaf node loads its related companies through
an authenticated backend endpoint protected by the `industry_tools` entitlement;
the browser receives company names, qualification tags, regions, establishment
dates, and registered capital without receiving the upstream URL or token. Agents
can use `ikc_screen_enterprises` for the same flow: omit all filters to page through
every company on the node, or provide region, age, capital, or label filters.
When the company panel opens, the map keeps a large scale and focuses the selected
leaf node with limited upstream context instead of shrinking to show every node.
Closing the panel restores the previous view. Expanding a branch focuses the newly
visible nodes so they stay inside the viewport. Each company row opens the
corresponding upstream company profile in a new tab.

The internal audit still records all 240 endpoints discovered in the web app,
but capability inventory, arbitrary endpoint invocation, region trees, generic
entity resolution, personal libraries, collections/subscriptions, report
Expand Down
1 change: 1 addition & 0 deletions document/zh-CN/deployment/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
|---|---|---|---|
| `MCP_HOST` | `mcp`(compose 默认) | MCP 容器主机名;本地调试可设 `127.0.0.1` | CE |
| `MCP_MARKET_REVALIDATE_INTERVAL` | `21600` | MCP 市场远程工具快照复检周期(秒,最小 `300`) | CE |
| `MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK` | `false` | 是否允许用户自助添加回环、RFC1918 或 IPv6 ULA 地址上的 MCP;仅应在用户可信的私有化环境开启,云元数据/链路本地等高风险地址仍会拦截,私有地址也不能由用户申请上架市场 | CE |
| `INTERNET_SEARCH_ENGINE` | `tavily`(compose 默认) | 联网搜索引擎:`tavily` / `baidu` / `langsearch` | CE |
| `TAVILY_API_KEY` | (空) | Tavily Search API Key;选择 Tavily 时必填 | CE |
| `BAIDU_API_KEY` | (空) | 百度搜索 API Key;选择百度时必填 | CE |
Expand Down
8 changes: 8 additions & 0 deletions document/zh-CN/modules/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ cn_only)`:通过 `INTERNET_SEARCH_ENGINE` 选择 Tavily、百度或 LangSearch
两类结果都不会把接口路径、HTTP 状态与执行计数带入回答上下文。多接口工作流并发执行并保留
部分成功结果,业务数据仍设置 25,000 字符安全上限。

产业链 Canvas 会读取 `get_chain_information` 图谱中的真实节点 ID。点击末级节点后,前端通过
受登录与 `industry_tools` 许可保护的后端接口分页加载该节点关联企业,展示企业名称、资质标签、
所属地区、成立日期和注册资本;上游地址与 Token 不会发送到浏览器。智能体侧也可直接调用
`ikc_screen_enterprises`:不传筛选条件时列出节点全部企业,传入地区、年限、资本或标签时进行筛选。
企业面板打开时,图谱会以较大比例聚焦当前末级节点,并保留少量上游链路作为上下文,不会为完整
展示全部节点而过度缩小;关闭面板后恢复原视图。展开分支后,画布会自动聚焦新增节点,避免内容
落到可视区域之外。企业列表中的每一行都可在新标签页打开对应的上游企业详情页。

内部仍审计页面发现的 240 条接口,但能力目录、任意接口调用、地区树、通用实体解析、个人
资料库、收藏订阅、上游报告工作台和写入操作均不再暴露给智能体。

Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ pymilvus>=2.5.0,<2.6.0

# Sandbox Provider — Tencent CubeSandbox / E2B-compatible (optional;
# only required when SANDBOX_PROVIDER=cube)
e2b-code-interpreter>=2.4.1,<3

# License 离线验签(Ed25519)
cryptography>=42.0.0
Expand Down
8 changes: 8 additions & 0 deletions src/backend/api/openapi_data_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,14 @@ def _ref(name: str) -> Dict[str, Any]:
"page": {"type": "integer"},
"page_size": {"type": "integer"},
"total": {"type": "integer"},
"status_counts": {
"type": "object",
"properties": {
"indexed": {"type": "integer"},
"processing": {"type": "integer"},
"failed": {"type": "integer"},
},
},
},
},
("GET", "/v1/catalog/kb/{kb_id}/documents/{document_id}"): _ref("KBDocumentDetail"),
Expand Down
16 changes: 14 additions & 2 deletions src/backend/api/routes/v1/kb.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,23 @@ async def list_documents(

if is_enabled():
result = list_documents(kb_id, page=page, limit=page_size, keyword=keyword or "")
return paginated_response(
response = paginated_response(
items=result.get("items", []),
page=result.get("page", page),
page_size=result.get("page_size", page_size),
total_items=result.get("total", 0),
message="Documents retrieved successfully",
)
from core.kb.external_provider import count_document_statuses

status_counts = count_document_statuses(
kb_id,
keyword=keyword or "",
initial_result=result,
)
if status_counts is not None:
response["data"]["status_counts"] = status_counts
return response
raise ResourceNotFoundError(resource_type="kb_space", resource_id=kb_id)

# Public KBs (created in the admin console, owned by the system principal) are readable by all logged-in users
Expand All @@ -448,13 +458,15 @@ async def list_documents(
}
for d in documents
]
return paginated_response(
response = paginated_response(
items=items,
page=page,
page_size=page_size,
total_items=total,
message="Documents retrieved successfully",
)
response["data"]["status_counts"] = kb_repo.count_document_statuses(kb_id, keyword=keyword)
return response


@router.get("/{kb_id}/documents/{document_id}", summary="获取知识库文档详情")
Expand Down
13 changes: 9 additions & 4 deletions src/backend/api/routes/v1/me_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from core.auth.backend import UserContext, get_current_user
from core.auth.capabilities import resolve_user_capabilities
from core.config.settings import settings
from core.db.engine import get_db
from core.db.models import AdminMcpServer, AdminSkill
from core.infra.exceptions import AccessDeniedError, BadRequestError, ResourceNotFoundError
Expand Down Expand Up @@ -92,10 +93,14 @@ async def create_my_mcp_server(

if not body.url.strip():
raise BadRequestError(message="url 不能为空")
# Public HTTP endpoints are allowed for controlled/test deployments. The
# shared validator still blocks loopback, private, link-local, and reserved
# targets; HTTPS remains the recommended production transport.
await validate_remote_mcp_url(body.url, require_https=False)
# Public HTTP endpoints are allowed for controlled/test deployments.
# Private-network targets remain blocked unless a trusted private deployment
# explicitly opts in; HTTPS remains the recommended production transport.
await validate_remote_mcp_url(
body.url,
allow_private_network=settings.server.mcp_self_service_allow_private_network,
require_https=False,
)

# Auto-generate a globally unique server_id to avoid collisions with public MCPs / other users
server_id = f"umcp_{uuid.uuid4().hex[:16]}"
Expand Down
6 changes: 6 additions & 0 deletions src/backend/core/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,12 @@ class ServerSettings:
_int(_env("MCP_MARKET_REVALIDATE_INTERVAL", "21600"), 21600),
)
)
# Self-service MCP endpoints are public-only by default to preserve the
# SSRF boundary. Private deployments may opt in when trusted users need to
# connect MCP services hosted on the same LAN or Docker network.
mcp_self_service_allow_private_network: bool = field(
default_factory=lambda: _bool(_env("MCP_SELF_SERVICE_ALLOW_PRIVATE_NETWORK", "false"))
)

@property
def is_prod(self) -> bool:
Expand Down
18 changes: 18 additions & 0 deletions src/backend/core/db/repository/kb.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import sqlalchemy as sa
from core.db.models import KBDocument, KBSpace
from core.kb.document_status import classify_document_status, empty_document_status_counts
from sqlalchemy import and_, desc, func, or_, select
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -137,6 +138,23 @@ def list_documents(

return documents, total

def count_document_statuses(self, kb_id: str, keyword: Optional[str] = None) -> dict[str, int]:
"""Count indexing states across the full KB, independent of pagination."""
query = self.db.query(
KBDocument.indexing_status,
func.count(KBDocument.document_id),
).filter(KBDocument.kb_id == kb_id, KBDocument.deleted_at.is_(None))
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
query = query.filter(
sa.or_(KBDocument.title.ilike(like), KBDocument.filename.ilike(like))
)

counts = empty_document_status_counts()
for status, count in query.group_by(KBDocument.indexing_status).all():
counts[classify_document_status(status)] += int(count or 0)
return counts

def create_document(self, document_data: Dict[str, Any]) -> KBDocument:
"""Create a new KB document."""
document = KBDocument(**document_data)
Expand Down
42 changes: 42 additions & 0 deletions src/backend/core/kb/document_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Knowledge-base document indexing-status normalization and aggregation."""

from collections.abc import Iterable, Mapping
from typing import Any, Literal

DocumentStatusCategory = Literal["indexed", "processing", "failed"]

_PROCESSING_STATUSES = {
"processing",
"indexing",
"waiting",
"pending",
"finalizing",
"parsing",
"queued",
"queuing",
"waiting_indexing",
"paused",
}
_FAILED_STATUSES = {"failed", "error"}


def classify_document_status(status: Any) -> DocumentStatusCategory:
"""Map local and external-provider statuses to the three UI categories."""
normalized = str(status or "").strip().lower()
if normalized in _PROCESSING_STATUSES:
return "processing"
if normalized in _FAILED_STATUSES:
return "failed"
return "indexed"


def empty_document_status_counts() -> dict[str, int]:
return {"indexed": 0, "processing": 0, "failed": 0}


def summarize_document_statuses(items: Iterable[Mapping[str, Any]]) -> dict[str, int]:
"""Count normalized indexing statuses from document-shaped mappings."""
counts = empty_document_status_counts()
for item in items:
counts[classify_document_status(item.get("indexing_status"))] += 1
return counts
38 changes: 32 additions & 6 deletions src/backend/core/services/mcp_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
ipaddress.ip_network("fc00::/18"),
ipaddress.ip_network("fdfe:dcba:9876::/48"),
)
_MCP_PRIVATE_NETWORKS = (
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("fc00::/7"),
)
_PUBLIC_DOH_ENDPOINTS = (
"https://cloudflare-dns.com/dns-query",
"https://dns.google/resolve",
Expand Down Expand Up @@ -238,6 +244,20 @@ def _is_synthetic_dns_proxy_ip(address: str) -> bool:
)


def _is_allowed_private_mcp_ip(address: str) -> bool:
"""Allow only loopback, RFC1918, and IPv6 ULA when private MCPs are enabled.

Link-local/cloud-metadata, multicast, unspecified, and other reserved ranges
stay blocked even in trusted private deployments.
"""
ip = ipaddress.ip_address(address)
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return ip.is_loopback or any(
ip.version == network.version and ip in network for network in _MCP_PRIVATE_NETWORKS
)


async def _resolve_public_dns_via_doh(hostname: str) -> set[str]:
"""Resolve a fake-IP hostname through fixed public DoH endpoints.

Expand Down Expand Up @@ -291,7 +311,8 @@ async def validate_remote_mcp_url(

Resolution is checked as well as the literal host so public-looking DNS names
cannot point at loopback, RFC1918, link-local, or cloud metadata addresses.
Admin-only publishing may explicitly allow private network targets.
Trusted deployments may explicitly allow loopback/RFC1918/ULA targets;
link-local/cloud-metadata and other reserved ranges always remain blocked.
"""
value = (url or "").strip()
parsed = urlparse(value)
Expand All @@ -306,13 +327,15 @@ async def validate_remote_mcp_url(
and not allow_private_network
):
raise BadRequestError(message="MCP 服务地址不能指向本机或内网")
if allow_private_network:
return
try:
literal = ipaddress.ip_address(parsed.hostname)
except ValueError:
literal = None
if literal is not None and _is_forbidden_ip(str(literal)):
if (
literal is not None
and _is_forbidden_ip(str(literal))
and not (allow_private_network and _is_allowed_private_mcp_ip(str(literal)))
):
raise BadRequestError(message="MCP 服务地址不能指向本机、内网或保留地址")

port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80)
Expand All @@ -328,10 +351,13 @@ async def validate_remote_mcp_url(
raise BadRequestError(message="MCP 服务域名解析到了本机、内网或保留地址")
synthetic_addresses = {address for address in addresses if _is_synthetic_dns_proxy_ip(address)}
if any(
_is_forbidden_ip(address) and address not in synthetic_addresses for address in addresses
_is_forbidden_ip(address)
and address not in synthetic_addresses
and not (allow_private_network and _is_allowed_private_mcp_ip(address))
for address in addresses
):
raise BadRequestError(message="MCP 服务域名解析到了本机、内网或保留地址")
if synthetic_addresses:
if synthetic_addresses and not allow_private_network:
public_addresses = await _resolve_public_dns_via_doh(parsed.hostname)
if not public_addresses or any(_is_forbidden_ip(address) for address in public_addresses):
raise BadRequestError(message="MCP 服务域名公共 DNS 安全复核失败")
Expand Down
3 changes: 2 additions & 1 deletion src/backend/tests/ce_release/test_ce_release_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,8 @@ async def probe_ok(row, db):
row.tools_json = [{"name": "example_tool", "description": "", "inputSchema": {}}]
return True, ""

async def validate_ok(url, *, require_https):
async def validate_ok(url, *, allow_private_network, require_https):
assert allow_private_network is False
assert require_https is False

monkeypatch.setattr(me_capabilities, "probe_mcp_connectivity", probe_ok)
Expand Down
68 changes: 68 additions & 0 deletions src/backend/tests/test_kb_document_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from datetime import datetime, timezone

from core.db.models import KBDocument, KBSpace, UserShadow
from core.db.repository import KBRepository
from core.kb.document_status import classify_document_status, summarize_document_statuses


def test_classify_document_status_normalizes_local_and_external_values():
for status in ("processing", "indexing", "waiting", "pending", "finalizing"):
assert classify_document_status(status) == "processing"
for status in ("failed", "error"):
assert classify_document_status(status) == "failed"
for status in ("completed", "completed_indexing", ""):
assert classify_document_status(status) == "indexed"


def test_summarize_document_statuses_counts_the_full_input():
items = [
{"indexing_status": "completed"},
{"indexing_status": "completed"},
{"indexing_status": "indexing"},
{"indexing_status": "error"},
]

assert summarize_document_statuses(items) == {
"indexed": 2,
"processing": 1,
"failed": 1,
}


def test_repository_counts_all_documents_not_only_the_current_page(db_session):
db_session.add(UserShadow(user_id="user-kb-stats", username="KB Stats"))
db_session.add(
KBSpace(
kb_id="kb-stats",
user_id="user-kb-stats",
name="统计测试库",
visibility="private",
)
)
statuses = ["completed"] * 23 + ["processing"] * 3 + ["failed"] * 2
for index, status in enumerate(statuses):
db_session.add(
KBDocument(
document_id=f"doc-stats-{index}",
kb_id="kb-stats",
title=f"统计文档 {index}",
filename=f"stats-{index}.txt",
size_bytes=1,
mime_type="text/plain",
storage_key=f"kb/stats-{index}.txt",
indexing_status=status,
uploaded_at=datetime.now(timezone.utc),
)
)
db_session.commit()

repository = KBRepository(db_session)
page_items, total = repository.list_documents("kb-stats", page=1, page_size=20)

assert len(page_items) == 20
assert total == 28
assert repository.count_document_statuses("kb-stats") == {
"indexed": 23,
"processing": 3,
"failed": 2,
}
Loading
Loading