feat: adds authentication to mcp server under feature flag (#9095)
* init auth forms * Add iam endpoint * Add radix radio * Add radio group UI element * Add IAM endpoint to types * Add auth modal * Remove auth from tools component * Add auth modal to mcp server tab * Add placeholders to fields * Add dynamic headers * changed authentication name * CHanged paddings * Added header and button under feature flag * [autofix.ci] apply automated fixes * update api key form field * add credential handling and fix feature flag * Update autologin condition * design update * style updates * ci details * revert ci logs * test update, ff name update, and username + pass -> basic * restore ci * default fix * added iam endpoint * add oauth * remove secretstr * updated backend test and schema * updated test * updated test user can update * test fix --------- Co-authored-by: Mike Fortman <michael.fortman@datastax.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
c1d417adf2
commit
9e24202466
19 changed files with 1066 additions and 141 deletions
|
|
@ -0,0 +1,58 @@
|
|||
"""Add auth_settings column to folder table and merge migration branches.
|
||||
|
||||
Revision ID: 3162e83e485f
|
||||
Revises: 0ae3a2674f32, d9a6ea21edcd
|
||||
Create Date: 2025-01-16 13:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3162e83e485f"
|
||||
down_revision: str | Sequence[str] | None = ("0ae3a2674f32", "d9a6ea21edcd")
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add auth_settings column to folder table and merge migration branches."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Check if folder table exists
|
||||
table_names = inspector.get_table_names()
|
||||
if "folder" not in table_names:
|
||||
# If folder table doesn't exist, skip this migration
|
||||
return
|
||||
|
||||
# Get current column names in folder table
|
||||
column_names = [column["name"] for column in inspector.get_columns("folder")]
|
||||
|
||||
# Add auth_settings column to folder table if it doesn't exist
|
||||
with op.batch_alter_table("folder", schema=None) as batch_op:
|
||||
if "auth_settings" not in column_names:
|
||||
batch_op.add_column(sa.Column("auth_settings", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove auth_settings column from folder table."""
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Check if folder table exists
|
||||
table_names = inspector.get_table_names()
|
||||
if "folder" not in table_names:
|
||||
# If folder table doesn't exist, skip this migration
|
||||
return
|
||||
|
||||
# Get current column names in folder table
|
||||
column_names = [column["name"] for column in inspector.get_columns("folder")]
|
||||
|
||||
# Remove auth_settings column from folder table if it exists
|
||||
with op.batch_alter_table("folder", schema=None) as batch_op:
|
||||
if "auth_settings" in column_names:
|
||||
batch_op.drop_column("auth_settings")
|
||||
|
|
@ -29,11 +29,17 @@ from langflow.api.v1.mcp_utils import (
|
|||
handle_mcp_errors,
|
||||
handle_read_resource,
|
||||
)
|
||||
from langflow.api.v1.schemas import MCPInstallRequest, MCPSettings
|
||||
from langflow.api.v1.schemas import (
|
||||
MCPInstallRequest,
|
||||
MCPProjectResponse,
|
||||
MCPProjectUpdateRequest,
|
||||
MCPSettings,
|
||||
)
|
||||
from langflow.base.mcp.constants import MAX_MCP_SERVER_NAME_LENGTH
|
||||
from langflow.base.mcp.util import sanitize_mcp_name
|
||||
from langflow.services.database.models import Flow, Folder
|
||||
from langflow.services.deps import get_settings_service, session_scope
|
||||
from langflow.services.settings.feature_flags import FEATURE_FLAGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -60,7 +66,7 @@ async def list_project_tools(
|
|||
current_user: CurrentActiveMCPUser,
|
||||
*,
|
||||
mcp_enabled: bool = True,
|
||||
) -> list[MCPSettings]:
|
||||
) -> MCPProjectResponse:
|
||||
"""List all tools in a project that are enabled for MCP."""
|
||||
tools: list[MCPSettings] = []
|
||||
try:
|
||||
|
|
@ -114,12 +120,19 @@ async def list_project_tools(
|
|||
logger.warning(msg)
|
||||
continue
|
||||
|
||||
# Get project-level auth settings
|
||||
auth_settings = None
|
||||
if project.auth_settings:
|
||||
from langflow.api.v1.schemas import AuthSettings
|
||||
|
||||
auth_settings = AuthSettings(**project.auth_settings)
|
||||
|
||||
except Exception as e:
|
||||
msg = f"Error listing project tools: {e!s}"
|
||||
logger.exception(msg)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
return tools
|
||||
return MCPProjectResponse(tools=tools, auth_settings=auth_settings)
|
||||
|
||||
|
||||
@router.head("/{project_id}/sse", response_class=HTMLResponse, include_in_schema=False)
|
||||
|
|
@ -218,10 +231,10 @@ async def handle_project_messages_with_slash(project_id: UUID, request: Request,
|
|||
@router.patch("/{project_id}", status_code=200)
|
||||
async def update_project_mcp_settings(
|
||||
project_id: UUID,
|
||||
settings: list[MCPSettings],
|
||||
request: MCPProjectUpdateRequest,
|
||||
current_user: CurrentActiveMCPUser,
|
||||
):
|
||||
"""Update the MCP settings of all flows in a project."""
|
||||
"""Update the MCP settings of all flows in a project and project-level auth settings."""
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
# Fetch the project first to verify it exists and belongs to the current user
|
||||
|
|
@ -236,9 +249,16 @@ async def update_project_mcp_settings(
|
|||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Update project-level auth settings
|
||||
if request.auth_settings:
|
||||
project.auth_settings = request.auth_settings.model_dump(mode="json")
|
||||
else:
|
||||
project.auth_settings = None
|
||||
session.add(project)
|
||||
|
||||
# Query flows in the project
|
||||
flows = (await session.exec(select(Flow).where(Flow.folder_id == project_id))).all()
|
||||
flows_to_update = {x.id: x for x in settings}
|
||||
flows_to_update = {x.id: x for x in request.settings}
|
||||
|
||||
updated_flows = []
|
||||
for flow in flows:
|
||||
|
|
@ -256,7 +276,7 @@ async def update_project_mcp_settings(
|
|||
|
||||
await session.commit()
|
||||
|
||||
return {"message": f"Updated MCP settings for {len(updated_flows)} flows"}
|
||||
return {"message": f"Updated MCP settings for {len(updated_flows)} flows and project auth settings"}
|
||||
|
||||
except Exception as e:
|
||||
msg = f"Error updating project MCP settings: {e!s}"
|
||||
|
|
@ -348,7 +368,8 @@ async def install_mcp_config(
|
|||
# Determine command and args based on operating system
|
||||
os_type = platform.system()
|
||||
command = "uvx"
|
||||
args = ["mcp-proxy", sse_url]
|
||||
mcp_tool = "mcp-composer" if FEATURE_FLAGS.mcp_composer else "mcp-proxy"
|
||||
args = [mcp_tool, sse_url]
|
||||
|
||||
# Check if running on WSL (will appear as Linux but with Microsoft in release info)
|
||||
is_wsl = os_type == "Linux" and "microsoft" in platform.uname().release.lower()
|
||||
|
|
@ -381,7 +402,7 @@ async def install_mcp_config(
|
|||
|
||||
if os_type == "Windows":
|
||||
command = "cmd"
|
||||
args = ["/c", "uvx", "mcp-proxy", sse_url]
|
||||
args = ["/c", "uvx", mcp_tool, sse_url]
|
||||
logger.debug("Windows detected, using cmd command")
|
||||
|
||||
name = project.name
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pydantic import (
|
|||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SecretStr,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
|
|
@ -440,6 +441,27 @@ class CancelFlowResponse(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class AuthSettings(BaseModel):
|
||||
"""Model representing authentication settings for MCP."""
|
||||
|
||||
auth_type: Literal["none", "apikey", "basic", "bearer", "iam", "oauth"] = "none"
|
||||
api_key: SecretStr | None = None
|
||||
username: str | None = None
|
||||
password: SecretStr | None = None
|
||||
bearer_token: SecretStr | None = None
|
||||
iam_endpoint: str | None = None
|
||||
oauth_host: str | None = None
|
||||
oauth_port: str | None = None
|
||||
oauth_server_url: str | None = None
|
||||
oauth_callback_path: str | None = None
|
||||
oauth_client_id: str | None = None
|
||||
oauth_client_secret: str | None = None
|
||||
oauth_auth_url: str | None = None
|
||||
oauth_token_url: str | None = None
|
||||
oauth_mcp_scope: str | None = None
|
||||
oauth_provider_scope: str | None = None
|
||||
|
||||
|
||||
class MCPSettings(BaseModel):
|
||||
"""Model representing MCP settings for a flow."""
|
||||
|
||||
|
|
@ -451,5 +473,19 @@ class MCPSettings(BaseModel):
|
|||
description: str | None = None
|
||||
|
||||
|
||||
class MCPProjectUpdateRequest(BaseModel):
|
||||
"""Request model for updating MCP project settings including auth."""
|
||||
|
||||
settings: list[MCPSettings]
|
||||
auth_settings: AuthSettings | None = None
|
||||
|
||||
|
||||
class MCPProjectResponse(BaseModel):
|
||||
"""Response model for MCP project tools with auth settings."""
|
||||
|
||||
tools: list[MCPSettings]
|
||||
auth_settings: AuthSettings | None = None
|
||||
|
||||
|
||||
class MCPInstallRequest(BaseModel):
|
||||
client: str
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from typing import Optional
|
|||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Text, UniqueConstraint
|
||||
from sqlmodel import Column, Field, Relationship, SQLModel
|
||||
from sqlmodel import JSON, Column, Field, Relationship, SQLModel
|
||||
|
||||
from langflow.services.database.models.flow.model import Flow, FlowRead
|
||||
from langflow.services.database.models.user.model import User
|
||||
|
|
@ -11,6 +11,11 @@ from langflow.services.database.models.user.model import User
|
|||
class FolderBase(SQLModel):
|
||||
name: str = Field(index=True)
|
||||
description: str | None = Field(default=None, sa_column=Column(Text))
|
||||
auth_settings: dict | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(JSON, nullable=True),
|
||||
description="Authentication settings for the folder/project",
|
||||
)
|
||||
|
||||
|
||||
class Folder(FolderBase, table=True): # type: ignore[call-arg]
|
||||
|
|
@ -53,3 +58,4 @@ class FolderUpdate(SQLModel):
|
|||
parent_id: UUID | None = None
|
||||
components: list[UUID] = Field(default_factory=list)
|
||||
flows: list[UUID] = Field(default_factory=list)
|
||||
auth_settings: dict | None = None
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from pydantic_settings import BaseSettings
|
|||
|
||||
class FeatureFlags(BaseSettings):
|
||||
mvp_components: bool = False
|
||||
mcp_composer: bool = True
|
||||
|
||||
class Config:
|
||||
env_prefix = "LANGFLOW_FEATURE_"
|
||||
|
|
|
|||
|
|
@ -206,20 +206,30 @@ async def test_update_project_mcp_settings_success(
|
|||
):
|
||||
"""Test successful update of MCP settings using real database."""
|
||||
# Create settings for updating the flow
|
||||
settings = [
|
||||
{
|
||||
"id": str(test_flow_for_update.id),
|
||||
"action_name": "updated_action",
|
||||
"action_description": "Updated description",
|
||||
"mcp_enabled": False,
|
||||
"name": test_flow_for_update.name,
|
||||
"description": test_flow_for_update.description,
|
||||
}
|
||||
]
|
||||
json_payload = {
|
||||
"settings": [
|
||||
{
|
||||
"id": str(test_flow_for_update.id),
|
||||
"action_name": "updated_action",
|
||||
"action_description": "Updated description",
|
||||
"mcp_enabled": False,
|
||||
"name": test_flow_for_update.name,
|
||||
"description": test_flow_for_update.description,
|
||||
}
|
||||
],
|
||||
"auth_settings": {
|
||||
"auth_type": "none",
|
||||
"api_key": None,
|
||||
"iam_endpoint": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"bearer_token": None,
|
||||
},
|
||||
}
|
||||
|
||||
# Make the real PATCH request
|
||||
response = await client.patch(
|
||||
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=settings
|
||||
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=json_payload
|
||||
)
|
||||
|
||||
# Assert response
|
||||
|
|
@ -268,11 +278,21 @@ async def test_update_project_mcp_settings_empty_settings(client: AsyncClient, u
|
|||
# Use real database objects instead of mocks to avoid the coroutine issue
|
||||
|
||||
# Empty settings list
|
||||
settings: list = []
|
||||
json_payload = {
|
||||
"settings": [],
|
||||
"auth_settings": {
|
||||
"auth_type": "none",
|
||||
"api_key": None,
|
||||
"iam_endpoint": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"bearer_token": None,
|
||||
},
|
||||
}
|
||||
|
||||
# Make the request to the actual endpoint
|
||||
response = await client.patch(
|
||||
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=settings
|
||||
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=json_payload
|
||||
)
|
||||
|
||||
# Verify response - the real endpoint should handle empty settings correctly
|
||||
|
|
@ -385,20 +405,30 @@ async def test_user_can_update_own_flow_mcp_settings(
|
|||
):
|
||||
"""Test that a user can update MCP settings for their own flows using real database."""
|
||||
# User attempts to update their own flow settings
|
||||
updated_settings = [
|
||||
{
|
||||
"id": str(user_test_flow.id),
|
||||
"action_name": "updated_user_action",
|
||||
"action_description": "Updated user action description",
|
||||
"mcp_enabled": False,
|
||||
"name": "User Test Flow",
|
||||
"description": "This flow belongs to the active user",
|
||||
}
|
||||
]
|
||||
json_payload = {
|
||||
"settings": [
|
||||
{
|
||||
"id": str(user_test_flow.id),
|
||||
"action_name": "updated_user_action",
|
||||
"action_description": "Updated user action description",
|
||||
"mcp_enabled": False,
|
||||
"name": "User Test Flow",
|
||||
"description": "This flow belongs to the active user",
|
||||
}
|
||||
],
|
||||
"auth_settings": {
|
||||
"auth_type": "none",
|
||||
"api_key": None,
|
||||
"iam_endpoint": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"bearer_token": None,
|
||||
},
|
||||
}
|
||||
|
||||
# Make the PATCH request to update settings
|
||||
response = await client.patch(
|
||||
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=updated_settings
|
||||
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=json_payload
|
||||
)
|
||||
|
||||
# Should succeed as the user owns this project and flow
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue