feat(plugins): separate workspace availability from personal defaults (#16188)

This commit is contained in:
roshan authored and GitHub committed 2026-10-17 19:32:04 +00:00
1 parent 2347a7beed
commit 9219632fc6
45 files changed
+3463 -50

No files matched your search

@@ -232,6 +232,11 @@ Custom-action OAuth lets each user connect a third-party account to an OpenAPI a
`supports_pkce` defaults to false; administrators can enable S256 PKCE for compatible providers.
MCP stores its credentials separately in `MCPConnectionConfig` ([[mcp-and-custom-tools]] §3).
`GET /user-oauth-token/status` reports literal access-token expiration and saved
refresh capability separately. `can_refresh` requires a nonempty refresh token
and configured client credentials. Status reads never refresh tokens or call providers.
Refresh capability does not prove that the provider will accept a refresh request.
---
## 4. How it works
@@ -68,6 +68,12 @@ sync (`server.py:_Readiness`).
### User-facing HTTP endpoints (outside the proxy)
Member app responses include `service_urls`: unique literal HTTP(S) origins for display.
The projection strips credentials, paths, queries, and default ports. Built-in
escaped hostname dots and hyphens are decoded. Unescaped regex dots and ambiguous
regex or wildcard hosts are omitted.
Raw upstream patterns, authentication templates, and organization credentials remain admin-only.
| Method | Path | Handler | Notes |
|---|---|---|---|
| GET | `/api/build/apps/{id}/oauth/start` | `start_external_app_oauth` (`server/features/build/external_apps/oauth.py`) | Begins a user's per-user OAuth connect flow for a built-in app. |
@@ -428,6 +434,10 @@ identical in shape whether a human or a grant decided.
### 4.6 OAuth connect and token refresh
OAuth start accepts an optional safe internal `return_path`. The authorization attempt
stores it and the callback returns it after saving credentials. Omitting the path
retains the existing callback behavior. External and unsafe paths are rejected.
Connect: `GET /api/build/apps/{id}/oauth/start` builds the provider's
authorize URL through the shared OAuth builder (`server/features/build/external_apps/oauth.py`).
Each attempt is bound to the user, tenant, app, and OAuth configuration.
@@ -306,6 +306,11 @@ Installation does not replace agent-managed skills or enter session snapshots.
## 5. Contracts and invariants
MCP host attribution remains workspace-wide when resolving targets without a
user. A personal Craft opt-out must not make a configured host look off-catalog.
The live resolver checks the current user's saved MCP preference before injecting
credentials, including no-auth servers. Disabling a personal default keeps credentials.
1. **A preview must only be reachable by someone entitled to that session.**
`_check_webapp_access` must run before every proxied HTTP request and
before the HMR websocket is accepted. A new route added to this router
@@ -671,3 +671,38 @@ See `backend/AGENTS.md` for authoritative env/secrets setup. Test helpers:
a client that tries to shortcut the flow by POSTing arbitrary
`code`/`server_id` values without a matching stored attempt gets rejected
at the fingerprint/connection-config-id checks (§4.4 step 3).
### Plugin API and personal defaults
Member MCP listings stamp `user_enabled_in_chat` and `user_enabled_in_craft`.
Member tool snapshots stamp `user_enabled_in_chat`. Missing preference rows mean enabled.
`default_chat_enabled` reports default-assistant attachments independently of credentials.
The summaries are computed in batches after existing visibility filters.
`PATCH /mcp/server/{server_id}/preferences` saves independent Chat and Craft preferences.
`PATCH /tool/{tool_id}/preferences` saves an OpenAPI Chat preference.
These operations require basic access and cannot enable unavailable workspace capabilities.
Preferences retain credentials. Default Chat enforces opt-outs before credential resolution.
Custom agents retain their attached tools and existing access behavior.
Craft configuration excludes personally disabled MCP servers. Its member catalog retains them
so a caller can enable them again. Updates reload that caller's Craft sessions.
The live proxy resolver also enforces Craft opt-outs, including no-auth servers.
Host attribution remains workspace-wide so disabled servers cannot become off-catalog.
`POST /admin/mcp/probe` checks MCP setup without credentials.
The stream uses newline-delimited JSON progress and one final result.
`POST /admin/mcp/oauth/discover` reads public OAuth metadata for a manageable server.
Both require `MANAGE_ACTIONS`, including scoped managers.
Discovery checks management access before network work and closes its database session first.
SSRF validation covers outbound hops and runs outside the event loop.
OAuth failures return safe messages and an `oauth_error` reason.
Simple server creation accepts `available_in_craft`, defaulting to false.
Changing a server URL invalidates existing user and administrator credentials,
resets setup status, and reloads affected Craft sessions.
Preferences use `MCPServerUserPreference` and `ToolUserPreference`.
Apply revisions `b71e3a9d2c04` and `c82f4a0e3d15` before deploying code that
reads the preference tables.
@@ -257,6 +257,12 @@ tool calls the LLM just made and the constructed `Tool` instances.
## 5. Contracts and invariants
Personal MCP and OpenAPI Chat opt-outs are enforced only for `DEFAULT_PERSONA_ID`,
before credential resolution or MCP cache reuse. An explicit `allowed_tool_ids`
list cannot override them. Custom agents retain their own attached tools.
Preferences are batch-read once per construction for configured MCP servers
and OpenAPI tool IDs. Missing preferences preserve the existing default-on behavior.
1. **A new built-in tool needs both `BUILT_IN_TOOL_MAP` and a DB row.** Adding the
class alone does nothing; `_construct_tools_impl` only sees tools present on
`persona.tools`, and those rows come from `Tool.in_code_tool_id` seeded via
@@ -422,3 +428,11 @@ See `backend/AGENTS.md` for authoritative commands and required env.
`_merge_tool_calls` collapses N calls to `SearchTool`/`WebSearchTool`/`OpenURLTool`
into one before execution and before it enters history, so the model later sees
the merged, expanded arguments rather than what it originally emitted.
### Default Chat MCP availability
For the default persona, `tool_constructor.py` checks both server availability in
Chat and the user's MCP server access before resolving credentials. This prevents
a group-restricted server from becoming available to all users through default
Chat. Custom agents retain their existing attached-tool access behavior.
@@ -0,0 +1,46 @@
"""add personal MCP server preferences
Revision ID: b71e3a9d2c04
Revises: b034d72b7abe
Create Date: 2026-10-15 19:25:16.614907
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b71e3a9d2c04"
down_revision = "b034d72b7abe"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"mcp_server_user_preference",
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("mcp_server_id", sa.Integer(), nullable=False),
sa.Column(
"enabled_in_chat",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
sa.Column(
"enabled_in_craft",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["mcp_server_id"], ["mcp_server.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("user_id", "mcp_server_id"),
)
def downgrade() -> None:
op.drop_table("mcp_server_user_preference")
@@ -0,0 +1,34 @@
"""add personal OpenAPI preferences
Revision ID: c82f4a0e3d15
Revises: b71e3a9d2c04
"""
from alembic import op
import sqlalchemy as sa
revision = "c82f4a0e3d15"
down_revision = "b71e3a9d2c04"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tool_user_preference",
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("tool_id", sa.Integer(), nullable=False),
sa.Column(
"enabled_in_chat",
sa.Boolean(),
server_default=sa.text("true"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tool_id"], ["tool.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id", "tool_id"),
)
def downgrade() -> None:
op.drop_table("tool_user_preference")
+104 -10
View File
@@ -2,10 +2,12 @@ import datetime
from uuid import UUID
from sqlalchemy import Select, and_, delete, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session, aliased, selectinload
from sqlalchemy.orm.attributes import flag_modified
from onyx.auth.permissions import has_global_permission
from onyx.configs.constants import DEFAULT_PERSONA_ID
from onyx.db.constants import UNSET, UnsetType
from onyx.db.enums import (
MCPAuthenticationPerformer,
@@ -21,7 +23,9 @@ from onyx.db.models import (
MCPServer,
MCPServer__User,
MCPServer__UserGroup,
MCPServerUserPreference,
Persona,
Persona__Tool,
Sandbox,
Tool,
User,
@@ -34,7 +38,75 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
def get_mcp_server_user_preferences(
server_ids: list[int], user_id: UUID, db_session: Session
) -> dict[int, MCPServerUserPreference]:
if not server_ids:
return {}
return {
preference.mcp_server_id: preference
for preference in db_session.scalars(
select(MCPServerUserPreference).where(
MCPServerUserPreference.user_id == user_id,
MCPServerUserPreference.mcp_server_id.in_(server_ids),
)
)
}
def upsert_mcp_server_user_preferences__no_commit(
server_id: int,
user_id: UUID,
db_session: Session,
*,
enabled_in_chat: bool | None = None,
enabled_in_craft: bool | None = None,
) -> MCPServerUserPreference:
values: dict[str, bool] = {}
if enabled_in_chat is not None:
values["enabled_in_chat"] = enabled_in_chat
if enabled_in_craft is not None:
values["enabled_in_craft"] = enabled_in_craft
if not values:
raise ValueError("At least one preference is required")
stmt = insert(MCPServerUserPreference).values(
user_id=user_id, mcp_server_id=server_id, **values
)
return db_session.scalars(
stmt.on_conflict_do_update(
index_elements=[
MCPServerUserPreference.user_id,
MCPServerUserPreference.mcp_server_id,
],
set_=values,
).returning(MCPServerUserPreference),
execution_options={"populate_existing": True},
).one()
# MCPServer operations
def get_default_chat_mcp_server_ids(
server_ids: list[int], db_session: Session
) -> set[int]:
if not server_ids:
return set()
stmt = (
select(Tool.mcp_server_id)
.join(Persona__Tool, Persona__Tool.tool_id == Tool.id)
.join(Persona, Persona.id == Persona__Tool.persona_id)
.where(
Persona.id == DEFAULT_PERSONA_ID,
Persona.builtin_persona.is_(True),
Persona.deleted.is_(False),
Tool.mcp_server_id.in_(server_ids),
)
.distinct()
)
return {
server_id for server_id in db_session.scalars(stmt) if server_id is not None
}
def get_all_mcp_servers(db_session: Session) -> list[MCPServer]:
"""Get all MCP servers"""
return list(
@@ -60,7 +132,7 @@ def get_mcp_servers_by_owner(owner_email: str, db_session: Session) -> list[MCPS
def get_craft_enabled_mcp_servers(
db_session: Session, user: User | None
db_session: Session, user: User | None, *, include_personal_disabled: bool = False
) -> list[MCPServer]:
"""MCP servers an admin has made available to the Craft agent, filtered to
those ``user`` may use (public / shared / owned). ``None`` skips the access
@@ -75,16 +147,27 @@ def get_craft_enabled_mcp_servers(
)
if user is not None:
stmt = _add_mcp_server_access_filter(stmt, user)
if not include_personal_disabled:
stmt = stmt.where(
~select(MCPServerUserPreference.mcp_server_id)
.where(
MCPServerUserPreference.user_id == user.id,
MCPServerUserPreference.mcp_server_id == MCPServer.id,
MCPServerUserPreference.enabled_in_craft.is_(False),
)
.exists()
)
return list(db_session.scalars(stmt).all())
def get_mcp_servers_for_persona(
persona_id: int,
db_session: Session,
user: User, # noqa: ARG001
user: User,
) -> list[MCPServer]:
"""Servers already on a persona's tools. No attach ACL — chat users of the
persona must see/auth these. ``user`` is for callers enforcing persona visibility.
"""Attached servers. Default Chat also checks server access.
Custom agents retain their attached-server access for all agent users.
"""
# Get the persona and its tools
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
@@ -101,16 +184,15 @@ def get_mcp_servers_for_persona(
return []
# Fetch the MCP servers
mcp_servers = (
db_session.query(MCPServer).filter(MCPServer.id.in_(mcp_server_ids)).all()
)
return list(mcp_servers)
stmt = select(MCPServer).where(MCPServer.id.in_(mcp_server_ids))
if persona_id == DEFAULT_PERSONA_ID:
stmt = _add_mcp_server_access_filter(stmt, user)
return list(db_session.scalars(stmt).all())
def _add_mcp_server_access_filter(stmt: Select, user: User) -> Select:
"""Servers the user may add to an agent (public / direct / group). Admins bypass.
Does not control chat use of agent-attached servers.
Default Chat also uses these grants; custom-agent attachments do not.
"""
if has_global_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS):
return stmt
@@ -238,6 +320,7 @@ def create_mcp_server__no_commit(
oauth_additional_auth_params: dict[str, str] | None = None,
admin_connection_config_id: int | None = None,
is_public: bool = True,
available_in_craft: bool = False,
) -> MCPServer:
"""Create a new MCP server"""
new_server = MCPServer(
@@ -255,6 +338,7 @@ def create_mcp_server__no_commit(
oauth_additional_auth_params=oauth_additional_auth_params,
admin_connection_config_id=admin_connection_config_id,
is_public=is_public,
available_in_craft=available_in_craft,
)
db_session.add(new_server)
db_session.flush() # Get the ID without committing
@@ -530,6 +614,16 @@ def upsert_user_connection_config(
)
def clear_mcp_admin_connection_config__no_commit(
server: MCPServer, db_session: Session
) -> None:
config_id = server.admin_connection_config_id
server.admin_connection_config = None
server.admin_connection_config_id = None
if config_id is not None:
delete_connection_config(config_id, db_session)
def delete_connection_config(config_id: int, db_session: Session) -> None:
"""Delete a connection config"""
config = get_connection_config_by_id(config_id, db_session)
+31
View File
@@ -4171,6 +4171,20 @@ class Tool(Base):
)
class ToolUserPreference(Base):
__tablename__ = "tool_user_preference"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
)
tool_id: Mapped[int] = mapped_column(
ForeignKey("tool.id", ondelete="CASCADE"), primary_key=True
)
enabled_in_chat: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
class OAuthConfig(Base):
"""OAuth provider configuration that can be shared across multiple tools"""
@@ -6096,6 +6110,23 @@ class MCPServer(Base):
)
class MCPServerUserPreference(Base):
__tablename__ = "mcp_server_user_preference"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
)
mcp_server_id: Mapped[int] = mapped_column(
ForeignKey("mcp_server.id", ondelete="CASCADE"), primary_key=True
)
enabled_in_chat: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
enabled_in_craft: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
class MCPServer__User(Base):
__tablename__ = "mcp_server__user"
mcp_server_id: Mapped[int] = mapped_column(
+6 -2
View File
@@ -2,7 +2,7 @@ from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from onyx.db.models import OAuthConfig, OAuthUserToken, Tool
from onyx.utils.logger import setup_logger
@@ -143,7 +143,11 @@ def get_all_user_oauth_tokens(
"""
Get all user OAuth tokens.
"""
stmt = select(OAuthUserToken).where(OAuthUserToken.user_id == user_id)
stmt = (
select(OAuthUserToken)
.where(OAuthUserToken.user_id == user_id)
.options(selectinload(OAuthUserToken.oauth_config))
)
return list(db_session.scalars(stmt).all())
+52
View File
@@ -3,9 +3,11 @@ from typing import TYPE_CHECKING, Any, Type, cast
from uuid import UUID
from sqlalchemy import Select, func, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import InstrumentedAttribute, Session
from onyx.auth.permissions import has_permission
from onyx.configs.constants import DEFAULT_PERSONA_ID
from onyx.context.search.models import PersonaSearchInfo
from onyx.db.constants import UNSET, UnsetType
from onyx.db.enums import MCPServerStatus, Permission, PermissionAuthority
@@ -17,6 +19,7 @@ from onyx.db.models import (
Persona__UserGroup,
Tool,
ToolCall,
ToolUserPreference,
User,
)
from onyx.server.features.tool.models import Header
@@ -32,6 +35,37 @@ if TYPE_CHECKING:
logger = setup_logger()
def get_tool_user_preferences(
tool_ids: list[int], user_id: UUID, db_session: Session
) -> dict[int, ToolUserPreference]:
if not tool_ids:
return {}
return {
preference.tool_id: preference
for preference in db_session.scalars(
select(ToolUserPreference).where(
ToolUserPreference.user_id == user_id,
ToolUserPreference.tool_id.in_(tool_ids),
)
)
}
def upsert_tool_user_preference__no_commit(
tool_id: int, user_id: UUID, db_session: Session, *, enabled_in_chat: bool
) -> ToolUserPreference:
stmt = insert(ToolUserPreference).values(
user_id=user_id, tool_id=tool_id, enabled_in_chat=enabled_in_chat
)
return db_session.scalars(
stmt.on_conflict_do_update(
index_elements=[ToolUserPreference.user_id, ToolUserPreference.tool_id],
set_={"enabled_in_chat": enabled_in_chat},
).returning(ToolUserPreference),
execution_options={"populate_existing": True},
).one()
def capture_persona_tool_configuration(persona: Persona) -> PersonaToolConfiguration:
"""Copy loaded configuration; the caller owns the session and transaction."""
return PersonaToolConfiguration(
@@ -105,6 +139,24 @@ def get_tools_by_ids(tool_ids: list[int], db_session: Session) -> list[Tool]:
return list(db_session.scalars(stmt).all())
def get_default_chat_tool_ids(tool_ids: list[int], db_session: Session) -> set[int]:
if not tool_ids:
return set()
stmt = (
select(Tool.id)
.join(Persona__Tool, Persona__Tool.tool_id == Tool.id)
.join(Persona, Persona.id == Persona__Tool.persona_id)
.where(
Persona.id == DEFAULT_PERSONA_ID,
Persona.builtin_persona.is_(True),
Persona.deleted.is_(False),
Tool.id.in_(tool_ids),
)
.distinct()
)
return set(db_session.scalars(stmt))
def get_tool_by_id(tool_id: int, db_session: Session) -> Tool:
tool = db_session.scalar(select(Tool).where(Tool.id == tool_id))
if not tool:
@@ -25,6 +25,7 @@ from onyx.db.enums import (
from onyx.db.mcp import (
get_craft_enabled_mcp_servers,
get_mcp_server_by_id,
get_mcp_server_user_preferences,
user_can_access_mcp_server,
)
from onyx.db.models import MCPServer
@@ -173,6 +174,13 @@ class MCPServerResolver(CredentialResolver):
raise CredentialUnavailableError(
f"sandbox user {short_log_id(user_id)} not found"
)
preference = get_mcp_server_user_preferences([server.id], user.id, db).get(
server.id
)
if preference is not None and not preference.enabled_in_craft:
raise CredentialUnavailableError(
f"user {short_log_id(user_id)} disabled MCP server {server.id} in Craft"
)
if not user_can_access_mcp_server(user, server.id, db):
# Not shared with this user — never inject admin creds for them.
raise CredentialUnavailableError(
@@ -1,3 +1,6 @@
import ipaddress
import re
from urllib.parse import urlsplit
from uuid import UUID
from fastapi import APIRouter, Depends
@@ -138,6 +141,40 @@ def _to_user_response(
if key in stored_raw
}
authenticated = all(key in stored_raw for key in required_keys)
service_urls: list[str] = []
for pattern in app.upstream_url_patterns:
if app.app_type != ExternalAppType.CUSTOM:
pattern = pattern.removeprefix("^")
authority = pattern.partition("://")[2].split("/", 1)[0].rsplit("@", 1)[-1]
if app.app_type != ExternalAppType.CUSTOM:
if "?" in authority or re.search(r"(?<!\\)\.", authority):
continue
pattern = pattern.replace(r"\.", ".").replace(r"\-", "-")
host_port, separator, query = authority.partition("?")
if separator and "=" not in query:
continue
if re.search(r"[\s*+|(){}^$]", host_port):
continue
try:
parsed = urlsplit(pattern)
hostname = parsed.hostname
port = parsed.port
if parsed.scheme not in ("http", "https") or not hostname:
continue
if ":" in hostname:
hostname = f"[{ipaddress.IPv6Address(hostname).compressed}]"
elif not re.fullmatch(
r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*",
hostname,
):
continue
except ValueError:
continue
origin = f"{parsed.scheme}://{hostname}"
if port is not None and port != {"http": 80, "https": 443}[parsed.scheme]:
origin += f":{port}"
if origin not in service_urls:
service_urls.append(origin)
return ExternalAppUserResponse(
id=app.id,
@@ -147,6 +184,7 @@ def _to_user_response(
credential_values=credential_values,
authenticated=authenticated,
supports_oauth=isinstance(get_provider_for_app(app), OAuthExternalAppProvider),
service_urls=service_urls,
)
@@ -1,7 +1,7 @@
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from onyx.db.enums import EndpointPolicy, ExternalAppType
from onyx.external_apps.models import ActionPolicyView
@@ -120,6 +120,7 @@ class ExternalAppUserResponse(BaseModel):
credential_values: dict[str, Any]
authenticated: bool
supports_oauth: bool
service_urls: list[str] = Field(default_factory=list)
class OAuthStartResponse(BaseModel):
@@ -134,6 +135,7 @@ class OAuthCallbackRequest(BaseModel):
class OAuthCallbackResponse(BaseModel):
success: bool
external_app_id: int
return_path: str | None = None
class ConnectAppDecisionRequest(BaseModel):
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
import requests
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, TypeAdapter
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
@@ -27,7 +27,11 @@ from onyx.oauth.authorization_attempt import (
canonical_json_fingerprint,
generate_authorization_state,
)
from onyx.oauth.models import OAuthConfigurationFingerprint, PKCECodeVerifier
from onyx.oauth.models import (
OAuthConfigurationFingerprint,
PKCECodeVerifier,
SafeOAuthReturnPath,
)
from onyx.oauth.protocol import build_authorization_url
from onyx.server.features.build.external_apps.models import (
OAuthCallbackRequest,
@@ -52,6 +56,7 @@ class _ExternalAppOAuthAttemptPayload(BaseModel):
external_app_id: int
configuration_fingerprint: OAuthConfigurationFingerprint
code_verifier: PKCECodeVerifier | None = None
return_path: SafeOAuthReturnPath | None = None
_AUTHORIZATION_ATTEMPTS = AuthorizationAttemptStore(
@@ -108,7 +113,16 @@ def start_external_app_oauth(
external_app_id: int,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
return_path: str | None = None,
) -> OAuthStartResponse:
try:
return_path = TypeAdapter(SafeOAuthReturnPath | None).validate_python(
return_path
)
except ValueError:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "return_path must be a safe internal path"
)
app = get_external_app_by_id(db_session, external_app_id)
if app is None:
raise OnyxError(
@@ -155,6 +169,7 @@ def start_external_app_oauth(
app, oauth, client_id, client_secret
),
code_verifier=code_verifier,
return_path=return_path,
),
)
return OAuthStartResponse(authorize_url=authorize_url)
@@ -293,4 +308,6 @@ def handle_external_app_oauth_callback(
push_skills_for_users({user.id}, db_session)
db_session.commit()
return OAuthCallbackResponse(success=True, external_app_id=app.id)
return OAuthCallbackResponse(
success=True, external_app_id=app.id, return_path=payload.return_path
)
+149 -18
View File
@@ -9,6 +9,8 @@ from urllib.parse import urlparse
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import StreamingResponse
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import OAuthClientInformationFull
from mcp.types import Tool as MCPLibTool
@@ -26,7 +28,7 @@ from onyx.auth.permissions import (
)
from onyx.auth.scoped_permissions import assert_within_scope, get_scoped_groups
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.db.engine.sql_engine import get_session, get_session_with_current_tenant
from onyx.db.enums import (
EndpointPolicy,
GatedAppKind,
@@ -45,6 +47,7 @@ from onyx.db.gated_app import (
)
from onyx.db.mcp import (
affected_user_ids_for_mcp_server,
clear_mcp_admin_connection_config__no_commit,
create_connection_config,
create_mcp_server__no_commit,
delete_all_user_connection_configs_for_server_no_commit,
@@ -54,7 +57,9 @@ from onyx.db.mcp import (
get_all_mcp_servers,
get_all_mcp_tools_for_server,
get_craft_enabled_mcp_servers,
get_default_chat_mcp_server_ids,
get_mcp_server_by_id,
get_mcp_server_user_preferences,
get_mcp_servers_accessible_to_user,
get_mcp_servers_for_persona,
get_user_connection_config,
@@ -62,6 +67,7 @@ from onyx.db.mcp import (
update_connection_config,
update_connection_config__no_commit,
update_mcp_server__no_commit,
upsert_mcp_server_user_preferences__no_commit,
upsert_user_connection_config,
user_can_access_mcp_server,
user_can_configure_mcp_credentials,
@@ -102,6 +108,8 @@ from onyx.server.features.mcp.models import (
MCPConnectionData,
MCPConnectionStatus,
MCPOAuthCallbackResponse,
MCPOAuthDiscoveryRequest,
MCPOAuthDiscoveryResponse,
MCPOAuthKeys,
MCPServer,
MCPServerCreateResponse,
@@ -109,12 +117,15 @@ from onyx.server.features.mcp.models import (
MCPServerSimpleUpdateRequest,
MCPServersResponse,
MCPServerUpdateResponse,
MCPSetupProbeRequest,
MCPToolCreateRequest,
MCPToolListResponse,
MCPToolUpdateRequest,
MCPUserCredentialsRequest,
MCPUserOAuthConnectRequest,
MCPUserOAuthConnectResponse,
MCPUserPreferencesResponse,
MCPUserPreferencesUpdateRequest,
contains_mcp_placeholder,
merge_mcp_headers,
)
@@ -124,6 +135,10 @@ from onyx.server.features.mcp.oauth import (
complete_mcp_oauth_authorization,
make_oauth_provider,
)
from onyx.server.features.mcp.oauth_discovery import (
discover_oauth_capabilities,
oauth_setup_error,
)
from onyx.server.features.mcp.oauth_flow import (
MCP_OAUTH_ATTEMPTS,
OAuthAlreadyAuthenticated,
@@ -132,6 +147,7 @@ from onyx.server.features.mcp.oauth_flow import (
start_known_provider_oauth_flow,
validate_mcp_oauth_flow_configuration,
)
from onyx.server.features.mcp.setup_probe import stream_mcp_probe
from onyx.server.features.mcp.ssrf import validate_mcp_outbound_url
from onyx.server.features.tool.models import ToolSnapshot
from onyx.utils.encryption import (
@@ -622,6 +638,46 @@ def test_mcp_server_credentials(
return False, f"Connection failed: {str(e)}"
@admin_router.post("/probe")
async def stream_admin_mcp_probe(
request: MCPSetupProbeRequest,
_user: User = Depends(
require_permission(Permission.MANAGE_ACTIONS, allow_scope=True)
),
) -> StreamingResponse:
return StreamingResponse(
stream_mcp_probe(request.server_url),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
)
@admin_router.post("/oauth/discover")
async def discover_admin_oauth(
request: MCPOAuthDiscoveryRequest,
user: User = Depends(
require_permission(Permission.MANAGE_ACTIONS, allow_scope=True)
),
) -> MCPOAuthDiscoveryResponse:
server_url = await run_in_threadpool(
lambda: _get_authorized_discovery_url(request.server_id, user)
)
return await discover_oauth_capabilities(server_url)
def _get_authorized_discovery_url(server_id: int, user: User) -> str:
with get_session_with_current_tenant() as db:
try:
server = get_mcp_server_by_id(server_id, db)
except ValueError as error:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "MCP server not found") from error
_ensure_mcp_server_owner_or_admin(server, user)
return server.server_url
@admin_router.post("/oauth/connect", response_model=MCPUserOAuthConnectResponse)
async def connect_admin_oauth(
request: MCPUserOAuthConnectRequest,
@@ -872,10 +928,7 @@ async def _connect_oauth(
except Exception as e:
saved_e = log_exception_group(e) if isinstance(e, ExceptionGroup) else e
logger.error("OAuth initialization failed: %s", saved_e)
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Failed to initialize OAuth client: {str(saved_e)}",
) from e
raise oauth_setup_error(e) from e
match oauth_result:
case OAuthAuthorizationRedirect(url=oauth_url):
@@ -1335,6 +1388,58 @@ def _db_mcp_server_to_api_mcp_server(
)
def _stamp_user_preferences(servers: list[MCPServer], user: User, db: Session) -> None:
preferences = get_mcp_server_user_preferences(
[server.id for server in servers], user.id, db
)
for server in servers:
preference = preferences.get(server.id)
if preference is not None:
server.user_enabled_in_chat = preference.enabled_in_chat
server.user_enabled_in_craft = preference.enabled_in_craft
@router.patch("/server/{server_id}/preferences")
def update_mcp_user_preferences(
server_id: int,
request: MCPUserPreferencesUpdateRequest,
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
) -> MCPUserPreferencesResponse:
if not user_can_access_mcp_server(user, server_id, db):
raise OnyxError(OnyxErrorCode.UNAUTHORIZED, "MCP server is not accessible")
try:
server = get_mcp_server_by_id(server_id, db)
except ValueError as error:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "MCP server not found") from error
if (
request.enabled_in_chat is True
and server_id not in get_default_chat_mcp_server_ids([server_id], db)
):
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "MCP server is not available in default Chat"
)
if request.enabled_in_craft is True and not server.available_in_craft:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "MCP server is not available in Craft"
)
preference = upsert_mcp_server_user_preferences__no_commit(
server_id,
user.id,
db,
enabled_in_chat=request.enabled_in_chat,
enabled_in_craft=request.enabled_in_craft,
)
response = MCPUserPreferencesResponse(
enabled_in_chat=preference.enabled_in_chat,
enabled_in_craft=preference.enabled_in_craft,
)
db.commit()
if request.enabled_in_craft is not None:
_hot_reload_craft_sessions({user.id}, db)
return response
@router.get("/servers/persona/{assistant_id}", response_model=MCPServersResponse)
def get_mcp_servers_for_assistant(
assistant_id: str,
@@ -1369,6 +1474,7 @@ def get_mcp_servers_for_assistant(
for db_server in db_mcp_servers
]
_stamp_user_preferences(mcp_servers, user, db)
return MCPServersResponse(assistant_id=assistant_id, mcp_servers=mcp_servers)
except Exception as e:
@@ -1386,10 +1492,16 @@ def get_mcp_servers_for_user(
Chat uses ``/servers/persona/{id}`` for servers already on a persona.
"""
db_mcp_servers = get_mcp_servers_accessible_to_user(user, db)
default_chat_server_ids = get_default_chat_mcp_server_ids(
[server.id for server in db_mcp_servers], db
)
mcp_servers = [
_db_mcp_server_to_api_mcp_server(db_server, db, request_user=user)
for db_server in db_mcp_servers
]
for server in mcp_servers:
server.default_chat_enabled = server.id in default_chat_server_ids
_stamp_user_preferences(mcp_servers, user, db)
return MCPServersResponse(mcp_servers=mcp_servers)
@@ -1403,11 +1515,12 @@ def get_craft_mcp_servers_for_user(
rows as chat, so a server connected in either surface shows as
authenticated here.
`craft_connected` is the only field here that answers "will this server
actually reach the user's sessions" — see `resolve_craft_mcp_servers`, which
filters emission on the same predicate.
Personal preferences remain visible so users can enable a server again.
`craft_connected` reports credentials independently of personal availability.
"""
db_mcp_servers = get_craft_enabled_mcp_servers(db, user)
db_mcp_servers = get_craft_enabled_mcp_servers(
db, user, include_personal_disabled=True
)
user_configs = get_user_connection_configs(
[s.id for s in db_mcp_servers], user.email, db
)
@@ -1420,6 +1533,7 @@ def get_craft_mcp_servers_for_user(
server.connection_status == MCPConnectionStatus.CONNECTED
)
mcp_servers.append(server)
_stamp_user_preferences(mcp_servers, user, db)
return MCPServersResponse(mcp_servers=mcp_servers)
@@ -1996,9 +2110,7 @@ def _upsert_mcp_server(
)
if server_url_changed and mcp_server.admin_connection_config_id:
previous_admin_config_id = mcp_server.admin_connection_config_id
mcp_server.admin_connection_config_id = None
delete_connection_config(previous_admin_config_id, db_session)
clear_mcp_admin_connection_config__no_commit(mcp_server, db_session)
if (
request.auth_type == MCPAuthenticationType.OAUTH
and request.oauth_provider_mode is MCPOAuthProviderMode.AUTO_DISCOVERY
@@ -2347,6 +2459,9 @@ def get_mcp_servers_for_admin(
if server.owner == user.email or server.id in connected
]
default_chat_server_ids = get_default_chat_mcp_server_ids(
[server.id for server in visible_servers], db
)
mcp_servers = [
_db_mcp_server_to_api_mcp_server(
db_server,
@@ -2358,6 +2473,8 @@ def get_mcp_servers_for_admin(
)
for db_server in visible_servers
]
for server in mcp_servers:
server.default_chat_enabled = server.id in default_chat_server_ids
return MCPServersResponse(mcp_servers=mcp_servers)
@@ -2573,6 +2690,7 @@ def create_mcp_server_simple(
auth_type=None, # To be configured later
transport=None, # To be configured later
auth_performer=None, # To be configured later
available_in_craft=request.available_in_craft,
db_session=db_session,
)
@@ -2634,6 +2752,14 @@ def update_mcp_server_simple(
_validate_mcp_server_url(request.server_url, "server_url", require_https=False)
url_changed = (
request.server_url is not None and request.server_url != mcp_server.server_url
)
reload_user_ids: set[UUID] = set()
if url_changed:
reload_user_ids.update(_invalidate_mcp_user_credentials(mcp_server, db_session))
clear_mcp_admin_connection_config__no_commit(mcp_server, db_session)
# Update only provided fields
updated_server = update_mcp_server__no_commit(
server_id=server_id,
@@ -2642,6 +2768,14 @@ def update_mcp_server_simple(
description=request.description,
server_url=request.server_url,
available_in_craft=request.available_in_craft,
status=(
MCPServerStatus.AWAITING_AUTH
if mcp_server.auth_type
not in (None, MCPAuthenticationType.NONE, MCPAuthenticationType.PT_OAUTH)
else MCPServerStatus.CREATED
)
if url_changed
else None,
)
acl_changing = any(
@@ -2651,13 +2785,10 @@ def update_mcp_server_simple(
# Only an ACL change can drop a user's access; snapshot the pre-change
# recipients in that case so they're reloaded to lose the server (the
# post-update query wouldn't include them).
reload_user_ids: set[UUID] = (
affected_user_ids_for_mcp_server(updated_server, db_session)
if acl_changing
else set()
)
if acl_changing:
reload_user_ids.update(
affected_user_ids_for_mcp_server(updated_server, db_session)
)
_apply_mcp_server_access(
mcp_server=updated_server,
acting_user=user,
+13 -4
View File
@@ -13,6 +13,7 @@ from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared._httpx_utils import McpHttpClientFactory
from mcp.types import (
CallToolResult,
InitializeResult,
@@ -91,6 +92,7 @@ def _create_mcp_client_function_runner(
connection_headers: dict[str, str] | None = None,
transport: MCPTransport = MCPTransport.STREAMABLE_HTTP,
auth: OAuthClientProvider | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
**kwargs: Any,
) -> Callable[[], Coroutine[Any, Any, T]]:
auth_headers = connection_headers or {}
@@ -111,7 +113,7 @@ def _create_mcp_client_function_runner(
server_url,
headers=auth_headers,
auth=auth_for_request,
httpx_client_factory=mcp_ssrf_httpx_client_factory,
httpx_client_factory=httpx_client_factory or mcp_ssrf_httpx_client_factory,
) as client_tuple:
if len(client_tuple) == 3:
read, write, _ = client_tuple
@@ -171,16 +173,23 @@ def _call_mcp_client_function_sync(
raise e
async def _call_mcp_client_function_async(
async def call_mcp_client_function_async(
function: Callable[[ClientSession], Coroutine[Any, Any, T]],
server_url: str,
connection_headers: dict[str, str] | None = None,
transport: MCPTransport = MCPTransport.STREAMABLE_HTTP,
auth: OAuthClientProvider | None = None,
httpx_client_factory: McpHttpClientFactory | None = None,
**kwargs: Any,
) -> T:
run_client_function = _create_mcp_client_function_runner(
function, server_url, connection_headers, transport, auth, **kwargs
function,
server_url,
connection_headers,
transport,
auth,
httpx_client_factory=httpx_client_factory,
**kwargs,
)
return await run_client_function()
@@ -241,7 +250,7 @@ async def initialize_mcp_client(
transport: MCPTransport = MCPTransport.STREAMABLE_HTTP,
auth: OAuthClientProvider | None = None,
) -> InitializeResult:
return await _call_mcp_client_function_async(
return await call_mcp_client_function_async(
lambda session: session.initialize(),
server_url,
connection_headers,
+78 -1
View File
@@ -1,7 +1,7 @@
import datetime
import re
from enum import Enum
from typing import Any, List, Literal, NotRequired, Optional, TypedDict
from typing import Annotated, Any, List, Literal, NotRequired, Optional, TypedDict
from uuid import UUID
from mcp.shared.auth import (
@@ -421,6 +421,9 @@ class MCPToolUpdateRequest(BaseModel):
class MCPServerSimpleCreateRequest(BaseModel):
available_in_craft: bool = Field(
default=False, description="Whether the Craft agent may use this server"
)
name: str = Field(..., description="Name of the MCP server")
description: Optional[str] = Field(
None, description="Description of the MCP server"
@@ -513,6 +516,53 @@ class MCPOAuthConnectResponse(BaseModel):
pending_tool: dict = Field(..., description="Pending tool configuration")
class MCPOAuthDiscoveryRequest(BaseModel):
server_id: int
class MCPOAuthDiscoveryResponse(BaseModel):
status: Literal["metadata_found", "metadata_missing", "unreachable"]
supports_automatic_registration: bool | None = None
authorization_endpoint: str | None = None
token_endpoint: str | None = None
class MCPSetupProbeRequest(BaseModel):
server_url: str
class MCPPublicProbeResult(BaseModel):
kind: Literal["public"] = "public"
transport: Literal[MCPTransport.STREAMABLE_HTTP, MCPTransport.SSE]
tool_count: int
server_name: str
class MCPOAuthProbeResult(BaseModel):
kind: Literal["oauth"] = "oauth"
transport: Literal[MCPTransport.STREAMABLE_HTTP, MCPTransport.SSE] | None = None
discovery: MCPOAuthDiscoveryResponse
class MCPUnknownAuthProbeResult(BaseModel):
kind: Literal["auth_unknown"] = "auth_unknown"
transport: Literal[MCPTransport.STREAMABLE_HTTP, MCPTransport.SSE] | None = None
class MCPUnreachableProbeResult(BaseModel):
kind: Literal["unreachable"] = "unreachable"
reason: Literal["blocked", "connection_failed"] = "connection_failed"
MCPSetupProbeResult = Annotated[
MCPPublicProbeResult
| MCPOAuthProbeResult
| MCPUnknownAuthProbeResult
| MCPUnreachableProbeResult,
Field(discriminator="kind"),
]
class MCPUserOAuthConnectRequest(BaseModel):
server_id: int = Field(..., description="ID of the MCP server")
return_path: SafeOAuthReturnPath = Field(
@@ -694,6 +744,24 @@ class MCPApiKeyResponse(BaseModel):
)
class MCPUserPreferencesUpdateRequest(BaseModel):
enabled_in_chat: bool | None = None
enabled_in_craft: bool | None = None
@model_validator(mode="after")
def validate_preferences(self) -> "MCPUserPreferencesUpdateRequest":
if self.enabled_in_chat is None and self.enabled_in_craft is None:
raise ValueError("At least one preference is required")
if any(value is None for value in self.model_dump(exclude_unset=True).values()):
raise ValueError("Preferences must be booleans")
return self
class MCPUserPreferencesResponse(BaseModel):
enabled_in_chat: bool
enabled_in_craft: bool
class MCPServer(BaseModel):
id: int
name: str
@@ -715,6 +783,15 @@ class MCPServer(BaseModel):
groups: list[int] = Field(default_factory=list)
users: list[UUID] = Field(default_factory=list)
available_in_craft: bool = False
user_enabled_in_chat: bool = True
user_enabled_in_craft: bool = True
default_chat_enabled: bool | None = Field(
None,
description=(
"Whether any server tool is selected for the default assistant. "
"Computed by member and admin listings, independently of credentials and tool enablement."
),
)
tool_policies: Optional[dict[str, EndpointPolicy]] = Field(
None,
description=(
@@ -0,0 +1,99 @@
import asyncio
import httpx
from mcp.client.auth.exceptions import OAuthRegistrationError
from mcp.client.auth.utils import (
build_oauth_authorization_server_metadata_discovery_urls,
build_protected_resource_metadata_discovery_urls,
create_oauth_metadata_request,
extract_resource_metadata_from_www_auth,
handle_auth_metadata_response,
handle_protected_resource_response,
)
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.mcp.models import MCPOAuthDiscoveryResponse
from onyx.server.features.mcp.ssrf import mcp_ssrf_httpx_client_factory
from onyx.utils.url import SSRFException
async def discover_oauth_capabilities(
server_url: str, *, challenge_url: str | None = None
) -> MCPOAuthDiscoveryResponse:
try:
async with asyncio.timeout(15):
return await _discover_oauth_capabilities(server_url, challenge_url)
except (httpx.HTTPError, TimeoutError, ValueError, SSRFException):
return MCPOAuthDiscoveryResponse(status="unreachable")
async def _discover_oauth_capabilities(
server_url: str, challenge_url: str | None
) -> MCPOAuthDiscoveryResponse:
saw_server_error = False
async with mcp_ssrf_httpx_client_factory(timeout=httpx.Timeout(5)) as client:
if challenge_url is None:
# An SSE endpoint can keep a GET body open forever.
async with client.stream("GET", server_url) as response:
challenge_url = extract_resource_metadata_from_www_auth(response)
auth_server_url: str | None = None
for url in build_protected_resource_metadata_discovery_urls(
challenge_url, server_url
):
response = await client.send(create_oauth_metadata_request(url))
saw_server_error |= response.status_code >= 500
resource_metadata = await handle_protected_resource_response(response)
if resource_metadata and check_resource_allowed(
requested_resource=resource_url_from_server_url(server_url),
configured_resource=str(resource_metadata.resource),
):
auth_server_url = str(resource_metadata.authorization_servers[0])
break
for url in build_oauth_authorization_server_metadata_discovery_urls(
auth_server_url, server_url
):
response = await client.send(create_oauth_metadata_request(url))
saw_server_error |= response.status_code >= 500
_, metadata = await handle_auth_metadata_response(response)
if metadata:
return MCPOAuthDiscoveryResponse(
status="metadata_found",
supports_automatic_registration=bool(
metadata.registration_endpoint
or metadata.client_id_metadata_document_supported
),
authorization_endpoint=str(metadata.authorization_endpoint),
token_endpoint=str(metadata.token_endpoint),
)
return MCPOAuthDiscoveryResponse(
status="unreachable" if saw_server_error else "metadata_missing"
)
def oauth_setup_error(error: Exception) -> OnyxError:
"""Do not expose upstream response bodies, URLs, or credentials to the browser."""
if isinstance(error, ExceptionGroup):
errors = [oauth_setup_error(child) for child in error.exceptions]
return next(
(item for item in errors if item.extra != {"oauth_error": "oauth_failed"}),
errors[0],
)
if isinstance(error, OAuthRegistrationError):
return OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Automatic OAuth client registration failed. Use your own OAuth client, or check the provider's registration requirements.",
extra={"oauth_error": "client_registration_failed"},
)
if isinstance(error, (httpx.HTTPError, TimeoutError)):
return OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Could not reach the OAuth provider. Check the server URL and try again.",
extra={"oauth_error": "connection_failed"},
)
return OnyxError(
OnyxErrorCode.INVALID_INPUT,
"OAuth setup failed. Check the provider's OAuth settings and try again.",
extra={"oauth_error": "oauth_failed"},
)
@@ -210,7 +210,8 @@ async def _start_oauth_from_well_known_metadata(
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"OAuth auto-discovery could not find protected resource metadata at "
"the MCP server's well-known URI.",
"the MCP server's well-known URI. Check the URL or enter OAuth endpoints manually.",
extra={"oauth_error": "discovery_failed"},
)
async with mcp_oauth_challenge_httpx_client_factory(
@@ -371,12 +372,14 @@ async def _start_auto_discovery_oauth_flow_once(
except TimeoutError as error:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Timed out during MCP OAuth discovery.",
"Timed out during MCP OAuth discovery. Check the server URL and try again.",
extra={"oauth_error": "connection_failed"},
) from error
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"OAuth auto-discovery did not produce an authorization redirect.",
"OAuth auto-discovery did not produce an authorization redirect. Check the provider settings.",
extra={"oauth_error": "discovery_failed"},
)
@@ -0,0 +1,202 @@
import asyncio
import json
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
from contextlib import suppress
from typing import Literal, TypedDict
import httpx
from mcp import ClientSession
from mcp.client.auth.utils import extract_resource_metadata_from_www_auth
from onyx.db.enums import MCPTransport
from onyx.server.features.mcp.client import call_mcp_client_function_async
from onyx.server.features.mcp.models import (
MCPOAuthProbeResult,
MCPPublicProbeResult,
MCPSetupProbeResult,
MCPUnknownAuthProbeResult,
MCPUnreachableProbeResult,
)
from onyx.server.features.mcp.oauth_discovery import discover_oauth_capabilities
from onyx.server.features.mcp.ssrf import mcp_ssrf_httpx_client_factory
from onyx.utils.url import SSRFException
class MCPProbeProgress(TypedDict):
type: Literal["progress"]
stage: Literal["contacting_server", "authentication"]
state: Literal["pending", "success", "error"]
class MCPProbeCompletion(TypedDict):
type: Literal["result"]
result: dict[str, object]
Progress = Callable[[MCPProbeProgress], Awaitable[None]]
async def stream_mcp_probe(server_url: str) -> AsyncGenerator[str, None]:
queue: asyncio.Queue[MCPProbeProgress | MCPProbeCompletion] = asyncio.Queue()
async def run() -> None:
try:
result = await probe_mcp_server(server_url, queue.put)
result_data = result.model_dump(mode="json")
except Exception:
result_data = MCPUnreachableProbeResult().model_dump(mode="json")
await queue.put({"type": "result", "result": result_data})
task = asyncio.create_task(run())
try:
while True:
event = await queue.get()
yield json.dumps(event) + "\n"
if event["type"] == "result":
break
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
def _leaf_errors(error: Exception) -> Iterator[Exception]:
if isinstance(error, ExceptionGroup):
for child in error.exceptions:
yield from _leaf_errors(child)
else:
yield error
async def probe_mcp_server(
server_url: str, progress: Progress | None = None
) -> MCPSetupProbeResult:
contacted = False
async def report_contact() -> None:
nonlocal contacted
if not contacted:
contacted = True
if progress:
await progress(
{
"type": "progress",
"stage": "contacting_server",
"state": "success",
}
)
await progress(
{"type": "progress", "stage": "authentication", "state": "pending"}
)
if progress:
await progress(
{"type": "progress", "stage": "contacting_server", "state": "pending"}
)
result: MCPSetupProbeResult = MCPUnreachableProbeResult()
try:
async with asyncio.timeout(25):
for transport in (MCPTransport.STREAMABLE_HTTP, MCPTransport.SSE):
transport_result = await _probe_transport(
server_url, transport, report_contact
)
if transport_result is not None:
result = transport_result
break
except Exception:
result = MCPUnreachableProbeResult()
if progress:
await progress(
{
"type": "progress",
"stage": "authentication" if contacted else "contacting_server",
"state": "success" if result.kind in ("public", "oauth") else "error",
}
)
return result
async def _probe_transport(
server_url: str,
transport: Literal[MCPTransport.STREAMABLE_HTTP, MCPTransport.SSE],
contacted: Callable[[], Awaitable[None]],
) -> MCPSetupProbeResult | None:
challenges: list[httpx.Response] = []
sse_established = False
async def record_challenge(response: httpx.Response) -> None:
# The SSE SDK logs message-POST errors instead of propagating them.
nonlocal sse_established
if (
transport == MCPTransport.SSE
and response.request.method == "GET"
and response.is_success
and response.headers.get("content-type", "").startswith("text/event-stream")
):
sse_established = True
if response.status_code in (401, 403):
challenges.append(response)
await contacted()
def probe_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
client = mcp_ssrf_httpx_client_factory(
headers=headers, timeout=timeout, auth=auth
)
client.event_hooks["response"].append(record_challenge)
return client
try:
async def inspect(session: ClientSession) -> tuple[str, int]:
initialized = await session.initialize()
await contacted()
tools = await session.list_tools()
return initialized.serverInfo.name, len(tools.tools)
async with asyncio.timeout(5):
name, tool_count = await call_mcp_client_function_async(
inspect,
server_url,
transport=transport,
httpx_client_factory=probe_client_factory,
)
await contacted()
return MCPPublicProbeResult(
transport=transport, server_name=name, tool_count=tool_count
)
except Exception as error:
errors = list(_leaf_errors(error))
if any(isinstance(item, SSRFException) for item in errors):
return MCPUnreachableProbeResult(reason="blocked")
challenge = (
challenges[0]
if challenges
else next(
(
item.response
for item in errors
if isinstance(item, httpx.HTTPStatusError)
and item.response.status_code in (401, 403)
),
None,
)
)
if challenge is not None:
await contacted()
discovery = await discover_oauth_capabilities(
server_url,
challenge_url=extract_resource_metadata_from_www_auth(challenge),
)
if discovery.status == "metadata_found":
return MCPOAuthProbeResult(
discovery=discovery,
transport=MCPTransport.SSE if sse_established else None,
)
return MCPUnknownAuthProbeResult(
transport=MCPTransport.SSE if sse_established else None
)
return None
+2 -1
View File
@@ -8,6 +8,7 @@ registration, and token request — is checked.
"""
import httpx
from fastapi.concurrency import run_in_threadpool
from onyx.server.security.models import outbound_ssrf_params
from onyx.server.security.store import get_security_settings
@@ -41,7 +42,7 @@ class _SSRFGuardAsyncTransport(httpx.AsyncHTTPTransport):
targets are validated too."""
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
validate_mcp_outbound_url(str(request.url))
await run_in_threadpool(validate_mcp_outbound_url, str(request.url))
return await super().handle_async_request(request)
+67 -1
View File
@@ -23,11 +23,14 @@ from onyx.db.tools import (
can_manage_tool,
create_tool__no_commit,
delete_tool__no_commit,
get_default_chat_tool_ids,
get_tool_by_id,
get_tool_ids_connected_to_groups,
get_tool_user_preferences,
get_tools,
get_tools_by_ids,
update_tool,
upsert_tool_user_preference__no_commit,
)
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
@@ -36,6 +39,7 @@ from onyx.server.features.tool.models import (
CustomToolUpdate,
Header,
ToolSnapshot,
ToolUserPreferences,
)
from onyx.server.features.tool.tool_visibility import should_expose_tool_to_fe
from onyx.tools.built_in_tools import get_built_in_tool_by_id
@@ -397,6 +401,7 @@ def list_openapi_tools(
)
)
_stamp_personal_tool_preferences(openapi_tools, user, db_session)
return openapi_tools
@@ -415,7 +420,60 @@ def get_custom_tool(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"You can only view actions you manage, or ones an agent in your groups uses.",
)
return ToolSnapshot.from_model(tool)
snapshot = ToolSnapshot.from_model(tool)
_stamp_personal_tool_preferences([snapshot], user, db_session)
return snapshot
def _stamp_personal_tool_preferences(
tools: list[ToolSnapshot], user: User, db_session: Session
) -> None:
preferences = get_tool_user_preferences(
[tool.id for tool in tools if tool.definition is not None], user.id, db_session
)
for tool in tools:
preference = preferences.get(tool.id)
if preference is not None:
tool.user_enabled_in_chat = preference.enabled_in_chat
@router.patch("/{tool_id}/preferences", tags=PUBLIC_API_TAGS)
def update_tool_user_preferences(
tool_id: int,
request: ToolUserPreferences,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
) -> ToolUserPreferences:
try:
tool = get_tool_by_id(tool_id, db_session)
except ValueError as error:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Plugin not found") from error
if (
tool.in_code_tool_id is not None
or tool.mcp_server_id is not None
or tool.openapi_schema is None
):
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Only OpenAPI plugins support these preferences",
)
if not should_expose_tool_to_fe(tool):
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS, "Plugin is not available"
)
if request.enabled_in_chat and (
not tool.enabled
or tool.id not in get_default_chat_tool_ids([tool.id], db_session)
):
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Your admin has disabled this plugin for main Chat",
)
preference = upsert_tool_user_preference__no_commit(
tool_id, user.id, db_session, enabled_in_chat=request.enabled_in_chat
)
db_session.commit()
return ToolUserPreferences(enabled_in_chat=preference.enabled_in_chat)
@router.get("", tags=PUBLIC_API_TAGS)
@@ -453,4 +511,12 @@ def list_tools(
# All custom tools and available built-in tools are included
filtered_tools.append(ToolSnapshot.from_model(tool))
default_chat_tool_ids = get_default_chat_tool_ids(
[tool.id for tool in filtered_tools], db_session
)
for tool in filtered_tools:
tool.default_chat_enabled = tool.id in default_chat_tool_ids
_stamp_personal_tool_preferences(filtered_tools, user, db_session)
return filtered_tools
@@ -38,6 +38,11 @@ class ToolSnapshot(BaseModel):
oauth_config_id: int | None = None
oauth_config_name: str | None = None
enabled: bool = True
user_enabled_in_chat: bool = True
default_chat_enabled: bool | None = Field(
None,
description="Whether this tool is selected for the default assistant.",
)
# Server-stamped affordance map; fail-closed empty (only the admin actions list stamps it).
permissions: dict[str, bool] = Field(default_factory=dict)
@@ -78,6 +83,10 @@ class ToolSnapshot(BaseModel):
)
class ToolUserPreferences(BaseModel):
enabled_in_chat: bool
class Header(BaseModel):
key: str
value: str
@@ -18,6 +18,7 @@ class OAuthTokenStatus(BaseModel):
oauth_config_id: int
expires_at: int | None # Unix timestamp
is_expired: bool
can_refresh: bool = False
@router.get("/status")
@@ -37,11 +38,21 @@ def get_user_oauth_token_status(
token_data = (
token.token_data.get_value(apply_mask=False) if token.token_data else {}
)
refresh_token = token_data.get("refresh_token")
config = token.oauth_config
result.append(
OAuthTokenStatus(
oauth_config_id=token.oauth_config_id,
expires_at=OAuthTokenManager.token_expiration_time(token_data),
is_expired=OAuthTokenManager.is_token_expired(token_data),
can_refresh=(
isinstance(refresh_token, str)
and bool(refresh_token)
and config.client_id is not None
and bool(config.client_id.get_value(apply_mask=False))
and config.client_secret is not None
and bool(config.client_secret.get_value(apply_mask=False))
),
)
)
return result
+47 -2
View File
@@ -9,14 +9,19 @@ from sqlalchemy.orm import Session
from onyx.auth.oauth_token_manager import OAuthTokenManager
from onyx.chat.emitter import Emitter
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import DEFAULT_PERSONA_ID
from onyx.configs.model_configs import GEN_AI_TEMPERATURE
from onyx.context.search.models import BaseFilters
from onyx.db.engine.sql_engine import get_session_with_current_tenant_if_none
from onyx.db.mcp import get_mcp_server_by_id
from onyx.db.mcp import (
get_mcp_server_by_id,
get_mcp_server_user_preferences,
user_can_access_mcp_server,
)
from onyx.db.models import User
from onyx.db.oauth_config import get_oauth_config
from onyx.db.search_settings import get_current_search_settings
from onyx.db.tools import get_builtin_tool
from onyx.db.tools import get_builtin_tool, get_tool_user_preferences
from onyx.document_index.factory import get_default_document_index
from onyx.image_gen.interfaces import ImageGenerationProviderCredentials
from onyx.llm.interfaces import LLM, LLMConfig
@@ -207,7 +212,35 @@ def _construct_tools_impl(
)
mcp_tool_cache: dict[int, dict[int, MCPTool]] = {}
mcp_preferences = (
get_mcp_server_user_preferences(
list(
{
tool.mcp_server_id
for tool in configuration.tools
if tool.mcp_server_id is not None
}
),
user.id,
db_session,
)
if configuration.persona_id == DEFAULT_PERSONA_ID
else {}
)
user_oauth_token: str | None = user.live_oauth_token
openapi_preferences = (
get_tool_user_preferences(
[
tool.id
for tool in configuration.tools
if tool.openapi_schema is not None
],
user.id,
db_session,
)
if configuration.persona_id == DEFAULT_PERSONA_ID
else {}
)
search_settings = get_current_search_settings(db_session)
document_index = get_default_document_index(search_settings, None)
@@ -398,6 +431,9 @@ def _construct_tools_impl(
# Handle custom tools
elif db_tool_model.openapi_schema:
preference = openapi_preferences.get(db_tool_model.id)
if preference is not None and not preference.enabled_in_chat:
continue
if not custom_tool_config:
custom_tool_config = CustomToolConfig()
@@ -458,6 +494,9 @@ def _construct_tools_impl(
# Handle MCP tools
elif db_tool_model.mcp_server_id:
preference = mcp_preferences.get(db_tool_model.mcp_server_id)
if preference is not None and not preference.enabled_in_chat:
continue
if db_tool_model.mcp_server_id in mcp_tool_cache:
tool_dict[db_tool_model.id] = [
mcp_tool_cache[db_tool_model.mcp_server_id][db_tool_model.id]
@@ -466,6 +505,12 @@ def _construct_tools_impl(
mcp_server = get_mcp_server_by_id(db_tool_model.mcp_server_id, db_session)
if (
configuration.persona_id == DEFAULT_PERSONA_ID
and not user_can_access_mcp_server(user, mcp_server.id, db_session)
):
continue
try:
mcp_credentials = resolve_mcp_credentials(mcp_server, user, db_session)
except MCPCredentialsError as e:
@@ -9,6 +9,7 @@ server key is stable + identifier-safe.
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
@@ -23,7 +24,11 @@ from onyx.db.enums import (
from onyx.db.mcp import (
create_connection_config,
create_mcp_server__no_commit,
get_craft_enabled_mcp_servers,
get_mcp_server_user_preferences,
get_user_connection_config,
update_mcp_server__no_commit,
upsert_mcp_server_user_preferences__no_commit,
)
from onyx.db.models import MCPServer, Tool
from onyx.server.features.build.sandbox.util.mcp_config import (
@@ -31,8 +36,9 @@ from onyx.server.features.build.sandbox.util.mcp_config import (
resolve_craft_mcp_servers,
)
from onyx.server.features.mcp.api import get_craft_mcp_servers_for_user
from onyx.server.features.mcp.credentials import extract_connection_data
from onyx.server.features.mcp.models import MCPConnectionData
from tests.external_dependency_unit.conftest import create_test_user
from tests.external_dependency_unit.conftest import create_test_user, delete_test_user
@pytest.fixture
@@ -92,6 +98,151 @@ def test_only_craft_enabled_servers_resolved_with_tool_curation(
assert config.disabled_tools == ("delete_issue",)
def test_personal_preferences_are_independent_and_keep_disabled_server_visible(
db_session: Session,
craft_server: tuple[MCPServer, MCPServer],
) -> None:
craft, _ = craft_server
user = create_test_user(db_session, "mcp_preferences")
other = create_test_user(db_session, "mcp_preferences_other")
try:
assert get_mcp_server_user_preferences([craft.id], user.id, db_session) == {}
preference = upsert_mcp_server_user_preferences__no_commit(
craft.id, user.id, db_session, enabled_in_chat=False
)
assert (preference.enabled_in_chat, preference.enabled_in_craft) == (
False,
True,
)
assert craft.server_url in {
s.url for s in resolve_craft_mcp_servers(db_session, user)
}
preference = upsert_mcp_server_user_preferences__no_commit(
craft.id, user.id, db_session, enabled_in_craft=False
)
assert (preference.enabled_in_chat, preference.enabled_in_craft) == (
False,
False,
)
db_session.commit()
assert craft.server_url not in {
s.url for s in resolve_craft_mcp_servers(db_session, user)
}
assert craft.server_url in {
s.url for s in resolve_craft_mcp_servers(db_session, other)
}
assert craft.id in {
s.id for s in get_craft_enabled_mcp_servers(db_session, None)
}
response = get_craft_mcp_servers_for_user(db_session, user)
visible = next(s for s in response.mcp_servers if s.id == craft.id)
assert visible.craft_connected is True
assert visible.user_enabled_in_chat is False
assert visible.user_enabled_in_craft is False
preference = upsert_mcp_server_user_preferences__no_commit(
craft.id, user.id, db_session, enabled_in_chat=True
)
assert (preference.enabled_in_chat, preference.enabled_in_craft) == (
True,
False,
)
finally:
db_session.rollback()
delete_test_user(db_session, user, other)
db_session.commit()
@pytest.mark.parametrize("persona_id", [0, 5])
def test_chat_optout_blocks_default_chat_but_preserves_custom_agent(
db_session: Session,
craft_server: tuple[MCPServer, MCPServer],
monkeypatch: pytest.MonkeyPatch,
persona_id: int,
) -> None:
from onyx.chat.emitter import Emitter
from onyx.llm.interfaces import LLM
from onyx.tools import tool_constructor
from onyx.tools.models import PersonaToolConfiguration, ToolConfiguration
from onyx.tools.tool_implementations.search.search_tool import PersonaSearchInfo
craft, _ = craft_server
user = create_test_user(db_session, "mcp_chat_preferences")
user.enable_memory_tool = False
tool = next(tool for tool in craft.current_actions if tool.enabled)
configuration = PersonaToolConfiguration(
persona_id=persona_id,
persona_name="Test agent",
tools=[ToolConfiguration.model_validate(tool)],
search=PersonaSearchInfo(
document_set_names=[],
search_start_date=None,
attached_document_ids=[],
hierarchy_node_ids=[],
),
)
monkeypatch.setattr(tool_constructor, "get_current_search_settings", MagicMock())
monkeypatch.setattr(tool_constructor, "get_default_document_index", MagicMock())
try:
upsert_mcp_server_user_preferences__no_commit(
craft.id, user.id, db_session, enabled_in_chat=False
)
result = tool_constructor._construct_tools_impl(
configuration,
db_session,
MagicMock(spec=Emitter),
user,
MagicMock(spec=LLM),
allowed_tool_ids=[tool.id],
)
assert set(result) == ({tool.id} if persona_id else set())
if persona_id:
assert result[tool.id][0].name == tool.name
finally:
db_session.rollback()
delete_test_user(db_session, user)
db_session.commit()
def test_personal_optout_retains_stored_user_credentials(
db_session: Session,
craft_server: tuple[MCPServer, MCPServer],
) -> None:
craft, _ = craft_server
craft.auth_type = MCPAuthenticationType.API_TOKEN
craft.auth_performer = MCPAuthenticationPerformer.PER_USER
user = create_test_user(db_session, "mcp_retained_credentials")
config = create_connection_config(
{"headers": {"Authorization": "Bearer test-user-token"}},
db_session,
mcp_server_id=craft.id,
user_email=user.email,
)
try:
upsert_mcp_server_user_preferences__no_commit(
craft.id, user.id, db_session, enabled_in_chat=False, enabled_in_craft=False
)
db_session.commit()
retained = get_user_connection_config(craft.id, user.email, db_session)
assert retained is not None
assert retained.id == config.id
assert extract_connection_data(retained, apply_mask=False)["headers"] == {
"Authorization": "Bearer test-user-token"
}
assert craft.id not in {
s.server_id for s in resolve_craft_mcp_servers(db_session, user)
}
upsert_mcp_server_user_preferences__no_commit(
craft.id, user.id, db_session, enabled_in_craft=True
)
assert craft.id in {
s.server_id for s in resolve_craft_mcp_servers(db_session, user)
}
finally:
db_session.rollback()
delete_test_user(db_session, user)
db_session.commit()
def test_per_user_server_emitted_only_once_credentials_connected(
db_session: Session,
craft_server: tuple[MCPServer, MCPServer],
@@ -324,7 +324,7 @@ def test_oauth_reconnect_preserves_status_until_authorization_succeeds(
force_reauthentication=True,
)
if initialization_fails:
with pytest.raises(OnyxError, match="Failed to initialize OAuth client"):
with pytest.raises(OnyxError, match="OAuth setup failed"):
asyncio.run(api._connect_oauth(request, db_session, False, user))
else:
response = asyncio.run(api._connect_oauth(request, db_session, False, user))
@@ -36,6 +36,7 @@ from onyx.db.mcp import (
create_mcp_server__no_commit,
get_connection_config_by_id,
update_mcp_server__no_commit,
upsert_mcp_server_user_preferences__no_commit,
)
from onyx.db.models import MCPServer, OAuthAccount, User
from onyx.external_apps.matching.engine import (
@@ -50,7 +51,7 @@ from onyx.sandbox_proxy.sandbox_identity.models import ResolvedSandbox
from onyx.server.features.mcp.credentials import extract_connection_data
from onyx.server.features.mcp.models import MCPConnectionData, MCPOAuthKeys
from shared_configs.contextvars import POSTGRES_DEFAULT_SCHEMA
from tests.external_dependency_unit.conftest import create_test_user
from tests.external_dependency_unit.conftest import create_test_user, delete_test_user
CraftServerFactory = Callable[..., MCPServer]
@@ -495,6 +496,34 @@ def test_admin_managed_server_detail_points_to_admin(
assert "Apps page in Craft" not in detail
def test_personal_craft_optout_keeps_host_claim_and_blocks_live_resolve(
db_session: Session, craft_server: CraftServerFactory
) -> None:
user = create_test_user(db_session, "mcp_resolver_personal_optout")
other = create_test_user(db_session, "mcp_resolver_personal_other")
server = craft_server(
auth_type=MCPAuthenticationType.NONE,
auth_performer=MCPAuthenticationPerformer.ADMIN,
)
resolver = MCPServerResolver()
request = _request(_server_host(server))
try:
assert resolver.claims(request, _ctx(user)) is True
assert resolver.resolve(request, _ctx(user)) == {}
upsert_mcp_server_user_preferences__no_commit(
server.id, user.id, db_session, enabled_in_craft=False
)
db_session.commit()
assert resolver.claims(request, _ctx(user)) is True
with pytest.raises(CredentialUnavailableError, match="disabled MCP server"):
resolver.resolve(request, _ctx(user))
assert resolver.resolve(request, _ctx(other)) == {}
finally:
db_session.rollback()
delete_test_user(db_session, user, other)
db_session.commit()
def test_none_auth_claims_without_injecting(
db_session: Session, craft_server: CraftServerFactory
) -> None:
@@ -0,0 +1,80 @@
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from pydantic import ValidationError
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.build.external_apps.models import OAuthCallbackRequest
from onyx.server.features.build.external_apps.oauth import (
_ExternalAppOAuthAttemptPayload,
handle_external_app_oauth_callback,
start_external_app_oauth,
)
from tests.unit.external_apps.test_oauth_flow import _configure_route, _user
@pytest.mark.parametrize(
"path", ["https://example.com", "//example.com", "/\\example.com", "/app\n"]
)
def test_oauth_attempt_rejects_external_or_unsafe_return_paths(path: str) -> None:
with pytest.raises(ValidationError):
_ExternalAppOAuthAttemptPayload(
external_app_id=7, configuration_fingerprint="a" * 64, return_path=path
)
def test_oauth_attempt_round_trips_member_completion_path() -> None:
path = (
"/app/plugins/oauth/complete?pluginKind=external-app&pluginId=7&attempt=nonce"
)
payload = _ExternalAppOAuthAttemptPayload(
external_app_id=7, configuration_fingerprint="a" * 64, return_path=path
)
assert (
_ExternalAppOAuthAttemptPayload.model_validate_json(
payload.model_dump_json()
).return_path
== path
)
def test_oauth_attempt_without_return_path_uses_default() -> None:
payload = _ExternalAppOAuthAttemptPayload(
external_app_id=7, configuration_fingerprint="a" * 64
)
assert payload.return_path is None
def test_oauth_start_rejects_unsafe_return_before_loading_app() -> None:
with patch(
"onyx.server.features.build.external_apps.oauth.get_external_app_by_id"
) as get_app:
with pytest.raises(OnyxError):
start_external_app_oauth(
7, user=MagicMock(), db_session=MagicMock(), return_path="//example.com"
)
get_app.assert_not_called()
@pytest.mark.parametrize(
"return_path", [None, "/app/plugins/oauth/complete?attempt=nonce"]
)
def test_callback_preserves_return_path_after_saving_credentials(
monkeypatch: pytest.MonkeyPatch, return_path: str | None
) -> None:
app, _, db_session, _ = _configure_route(monkeypatch)
user = _user("owner")
start = start_external_app_oauth(
app.id, user=user, db_session=db_session, return_path=return_path
)
state = parse_qs(urlparse(start.authorize_url).query)["state"][0]
request = OAuthCallbackRequest(code="code", state=state)
result = handle_external_app_oauth_callback(
request, user=user, db_session=db_session
)
assert result.success is True
assert result.external_app_id == 7
assert result.return_path == return_path
with pytest.raises(OnyxError):
handle_external_app_oauth_callback(request, user=user, db_session=db_session)
@@ -4,9 +4,12 @@ import json
from types import SimpleNamespace
from typing import Any, cast
import pytest
from onyx.db.enums import ExternalAppType
from onyx.db.models import ExternalApp, ExternalAppUserCredential
from onyx.server.features.build.external_apps.api import _to_user_response
from onyx.server.features.build.external_apps.models import ExternalAppUserResponse
from onyx.utils.encryption import mask_string
from onyx.utils.sensitive import SensitiveValue
@@ -24,6 +27,7 @@ def _external_app(
auth_template: dict[str, Any],
organization_credentials: dict[str, Any],
app_type: ExternalAppType = ExternalAppType.CUSTOM,
upstream_url_patterns: list[str] | None = None,
) -> ExternalApp:
return cast(
ExternalApp,
@@ -33,6 +37,7 @@ def _external_app(
app_type=app_type,
auth_template=auth_template,
organization_credentials=_sensitive_dict(organization_credentials),
upstream_url_patterns=upstream_url_patterns or [],
),
)
@@ -115,3 +120,113 @@ def test_user_response_uses_raw_presence_for_authentication() -> None:
assert response.credential_values == {
"access_token": mask_string("USER_ACCESS_TOKEN")
}
@pytest.mark.parametrize(
("app_type", "patterns", "origins"),
[
(
ExternalAppType.LINEAR,
[r"https://api\.linear\.app/.*"],
["https://api.linear.app"],
),
(
ExternalAppType.CUSTOM,
["https://api.linear.app/*"],
["https://api.linear.app"],
),
(
ExternalAppType.ZENDESK,
[r"https://acme\-support\.zendesk\.com/api/v2/.*"],
["https://acme-support.zendesk.com"],
),
(
ExternalAppType.LINEAR,
["https://api.linear.app/.*", r"https://api\.linear.app/.*"],
[],
),
(
ExternalAppType.CUSTOM,
["https://user:secret@API.Example.com:443/private?token=secret#fragment"],
["https://api.example.com"],
),
(
ExternalAppType.CUSTOM,
["https://api.example.com?token=secret", "https://api.example.com:8443/*"],
["https://api.example.com", "https://api.example.com:8443"],
),
(
ExternalAppType.CUSTOM,
[
"http://API.Example.com:80/a",
"http://api.example.com/*",
"https://api.example.com:443/*",
],
["http://api.example.com", "https://api.example.com"],
),
(
ExternalAppType.LINEAR,
[
r"https://(?:api|www)\.linear\.app/.*",
r"https://api\.linear\.app?/.*",
r"https://.*\.linear\.app/.*",
],
[],
),
(
ExternalAppType.CUSTOM,
[
"https://*.example.com/*",
"https://api.example.com:bad/*",
"ftp://api.example.com/*",
"https://api%2eexample.com/*",
"https://api?.example.com/*",
"https://api\n.example.com/*",
],
[],
),
(
ExternalAppType.CUSTOM,
["https://[2001:db8::1]:443/*"],
["https://[2001:db8::1]"],
),
],
)
def test_user_response_projects_only_clean_service_origins(
app_type: ExternalAppType, patterns: list[str], origins: list[str]
) -> None:
app = _external_app(
auth_template={},
organization_credentials={"client_secret": "org-secret"},
app_type=app_type,
upstream_url_patterns=patterns,
)
response = _to_user_response(app, None).model_dump()
assert response["service_urls"] == origins
assert set(response) == {
"id",
"name",
"app_type",
"credential_keys",
"credential_values",
"authenticated",
"supports_oauth",
"service_urls",
}
assert "org-secret" not in json.dumps(response)
def test_user_response_service_origins_default_to_empty() -> None:
response = ExternalAppUserResponse(
id=1,
name="Test App",
app_type=ExternalAppType.CUSTOM,
credential_keys=[],
credential_values={},
authenticated=True,
supports_oauth=False,
)
assert response.service_urls == []
@@ -0,0 +1,312 @@
from unittest.mock import MagicMock
import pytest
from sqlalchemy import Boolean, Column, Integer, MetaData, Table, create_engine
from sqlalchemy.orm import Session
from onyx.db import persona as persona_db
from onyx.db.models import MCPServer, Persona, Tool, User
from onyx.server.features.default_assistant import api as default_api
from onyx.server.features.default_assistant.models import DefaultAssistantUpdateRequest
from onyx.server.features.mcp.models import MCPConnectionStatus
@pytest.mark.parametrize("nondefault_builtin_deleted", [True, False])
def test_default_chat_server_summary_uses_visible_tool_attachments(
nondefault_builtin_deleted: bool,
) -> None:
from onyx.db.mcp import get_default_chat_mcp_server_ids
metadata = MetaData()
personas = Table(
"persona",
metadata,
Column("id", Integer, primary_key=True),
Column("builtin_persona", Boolean),
Column("deleted", Boolean),
)
tools = Table(
"tool",
metadata,
Column("id", Integer, primary_key=True),
Column("mcp_server_id", Integer),
Column("enabled", Boolean),
)
attachments = Table(
"persona__tool",
metadata,
Column("persona_id", Integer),
Column("tool_id", Integer),
)
engine = create_engine("sqlite://")
metadata.create_all(engine)
with Session(engine) as db:
db.execute(
personas.insert(),
[
{"id": 0, "builtin_persona": True, "deleted": False},
{"id": 1, "builtin_persona": False, "deleted": False},
{
"id": 2,
"builtin_persona": True,
"deleted": nondefault_builtin_deleted,
},
],
)
db.execute(
tools.insert(),
[
{"id": 1, "mcp_server_id": 42, "enabled": True},
{"id": 2, "mcp_server_id": 42, "enabled": False},
{"id": 3, "mcp_server_id": 43, "enabled": False},
{"id": 4, "mcp_server_id": 44, "enabled": True},
{"id": 5, "mcp_server_id": 45, "enabled": True},
{"id": 6, "mcp_server_id": 46, "enabled": True},
{"id": 7, "mcp_server_id": None, "enabled": True},
],
)
db.execute(
attachments.insert(),
[
{"persona_id": persona_id, "tool_id": tool_id}
for persona_id, tool_id in [
(0, 1),
(0, 2),
(0, 3),
(1, 4),
(2, 5),
(0, 6),
(0, 7),
]
],
)
assert get_default_chat_mcp_server_ids([42, 43, 44, 45], db) == {42, 43}
assert get_default_chat_mcp_server_ids([44, 45], db) == set()
assert get_default_chat_mcp_server_ids([], db) == set()
def test_scoped_manager_server_summary_reports_only_visible_servers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from onyx.db.enums import MCPServerStatus, PermissionAuthority
from onyx.server.features.mcp import api
from onyx.server.features.mcp.models import MCPServer as MCPServerResponse
db = MagicMock(spec=Session)
user = User(email="manager@example.com")
servers = [
MCPServer(id=42, owner=user.email),
MCPServer(id=43, owner="other@example.com"),
MCPServer(id=44, owner="other@example.com"),
]
selected_servers = MagicMock(return_value={43})
monkeypatch.setattr(api, "get_all_mcp_servers", lambda _: servers)
monkeypatch.setattr(api, "has_permission", lambda *_: PermissionAuthority.SCOPED)
monkeypatch.setattr(api, "get_scoped_groups", lambda *_: [])
monkeypatch.setattr(api, "get_mcp_server_ids_connected_to_groups", lambda *_: {43})
monkeypatch.setattr(api, "get_default_chat_mcp_server_ids", selected_servers)
monkeypatch.setattr(api, "can_manage_mcp_server", lambda *_: False)
monkeypatch.setattr(
api,
"_db_mcp_server_to_api_mcp_server",
lambda server, *_, **__: MCPServerResponse(
id=server.id,
name="Server",
server_url="",
owner="",
status=MCPServerStatus.CREATED,
connection_status=MCPConnectionStatus.NOT_CONNECTED,
),
)
response = api.get_mcp_servers_for_admin(db, user)
selected_servers.assert_called_once_with([42, 43], db)
assert [
(server.id, server.default_chat_enabled) for server in response.mcp_servers
] == [
(42, False),
(43, True),
]
def test_member_server_summary_reports_only_accessible_servers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from onyx.db.enums import MCPServerStatus
from onyx.server.features.mcp import api
from onyx.server.features.mcp.models import MCPServer as MCPServerResponse
db = MagicMock(spec=Session)
user = User(email="member@example.com")
servers = [MCPServer(id=42), MCPServer(id=43)]
selected_servers = MagicMock(return_value={43})
monkeypatch.setattr(api, "get_mcp_servers_accessible_to_user", lambda *_: servers)
monkeypatch.setattr(api, "get_default_chat_mcp_server_ids", selected_servers)
monkeypatch.setattr(
api,
"_db_mcp_server_to_api_mcp_server",
lambda server, *_, **__: MCPServerResponse(
id=server.id,
name="Server",
server_url="",
owner="",
status=MCPServerStatus.CREATED,
connection_status=MCPConnectionStatus.NOT_CONNECTED,
),
)
response = api.get_mcp_servers_for_user(db, user)
selected_servers.assert_called_once_with([42, 43], db)
assert [
(server.id, server.default_chat_enabled) for server in response.mcp_servers
] == [(42, False), (43, True)]
def tool(tool_id: int, server_id: int | None, name: str) -> Tool:
return Tool(id=tool_id, name=name, mcp_server_id=server_id, enabled=True)
def test_default_chat_views_share_existing_tool_attachments(
monkeypatch: pytest.MonkeyPatch,
) -> None:
selected = tool(1, 42, "selected")
other_mcp = tool(2, 43, "other_server")
built_in = tool(3, None, "search")
unselected = tool(4, 42, "unselected")
default = Persona(id=0, tools=[selected, other_mcp, built_in])
custom = Persona(id=5, tools=[selected, unselected])
db = MagicMock(spec=Session)
user = User()
monkeypatch.setattr(persona_db, "get_default_assistant", lambda _: default)
monkeypatch.setattr(default_api, "get_default_assistant", lambda _: default)
monkeypatch.setattr(persona_db, "should_expose_tool_to_fe", lambda _: True)
def save_selection(tools: list[Tool]) -> None:
db.query.return_value.filter.return_value.one_or_none.side_effect = tools
response = default_api.update_default_assistant(
DefaultAssistantUpdateRequest(tool_ids=[item.id for item in tools]),
user,
db,
)
assert response.tool_ids == [item.id for item in tools]
assert (
default_api.get_default_assistant_configuration(user, db).tool_ids
== response.tool_ids
)
assert custom.tools == [selected, unselected]
save_selection([other_mcp, built_in])
save_selection([selected, other_mcp, built_in])
save_selection([selected, unselected, other_mcp, built_in])
assert default.system_prompt is None
assert db.commit.call_count == 3
def test_default_chat_availability_has_no_separate_server_field() -> None:
from onyx.server.features.mcp.models import MCPServer as MCPServerResponse
assert "available_in_default_chat" not in MCPServer.__table__.columns
assert "available_in_default_chat" not in MCPServerResponse.model_fields
def test_discovery_does_not_change_default_or_custom_agent_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from mcp.types import Tool as DiscoveredTool
from onyx.server.features.mcp import api
existing = tool(1, 42, "old")
default = Persona(id=0, tools=[existing])
custom = Persona(id=5, tools=[existing])
db = MagicMock(spec=Session)
monkeypatch.setattr(api, "get_tools_by_mcp_server_id", lambda *_, **__: [existing])
monkeypatch.setattr(api, "create_tool__no_commit", lambda **_: tool(2, 42, "new"))
api._sync_mcp_server_tools(
42,
[
DiscoveredTool(name="old", inputSchema={}),
DiscoveredTool(name="new", inputSchema={}),
],
db,
)
assert default.tools == [existing]
assert custom.tools == [existing]
db.scalar.assert_not_called()
db.scalars.assert_not_called()
db.commit.assert_called_once()
@pytest.mark.parametrize(
"persona_id,accessible,expected_resolution",
[
(0, False, False),
(0, True, True),
(5, False, True),
],
)
def test_default_chat_checks_access_before_resolving_credentials(
monkeypatch: pytest.MonkeyPatch,
persona_id: int,
accessible: bool,
expected_resolution: bool,
) -> None:
from onyx.chat.emitter import Emitter
from onyx.db.models import User
from onyx.llm.interfaces import LLM
from onyx.server.features.mcp.credentials import MCPCredentialsError
from onyx.tools import tool_constructor
from onyx.tools.models import PersonaToolConfiguration, ToolConfiguration
from onyx.tools.tool_implementations.search.search_tool import PersonaSearchInfo
server = MCPServer(id=42)
configuration = PersonaToolConfiguration(
persona_id=persona_id,
persona_name="Test",
tools=[
ToolConfiguration(
id=1,
name="tool",
description=None,
display_name=None,
in_code_tool_id=None,
enabled=True,
openapi_schema=None,
mcp_input_schema=None,
custom_headers=None,
passthrough_auth=False,
mcp_server_id=42,
oauth_config_id=None,
)
],
search=PersonaSearchInfo(
document_set_names=[],
search_start_date=None,
attached_document_ids=[],
hierarchy_node_ids=[],
),
)
resolve = MagicMock(side_effect=MCPCredentialsError("No credential"))
monkeypatch.setattr(tool_constructor, "get_current_search_settings", MagicMock())
monkeypatch.setattr(tool_constructor, "get_default_document_index", MagicMock())
monkeypatch.setattr(tool_constructor, "get_mcp_server_by_id", lambda *_: server)
monkeypatch.setattr(
tool_constructor, "user_can_access_mcp_server", lambda *_: accessible
)
monkeypatch.setattr(tool_constructor, "resolve_mcp_credentials", resolve)
result = tool_constructor._construct_tools_impl(
configuration=configuration,
db_session=MagicMock(spec=Session),
emitter=MagicMock(spec=Emitter),
user=User(enable_memory_tool=False),
llm=MagicMock(spec=LLM),
)
assert result == {}
assert resolve.called is expected_resolution
@@ -7,6 +7,7 @@ from uuid import uuid4
import pytest
from mcp.shared.auth import OAuthClientInformationFull
from onyx.db import mcp as mcp_db
from onyx.db.enums import (
MCPAuthenticationPerformer,
MCPAuthenticationType,
@@ -267,7 +268,7 @@ def test_server_url_change_invalidates_old_credentials_without_losing_new_client
"delete_all_user_connection_configs_for_server_no_commit",
delete_user_configs,
)
monkeypatch.setattr(api, "delete_connection_config", delete_admin_config)
monkeypatch.setattr(mcp_db, "delete_connection_config", delete_admin_config)
monkeypatch.setattr(api, "update_mcp_server__no_commit", update_server)
def persist_admin_config(
@@ -0,0 +1,331 @@
import asyncio
import httpx
import pytest
from mcp.client.auth.exceptions import OAuthRegistrationError
from onyx.server.features.mcp import oauth_discovery
from onyx.server.features.mcp.models import MCPOAuthDiscoveryRequest
@pytest.mark.parametrize(
"registration,cimd,expected",
[
(None, False, False),
("https://issuer.example/register", False, True),
(None, True, True),
],
)
def test_discovery_reads_capabilities_without_registration(
monkeypatch: pytest.MonkeyPatch,
registration: str | None,
cimd: bool,
expected: bool,
) -> None:
requests: list[httpx.Request] = []
def respond(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert request.method == "GET"
assert "authorization" not in request.headers
if request.url.path == "/mcp":
return httpx.Response(
401,
headers={
"www-authenticate": 'Bearer resource_metadata="https://mcp.example/metadata"'
},
)
if request.url.path == "/metadata":
return httpx.Response(
200,
json={
"resource": "https://mcp.example/mcp",
"authorization_servers": ["https://issuer.example"],
},
)
return httpx.Response(
200,
json={
"issuer": "https://issuer.example",
"authorization_endpoint": "https://issuer.example/authorize",
"token_endpoint": "https://issuer.example/token",
"response_types_supported": ["code"],
"registration_endpoint": registration,
"client_id_metadata_document_supported": cimd,
},
)
monkeypatch.setattr(
oauth_discovery,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(
oauth_discovery.discover_oauth_capabilities("https://mcp.example/mcp")
)
assert result.status == "metadata_found"
assert result.supports_automatic_registration is expected
assert result.authorization_endpoint == "https://issuer.example/authorize"
assert len(requests) == 3
@pytest.mark.parametrize("unreachable", [False, True])
def test_unknown_metadata_does_not_claim_registration_is_unsupported(
monkeypatch: pytest.MonkeyPatch, unreachable: bool
) -> None:
def respond(request: httpx.Request) -> httpx.Response:
if unreachable:
raise httpx.ConnectError("private details", request=request)
return httpx.Response(404, text="<html>private details</html>")
monkeypatch.setattr(
oauth_discovery,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(
oauth_discovery.discover_oauth_capabilities("https://mcp.example/mcp")
)
assert result.status == ("unreachable" if unreachable else "metadata_missing")
assert result.supports_automatic_registration is None
def test_registration_error_is_sanitized_through_nested_task_groups() -> None:
upstream = OAuthRegistrationError(
"Registration failed: 404 <html>secret=private</html>"
)
error = oauth_discovery.oauth_setup_error(
ExceptionGroup("transport", [ExceptionGroup("oauth", [upstream])])
)
assert error.extra == {"oauth_error": "client_registration_failed"}
assert "own OAuth client" in error.detail
assert "private" not in error.detail
assert "html" not in error.detail
def test_discovery_checks_server_management_before_network(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from unittest.mock import AsyncMock, MagicMock
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.mcp import api
server = MagicMock()
monkeypatch.setattr(api, "get_session_with_current_tenant", MagicMock())
monkeypatch.setattr(api, "get_mcp_server_by_id", lambda *_: server)
denied = MagicMock(side_effect=OnyxError(OnyxErrorCode.INSUFFICIENT_PERMISSIONS))
discover = AsyncMock()
monkeypatch.setattr(api, "_ensure_mcp_server_owner_or_admin", denied)
monkeypatch.setattr(api, "discover_oauth_capabilities", discover)
with pytest.raises(OnyxError):
asyncio.run(
api.discover_admin_oauth(
MCPOAuthDiscoveryRequest(server_id=42),
MagicMock(),
)
)
discover.assert_not_called()
@pytest.mark.parametrize(
"metadata_kind", ["wrong_resource", "empty_servers", "invalid_json"]
)
def test_invalid_resource_metadata_never_follows_untrusted_issuer(
monkeypatch: pytest.MonkeyPatch, metadata_kind: str
) -> None:
requested_hosts: list[str] = []
def respond(request: httpx.Request) -> httpx.Response:
requested_hosts.append(request.url.host)
if request.url.path == "/mcp":
return httpx.Response(401)
if "oauth-protected-resource" in request.url.path:
if metadata_kind == "invalid_json":
return httpx.Response(200, text="<html>not metadata</html>")
return httpx.Response(
200,
json={
"resource": "https://other.example/mcp"
if metadata_kind == "wrong_resource"
else "https://mcp.example/mcp",
"authorization_servers": []
if metadata_kind == "empty_servers"
else ["https://untrusted.example"],
},
)
return httpx.Response(404)
monkeypatch.setattr(
oauth_discovery,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(
oauth_discovery.discover_oauth_capabilities("https://mcp.example/mcp")
)
assert result.status == "metadata_missing"
assert result.supports_automatic_registration is None
assert set(requested_hosts) == {"mcp.example"}
@pytest.mark.parametrize("failure", ["server_error", "timeout"])
def test_provider_outage_is_unknown_not_unsupported(
monkeypatch: pytest.MonkeyPatch, failure: str
) -> None:
def respond(request: httpx.Request) -> httpx.Response:
if failure == "timeout":
raise httpx.ReadTimeout("secret upstream URL", request=request)
return httpx.Response(503)
monkeypatch.setattr(
oauth_discovery,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(
oauth_discovery.discover_oauth_capabilities("https://mcp.example/mcp")
)
assert result.status == "unreachable"
assert result.supports_automatic_registration is None
@pytest.mark.parametrize("hop", ["challenge", "redirect"])
def test_discovery_blocks_metadata_network_hops_without_exposing_internal_url(
monkeypatch: pytest.MonkeyPatch, hop: str
) -> None:
from onyx.server.features.mcp import ssrf
from onyx.server.security.store import _build_env_defaults
monkeypatch.setattr(ssrf, "get_security_settings", _build_env_defaults)
requests: list[str] = []
blocked_url = "http://169.254.169.254/latest/meta-data/"
async def respond(
_transport: httpx.AsyncHTTPTransport, request: httpx.Request
) -> httpx.Response:
requests.append(str(request.url))
if hop == "redirect":
return httpx.Response(302, headers={"location": blocked_url})
return httpx.Response(
401,
headers={"www-authenticate": f'Bearer resource_metadata="{blocked_url}"'},
)
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", respond)
result = asyncio.run(
oauth_discovery.discover_oauth_capabilities("https://8.8.8.8/mcp")
)
assert result.status == "unreachable"
assert result.supports_automatic_registration is None
assert requests == ["https://8.8.8.8/mcp"]
assert blocked_url not in result.model_dump_json()
@pytest.mark.parametrize(
"error,reason",
[
(httpx.ConnectError("secret"), "connection_failed"),
(TimeoutError("secret"), "connection_failed"),
(RuntimeError("secret"), "oauth_failed"),
(ExceptionGroup("secret", [RuntimeError("secret")]), "oauth_failed"),
(
ExceptionGroup(
"secret", [RuntimeError("secret"), OAuthRegistrationError("secret")]
),
"client_registration_failed",
),
],
)
def test_oauth_error_classification_never_exposes_upstream_details(
error: Exception, reason: str
) -> None:
result = oauth_discovery.oauth_setup_error(error)
assert result.extra == {"oauth_error": reason}
assert "secret" not in result.detail
@pytest.mark.parametrize("outcome", ["allowed", "forbidden", "missing"])
def test_discovery_route_authorizes_in_worker_and_closes_session_before_http(
monkeypatch: pytest.MonkeyPatch, outcome: str
) -> None:
import threading
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Iterator, cast
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from onyx.db.models import MCPServer, User
from onyx.error_handling.exceptions import register_onyx_exception_handlers
from onyx.server.features.mcp import api
from onyx.server.features.mcp.models import MCPOAuthDiscoveryResponse
app = FastAPI()
app.include_router(api.admin_router)
register_onyx_exception_handlers(app)
route = next(
route
for route in app.routes
if isinstance(route, APIRoute) and route.path == "/admin/mcp/oauth/discover"
)
user_dependency = route.dependant.dependencies[0].call
assert user_dependency is not None
user = cast(User, SimpleNamespace(email="admin@example.com"))
app.dependency_overrides[user_dependency] = lambda: user
events: list[str] = []
worker_threads: list[int] = []
http_threads: list[int] = []
@contextmanager
def session() -> Iterator[Session]:
worker_threads.append(threading.get_ident())
events.append("session_open")
try:
yield MagicMock(spec=Session)
finally:
events.append("session_closed")
def lookup(_id: int, _db: Session) -> MCPServer:
worker_threads.append(threading.get_ident())
if outcome == "missing":
raise ValueError("not found")
return cast(
MCPServer,
SimpleNamespace(
server_url="https://example.com/mcp", name="MCP", owner=user.email
),
)
def can_manage(_user: User, _server: MCPServer) -> bool:
worker_threads.append(threading.get_ident())
return outcome != "forbidden"
async def discover(url: str) -> MCPOAuthDiscoveryResponse:
assert url == "https://example.com/mcp"
assert events == ["session_open", "session_closed"]
http_threads.append(threading.get_ident())
return MCPOAuthDiscoveryResponse(status="metadata_missing")
monkeypatch.setattr(api, "get_session_with_current_tenant", session)
monkeypatch.setattr(api, "get_mcp_server_by_id", lookup)
monkeypatch.setattr(api, "can_manage_mcp_server", can_manage)
monkeypatch.setattr(api, "discover_oauth_capabilities", discover)
with TestClient(app) as client:
response = client.post("/admin/mcp/oauth/discover", json={"server_id": 42})
assert (
response.status_code
== {"allowed": 200, "forbidden": 403, "missing": 404}[outcome]
)
assert events == ["session_open", "session_closed"]
assert len(set(worker_threads)) == 1
if outcome == "allowed":
assert http_threads[0] != worker_threads[0]
assert response.json()["supports_automatic_registration"] is None
else:
assert http_threads == []
@@ -0,0 +1,38 @@
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session
from onyx.db.enums import MCPServerStatus
from onyx.db.models import MCPServer, User
from onyx.server.features.mcp import api
from onyx.server.features.mcp.models import MCPServerSimpleCreateRequest
@pytest.mark.parametrize("available", [None, False, True])
def test_create_persists_craft_availability_atomically(
monkeypatch: pytest.MonkeyPatch, available: bool | None
) -> None:
db = MagicMock(spec=Session)
user = User(email="admin@example.com")
added: list[MCPServer] = []
def add(server: MCPServer) -> None:
server.id = 42
server.status = MCPServerStatus.CREATED
added.append(server)
db.add.side_effect = add
monkeypatch.setattr(api, "_validate_mcp_server_url", lambda *_, **__: None)
monkeypatch.setattr(api, "_apply_mcp_server_access", lambda **_: None)
request = MCPServerSimpleCreateRequest(
name="Example", server_url="https://example.com/mcp"
)
if available is not None:
request.available_in_craft = available
result = api.create_mcp_server_simple(request, db, user)
assert added[0].available_in_craft is (available is True)
assert result.available_in_craft is (available is True)
db.commit.assert_called_once_with()
@@ -0,0 +1,125 @@
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.db import mcp as mcp_db
from onyx.db.enums import (
MCPAuthenticationPerformer,
MCPAuthenticationType,
MCPOAuthProviderMode,
MCPServerStatus,
MCPTransport,
Permission,
)
from onyx.db.models import MCPConnectionConfig, MCPServer, User
from onyx.server.features.mcp import api
from onyx.server.features.mcp.models import (
MCPConnectionStatus,
MCPServerSimpleUpdateRequest,
)
@pytest.mark.parametrize(
"auth_type,performer",
[
(MCPAuthenticationType.OAUTH, MCPAuthenticationPerformer.PER_USER),
(MCPAuthenticationType.API_TOKEN, MCPAuthenticationPerformer.ADMIN),
(MCPAuthenticationType.API_TOKEN, MCPAuthenticationPerformer.PER_USER),
(MCPAuthenticationType.NONE, MCPAuthenticationPerformer.PER_USER),
],
)
@pytest.mark.parametrize("change_url", [False, True])
def test_details_url_change_invalidates_credentials_before_returning_fresh_settings(
monkeypatch: pytest.MonkeyPatch,
auth_type: MCPAuthenticationType,
performer: MCPAuthenticationPerformer,
change_url: bool,
) -> None:
config = MCPConnectionConfig(
id=81, config={"headers": {"Authorization": "Bearer old-secret"}}
)
server = MCPServer(
id=42,
name="MCP",
description="Tools",
server_url="https://old.example/mcp",
owner="owner@example.com",
auth_type=auth_type,
auth_performer=performer,
oauth_provider_mode=MCPOAuthProviderMode.AUTO_DISCOVERY,
transport=MCPTransport.STREAMABLE_HTTP,
status=MCPServerStatus.CONNECTED,
is_public=True,
available_in_craft=True,
admin_connection_config=config,
admin_connection_config_id=config.id,
)
user = cast(User, SimpleNamespace(id=uuid4(), email=server.owner))
db = MagicMock(spec=Session)
old_user, current_user = uuid4(), uuid4()
affected = MagicMock(
side_effect=[{old_user}, {current_user}] if change_url else [{current_user}]
)
delete_users = MagicMock()
reload_sessions = MagicMock()
monkeypatch.setattr(api, "get_mcp_server_by_id", lambda *_: server)
monkeypatch.setattr(mcp_db, "get_mcp_server_by_id", lambda *_: server)
monkeypatch.setattr(mcp_db, "get_connection_config_by_id", lambda *_: config)
monkeypatch.setattr(api, "_validate_mcp_server_url", lambda *_, **__: None)
monkeypatch.setattr(api, "can_manage_mcp_server", lambda *_: True)
monkeypatch.setattr(
api,
"get_effective_permissions",
lambda *_: {Permission.FULL_ADMIN_PANEL_ACCESS},
)
monkeypatch.setattr(api, "affected_user_ids_for_mcp_server", affected)
monkeypatch.setattr(
api, "delete_all_user_connection_configs_for_server_no_commit", delete_users
)
monkeypatch.setattr(api, "_hot_reload_craft_sessions", reload_sessions)
monkeypatch.setattr(api, "get_user_connection_config", lambda *_: None)
monkeypatch.setattr(api, "get_action_policies", lambda *_: {})
result = api.update_mcp_server_simple(
server.id,
MCPServerSimpleUpdateRequest(
name="Updated MCP",
server_url="https://new.example/mcp" if change_url else server.server_url,
),
db,
user,
)
assert result.name == "Updated MCP"
assert result.tool_count == 0
if change_url:
delete_users.assert_called_once_with(server.id, db)
db.delete.assert_called_once_with(config)
assert server.admin_connection_config_id is None
assert server.admin_connection_config is None
assert result.status == (
MCPServerStatus.CREATED
if auth_type == MCPAuthenticationType.NONE
else MCPServerStatus.AWAITING_AUTH
)
assert result.server_url == "https://new.example/mcp"
fresh = api._db_mcp_server_to_api_mcp_server(
server, db, request_user=user, include_auth_config=True
)
assert fresh.admin_credentials is None
assert fresh.user_credentials is None
assert fresh.auth_template is None
if auth_type != MCPAuthenticationType.NONE:
assert fresh.connection_status is MCPConnectionStatus.NOT_CONNECTED
assert "old-secret" not in fresh.model_dump_json()
reload_sessions.assert_called_once_with({old_user, current_user}, db)
else:
delete_users.assert_not_called()
db.delete.assert_not_called()
assert server.admin_connection_config is config
assert server.admin_connection_config_id == 81
assert result.status == MCPServerStatus.CONNECTED
reload_sessions.assert_called_once_with({current_user}, db)
db.commit.assert_called_once()
@@ -233,3 +233,41 @@ def test_server_url_allows_http() -> None:
api._validate_mcp_server_url(
"http://8.8.8.8/mcp", "server_url", require_https=False
)
def test_async_guard_offloads_validation_and_preserves_tenant_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import threading
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
validation_threads: list[int] = []
tenant_ids: list[str | None] = []
loop_threads: list[int] = []
def validate(url: str) -> str:
validation_threads.append(threading.get_ident())
tenant_ids.append(CURRENT_TENANT_ID_CONTEXTVAR.get())
return url
async def send(
_transport: httpx.AsyncHTTPTransport, request: httpx.Request
) -> httpx.Response:
loop_threads.append(threading.get_ident())
return httpx.Response(200, request=request)
async def run() -> None:
token = CURRENT_TENANT_ID_CONTEXTVAR.set("discovery-test-tenant")
try:
async with mcp_ssrf.mcp_ssrf_httpx_client_factory() as client:
await client.get("https://example.com/mcp")
finally:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
monkeypatch.setattr(mcp_ssrf, "validate_mcp_outbound_url", validate)
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", send)
asyncio.run(run())
assert len(validation_threads) == 1
assert validation_threads[0] != loop_threads[0]
assert tenant_ids == ["discovery-test-tenant"]
@@ -0,0 +1,415 @@
import asyncio
import json
from collections.abc import AsyncIterator
import httpx
import pytest
from onyx.db.enums import MCPTransport
from onyx.server.features.mcp import oauth_discovery, setup_probe
from onyx.utils.url import SSRFException
def _rpc_result(payload: dict) -> dict:
if payload["method"] == "initialize":
return {
"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}},
"serverInfo": {"name": "Support tools", "version": "1"},
}
assert payload["method"] == "tools/list"
return {"tools": [{"name": "lookup_ticket", "inputSchema": {"type": "object"}}]}
def test_public_requires_mcp_initialization_and_lists_tools_without_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
methods: list[str] = []
events: list[setup_probe.MCPProbeProgress] = []
async def progress(event: setup_probe.MCPProbeProgress) -> None:
if event["stage"] == "authentication" and event["state"] == "pending":
assert methods[0] == "initialize"
assert "tools/list" not in methods
events.append(event)
def respond(request: httpx.Request) -> httpx.Response:
assert "authorization" not in request.headers
if request.method == "DELETE":
return httpx.Response(204)
if request.method == "GET":
return httpx.Response(405)
payload = json.loads(request.content)
methods.append(payload["method"])
if "id" not in payload:
return httpx.Response(202)
return httpx.Response(
200,
json={
"jsonrpc": "2.0",
"id": payload["id"],
"result": _rpc_result(payload),
},
)
monkeypatch.setattr(
setup_probe,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(
setup_probe.probe_mcp_server("https://mcp.example/mcp", progress)
)
assert result.model_dump() == {
"kind": "public",
"transport": MCPTransport.STREAMABLE_HTTP,
"tool_count": 1,
"server_name": "Support tools",
}
assert methods == ["initialize", "notifications/initialized", "tools/list"]
assert [(event["stage"], event["state"]) for event in events] == [
("contacting_server", "pending"),
("contacting_server", "success"),
("authentication", "pending"),
("authentication", "success"),
]
def test_stream_close_cancels_inflight_probe(monkeypatch: pytest.MonkeyPatch) -> None:
async def run() -> None:
cancelled = asyncio.Event()
async def probe(_url: str, progress: setup_probe.Progress) -> None:
try:
await progress(
{
"type": "progress",
"stage": "contacting_server",
"state": "pending",
}
)
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(setup_probe, "probe_mcp_server", probe)
stream = setup_probe.stream_mcp_probe("https://mcp.example/mcp")
assert json.loads(await anext(stream))["state"] == "pending"
await stream.aclose()
assert cancelled.is_set()
asyncio.run(run())
@pytest.mark.parametrize("failure", [None, "probe", "serialization"])
def test_stream_delivers_one_terminal_result_after_producer_failure(
monkeypatch: pytest.MonkeyPatch, failure: str | None
) -> None:
async def probe(
_url: str, _progress: setup_probe.Progress
) -> setup_probe.MCPSetupProbeResult:
if failure == "probe":
raise RuntimeError("Probe failed")
return setup_probe.MCPPublicProbeResult(
transport=MCPTransport.STREAMABLE_HTTP,
tool_count=1,
server_name="Support tools",
)
def fail_serialization(*_: object, **__: object) -> None:
raise ValueError("Serialization failed")
monkeypatch.setattr(setup_probe, "probe_mcp_server", probe)
if failure == "serialization":
monkeypatch.setattr(
setup_probe.MCPPublicProbeResult, "model_dump", fail_serialization
)
async def collect() -> list[dict]:
return [
json.loads(event)
async for event in setup_probe.stream_mcp_probe("https://mcp.example/mcp")
]
async def run() -> list[dict]:
return await asyncio.wait_for(collect(), timeout=1)
events = asyncio.run(run())
assert len(events) == 1
assert events[0]["type"] == "result"
if failure is None:
assert events[0]["result"] == {
"kind": "public",
"transport": "STREAMABLE_HTTP",
"tool_count": 1,
"server_name": "Support tools",
}
else:
assert events[0]["result"] == {
"kind": "unreachable",
"reason": "connection_failed",
}
@pytest.mark.parametrize("registration", [True, False])
def test_oauth_uses_post_challenge_and_only_reads_metadata(
monkeypatch: pytest.MonkeyPatch, registration: bool
) -> None:
requests: list[httpx.Request] = []
def respond(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert "authorization" not in request.headers
if request.method == "POST":
assert request.url.path == "/mcp"
assert json.loads(request.content)["method"] == "initialize"
return httpx.Response(
401,
headers={
"WWW-Authenticate": 'Bearer resource_metadata="https://mcp.example/metadata"'
},
)
assert request.method == "GET"
if request.url.path == "/metadata":
return httpx.Response(
200,
json={
"resource": "https://mcp.example/mcp",
"authorization_servers": ["https://identity.example"],
},
)
return httpx.Response(
200,
json={
"issuer": "https://identity.example",
"authorization_endpoint": "https://identity.example/authorize",
"token_endpoint": "https://identity.example/token",
"response_types_supported": ["code"],
"registration_endpoint": "https://identity.example/register"
if registration
else None,
},
)
def factory(**_: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(respond))
monkeypatch.setattr(setup_probe, "mcp_ssrf_httpx_client_factory", factory)
monkeypatch.setattr(oauth_discovery, "mcp_ssrf_httpx_client_factory", factory)
result = asyncio.run(setup_probe.probe_mcp_server("https://mcp.example/mcp"))
assert result.kind == "oauth"
assert result.discovery.supports_automatic_registration is registration
assert result.transport is None
assert [(request.method, request.url.path) for request in requests] == [
("POST", "/mcp"),
("GET", "/metadata"),
("GET", "/.well-known/oauth-authorization-server"),
]
def test_missing_oauth_metadata_is_unknown_auth_not_public(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(401 if request.url.path == "/mcp" else 404)
def factory(**_: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(respond))
monkeypatch.setattr(setup_probe, "mcp_ssrf_httpx_client_factory", factory)
monkeypatch.setattr(oauth_discovery, "mcp_ssrf_httpx_client_factory", factory)
result = asyncio.run(setup_probe.probe_mcp_server("https://mcp.example/mcp"))
assert result.model_dump() == {"kind": "auth_unknown", "transport": None}
@pytest.mark.parametrize("failure", ["html", "network", "blocked"])
def test_failure_is_safe_and_ssrf_block_never_falls_back(
monkeypatch: pytest.MonkeyPatch, failure: str
) -> None:
methods: list[str] = []
def respond(request: httpx.Request) -> httpx.Response:
methods.append(request.method)
if failure == "blocked":
raise SSRFException("private URL and secret response")
if failure == "network":
raise httpx.ConnectError("private URL and secret response")
return httpx.Response(200, text="<html>private URL and secret response</html>")
monkeypatch.setattr(
setup_probe,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(setup_probe.probe_mcp_server("https://mcp.example/mcp"))
assert result.model_dump() == {
"kind": "unreachable",
"reason": "blocked" if failure == "blocked" else "connection_failed",
}
assert "private" not in result.model_dump_json()
assert methods == (["POST"] if failure == "blocked" else ["POST", "GET"])
def test_compatible_sse_fallback_initializes_and_lists_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:
messages: asyncio.Queue[bytes] = asyncio.Queue()
requests: list[tuple[str, str]] = []
class EventStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield b"event: endpoint\ndata: /messages\n\n"
while True:
yield await messages.get()
async def respond(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path))
if request.url.path == "/sse":
if request.method == "POST":
return httpx.Response(405)
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, stream=EventStream()
)
assert request.url.path == "/messages"
payload = json.loads(request.content)
if "id" in payload:
result = {
"jsonrpc": "2.0",
"id": payload["id"],
"result": _rpc_result(payload),
}
await messages.put(
f"event: message\ndata: {json.dumps(result)}\n\n".encode()
)
return httpx.Response(202)
monkeypatch.setattr(
setup_probe,
"mcp_ssrf_httpx_client_factory",
lambda **_: httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
result = asyncio.run(setup_probe.probe_mcp_server("https://mcp.example/sse"))
assert result.kind == "public"
assert result.transport == MCPTransport.SSE
assert result.tool_count == 1
assert requests[:2] == [("POST", "/sse"), ("GET", "/sse")]
def test_protected_sse_message_endpoint_uses_its_oauth_challenge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
requests: list[tuple[str, str]] = []
class EventStream(httpx.AsyncByteStream):
async def __aiter__(self) -> AsyncIterator[bytes]:
yield b"event: endpoint\ndata: /messages\n\n"
await asyncio.Event().wait()
async def respond(request: httpx.Request) -> httpx.Response:
requests.append((request.method, request.url.path))
if request.url.path == "/sse":
if request.method == "POST":
return httpx.Response(405)
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, stream=EventStream()
)
if request.url.path == "/messages":
return httpx.Response(
401,
headers={
"www-authenticate": 'Bearer resource_metadata="https://mcp.example/metadata"'
},
)
if request.url.path == "/metadata":
return httpx.Response(
200,
json={
"resource": "https://mcp.example/sse",
"authorization_servers": ["https://identity.example"],
},
)
return httpx.Response(
200,
json={
"issuer": "https://identity.example",
"authorization_endpoint": "https://identity.example/authorize",
"token_endpoint": "https://identity.example/token",
"response_types_supported": ["code"],
"client_id_metadata_document_supported": True,
},
)
def factory(**_: object) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(respond))
monkeypatch.setattr(setup_probe, "mcp_ssrf_httpx_client_factory", factory)
monkeypatch.setattr(oauth_discovery, "mcp_ssrf_httpx_client_factory", factory)
result = asyncio.run(setup_probe.probe_mcp_server("https://mcp.example/sse"))
assert result.kind == "oauth"
assert result.transport == MCPTransport.SSE
assert result.discovery.supports_automatic_registration is True
assert requests == [
("POST", "/sse"),
("GET", "/sse"),
("POST", "/messages"),
("GET", "/metadata"),
("GET", "/.well-known/oauth-authorization-server"),
]
@pytest.mark.parametrize("authorized", [True, False])
def test_probe_route_checks_permission_before_network(
monkeypatch: pytest.MonkeyPatch, authorized: bool
) -> None:
from unittest.mock import AsyncMock
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import (
OnyxError,
register_onyx_exception_handlers,
)
from onyx.server.features.mcp import api
app = FastAPI()
app.include_router(api.admin_router)
register_onyx_exception_handlers(app)
route = next(
route
for route in api.admin_router.routes
if isinstance(route, APIRoute) and route.path == "/admin/mcp/probe"
)
dependency = route.dependant.dependencies[0].call
assert dependency is not None
def permission() -> None:
if not authorized:
raise OnyxError(OnyxErrorCode.INSUFFICIENT_PERMISSIONS)
app.dependency_overrides[dependency] = permission
probe = AsyncMock(return_value=setup_probe.MCPUnknownAuthProbeResult())
monkeypatch.setattr(setup_probe, "probe_mcp_server", probe)
with TestClient(app) as test_client:
response = test_client.post(
"/admin/mcp/probe", json={"server_url": "https://mcp.example/mcp"}
)
assert response.status_code == (200 if authorized else 403)
if authorized:
assert probe.await_count == 1
awaited = probe.await_args
assert awaited is not None
assert awaited.args[0] == "https://mcp.example/mcp"
assert response.headers["X-Accel-Buffering"] == "no"
assert response.headers["Cache-Control"] == "no-cache, no-transform"
events = [json.loads(line) for line in response.text.splitlines()]
assert events == [
{
"type": "result",
"result": {"kind": "auth_unknown", "transport": None},
}
]
else:
probe.assert_not_called()
@@ -0,0 +1,124 @@
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.db.enums import (
MCPAuthenticationPerformer,
MCPAuthenticationType,
MCPTransport,
)
from onyx.db.models import MCPServer, User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.mcp import api
from onyx.server.features.mcp.credentials import ResolvedMCPCredentials
from onyx.server.features.mcp.models import MCPUserCredentialsRequest
@pytest.mark.parametrize(
"auth_type", [MCPAuthenticationType.API_TOKEN, MCPAuthenticationType.OAUTH]
)
def test_inaccessible_server_denies_credentials_before_reads_writes_or_validation(
monkeypatch: pytest.MonkeyPatch, auth_type: MCPAuthenticationType
) -> None:
db = MagicMock(spec=Session)
user = User(id=uuid4(), email="member@example.com")
server = MCPServer(id=42, auth_type=auth_type)
access = MagicMock(return_value=False)
monkeypatch.setattr(api, "get_mcp_server_by_id", lambda *_: server)
monkeypatch.setattr(api, "user_can_configure_mcp_credentials", access)
credential_operations = [
"get_mcp_auth_template",
"get_user_connection_config",
"extract_connection_data",
"test_mcp_server_credentials",
"upsert_user_connection_config",
"resolve_mcp_credentials",
"_hot_reload_craft_sessions",
]
operations = {name: MagicMock() for name in credential_operations}
for name, operation in operations.items():
monkeypatch.setattr(api, name, operation)
with pytest.raises(OnyxError) as error:
api.save_user_credentials(
MCPUserCredentialsRequest(
server_id=42,
credentials={"api_key": "personal-key"},
transport="streamable-http",
),
db,
user,
)
assert error.value.error_code is OnyxErrorCode.NOT_FOUND
access.assert_called_once_with(user, server, db)
for operation in operations.values():
operation.assert_not_called()
db.commit.assert_not_called()
def test_accessible_member_saves_only_their_validated_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock(spec=Session)
user = User(id=uuid4(), email="member@example.com")
server = MCPServer(
id=42,
name="Personal integration",
server_url="https://example.com/mcp",
auth_type=MCPAuthenticationType.API_TOKEN,
auth_performer=MCPAuthenticationPerformer.PER_USER,
)
access = MagicMock(return_value=True)
validate = MagicMock(return_value=(True, "Connected"))
save = MagicMock()
reload = MagicMock()
resolved = ResolvedMCPCredentials(
connection_config_id=1,
connection_data={"headers": {"Authorization": "Bearer personal-key"}},
user_oauth_token=None,
auth_type=MCPAuthenticationType.API_TOKEN,
)
monkeypatch.setattr(api, "get_mcp_server_by_id", lambda *_: server)
monkeypatch.setattr(api, "user_can_configure_mcp_credentials", access)
monkeypatch.setattr(api, "get_mcp_auth_template", lambda *_: None)
monkeypatch.setattr(api, "test_mcp_server_credentials", validate)
monkeypatch.setattr(api, "upsert_user_connection_config", save)
monkeypatch.setattr(api, "resolve_mcp_credentials", lambda *_: resolved)
monkeypatch.setattr(api, "_hot_reload_craft_sessions", reload)
response = api.save_user_credentials(
MCPUserCredentialsRequest(
server_id=42,
credentials={"api_key": "personal-key"},
transport="streamable-http",
),
db,
user,
)
access.assert_called_once_with(user, server, db)
validate.assert_called_once_with(
"https://example.com/mcp",
{"Authorization": "Bearer personal-key"},
transport=MCPTransport.STREAMABLE_HTTP,
auth=None,
)
save.assert_called_once_with(
server_id=42,
user_email="member@example.com",
config_data={
"headers": {"Authorization": "Bearer personal-key"},
"header_substitutions": {"api_key": "personal-key"},
},
db_session=db,
)
db.commit.assert_called_once()
reload.assert_called_once_with({user.id}, db)
assert response.success is True
assert response.authenticated is True
assert response.validation_tested is True
assert response.server_id == 42
@@ -0,0 +1,190 @@
from collections.abc import Generator
from typing import cast
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from pydantic import ValidationError
from sqlalchemy import Column, Integer, MetaData, Table, Uuid, create_engine
from sqlalchemy.orm import Session
from onyx.db.mcp import (
get_mcp_server_user_preferences,
upsert_mcp_server_user_preferences__no_commit,
)
from onyx.db.models import MCPServer, MCPServerUserPreference, User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.mcp import api
from onyx.server.features.mcp.models import (
MCPConnectionStatus,
MCPUserPreferencesUpdateRequest,
)
@pytest.fixture
def preference_db() -> Generator[Session, None, None]:
metadata = MetaData()
Table("user", metadata, Column("id", Uuid, primary_key=True))
Table("mcp_server", metadata, Column("id", Integer, primary_key=True))
cast(Table, MCPServerUserPreference.__table__).to_metadata(metadata)
engine = create_engine("sqlite://")
metadata.create_all(engine)
with Session(engine) as db:
yield db
engine.dispose()
def test_partial_updates_preserve_other_surface_and_user(
preference_db: Session,
) -> None:
user_id, other_user_id = uuid4(), uuid4()
assert get_mcp_server_user_preferences([42], user_id, preference_db) == {}
first = upsert_mcp_server_user_preferences__no_commit(
42, user_id, preference_db, enabled_in_chat=False
)
assert (first.enabled_in_chat, first.enabled_in_craft) == (False, True)
updated = upsert_mcp_server_user_preferences__no_commit(
42, user_id, preference_db, enabled_in_craft=False
)
assert (updated.enabled_in_chat, updated.enabled_in_craft) == (False, False)
updated = upsert_mcp_server_user_preferences__no_commit(
42, user_id, preference_db, enabled_in_chat=True
)
assert (updated.enabled_in_chat, updated.enabled_in_craft) == (True, False)
assert get_mcp_server_user_preferences([42], other_user_id, preference_db) == {}
other = upsert_mcp_server_user_preferences__no_commit(
42, other_user_id, preference_db, enabled_in_craft=True
)
assert (other.enabled_in_chat, other.enabled_in_craft) == (True, True)
assert get_mcp_server_user_preferences([43], user_id, preference_db) == {}
@pytest.mark.parametrize(
"payload", [{}, {"enabled_in_chat": None}, {"enabled_in_craft": None}]
)
def test_update_requires_boolean_preference(payload: dict[str, None]) -> None:
with pytest.raises(ValidationError):
MCPUserPreferencesUpdateRequest.model_validate(payload)
def test_deleted_server_returns_not_found_without_saving_preferences(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock(spec=Session)
missing = ValueError("Server no longer exists")
monkeypatch.setattr(api, "user_can_access_mcp_server", lambda *_: True)
monkeypatch.setattr(api, "get_mcp_server_by_id", MagicMock(side_effect=missing))
write = MagicMock()
monkeypatch.setattr(api, "upsert_mcp_server_user_preferences__no_commit", write)
with pytest.raises(OnyxError) as error:
api.update_mcp_user_preferences(
42,
MCPUserPreferencesUpdateRequest(enabled_in_chat=False),
db,
User(id=uuid4()),
)
assert error.value.error_code is OnyxErrorCode.NOT_FOUND
assert error.value.__cause__ is missing
write.assert_not_called()
db.commit.assert_not_called()
@pytest.mark.parametrize(
("accessible", "chat_available", "craft_available", "payload"),
[
(False, True, True, {"enabled_in_chat": False}),
(True, False, True, {"enabled_in_chat": True}),
(True, True, False, {"enabled_in_craft": True}),
],
)
def test_update_cannot_exceed_access_or_admin_availability(
monkeypatch: pytest.MonkeyPatch,
accessible: bool,
chat_available: bool,
craft_available: bool,
payload: dict[str, bool],
) -> None:
db = MagicMock(spec=Session)
monkeypatch.setattr(api, "user_can_access_mcp_server", lambda *_: accessible)
monkeypatch.setattr(
api,
"get_mcp_server_by_id",
lambda *_: MCPServer(id=42, available_in_craft=craft_available),
)
monkeypatch.setattr(
api,
"get_default_chat_mcp_server_ids",
lambda *_: {42} if chat_available else set(),
)
write = MagicMock()
monkeypatch.setattr(api, "upsert_mcp_server_user_preferences__no_commit", write)
with pytest.raises(OnyxError):
api.update_mcp_user_preferences(
42, MCPUserPreferencesUpdateRequest(**payload), db, User(id=uuid4())
)
write.assert_not_called()
db.commit.assert_not_called()
def test_update_preserves_credentials_and_restamps_only_current_user(
monkeypatch: pytest.MonkeyPatch,
preference_db: Session,
) -> None:
user = User(id=uuid4())
monkeypatch.setattr(api, "user_can_access_mcp_server", lambda *_: True)
monkeypatch.setattr(
api,
"get_mcp_server_by_id",
lambda *_: MCPServer(id=42, available_in_craft=True),
)
restamp = MagicMock()
delete_credentials = MagicMock()
monkeypatch.setattr(api, "_hot_reload_craft_sessions", restamp)
monkeypatch.setattr(
api, "delete_user_connection_configs_for_server", delete_credentials
)
response = api.update_mcp_user_preferences(
42, MCPUserPreferencesUpdateRequest(enabled_in_craft=False), preference_db, user
)
assert response.model_dump() == {"enabled_in_chat": True, "enabled_in_craft": False}
restamp.assert_called_once_with({user.id}, preference_db)
delete_credentials.assert_not_called()
def test_member_catalog_retains_personal_disabled_servers(
monkeypatch: pytest.MonkeyPatch,
preference_db: Session,
) -> None:
from onyx.db.enums import MCPServerStatus
from onyx.server.features.mcp.models import MCPServer as MCPServerResponse
user = User(id=uuid4())
upsert_mcp_server_user_preferences__no_commit(
42, user.id, preference_db, enabled_in_chat=False, enabled_in_craft=False
)
monkeypatch.setattr(
api,
"get_mcp_servers_accessible_to_user",
lambda *_: [MCPServer(id=42), MCPServer(id=43)],
)
monkeypatch.setattr(api, "get_default_chat_mcp_server_ids", lambda *_: {42, 43})
monkeypatch.setattr(
api,
"_db_mcp_server_to_api_mcp_server",
lambda server, *_, **__: MCPServerResponse(
id=server.id,
name="Server",
owner="admin",
server_url="https://example.com",
status=MCPServerStatus.CREATED,
connection_status=MCPConnectionStatus.NOT_CONNECTED,
),
)
response = api.get_mcp_servers_for_user(preference_db, user)
assert [
(s.id, s.default_chat_enabled, s.user_enabled_in_chat, s.user_enabled_in_craft)
for s in response.mcp_servers
] == [(42, True, False, False), (43, True, True, True)]
@@ -0,0 +1,125 @@
from unittest.mock import MagicMock
import pytest
from sqlalchemy import Boolean, Column, Integer, MetaData, Table, create_engine
from sqlalchemy.orm import Session
from onyx.db.models import MCPServer, Tool, User
from onyx.server.features.tool import api
from onyx.server.features.tool.models import ToolSnapshot
@pytest.mark.parametrize("nondefault_builtin_deleted", [True, False])
def test_default_chat_tool_summary_uses_visible_tool_attachments(
nondefault_builtin_deleted: bool,
) -> None:
from onyx.db.tools import get_default_chat_tool_ids
metadata = MetaData()
personas = Table(
"persona",
metadata,
Column("id", Integer, primary_key=True),
Column("builtin_persona", Boolean),
Column("deleted", Boolean),
)
tools = Table(
"tool",
metadata,
Column("id", Integer, primary_key=True),
Column("enabled", Boolean),
)
attachments = Table(
"persona__tool",
metadata,
Column("persona_id", Integer),
Column("tool_id", Integer),
)
engine = create_engine("sqlite://")
metadata.create_all(engine)
with Session(engine) as db:
db.execute(
personas.insert(),
[
{"id": 0, "builtin_persona": True, "deleted": False},
{"id": 1, "builtin_persona": False, "deleted": False},
{
"id": 2,
"builtin_persona": True,
"deleted": nondefault_builtin_deleted,
},
],
)
db.execute(
tools.insert(),
[
{"id": 1, "enabled": True},
{"id": 2, "enabled": False},
{"id": 3, "enabled": True},
{"id": 4, "enabled": True},
{"id": 5, "enabled": True},
],
)
db.execute(
attachments.insert(),
[
{"persona_id": persona_id, "tool_id": tool_id}
for persona_id, tool_id in [(0, 1), (0, 2), (1, 3), (2, 4), (0, 5)]
],
)
assert get_default_chat_tool_ids([1, 2, 3, 4], db) == {1, 2}
assert get_default_chat_tool_ids([3, 4], db) == set()
assert get_default_chat_tool_ids([], db) == set()
def test_member_tool_list_stamps_default_chat_after_existing_visibility_filters(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock(spec=Session)
user = User(email="member@example.com")
selected = Tool(
id=1,
name="selected",
enabled=True,
passthrough_auth=False,
openapi_schema={"paths": {}},
)
unselected = Tool(
id=2,
name="unselected",
enabled=True,
passthrough_auth=False,
openapi_schema={"paths": {}},
)
hidden = Tool(id=3, name="hidden", enabled=True, passthrough_auth=False)
inaccessible_mcp = Tool(
id=4, name="private", mcp_server_id=44, enabled=True, passthrough_auth=False
)
get_tools = MagicMock(return_value=[selected, unselected, hidden, inaccessible_mcp])
selected_tools = MagicMock(return_value={1})
monkeypatch.setattr(api, "get_tools", get_tools)
monkeypatch.setattr(
api,
"get_mcp_servers_accessible_to_user",
lambda *_: [MCPServer(id=42)],
)
monkeypatch.setattr(api, "should_expose_tool_to_fe", lambda tool: tool.id != 3)
monkeypatch.setattr(api, "get_default_chat_tool_ids", selected_tools)
response = api.list_tools(db, user)
get_tools.assert_called_once_with(db, only_enabled=True, only_connected_mcp=True)
selected_tools.assert_called_once_with([1, 2], db)
assert [(tool.id, tool.default_chat_enabled) for tool in response] == [
(1, True),
(2, False),
]
assert all(tool.permissions == {} for tool in response)
def test_default_chat_summary_is_response_only_and_optional() -> None:
tool = Tool(id=1, name="custom", enabled=True, passthrough_auth=False)
assert "default_chat_enabled" not in Tool.__table__.columns
assert ToolSnapshot.from_model(tool).default_chat_enabled is None
@@ -0,0 +1,207 @@
from collections.abc import Generator
from typing import cast
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from pydantic import ValidationError
from sqlalchemy import Column, Integer, MetaData, Table, Uuid, create_engine
from sqlalchemy.orm import Session
from onyx.db.models import Tool, ToolUserPreference, User
from onyx.db.tools import (
get_tool_user_preferences,
upsert_tool_user_preference__no_commit,
)
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.tool import api
from onyx.server.features.tool.models import ToolSnapshot, ToolUserPreferences
@pytest.fixture
def preference_db() -> Generator[Session, None, None]:
metadata = MetaData()
Table("user", metadata, Column("id", Uuid, primary_key=True))
Table("tool", metadata, Column("id", Integer, primary_key=True))
cast(Table, ToolUserPreference.__table__).to_metadata(metadata)
engine = create_engine("sqlite://")
metadata.create_all(engine)
with Session(engine) as db:
yield db
engine.dispose()
def test_updates_are_scoped_to_user_and_tool(preference_db: Session) -> None:
user_id, other_user_id = uuid4(), uuid4()
assert get_tool_user_preferences([42], user_id, preference_db) == {}
for enabled in (False, True, False):
preference = upsert_tool_user_preference__no_commit(
42, user_id, preference_db, enabled_in_chat=enabled
)
assert preference.enabled_in_chat is enabled
assert get_tool_user_preferences([42], other_user_id, preference_db) == {}
assert get_tool_user_preferences([43], user_id, preference_db) == {}
@pytest.mark.parametrize("payload", [{}, {"enabled_in_chat": None}])
def test_update_requires_boolean(payload: dict[str, None]) -> None:
with pytest.raises(ValidationError):
ToolUserPreferences.model_validate(payload)
@pytest.mark.parametrize(
"tool",
[
Tool(id=42, in_code_tool_id="SearchTool"),
Tool(id=42, mcp_server_id=1),
Tool(id=42),
Tool(id=42, openapi_schema={"paths": {}}, enabled=False),
],
)
def test_update_rejects_wrong_kind_or_disabled_plugin(
monkeypatch: pytest.MonkeyPatch, tool: Tool
) -> None:
db = MagicMock(spec=Session)
monkeypatch.setattr(api, "get_tool_by_id", lambda *_: tool)
write = MagicMock()
monkeypatch.setattr(api, "upsert_tool_user_preference__no_commit", write)
with pytest.raises(OnyxError):
api.update_tool_user_preferences(
42, ToolUserPreferences(enabled_in_chat=True), db, User(id=uuid4())
)
write.assert_not_called()
db.commit.assert_not_called()
def test_update_rejects_missing_hidden_or_admin_unselected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock(spec=Session)
user = User(id=uuid4())
tool = Tool(id=42, openapi_schema={"paths": {}}, enabled=True)
monkeypatch.setattr(api, "get_tool_by_id", MagicMock(side_effect=ValueError()))
with pytest.raises(OnyxError):
api.update_tool_user_preferences(
42, ToolUserPreferences(enabled_in_chat=False), db, user
)
monkeypatch.setattr(api, "get_tool_by_id", lambda *_: tool)
monkeypatch.setattr(api, "should_expose_tool_to_fe", lambda *_: False)
with pytest.raises(OnyxError):
api.update_tool_user_preferences(
42, ToolUserPreferences(enabled_in_chat=False), db, user
)
monkeypatch.setattr(api, "should_expose_tool_to_fe", lambda *_: True)
monkeypatch.setattr(api, "get_default_chat_tool_ids", lambda *_: set())
with pytest.raises(OnyxError):
api.update_tool_user_preferences(
42, ToolUserPreferences(enabled_in_chat=True), db, user
)
db.commit.assert_not_called()
def test_update_and_catalog_preserve_admin_state_and_connection(
monkeypatch: pytest.MonkeyPatch, preference_db: Session
) -> None:
user = User(id=uuid4())
tool = Tool(
id=42,
name="Plugin",
openapi_schema={"paths": {}},
enabled=True,
passthrough_auth=False,
oauth_config_id=12,
)
monkeypatch.setattr(api, "get_tool_by_id", lambda *_: tool)
response = api.update_tool_user_preferences(
42, ToolUserPreferences(enabled_in_chat=False), preference_db, user
)
snapshots = [ToolSnapshot.from_model(tool)]
api._stamp_personal_tool_preferences(snapshots, user, preference_db)
assert response.enabled_in_chat is False
assert snapshots[0].user_enabled_in_chat is False
assert tool.enabled is True
assert tool.oauth_config_id == 12
other_snapshots = [ToolSnapshot.from_model(tool)]
api._stamp_personal_tool_preferences(
other_snapshots, User(id=uuid4()), preference_db
)
assert other_snapshots[0].user_enabled_in_chat is True
monkeypatch.setattr(api, "get_tools", lambda *_, **__: [tool])
monkeypatch.setattr(api, "get_mcp_servers_accessible_to_user", lambda *_: [])
monkeypatch.setattr(api, "get_default_chat_tool_ids", lambda *_: {42})
catalog = api.list_tools(preference_db, user)
assert [
(item.id, item.default_chat_enabled, item.user_enabled_in_chat)
for item in catalog
] == [(42, True, False)]
response = api.update_tool_user_preferences(
42, ToolUserPreferences(enabled_in_chat=True), preference_db, user
)
assert response.enabled_in_chat is True
assert tool.enabled is True
assert tool.oauth_config_id == 12
@pytest.mark.parametrize(
("persona_id", "enabled", "constructed"),
[(0, False, False), (0, True, True), (0, None, True), (7, False, True)],
)
def test_default_chat_optout_before_credentials_and_allowlist(
monkeypatch: pytest.MonkeyPatch,
persona_id: int,
enabled: bool | None,
constructed: bool,
) -> None:
from onyx.chat.emitter import Emitter
from onyx.context.search.models import PersonaSearchInfo
from onyx.llm.interfaces import LLM
from onyx.tools import tool_constructor
from onyx.tools.models import PersonaToolConfiguration, ToolConfiguration
tool = Tool(
id=42,
name="Plugin",
openapi_schema={"paths": {}},
enabled=True,
passthrough_auth=False,
)
configuration = PersonaToolConfiguration(
persona_id=persona_id,
persona_name="Test",
tools=[
ToolConfiguration.model_validate(tool).model_copy(
update={"oauth_config_id": 12}
)
],
search=PersonaSearchInfo(
document_set_names=[],
search_start_date=None,
attached_document_ids=[],
hierarchy_node_ids=[],
),
)
monkeypatch.setattr(
tool_constructor,
"get_tool_user_preferences",
lambda *_: (
{} if enabled is None else {42: ToolUserPreference(enabled_in_chat=enabled)}
),
)
monkeypatch.setattr(tool_constructor, "get_current_search_settings", MagicMock())
monkeypatch.setattr(tool_constructor, "get_default_document_index", MagicMock())
build = MagicMock(return_value=[])
credentials = MagicMock(return_value=None)
monkeypatch.setattr(tool_constructor, "get_oauth_config", credentials)
monkeypatch.setattr(
tool_constructor, "build_custom_tools_from_openapi_schema_and_headers", build
)
tool_constructor._construct_tools_impl(
configuration=configuration,
db_session=MagicMock(spec=Session),
emitter=MagicMock(spec=Emitter),
user=User(id=uuid4(), enable_memory_tool=False),
llm=MagicMock(spec=LLM),
allowed_tool_ids=[42],
)
assert build.called is constructed
assert credentials.called is constructed
@@ -0,0 +1,79 @@
from typing import Any
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.auth import oauth_token_manager
from onyx.db.models import OAuthConfig, OAuthUserToken, User
from onyx.server.features.user_oauth_token import api
from onyx.utils.sensitive import make_mock_sensitive_value
@pytest.mark.parametrize(
("expires_at", "refresh_token", "client_id", "client_secret", "can_refresh"),
[
(1, "refresh-token", "client-id", "client-secret", True),
(1, None, "client-id", "client-secret", False),
(1, "", "client-id", "client-secret", False),
(1, "refresh-token", None, "client-secret", False),
(1, "refresh-token", "client-id", None, False),
(1, "refresh-token", "", "client-secret", False),
(1, "refresh-token", "client-id", "", False),
(99999999999, "refresh-token", "client-id", "client-secret", True),
],
)
def test_token_status_reports_saved_refresh_capability_without_provider_calls(
monkeypatch: pytest.MonkeyPatch,
expires_at: int,
refresh_token: str | None,
client_id: str | None,
client_secret: str | None,
can_refresh: bool,
) -> None:
config = OAuthConfig(id=17)
config.client_id = (
make_mock_sensitive_value(client_id) if client_id is not None else None
)
config.client_secret = (
make_mock_sensitive_value(client_secret) if client_secret is not None else None
)
token_data: dict[str, Any] = {
"access_token": "access-token",
"expires_at": expires_at,
}
if refresh_token is not None:
token_data["refresh_token"] = refresh_token
token = OAuthUserToken(oauth_config_id=17, oauth_config=config)
token.token_data = make_mock_sensitive_value(token_data)
user = User(id=uuid4())
db = MagicMock(spec=Session)
read_tokens = MagicMock(return_value=[token])
monkeypatch.setattr(api, "get_all_user_oauth_tokens", read_tokens)
refresh = MagicMock(side_effect=AssertionError("Status must not refresh tokens"))
monkeypatch.setattr(oauth_token_manager.requests, "post", refresh)
result = api.get_user_oauth_token_status(db, user)
assert [status.model_dump() for status in result] == [
{
"oauth_config_id": 17,
"expires_at": expires_at,
"is_expired": expires_at == 1,
"can_refresh": can_refresh,
}
]
read_tokens.assert_called_once_with(user.id, db)
refresh.assert_not_called()
db.commit.assert_not_called()
def test_token_status_does_not_invent_missing_connections(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(api, "get_all_user_oauth_tokens", lambda *_: [])
assert (
api.get_user_oauth_token_status(MagicMock(spec=Session), User(id=uuid4())) == []
)
@@ -9,6 +9,7 @@
"GET /manage/admin/connector-checks/runs/{run_id}": "Existing. No registered GATE-2 helper found. Review.",
"GET /manage/admin/indexing/targeted-reindex/{job_id}": "Existing. No registered GATE-2 helper found. Review.",
"GET /manage/connector-group-restrictions": "Existing. No registered GATE-2 helper found. Review.",
"POST /admin/mcp/probe": "Create-flow probe. Takes only a URL and reads or writes no stored resource, so there is nothing to scope. The SSRF-guarded client blocks internal hosts.",
"POST /admin/oauth-config/create": "Existing. Create route: no stored resource to scope yet. Review.",
"POST /admin/tool/custom": "Existing. Create route: no stored resource to scope yet. Review.",
"POST /admin/tool/custom/validate": "Existing. No registered GATE-2 helper found. Review.",
@@ -413,6 +413,7 @@
"PATCH /manage/admin/users/admin-access": "admin",
"PATCH /manage/admin/users/craft-enabled": "admin",
"PATCH /manage/credential/{credential_id}": "basic",
"PATCH /mcp/server/{server_id}/preferences": "basic",
"PATCH /paste-as-tile": "basic",
"PATCH /persona/{persona_id}": "basic",
"PATCH /persona/{persona_id}/public": "basic",
@@ -425,6 +426,7 @@
"PATCH /skills/custom/{skill_id}/share": "basic",
"PATCH /temperature-default": "basic",
"PATCH /temperature-override-enabled": "basic",
"PATCH /tool/{tool_id}/preferences": "basic",
"PATCH /user/assistant-list/update/{assistant_id}": "basic",
"PATCH /user/assistant/{assistant_id}/preferences": "basic",
"PATCH /user/chat-background": "basic",
@@ -473,6 +475,8 @@
"POST /admin/llm/vercel-ai-gateway/available-models": "manage:llms",
"POST /admin/log-export": "admin",
"POST /admin/mcp/oauth/connect": "manage:actions +scope",
"POST /admin/mcp/oauth/discover": "manage:actions +scope",
"POST /admin/mcp/probe": "manage:actions +scope",
"POST /admin/mcp/server": "manage:actions +scope",
"POST /admin/mcp/servers/create": "manage:actions +scope",
"POST /admin/mcp/servers/update": "manage:actions +scope",