mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 08:26:07 +00:00
feat(mcp): support mcp resources (#2215)
* feat(mcp): support mcp resources * feat(web): split MCP resources into tab * docs: add MCP resources PR review * feat(mcp): productionize resource support * feat(mcp): scope local agent tools and resources * fix(web): gate space embedding models behind login * fix(web): prevent clipped space model CTA * test: update preproc resource tool expectations * fix(web): expose skill authoring tools in selector --------- Co-authored-by: yang.xiang <yang.xiang@advancegroup.com> Co-authored-by: Hyu <chenhyu@proton.me> Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -90,6 +90,56 @@ class TestMCPServiceGetRuntimeInfo:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestMCPServiceResources:
|
||||
"""Tests for MCP resource helpers."""
|
||||
|
||||
async def test_get_resource_templates_delegates_to_loader(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.tool_mgr = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock(
|
||||
return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}]
|
||||
)
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
result = await service.get_mcp_server_resource_templates('docs')
|
||||
|
||||
assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}]
|
||||
ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs')
|
||||
|
||||
async def test_read_resource_envelope_uses_ui_preview_source(self):
|
||||
ap = SimpleNamespace()
|
||||
ap.tool_mgr = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
||||
ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock(
|
||||
return_value={
|
||||
'server_name': 'docs',
|
||||
'uri': 'file:///README.md',
|
||||
'contents': [],
|
||||
'source': 'ui_preview',
|
||||
}
|
||||
)
|
||||
|
||||
service = MCPService(ap)
|
||||
|
||||
result = await service.read_mcp_server_resource_envelope(
|
||||
'docs',
|
||||
'file:///README.md',
|
||||
max_bytes=4096,
|
||||
include_blob=True,
|
||||
)
|
||||
|
||||
assert result['source'] == 'ui_preview'
|
||||
ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with(
|
||||
'docs',
|
||||
'file:///README.md',
|
||||
include_blob=True,
|
||||
source='ui_preview',
|
||||
max_bytes=4096,
|
||||
)
|
||||
|
||||
|
||||
class TestMCPServiceGetMCPServers:
|
||||
"""Tests for get_mcp_servers method."""
|
||||
|
||||
|
||||
@@ -348,6 +348,8 @@ class TestPipelineServiceCreatePipeline:
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'mcp_resources': [],
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
|
||||
@@ -814,6 +816,47 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
||||
# Verify - persistence was called
|
||||
ap.persistence_mgr.execute_async.assert_called()
|
||||
|
||||
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
|
||||
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.pipeline_mgr = SimpleNamespace()
|
||||
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
||||
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
||||
|
||||
original_pipeline = _create_mock_pipeline(
|
||||
extensions_preferences={
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
|
||||
'mcp_resource_agent_read_enabled': False,
|
||||
}
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_execute(query):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return _create_mock_result(first_item=original_pipeline)
|
||||
return Mock()
|
||||
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'})
|
||||
|
||||
service = PipelineService(ap)
|
||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
||||
|
||||
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
|
||||
|
||||
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
|
||||
assert original_pipeline.extensions_preferences['mcp_resources'] == [
|
||||
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
|
||||
]
|
||||
|
||||
|
||||
class TestDefaultStageOrder:
|
||||
"""Tests for default_stage_order constant."""
|
||||
|
||||
@@ -162,3 +162,46 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
||||
|
||||
# Verify stage was called
|
||||
mock_stage.process.assert_called_once()
|
||||
|
||||
|
||||
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
|
||||
"""Local Agent resource selection should override legacy extension prefs."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {
|
||||
'ai': {
|
||||
'local-agent': {
|
||||
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
|
||||
'mcp-resource-agent-read-enabled': False,
|
||||
}
|
||||
}
|
||||
}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
|
||||
'mcp_resource_agent_read_enabled': True,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}]
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
|
||||
def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
|
||||
"""Existing extension prefs remain compatible until a Local Agent value exists."""
|
||||
pipelinemgr = get_pipelinemgr_module()
|
||||
persistence_pipeline = get_persistence_pipeline_module()
|
||||
|
||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
||||
pipeline_entity.config = {'ai': {'local-agent': {}}}
|
||||
pipeline_entity.extensions_preferences = {
|
||||
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
|
||||
'mcp_resource_agent_read_enabled': False,
|
||||
}
|
||||
|
||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
||||
|
||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
|
||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tests.factories import (
|
||||
FakeApp,
|
||||
@@ -431,3 +432,60 @@ class TestPreProcessorVariables:
|
||||
variables = result.new_query.variables
|
||||
assert 'group_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
|
||||
|
||||
class TestPreProcessorToolSelection:
|
||||
"""Tests for Local Agent tool selection."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_agent_filters_selected_tools(self):
|
||||
"""Only selected tools should be exposed when all-tools mode is off."""
|
||||
preproc = get_preproc_module()
|
||||
|
||||
app = FakeApp()
|
||||
mock_session = Mock()
|
||||
mock_session.launcher_type = Mock(value='person')
|
||||
mock_session.launcher_id = 12345
|
||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
||||
|
||||
mock_conversation = Mock()
|
||||
mock_conversation.prompt = Mock(messages=[])
|
||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
mock_conversation.messages = []
|
||||
mock_conversation.uuid = None
|
||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
||||
|
||||
mock_model = Mock()
|
||||
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
|
||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
||||
app.tool_mgr.get_all_tools = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(name='exec'),
|
||||
SimpleNamespace(name='plugin_tool'),
|
||||
SimpleNamespace(name='mcp_tool'),
|
||||
]
|
||||
)
|
||||
|
||||
mock_event_ctx = Mock()
|
||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
|
||||
stage = preproc.PreProcessor(app)
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
|
||||
'prompt': 'default',
|
||||
'enable-all-tools': False,
|
||||
'tools': ['plugin_tool'],
|
||||
},
|
||||
},
|
||||
'output': {'misc': {'at-sender': False}},
|
||||
'trigger': {'misc': {}},
|
||||
}
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from mcp import types as mcp_types
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||
MCP_RESOURCE_CONTEXT_QUERY_KEY,
|
||||
MCP_RESOURCE_TRACE_QUERY_KEY,
|
||||
MCP_TOOL_LIST_RESOURCES,
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
MCPLoader,
|
||||
MCPSessionStatus,
|
||||
RuntimeMCPSession,
|
||||
)
|
||||
from langbot.pkg.telemetry import features as telemetry_features
|
||||
|
||||
|
||||
def _app() -> SimpleNamespace:
|
||||
return SimpleNamespace(logger=Mock())
|
||||
|
||||
|
||||
def _connected_session(
|
||||
*,
|
||||
name: str = 'docs',
|
||||
uuid: str = 'srv-1',
|
||||
resources: list[dict] | None = None,
|
||||
templates: list[dict] | None = None,
|
||||
) -> RuntimeMCPSession:
|
||||
session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app())
|
||||
session.status = MCPSessionStatus.CONNECTED
|
||||
session.session = SimpleNamespace(read_resource=AsyncMock())
|
||||
session.resources = resources or [
|
||||
{
|
||||
'uri': 'file:///README.md',
|
||||
'name': 'README.md',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'mime_type': 'text/markdown',
|
||||
'size': None,
|
||||
'icons': [],
|
||||
'annotations': {},
|
||||
'_meta': {},
|
||||
}
|
||||
]
|
||||
session.resource_templates = templates or []
|
||||
return session
|
||||
|
||||
|
||||
def _query() -> SimpleNamespace:
|
||||
return SimpleNamespace(variables={})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_truncates_caches_and_records_trace():
|
||||
session = _connected_session()
|
||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md',
|
||||
mimeType='text/markdown',
|
||||
text='abcdef',
|
||||
)
|
||||
]
|
||||
)
|
||||
query = _query()
|
||||
|
||||
first = await session.read_resource_envelope(
|
||||
'file:///README.md',
|
||||
max_bytes=4,
|
||||
source='ui_preview',
|
||||
query=query,
|
||||
)
|
||||
second = await session.read_resource_envelope(
|
||||
'file:///README.md',
|
||||
max_bytes=4,
|
||||
source='agent_tool',
|
||||
query=query,
|
||||
)
|
||||
|
||||
assert first['contents'][0]['text'] == 'abcd'
|
||||
assert first['contents'][0]['bytes'] == 6
|
||||
assert first['truncated'] is True
|
||||
assert first['cache_hit'] is False
|
||||
assert second['cache_hit'] is True
|
||||
assert second['source'] == 'agent_tool'
|
||||
assert session.session.read_resource.await_count == 1
|
||||
|
||||
traces = query.variables[MCP_RESOURCE_TRACE_QUERY_KEY]
|
||||
assert [trace['source'] for trace in traces] == ['ui_preview', 'agent_tool']
|
||||
assert traces[1]['cache_hit'] is True
|
||||
assert query.variables[telemetry_features.FEATURES_KEY]['mcp_resource_reads'] == {
|
||||
'ui_preview': 1,
|
||||
'agent_tool': 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_shares_byte_budget_across_text_contents():
|
||||
session = _connected_session()
|
||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md#first',
|
||||
mimeType='text/plain',
|
||||
text='abc',
|
||||
),
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md#second',
|
||||
mimeType='text/plain',
|
||||
text='def',
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
envelope = await session.read_resource_envelope('file:///README.md', max_bytes=4)
|
||||
|
||||
assert [item['text'] for item in envelope['contents']] == ['abc', 'd']
|
||||
assert envelope['contents'][0]['truncated'] is False
|
||||
assert envelope['contents'][1]['truncated'] is True
|
||||
assert envelope['bytes'] == 6
|
||||
assert envelope['truncated'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_omits_binary_by_default():
|
||||
session = _connected_session(
|
||||
resources=[
|
||||
{
|
||||
'uri': 'file:///image.png',
|
||||
'name': 'image.png',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'mime_type': 'image/png',
|
||||
'size': 4,
|
||||
'icons': [],
|
||||
'annotations': {},
|
||||
'_meta': {},
|
||||
}
|
||||
]
|
||||
)
|
||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.BlobResourceContents(
|
||||
uri='file:///image.png',
|
||||
mimeType='image/png',
|
||||
blob=base64.b64encode(b'\x00\x01\x02\x03').decode(),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
envelope = await session.read_resource_envelope('file:///image.png')
|
||||
|
||||
content = envelope['contents'][0]
|
||||
assert content['type'] == 'blob'
|
||||
assert content['blob'] is None
|
||||
assert content['bytes'] == 4
|
||||
assert content['binary_omitted'] is True
|
||||
assert envelope['truncated'] is True
|
||||
assert envelope['warnings'] == ['Binary resource content omitted from response.']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_resource_envelope_rejects_unlisted_uri():
|
||||
session = _connected_session()
|
||||
|
||||
with pytest.raises(ValueError, match='Resource URI is not available'):
|
||||
await session.read_resource_envelope('file:///secret.txt')
|
||||
|
||||
session.session.read_resource.assert_not_called()
|
||||
|
||||
|
||||
def test_resource_uri_allowed_supports_listed_templates_conservatively():
|
||||
session = _connected_session(
|
||||
resources=[],
|
||||
templates=[
|
||||
{
|
||||
'uri_template': 'repo://{owner}/{repo}/file/{path}',
|
||||
'name': 'repository file',
|
||||
'title': '',
|
||||
'description': '',
|
||||
'mime_type': 'text/plain',
|
||||
'icons': [],
|
||||
'annotations': {},
|
||||
'_meta': {},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert session.resource_uri_allowed('repo://langbot-app/LangBot/file/src/main.py') is True
|
||||
assert session.resource_uri_allowed('repo://langbot-app/LangBot/issues/1') is False
|
||||
assert session.resource_uri_allowed('https://example.com/secret') is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_loader_can_hide_synthetic_resource_tools():
|
||||
loader = MCPLoader(_app())
|
||||
session = _connected_session()
|
||||
loader.sessions = {'docs': session}
|
||||
|
||||
with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True)
|
||||
without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False)
|
||||
|
||||
assert {tool.name for tool in with_resource_tools} == {
|
||||
MCP_TOOL_LIST_RESOURCES,
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
}
|
||||
assert without_resource_tools == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled():
|
||||
loader = MCPLoader(_app())
|
||||
session = _connected_session()
|
||||
loader.sessions = {'docs': session}
|
||||
query = SimpleNamespace(
|
||||
variables={
|
||||
'_pipeline_bound_mcp_servers': ['srv-1'],
|
||||
'_pipeline_mcp_resource_agent_read_enabled': False,
|
||||
}
|
||||
)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
{'server_name': 'docs', 'uri': 'file:///README.md'},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result[0].text == 'Error: MCP resource agent reads are disabled.'
|
||||
session.session.read_resource.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_resource_context_for_query_uses_only_bound_attached_text_resources():
|
||||
loader = MCPLoader(_app())
|
||||
docs = _connected_session(name='docs', uuid='srv-1')
|
||||
docs.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md',
|
||||
mimeType='text/markdown',
|
||||
text='LangBot MCP resource context',
|
||||
)
|
||||
]
|
||||
)
|
||||
other = _connected_session(name='other', uuid='srv-2')
|
||||
other.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
||||
contents=[
|
||||
mcp_types.TextResourceContents(
|
||||
uri='file:///README.md',
|
||||
mimeType='text/markdown',
|
||||
text='must not be injected',
|
||||
)
|
||||
]
|
||||
)
|
||||
loader.sessions = {'docs': docs, 'other': other}
|
||||
query = SimpleNamespace(
|
||||
variables={
|
||||
'_pipeline_bound_mcp_servers': ['srv-1'],
|
||||
'_pipeline_mcp_resource_attachments': [
|
||||
{'server_uuid': 'srv-1', 'server_name': 'docs', 'uri': 'file:///README.md', 'mode': 'pinned'},
|
||||
{'server_uuid': 'srv-2', 'server_name': 'other', 'uri': 'file:///README.md', 'mode': 'pinned'},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
context = await loader.build_resource_context_for_query(query)
|
||||
|
||||
assert '<mcp_resource ' in context
|
||||
assert 'server="docs"' in context
|
||||
assert 'LangBot MCP resource context' in context
|
||||
assert 'must not be injected' not in context
|
||||
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
|
||||
docs.session.read_resource.assert_awaited_once()
|
||||
other.session.read_resource.assert_not_called()
|
||||
@@ -15,13 +15,35 @@ from langbot.pkg.provider.tools.toolmgr import ToolManager
|
||||
|
||||
|
||||
class StubLoader:
|
||||
def __init__(self, tools: list[resource_tool.LLMTool] | None = None, invoke_result=None):
|
||||
def __init__(
|
||||
self,
|
||||
tools: list[resource_tool.LLMTool] | None = None,
|
||||
invoke_result=None,
|
||||
catalog_source: str = 'mcp',
|
||||
catalog_source_name: str = 'fixture-server',
|
||||
):
|
||||
self._tools = tools or []
|
||||
self._invoke_result = invoke_result
|
||||
self._catalog_source = catalog_source
|
||||
self._catalog_source_name = catalog_source_name
|
||||
|
||||
async def get_tools(self, *_args, **_kwargs):
|
||||
return self._tools
|
||||
|
||||
async def get_tool_catalog(self, *_args, **_kwargs):
|
||||
return [
|
||||
{
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'human_desc': tool.human_desc,
|
||||
'parameters': tool.parameters,
|
||||
'source': self._catalog_source,
|
||||
'source_name': self._catalog_source_name,
|
||||
'source_id': self._catalog_source_name,
|
||||
}
|
||||
for tool in self._tools
|
||||
]
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return any(tool.name == name for tool in self._tools)
|
||||
|
||||
@@ -68,6 +90,28 @@ async def test_tool_manager_includes_skill_authoring_tools_when_requested():
|
||||
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_catalog_labels_tool_sources():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
||||
manager.plugin_tool_loader = StubLoader(
|
||||
[make_tool('plugin_tool')],
|
||||
catalog_source='plugin',
|
||||
catalog_source_name='fixture-plugin',
|
||||
)
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
catalog = await manager.get_tool_catalog(include_skill_authoring=True)
|
||||
|
||||
assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [
|
||||
('exec', 'builtin', 'LangBot'),
|
||||
('activate', 'skill', 'LangBot'),
|
||||
('plugin_tool', 'plugin', 'fixture-plugin'),
|
||||
('mcp_tool', 'mcp', 'fixture-server'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_routes_native_tool_calls():
|
||||
app = SimpleNamespace()
|
||||
|
||||
@@ -118,7 +118,12 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -131,7 +136,32 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False)
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=False,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
query = _make_query()
|
||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||
None,
|
||||
None,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user