mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 21:57:46 +08:00
Compare commits
2 Commits
feat/mcp-t
...
feat/mcp-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 585d292511 | |||
| d3fa24d7e3 |
@ -467,8 +467,7 @@ class AppListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session):
|
||||
def get(self):
|
||||
"""Get app list"""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
@ -505,7 +504,7 @@ class AppListApi(Resource):
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
session.execute(
|
||||
db.session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
|
||||
@ -2,7 +2,6 @@ from collections.abc import Sequence
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -12,7 +11,6 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@ -21,6 +19,7 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
|
||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
@ -159,8 +158,7 @@ class InstructionGenerateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, current_tenant_id: str):
|
||||
def post(self, current_tenant_id: str):
|
||||
args = InstructionGeneratePayload.model_validate(console_ns.payload)
|
||||
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
|
||||
code_provider: type[CodeNodeProvider] | None = next(
|
||||
@ -170,10 +168,10 @@ class InstructionGenerateApi(Resource):
|
||||
try:
|
||||
# Generate from nothing for a workflow node
|
||||
if (args.current in (code_template, "")) and args.node_id != "":
|
||||
app = session.get(App, args.flow_id)
|
||||
app = db.session.get(App, args.flow_id)
|
||||
if not app:
|
||||
return {"error": f"app {args.flow_id} not found"}, 400
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app)
|
||||
if not workflow:
|
||||
return {"error": f"workflow {args.flow_id} not found"}, 400
|
||||
nodes: Sequence = workflow.graph_dict["nodes"]
|
||||
|
||||
@ -59,7 +59,9 @@ class ToolProviderApiEntity(BaseModel):
|
||||
forward_user_identity: bool = Field(
|
||||
default=False, description="Whether Dify forwards the calling user's SSO identity to this MCP server"
|
||||
)
|
||||
identity_mode: str = Field(default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'")
|
||||
identity_mode: str = Field(
|
||||
default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'"
|
||||
)
|
||||
# Workflow
|
||||
workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool")
|
||||
|
||||
|
||||
@ -13761,12 +13761,10 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| authentication | object | | No |
|
||||
| configuration | object | | No |
|
||||
| forward_user_identity | boolean | | No |
|
||||
| headers | object | | No |
|
||||
| icon | string | | Yes |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | Yes |
|
||||
| identity_mode | string | *Enum:* `"idp_token"`, `"off"` | No |
|
||||
| name | string | | Yes |
|
||||
| server_identifier | string | | Yes |
|
||||
| server_url | string | | Yes |
|
||||
@ -13783,12 +13781,10 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| authentication | object | | No |
|
||||
| configuration | object | | No |
|
||||
| forward_user_identity | boolean | | No |
|
||||
| headers | object | | No |
|
||||
| icon | string | | Yes |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | Yes |
|
||||
| identity_mode | string | *Enum:* `"idp_token"`, `"off"` | No |
|
||||
| name | string | | Yes |
|
||||
| provider_id | string | | Yes |
|
||||
| server_identifier | string | | Yes |
|
||||
|
||||
@ -39,7 +39,6 @@ class MCPIdentityRefreshError(MCPTokenError):
|
||||
def __init__(self, description: str = ""):
|
||||
super().__init__(description, status_code=401)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@ -140,21 +140,14 @@ class WorkflowService:
|
||||
)
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
def get_draft_workflow(
|
||||
self, app_model: App, workflow_id: str | None = None, session: Session | None = None
|
||||
) -> Workflow | None:
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
"""
|
||||
Get draft workflow
|
||||
|
||||
When ``session`` is provided, reuse it so callers that already hold a
|
||||
Session avoid checking out an extra request-scoped ``db.session``
|
||||
connection. Falls back to ``db.session`` for backward compatibility.
|
||||
"""
|
||||
if workflow_id:
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id, session=session)
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id)
|
||||
# fetch draft workflow by app_model
|
||||
bind = session if session is not None else db.session
|
||||
workflow = bind.scalar(
|
||||
workflow = db.session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
|
||||
@ -7,7 +7,6 @@ from importlib import util
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask.views import MethodView
|
||||
@ -19,15 +18,6 @@ if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
bound_self = getattr(func, "__self__", None)
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
if bound_self is not None:
|
||||
return func.__get__(bound_self, bound_self.__class__)
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_module():
|
||||
module_name = "controllers.console.app.app"
|
||||
@ -405,46 +395,3 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
|
||||
assert len(serialized["data"]) == 2
|
||||
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
|
||||
assert serialized["data"][1]["icon_url"] is None
|
||||
|
||||
|
||||
def test_app_list_uses_injected_session_for_draft_workflows(app, app_module, monkeypatch):
|
||||
api = app_module.AppListApi()
|
||||
method = _unwrap(api.get)
|
||||
current_user = SimpleNamespace(id="user-1")
|
||||
app_item = SimpleNamespace(
|
||||
id="app-1",
|
||||
name="Workflow App",
|
||||
desc_or_prompt="Summary",
|
||||
mode="workflow",
|
||||
mode_compatible_with_agent="workflow",
|
||||
)
|
||||
app_pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_item])
|
||||
workflow = SimpleNamespace(
|
||||
id="workflow-1",
|
||||
app_id="app-1",
|
||||
walk_nodes=lambda: iter([("trigger-1", {"type": "trigger-webhook"})]),
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = [workflow]
|
||||
scoped_session = SimpleNamespace(execute=MagicMock(side_effect=AssertionError("db.session should not be used")))
|
||||
|
||||
monkeypatch.setattr(app_module, "current_account_with_tenant", lambda: (current_user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"AppService",
|
||||
lambda: SimpleNamespace(get_paginate_apps=lambda *_args, **_kwargs: app_pagination),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"FeatureService",
|
||||
SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))),
|
||||
)
|
||||
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session))
|
||||
|
||||
with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"):
|
||||
response, status = method(session)
|
||||
|
||||
assert status == 200
|
||||
assert response["data"][0]["has_draft_trigger"] is True
|
||||
session.execute.assert_called_once()
|
||||
scoped_session.execute.assert_not_called()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -25,17 +24,10 @@ def _model_config_payload():
|
||||
|
||||
def _install_workflow_service(monkeypatch: pytest.MonkeyPatch, workflow):
|
||||
class _Service:
|
||||
app_model = None
|
||||
session = None
|
||||
|
||||
def get_draft_workflow(self, app_model, session=None):
|
||||
self.app_model = app_model
|
||||
self.session = session
|
||||
def get_draft_workflow(self, app_model):
|
||||
return workflow
|
||||
|
||||
service = _Service()
|
||||
monkeypatch.setattr(generator_module, "WorkflowService", lambda: service)
|
||||
return service
|
||||
monkeypatch.setattr(generator_module, "WorkflowService", lambda: _Service())
|
||||
|
||||
|
||||
def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -76,8 +68,7 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
session = MagicMock()
|
||||
session.get.return_value = None
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -89,11 +80,10 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "app app-1 not found"
|
||||
session.get.assert_called_once_with(generator_module.App, "app-1")
|
||||
|
||||
|
||||
def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -101,7 +91,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
_install_workflow_service(monkeypatch, workflow=None)
|
||||
|
||||
with app.test_request_context(
|
||||
@ -114,7 +104,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "workflow app-1 not found"
|
||||
@ -125,7 +115,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
workflow = SimpleNamespace(graph_dict={"nodes": []})
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
@ -140,7 +130,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "node node-1 not found"
|
||||
@ -151,7 +141,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={
|
||||
@ -160,7 +150,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
]
|
||||
}
|
||||
)
|
||||
workflow_service = _install_workflow_service(monkeypatch, workflow=workflow)
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", lambda **_kwargs: {"code": "x"})
|
||||
|
||||
with app.test_request_context(
|
||||
@ -173,17 +163,14 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method(session, "t1")
|
||||
response = method("t1")
|
||||
|
||||
assert response == {"code": "x"}
|
||||
assert workflow_service.app_model is app_model
|
||||
assert workflow_service.session is session
|
||||
|
||||
|
||||
def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
generator_module.LLMGenerator,
|
||||
@ -202,7 +189,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method(session, "t1")
|
||||
response = method("t1")
|
||||
|
||||
assert response == {"instruction": "ok"}
|
||||
|
||||
@ -210,7 +197,6 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
|
||||
def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -223,7 +209,7 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "incompatible parameters"
|
||||
|
||||
@ -346,19 +346,6 @@ class TestWorkflowService:
|
||||
|
||||
assert result == mock_workflow
|
||||
|
||||
def test_get_draft_workflow_uses_provided_session(self, workflow_service, mock_db_session):
|
||||
"""Test get_draft_workflow can reuse an injected SQLAlchemy session."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app, session=session)
|
||||
|
||||
assert result == mock_workflow
|
||||
session.scalar.assert_called_once()
|
||||
mock_db_session.session.scalar.assert_not_called()
|
||||
|
||||
def test_get_draft_workflow_returns_none(self, workflow_service, mock_db_session):
|
||||
"""Test get_draft_workflow returns None when no draft exists."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
@ -383,21 +370,6 @@ class TestWorkflowService:
|
||||
|
||||
assert result == mock_workflow
|
||||
|
||||
def test_get_draft_workflow_with_workflow_id_reuses_provided_session(self, workflow_service):
|
||||
"""Test get_draft_workflow passes an injected session to published workflow lookup."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
workflow_id = "workflow-123"
|
||||
session = MagicMock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
|
||||
|
||||
with patch.object(
|
||||
workflow_service, "get_published_workflow_by_id", return_value=mock_workflow
|
||||
) as mock_get_published:
|
||||
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id, session=session)
|
||||
|
||||
assert result == mock_workflow
|
||||
mock_get_published.assert_called_once_with(app, workflow_id, session=session)
|
||||
|
||||
# ==================== Get Published Workflow Tests ====================
|
||||
# These tests verify retrieval of published workflows (versioned snapshots)
|
||||
|
||||
|
||||
@ -392,14 +392,12 @@ export type McpProviderCreatePayload = {
|
||||
configuration?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
forward_user_identity?: boolean
|
||||
headers?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
icon: string
|
||||
icon_background?: string
|
||||
icon_type: string
|
||||
identity_mode?: 'idp_token' | 'off'
|
||||
name: string
|
||||
server_identifier: string
|
||||
server_url: string
|
||||
@ -412,14 +410,12 @@ export type McpProviderUpdatePayload = {
|
||||
configuration?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
forward_user_identity?: boolean
|
||||
headers?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
icon: string
|
||||
icon_background?: string
|
||||
icon_type: string
|
||||
identity_mode?: 'idp_token' | 'off'
|
||||
name: string
|
||||
provider_id: string
|
||||
server_identifier: string
|
||||
|
||||
@ -361,12 +361,10 @@ export const zMcpProviderDeletePayload = z.object({
|
||||
export const zMcpProviderCreatePayload = z.object({
|
||||
authentication: z.record(z.string(), z.unknown()).nullish(),
|
||||
configuration: z.record(z.string(), z.unknown()).nullish(),
|
||||
forward_user_identity: z.boolean().optional().default(false),
|
||||
headers: z.record(z.string(), z.unknown()).nullish(),
|
||||
icon: z.string(),
|
||||
icon_background: z.string().optional().default(''),
|
||||
icon_type: z.string(),
|
||||
identity_mode: z.enum(['idp_token', 'off']).optional().default('off'),
|
||||
name: z.string(),
|
||||
server_identifier: z.string(),
|
||||
server_url: z.string(),
|
||||
@ -378,12 +376,10 @@ export const zMcpProviderCreatePayload = z.object({
|
||||
export const zMcpProviderUpdatePayload = z.object({
|
||||
authentication: z.record(z.string(), z.unknown()).nullish(),
|
||||
configuration: z.record(z.string(), z.unknown()).nullish(),
|
||||
forward_user_identity: z.boolean().optional().default(false),
|
||||
headers: z.record(z.string(), z.unknown()).nullish(),
|
||||
icon: z.string(),
|
||||
icon_background: z.string().optional().default(''),
|
||||
icon_type: z.string(),
|
||||
identity_mode: z.enum(['idp_token', 'off']).optional().default('off'),
|
||||
name: z.string(),
|
||||
provider_id: z.string(),
|
||||
server_identifier: z.string(),
|
||||
|
||||
@ -54,6 +54,7 @@ type MCPModalFormState = {
|
||||
isDynamicRegistration: boolean
|
||||
clientID: string
|
||||
credentials: string
|
||||
forwardUserIdentity: boolean
|
||||
}
|
||||
type MCPModalFormActions = {
|
||||
setUrl: (url: string) => void
|
||||
@ -68,6 +69,7 @@ type MCPModalFormActions = {
|
||||
setIsDynamicRegistration: (value: boolean) => void
|
||||
setClientID: (id: string) => void
|
||||
setCredentials: (credentials: string) => void
|
||||
setForwardUserIdentity: (value: boolean) => void
|
||||
handleUrlBlur: (url: string) => Promise<void>
|
||||
resetIcon: () => void
|
||||
}
|
||||
@ -100,6 +102,11 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
|
||||
const [isDynamicRegistration, setIsDynamicRegistration] = useState(() => isCreate ? true : (data?.is_dynamic_registration ?? true))
|
||||
const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '')
|
||||
const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '')
|
||||
// M3 — user-identity forwarding. Identity mode is implied by the toggle:
|
||||
// off → "off", on → "idp_token" (only mode currently supported).
|
||||
const [forwardUserIdentity, setForwardUserIdentity] = useState(
|
||||
() => Boolean(data?.forward_user_identity),
|
||||
)
|
||||
const handleUrlBlur = useCallback(async (urlValue: string) => {
|
||||
if (data)
|
||||
return
|
||||
@ -163,6 +170,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
|
||||
isDynamicRegistration,
|
||||
clientID,
|
||||
credentials,
|
||||
forwardUserIdentity,
|
||||
} satisfies MCPModalFormState,
|
||||
// Actions
|
||||
actions: {
|
||||
@ -178,6 +186,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => {
|
||||
setIsDynamicRegistration,
|
||||
setClientID,
|
||||
setCredentials,
|
||||
setForwardUserIdentity,
|
||||
handleUrlBlur,
|
||||
resetIcon,
|
||||
} satisfies MCPModalFormActions,
|
||||
|
||||
@ -5,6 +5,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine, RiEditLine } from '@remixicon/react'
|
||||
import { useHover } from 'ahooks'
|
||||
@ -39,6 +40,8 @@ type MCPModalConfirmPayload = {
|
||||
timeout: number
|
||||
sse_read_timeout: number
|
||||
}
|
||||
forward_user_identity?: boolean
|
||||
identity_mode?: 'off' | 'idp_token'
|
||||
}
|
||||
|
||||
type DuplicateAppModalProps = {
|
||||
@ -110,6 +113,8 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
|
||||
timeout: state.timeout || 30,
|
||||
sse_read_timeout: state.sseReadTimeout || 300,
|
||||
},
|
||||
forward_user_identity: state.forwardUserIdentity,
|
||||
identity_mode: state.forwardUserIdentity ? 'idp_token' : 'off',
|
||||
})
|
||||
if (isCreate)
|
||||
onHide()
|
||||
@ -207,6 +212,23 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forward user identity (M3 — enterprise SSO identity-forwarding) */}
|
||||
<div>
|
||||
<div className="mb-1 flex h-6 items-center">
|
||||
<Switch
|
||||
className="mr-2"
|
||||
checked={state.forwardUserIdentity}
|
||||
onCheckedChange={actions.setForwardUserIdentity}
|
||||
/>
|
||||
<span className="system-sm-medium text-text-secondary">
|
||||
{t('mcp.modal.forwardUserIdentity', { ns: 'tools' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="body-xs-regular text-text-tertiary">
|
||||
{t('mcp.modal.forwardUserIdentityTip', { ns: 'tools' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth Method Tabs */}
|
||||
<TabSlider
|
||||
className="w-full"
|
||||
|
||||
@ -78,6 +78,9 @@ export type Collection = {
|
||||
timeout?: number
|
||||
sse_read_timeout?: number
|
||||
}
|
||||
// M3 — user-identity forwarding (MCP)
|
||||
forward_user_identity?: boolean
|
||||
identity_mode?: 'off' | 'idp_token'
|
||||
// Workflow
|
||||
workflow_app_id?: string
|
||||
}
|
||||
|
||||
@ -145,6 +145,8 @@
|
||||
"mcp.modal.timeout": "Timeout",
|
||||
"mcp.modal.timeoutPlaceholder": "30",
|
||||
"mcp.modal.title": "Add MCP Server (HTTP)",
|
||||
"mcp.modal.forwardUserIdentity": "Forward user identity",
|
||||
"mcp.modal.forwardUserIdentityTip": "Send the calling user's verified SSO identity to this MCP server as an Authorization Bearer token. Requires Dify Enterprise SSO.",
|
||||
"mcp.modal.useDynamicClientRegistration": "Use Dynamic Client Registration",
|
||||
"mcp.noConfigured": "Unconfigured",
|
||||
"mcp.noTools": "No tools available",
|
||||
|
||||
@ -145,6 +145,8 @@
|
||||
"mcp.modal.timeout": "超时时间",
|
||||
"mcp.modal.timeoutPlaceholder": "30",
|
||||
"mcp.modal.title": "添加 MCP 服务 (HTTP)",
|
||||
"mcp.modal.forwardUserIdentity": "转发用户身份",
|
||||
"mcp.modal.forwardUserIdentityTip": "将调用用户的已验证 SSO 身份作为 Authorization Bearer token 转发到该 MCP 服务器。需要 Dify Enterprise SSO。",
|
||||
"mcp.modal.useDynamicClientRegistration": "使用动态客户端注册",
|
||||
"mcp.noConfigured": "未配置",
|
||||
"mcp.noTools": "没有可用的工具",
|
||||
|
||||
@ -106,6 +106,8 @@ export const useCreateMCP = () => {
|
||||
timeout?: number
|
||||
sse_read_timeout?: number
|
||||
headers?: Record<string, string>
|
||||
forward_user_identity?: boolean
|
||||
identity_mode?: 'off' | 'idp_token'
|
||||
}) => {
|
||||
return post<ToolWithProvider>('workspaces/current/tool-provider/mcp', {
|
||||
body: {
|
||||
@ -133,6 +135,8 @@ export const useUpdateMCP = ({
|
||||
timeout?: number
|
||||
sse_read_timeout?: number
|
||||
headers?: Record<string, string>
|
||||
forward_user_identity?: boolean
|
||||
identity_mode?: 'off' | 'idp_token'
|
||||
}) => {
|
||||
return put('workspaces/current/tool-provider/mcp', {
|
||||
body: {
|
||||
|
||||
Reference in New Issue
Block a user