feat(api): unify pipeline application adapters

This commit is contained in:
huanghuoguoguo
2026-10-11 11:06:12 +08:00
parent 49e2db8331
commit 7ec78c2c42
13 changed files with 660 additions and 151 deletions
+37 -2
View File
@@ -1,5 +1,40 @@
"""Versioned, workspace-scoped application APIs shared by protocol adapters."""
from .api import APPLICATION_API_VERSION, ApplicationAPI, OperationSpec
from .api import (
APPLICATION_API_VERSION,
OPERATIONS,
ApplicationAPI,
ApplicationAPIError,
ApplicationAPIException,
ApplicationSurface,
OperationSpec,
PipelineExtensionsGetInput,
PipelineExtensionsGetOutput,
PipelineExtensionsUpdateInput,
PipelineExtensionsUpdateOutput,
PipelineGetInput,
PipelineGetOutput,
PipelineListInput,
PipelineListOutput,
)
from .client import ApplicationAPIClient, ApplicationAPIClientError
__all__ = ['APPLICATION_API_VERSION', 'ApplicationAPI', 'OperationSpec']
__all__ = [
'APPLICATION_API_VERSION',
'OPERATIONS',
'ApplicationAPI',
'ApplicationAPIError',
'ApplicationAPIException',
'ApplicationAPIClient',
'ApplicationAPIClientError',
'ApplicationSurface',
'OperationSpec',
'PipelineExtensionsGetInput',
'PipelineExtensionsGetOutput',
'PipelineExtensionsUpdateInput',
'PipelineExtensionsUpdateOutput',
'PipelineGetInput',
'PipelineGetOutput',
'PipelineListInput',
'PipelineListOutput',
]
@@ -0,0 +1,5 @@
from .cli import main
if __name__ == '__main__':
raise SystemExit(main())
+164 -16
View File
@@ -9,14 +9,92 @@ boundary.
from __future__ import annotations
import dataclasses
import enum
import typing
from ..http.authz import Permission, require_permission
from ..http.context import RequestContext
from ..context import RequestContext
APPLICATION_API_VERSION = 'v1'
class ApplicationSurface(enum.StrEnum):
"""Protocol surface allowed to invoke an application operation."""
HTTP = 'http'
MCP = 'mcp'
ASSISTANT = 'assistant'
CLI = 'cli'
@dataclasses.dataclass(frozen=True, slots=True)
class ApplicationAPIError:
"""Stable, serializable operation error metadata."""
code: str
message: str
status_code: int = 400
class ApplicationAPIException(Exception):
"""Typed application-boundary failure for non-HTTP callers."""
def __init__(self, error: ApplicationAPIError):
super().__init__(error.message)
self.error = error
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineListInput:
sort_by: str = 'created_at'
sort_order: str = 'DESC'
include_secret: bool = False
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineListOutput:
pipelines: list[dict]
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineGetInput:
pipeline_uuid: str
include_secret: bool = False
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineGetOutput:
pipeline: dict | None
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineExtensionsGetInput:
pipeline_uuid: str
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineExtensionsGetOutput:
extensions: dict
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineExtensionsUpdateInput:
pipeline_uuid: str
bound_plugins: list[dict]
bound_mcp_servers: list[str] | None = None
enable_all_plugins: bool = True
enable_all_mcp_servers: bool = True
bound_skills: list[str] | None = None
enable_all_skills: bool = True
bound_mcp_resources: list[dict] | None = None
mcp_resource_agent_read_enabled: bool | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class PipelineExtensionsUpdateOutput:
ok: bool = True
@dataclasses.dataclass(frozen=True, slots=True)
class OperationSpec:
"""Stable metadata for one public workspace operation."""
@@ -26,19 +104,66 @@ class OperationSpec:
permission: Permission
scope: str = 'workspace'
side_effect: bool = False
input_type: type = dict
output_type: type = dict
error_type: type = ApplicationAPIError
allowed_surfaces: frozenset[ApplicationSurface] = frozenset()
def as_dict(self) -> dict[str, typing.Any]:
"""Return JSON-safe metadata for capability discovery."""
return {
'id': self.operation_id,
'version': self.version,
'scope': self.scope,
'permission': self.permission.value,
'side_effect': self.side_effect,
'input_type': self.input_type.__name__,
'output_type': self.output_type.__name__,
'error_type': self.error_type.__name__,
'allowed_surfaces': sorted(surface.value for surface in self.allowed_surfaces),
}
OPERATIONS: dict[str, OperationSpec] = {
'pipeline.list': OperationSpec('pipeline.list', APPLICATION_API_VERSION, Permission.RESOURCE_VIEW),
'pipeline.get': OperationSpec('pipeline.get', APPLICATION_API_VERSION, Permission.RESOURCE_VIEW),
'pipeline.list': OperationSpec(
'pipeline.list',
APPLICATION_API_VERSION,
Permission.RESOURCE_VIEW,
input_type=PipelineListInput,
output_type=PipelineListOutput,
allowed_surfaces=frozenset(ApplicationSurface),
),
'pipeline.get': OperationSpec(
'pipeline.get',
APPLICATION_API_VERSION,
Permission.RESOURCE_VIEW,
input_type=PipelineGetInput,
output_type=PipelineGetOutput,
allowed_surfaces=frozenset(ApplicationSurface),
),
'pipeline.extensions.get': OperationSpec(
'pipeline.extensions.get', APPLICATION_API_VERSION, Permission.RESOURCE_VIEW
'pipeline.extensions.get',
APPLICATION_API_VERSION,
Permission.RESOURCE_VIEW,
input_type=PipelineExtensionsGetInput,
output_type=PipelineExtensionsGetOutput,
allowed_surfaces=frozenset(ApplicationSurface),
),
'pipeline.extensions.update': OperationSpec(
'pipeline.extensions.update',
APPLICATION_API_VERSION,
Permission.RESOURCE_MANAGE,
side_effect=True,
input_type=PipelineExtensionsUpdateInput,
output_type=PipelineExtensionsUpdateOutput,
allowed_surfaces=frozenset(
{
ApplicationSurface.HTTP,
ApplicationSurface.MCP,
ApplicationSurface.CLI,
}
),
),
}
@@ -59,13 +184,7 @@ class ApplicationAPI:
"""Return serializable operation metadata for capability discovery."""
return [
{
'id': spec.operation_id,
'version': spec.version,
'scope': spec.scope,
'permission': spec.permission.value,
'side_effect': spec.side_effect,
}
spec.as_dict()
for spec in OPERATIONS.values()
]
@@ -80,20 +199,31 @@ class ApplicationAPI:
sort_by: str = 'created_at',
sort_order: str = 'DESC',
*,
include_secret: bool = False,
include_secret: bool | None = None,
) -> list[dict]:
self._authorize(context, 'pipeline.list')
# Secret-bearing pipeline configuration must never be exposed merely
# because a caller supplied ``include_secret=True``. The HTTP route
# derives this flag from the same permission, but MCP/assistant callers
# can invoke the facade directly.
if include_secret:
include_secret_requested = include_secret is True
if include_secret_requested:
require_permission(context, Permission.RESOURCE_MANAGE)
# The assistant and MCP adapters use the ordinary defaults and keep
# the legacy service call shape. HTTP passes an explicit boolean so
# the redaction intent remains visible to service-level tests and
# lightweight doubles.
if (
include_secret is None
and sort_by == 'created_at'
and sort_order == 'DESC'
):
return await self.ap.pipeline_service.get_pipelines(context)
return await self.ap.pipeline_service.get_pipelines(
context,
sort_by,
sort_order,
include_secret=include_secret,
include_secret=include_secret_requested,
)
async def get_pipeline(
@@ -170,6 +300,24 @@ class ApplicationAPI:
}
async def update_pipeline_extensions(self, context: RequestContext, pipeline_uuid: str, **kwargs) -> None:
async def update_pipeline_extensions(
self,
context: RequestContext,
input: PipelineExtensionsUpdateInput,
) -> PipelineExtensionsUpdateOutput:
self._authorize(context, 'pipeline.extensions.update')
await self.ap.pipeline_service.update_pipeline_extensions(context, pipeline_uuid, **kwargs)
if not isinstance(input, PipelineExtensionsUpdateInput):
raise TypeError('input must be PipelineExtensionsUpdateInput')
await self.ap.pipeline_service.update_pipeline_extensions(
context,
input.pipeline_uuid,
bound_plugins=input.bound_plugins,
bound_mcp_servers=input.bound_mcp_servers,
enable_all_plugins=input.enable_all_plugins,
enable_all_mcp_servers=input.enable_all_mcp_servers,
bound_skills=input.bound_skills,
enable_all_skills=input.enable_all_skills,
bound_mcp_resources=input.bound_mcp_resources,
mcp_resource_agent_read_enabled=input.mcp_resource_agent_read_enabled,
)
return PipelineExtensionsUpdateOutput()
+55
View File
@@ -0,0 +1,55 @@
"""Minimal command-line client for Pipeline Application API operations."""
from __future__ import annotations
import argparse
import json
import os
from .client import ApplicationAPIClient, ApplicationAPIClientError
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog='langbot-api')
parser.add_argument('--base-url', default=os.getenv('LANGBOT_URL', 'http://127.0.0.1:5300'))
parser.add_argument('--api-key', default=os.getenv('LANGBOT_API_KEY'))
subparsers = parser.add_subparsers(dest='command', required=True)
subparsers.add_parser('pipelines-list')
get_parser = subparsers.add_parser('pipeline-get')
get_parser.add_argument('pipeline_uuid')
extensions_parser = subparsers.add_parser('pipeline-extensions-get')
extensions_parser.add_argument('pipeline_uuid')
update_parser = subparsers.add_parser('pipeline-extensions-update')
update_parser.add_argument('pipeline_uuid')
update_parser.add_argument('--json', required=True, help='Complete extension replacement object')
return parser
def main(argv: list[str] | None = None) -> int:
args = _parser().parse_args(argv)
if not args.api_key:
raise SystemExit('--api-key or LANGBOT_API_KEY is required')
try:
with ApplicationAPIClient(args.base_url, api_key=args.api_key) as client:
if args.command == 'pipelines-list':
result = client.list_pipelines()
elif args.command == 'pipeline-get':
result = client.get_pipeline(args.pipeline_uuid)
elif args.command == 'pipeline-extensions-get':
result = client.get_pipeline_extensions(args.pipeline_uuid)
else:
values = json.loads(args.json)
if not isinstance(values, dict):
raise ValueError('--json must contain an object')
client.update_pipeline_extensions(args.pipeline_uuid, **values)
result = {'ok': True}
except (ApplicationAPIClientError, ValueError) as exc:
print(json.dumps({'error': str(exc)}, ensure_ascii=False))
return 1
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+135
View File
@@ -0,0 +1,135 @@
"""Small HTTP client for the versioned Pipeline Application API.
This module intentionally talks to the Service API over HTTP. It does not
import the server's Application object, persistence layer, or authorization
context, so it can be used by a local CLI or another process safely.
"""
from __future__ import annotations
import typing
import httpx
from .api import PipelineExtensionsUpdateInput
class ApplicationAPIClientError(RuntimeError):
"""Raised when the LangBot Service API rejects an operation."""
def __init__(self, message: str, *, status_code: int | None = None, code: typing.Any = None):
super().__init__(message)
self.status_code = status_code
self.code = code
class ApplicationAPIClient:
"""HTTP client for the four Pipeline Application API operations."""
def __init__(
self,
base_url: str,
*,
api_key: str,
timeout: float = 10.0,
transport: httpx.BaseTransport | None = None,
) -> None:
self._client = httpx.Client(
base_url=base_url.rstrip('/'),
headers={'X-API-Key': api_key},
timeout=timeout,
transport=transport,
follow_redirects=False,
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> ApplicationAPIClient:
return self
def __exit__(self, *_exc: object) -> None:
self.close()
def list_pipelines(
self,
*,
sort_by: str = 'created_at',
sort_order: str = 'DESC',
) -> list[dict]:
payload = self._request(
'GET',
'/api/v1/pipelines',
params={'sort_by': sort_by, 'sort_order': sort_order},
)
return payload.get('pipelines', [])
def get_pipeline(self, pipeline_uuid: str) -> dict | None:
payload = self._request('GET', f'/api/v1/pipelines/{pipeline_uuid}')
return payload.get('pipeline')
def get_pipeline_extensions(self, pipeline_uuid: str) -> dict:
return self._request('GET', f'/api/v1/pipelines/{pipeline_uuid}/extensions')
def update_pipeline_extensions(
self,
pipeline_uuid: str,
*,
bound_plugins: list[dict],
bound_mcp_servers: list[str],
bound_skills: list[str],
bound_mcp_resources: list[dict],
enable_all_plugins: bool,
enable_all_mcp_servers: bool,
enable_all_skills: bool,
mcp_resource_agent_read_enabled: bool,
) -> None:
input = PipelineExtensionsUpdateInput(
pipeline_uuid=pipeline_uuid,
bound_plugins=bound_plugins,
bound_mcp_servers=bound_mcp_servers,
bound_skills=bound_skills,
bound_mcp_resources=bound_mcp_resources,
enable_all_plugins=enable_all_plugins,
enable_all_mcp_servers=enable_all_mcp_servers,
enable_all_skills=enable_all_skills,
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
)
self._request(
'PUT',
f'/api/v1/pipelines/{pipeline_uuid}/extensions',
json={
'bound_plugins': input.bound_plugins,
'bound_mcp_servers': input.bound_mcp_servers,
'bound_skills': input.bound_skills,
'bound_mcp_resources': input.bound_mcp_resources,
'enable_all_plugins': input.enable_all_plugins,
'enable_all_mcp_servers': input.enable_all_mcp_servers,
'enable_all_skills': input.enable_all_skills,
'mcp_resource_agent_read_enabled': input.mcp_resource_agent_read_enabled,
},
)
def _request(self, method: str, path: str, **kwargs: typing.Any) -> dict:
try:
response = self._client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise ApplicationAPIClientError(str(exc)) from exc
try:
payload = response.json()
except ValueError as exc:
raise ApplicationAPIClientError(
'LangBot returned a non-JSON response',
status_code=response.status_code,
) from exc
if response.status_code >= 400 or payload.get('code') not in (0, None):
raise ApplicationAPIClientError(
str(payload.get('msg') or payload.get('message') or 'LangBot request failed'),
status_code=response.status_code,
code=payload.get('code'),
)
data = payload.get('data', {})
return data if isinstance(data, dict) else {}
__all__ = ['ApplicationAPIClient', 'ApplicationAPIClientError']
+113
View File
@@ -0,0 +1,113 @@
"""Transport-neutral identity and Workspace execution context types.
The application operation layer uses these immutable values as its trusted
authorization boundary. HTTP, MCP and other protocol adapters may construct
or carry them, but the types themselves do not depend on any transport.
"""
from __future__ import annotations
import dataclasses
import enum
class PrincipalType(enum.StrEnum):
"""Kinds of authenticated principals accepted by LangBot."""
ACCOUNT = 'account'
API_KEY = 'api_key'
SUPPORT_ADMIN = 'support_admin'
SYSTEM = 'system'
PUBLIC_BOT = 'public_bot'
@dataclasses.dataclass(frozen=True, slots=True)
class PrincipalContext:
"""Authenticated identity before Workspace authorization is applied."""
principal_type: PrincipalType
account_uuid: str | None = None
actor_account_uuid: str | None = None
api_key_uuid: str | None = None
support_session_id: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceContext:
"""Workspace membership and effective permissions for one request."""
workspace_uuid: str
membership_uuid: str | None
role: str | None
permissions: frozenset[str]
membership_revision: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class RequestContext:
"""Trusted authorization context passed to application operations."""
instance_uuid: str
placement_generation: int
request_id: str
auth_type: str
principal: PrincipalContext
workspace: WorkspaceContext
entitlement_revision: int = 0
@property
def workspace_uuid(self) -> str:
"""Return the selected Workspace UUID."""
return self.workspace.workspace_uuid
@property
def account_uuid(self) -> str | None:
"""Return the Account UUID when the principal is an Account."""
return self.principal.account_uuid
@dataclasses.dataclass(frozen=True, slots=True)
class ExecutionContext:
"""Workspace context propagated to asynchronous and runtime work."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
bot_uuid: str | None = None
pipeline_uuid: str | None = None
query_uuid: str | None = None
trigger_principal: PrincipalContext | None = None
entitlement_revision: int = 0
@classmethod
def from_request(
cls,
ctx: RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Create a runtime context without losing the tenant generation."""
return cls(
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
trigger_principal=ctx.principal,
entitlement_revision=ctx.entitlement_revision,
)
__all__ = [
'ExecutionContext',
'PrincipalContext',
'PrincipalType',
'RequestContext',
'WorkspaceContext',
]
+15 -95
View File
@@ -1,97 +1,17 @@
from __future__ import annotations
"""Backward-compatible HTTP import surface for transport-neutral contexts."""
import dataclasses
import enum
from ..context import (
ExecutionContext,
PrincipalContext,
PrincipalType,
RequestContext,
WorkspaceContext,
)
class PrincipalType(enum.StrEnum):
"""Kinds of authenticated principals accepted by LangBot."""
ACCOUNT = 'account'
API_KEY = 'api_key'
SUPPORT_ADMIN = 'support_admin'
SYSTEM = 'system'
PUBLIC_BOT = 'public_bot'
@dataclasses.dataclass(frozen=True, slots=True)
class PrincipalContext:
"""Authenticated identity before Workspace authorization is applied."""
principal_type: PrincipalType
account_uuid: str | None = None
actor_account_uuid: str | None = None
api_key_uuid: str | None = None
support_session_id: str | None = None
@dataclasses.dataclass(frozen=True, slots=True)
class WorkspaceContext:
"""Workspace membership and effective permissions for one request."""
workspace_uuid: str
membership_uuid: str | None
role: str | None
permissions: frozenset[str]
membership_revision: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class RequestContext:
"""Trusted authorization context passed to HTTP services."""
instance_uuid: str
placement_generation: int
request_id: str
auth_type: str
principal: PrincipalContext
workspace: WorkspaceContext
entitlement_revision: int = 0
@property
def workspace_uuid(self) -> str:
"""Return the selected Workspace UUID."""
return self.workspace.workspace_uuid
@property
def account_uuid(self) -> str | None:
"""Return the Account UUID when the principal is an Account."""
return self.principal.account_uuid
@dataclasses.dataclass(frozen=True, slots=True)
class ExecutionContext:
"""Workspace context propagated to asynchronous and runtime work."""
instance_uuid: str
workspace_uuid: str
placement_generation: int
bot_uuid: str | None = None
pipeline_uuid: str | None = None
query_uuid: str | None = None
trigger_principal: PrincipalContext | None = None
entitlement_revision: int = 0
@classmethod
def from_request(
cls,
ctx: RequestContext,
*,
bot_uuid: str | None = None,
pipeline_uuid: str | None = None,
query_uuid: str | None = None,
) -> ExecutionContext:
"""Create a runtime context without losing the tenant generation."""
return cls(
instance_uuid=ctx.instance_uuid,
workspace_uuid=ctx.workspace_uuid,
placement_generation=ctx.placement_generation,
bot_uuid=bot_uuid,
pipeline_uuid=pipeline_uuid,
query_uuid=query_uuid,
trigger_principal=ctx.principal,
entitlement_revision=ctx.entitlement_revision,
)
__all__ = [
'ExecutionContext',
'PrincipalContext',
'PrincipalType',
'RequestContext',
'WorkspaceContext',
]
@@ -2,7 +2,7 @@ from __future__ import annotations
import quart
from .....application import ApplicationAPI
from .....application import ApplicationAPI, PipelineExtensionsUpdateInput
from ....authz import Permission, has_permission
from ....context import RequestContext
@@ -26,7 +26,7 @@ def _application_api(ap) -> ApplicationAPI:
"""
application_api = getattr(ap, 'application_api', None)
return application_api if application_api is not None else ApplicationAPI(ap)
return application_api if isinstance(application_api, ApplicationAPI) else ApplicationAPI(ap)
@group.group_class('pipelines', '/api/v1/pipelines')
@@ -89,7 +89,7 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
application_api = getattr(self.ap, 'application_api', self.ap.pipeline_service)
application_api = _application_api(self.ap)
pipeline = await application_api.get_pipeline(
request_context,
pipeline_uuid,
@@ -154,7 +154,7 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_VIEW,
)
async def _(pipeline_uuid: str, request_context: RequestContext) -> str:
application_api = getattr(self.ap, 'application_api', self.ap.pipeline_service)
application_api = _application_api(self.ap)
pipeline = await application_api.get_pipeline(request_context, pipeline_uuid)
if pipeline is None:
return self.http_status(404, -1, 'pipeline not found')
@@ -226,18 +226,20 @@ class PipelinesRouterGroup(group.RouterGroup):
'mcp_resources': 'bound_mcp_resources',
},
)
application_api = getattr(self.ap, 'application_api', self.ap.pipeline_service)
application_api = _application_api(self.ap)
await application_api.update_pipeline_extensions(
request_context,
pipeline_uuid,
bound_plugins=json_data.get('bound_plugins', []),
bound_mcp_servers=json_data.get('bound_mcp_servers', []),
enable_all_plugins=json_data.get('enable_all_plugins', True),
enable_all_mcp_servers=json_data.get('enable_all_mcp_servers', True),
bound_skills=json_data.get('bound_skills', []),
enable_all_skills=json_data.get('enable_all_skills', True),
bound_mcp_resources=json_data.get('bound_mcp_resources'),
mcp_resource_agent_read_enabled=json_data.get('mcp_resource_agent_read_enabled'),
PipelineExtensionsUpdateInput(
pipeline_uuid=pipeline_uuid,
bound_plugins=json_data.get('bound_plugins', []),
bound_mcp_servers=json_data.get('bound_mcp_servers'),
enable_all_plugins=json_data.get('enable_all_plugins', True),
enable_all_mcp_servers=json_data.get('enable_all_mcp_servers', True),
bound_skills=json_data.get('bound_skills'),
enable_all_skills=json_data.get('enable_all_skills', True),
bound_mcp_resources=json_data.get('bound_mcp_resources'),
mcp_resource_agent_read_enabled=json_data.get('mcp_resource_agent_read_enabled'),
),
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
@@ -240,7 +240,7 @@ def _application_api(ap) -> ApplicationAPI:
"""Resolve the shared facade without evaluating a legacy fallback eagerly."""
application_api = getattr(ap, 'application_api', None)
return application_api if application_api is not None else ApplicationAPI(ap)
return application_api if isinstance(application_api, ApplicationAPI) else ApplicationAPI(ap)
def _authorized(context, name: str) -> bool:
@@ -276,11 +276,13 @@ async def execute_tool(ap, context, name: str, arguments: dict):
readers = {
'models': ap.llm_model_service.get_llm_models,
'embedding_models': ap.embedding_models_service.get_embedding_models,
'pipelines': ap.pipeline_service.get_pipelines,
'knowledge_bases': ap.knowledge_service.get_knowledge_bases,
'knowledge_engines': ap.knowledge_service.list_knowledge_engines,
}
resources = await readers[args['kind']](context)
if args['kind'] == 'pipelines':
resources = await _application_api(ap).list_pipelines(context)
else:
resources = await readers[args['kind']](context)
if args['kind'] != 'knowledge_engines':
# Lists discover resources; get_pipeline / get_knowledge_schema supply configuration details.
fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id')
@@ -306,9 +308,7 @@ async def execute_tool(ap, context, name: str, arguments: dict):
if name == 'get_pipeline':
return await _application_api(ap).get_pipeline(context, args['pipeline_uuid'])
if name == 'get_pipeline_extensions':
return await getattr(ap, 'application_api', ap.pipeline_service).get_pipeline_extensions(
context, args['pipeline_uuid']
)
return await _application_api(ap).get_pipeline_extensions(context, args['pipeline_uuid'])
if name == 'get_knowledge_schema':
return {
'creation': await ap.knowledge_service.get_engine_creation_schema(context, args['plugin_id']),
+18 -16
View File
@@ -23,7 +23,7 @@ import typing
from mcp.server.fastmcp import FastMCP
from ..application import ApplicationAPI
from ..application import ApplicationAPI, PipelineExtensionsUpdateInput
from ..http.authz import Permission, require_permission
from .context import get_request_context
@@ -68,7 +68,7 @@ def _application_api(ap) -> ApplicationAPI:
"""
application_api = getattr(ap, 'application_api', None)
return application_api if application_api is not None else ApplicationAPI(ap)
return application_api if isinstance(application_api, ApplicationAPI) else ApplicationAPI(ap)
class LangBotMCPServer:
@@ -128,7 +128,7 @@ class LangBotMCPServer:
@mcp.tool(description='Read Pipeline extension bindings, enable-all switches and available extensions.')
async def get_pipeline_extensions(pipeline_uuid: str) -> str:
return _dump(
await getattr(ap, 'application_api', ap.pipeline_service).get_pipeline_extensions(_authorized(Permission.RESOURCE_VIEW), pipeline_uuid)
await _application_api(ap).get_pipeline_extensions(_authorized(Permission.RESOURCE_VIEW), pipeline_uuid)
)
@mcp.tool(description='Replace all Pipeline extension bindings. All binding lists and switches are required.')
@@ -145,19 +145,21 @@ class LangBotMCPServer:
) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
require_permission(context, Permission.RESOURCE_VIEW)
await getattr(ap, 'application_api', ap.pipeline_service).update_pipeline_extensions(
await _application_api(ap).update_pipeline_extensions(
context,
pipeline_uuid,
bound_plugins=bound_plugins,
bound_mcp_servers=bound_mcp_servers,
enable_all_plugins=enable_all_plugins,
enable_all_mcp_servers=enable_all_mcp_servers,
bound_skills=bound_skills,
enable_all_skills=enable_all_skills,
bound_mcp_resources=bound_mcp_resources,
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
PipelineExtensionsUpdateInput(
pipeline_uuid=pipeline_uuid,
bound_plugins=bound_plugins,
bound_mcp_servers=bound_mcp_servers,
bound_skills=bound_skills,
bound_mcp_resources=bound_mcp_resources,
enable_all_plugins=enable_all_plugins,
enable_all_mcp_servers=enable_all_mcp_servers,
enable_all_skills=enable_all_skills,
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
),
)
return _dump(await ap.pipeline_service.get_pipeline_extensions(context, pipeline_uuid))
return _dump(await _application_api(ap).get_pipeline_extensions(context, pipeline_uuid))
@mcp.tool(
description='Run one Pipeline turn in a fresh session. Calls configured models/tools. Never retry an unknown outcome automatically.'
@@ -319,12 +321,12 @@ class LangBotMCPServer:
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await getattr(ap, 'application_api', ap.pipeline_service).list_pipelines(context))
return _dump(await _application_api(ap).list_pipelines(context))
@mcp.tool(description='Get a single pipeline by UUID.')
async def get_pipeline(pipeline_uuid: str) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await getattr(ap, 'application_api', ap.pipeline_service).get_pipeline(context, pipeline_uuid))
return _dump(await _application_api(ap).get_pipeline(context, pipeline_uuid))
@mcp.tool(
description=(
+44 -2
View File
@@ -1,9 +1,16 @@
import inspect
import typing
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.api.application import ApplicationAPI
from langbot.pkg.api.application import (
APPLICATION_API_VERSION,
ApplicationAPI,
ApplicationSurface,
PipelineExtensionsUpdateInput,
)
from langbot.pkg.api.http.authz import Permission, PermissionDeniedError
@@ -45,6 +52,41 @@ async def test_pipeline_operation_rejects_missing_permission():
def test_catalog_is_versioned_and_workspace_scoped():
catalog = {item['id']: item for item in ApplicationAPI.operation_catalog()}
assert catalog['pipeline.list']['version'] == 'v1'
assert len(catalog) == 4
assert catalog['pipeline.list']['version'] == APPLICATION_API_VERSION
assert catalog['pipeline.list']['scope'] == 'workspace'
assert catalog['pipeline.extensions.update']['side_effect'] is True
assert catalog['pipeline.list']['input_type'] == 'PipelineListInput'
assert catalog['pipeline.list']['output_type'] == 'PipelineListOutput'
assert catalog['pipeline.extensions.update']['error_type'] == 'ApplicationAPIError'
assert set(catalog['pipeline.get']['allowed_surfaces']) == {
ApplicationSurface.HTTP.value,
ApplicationSurface.MCP.value,
ApplicationSurface.ASSISTANT.value,
ApplicationSurface.CLI.value,
}
def test_update_pipeline_extensions_has_explicit_typed_parameters():
signature = inspect.signature(ApplicationAPI.update_pipeline_extensions)
assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
assert typing.get_type_hints(ApplicationAPI.update_pipeline_extensions)['input'] is PipelineExtensionsUpdateInput
def test_application_api_is_part_of_minimal_application_graph():
service = SimpleNamespace(
get_pipelines=AsyncMock(return_value=[]),
get_pipeline=AsyncMock(return_value=None),
get_pipeline_extensions=AsyncMock(return_value={}),
update_pipeline_extensions=AsyncMock(),
)
graph = SimpleNamespace(pipeline_service=service)
graph.application_api = ApplicationAPI(graph)
assert graph.application_api.ap is graph
assert {item['id'] for item in graph.application_api.operation_catalog()} == {
'pipeline.list',
'pipeline.get',
'pipeline.extensions.get',
'pipeline.extensions.update',
}
@@ -0,0 +1,43 @@
import httpx
from langbot.pkg.api.application.client import ApplicationAPIClient
def test_application_client_uses_http_service_api_for_pipeline_operations():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.method, request.url.path, request.read()))
if request.url.path == '/api/v1/pipelines':
return httpx.Response(200, json={'code': 0, 'data': {'pipelines': [{'uuid': 'p1'}]}})
if request.url.path == '/api/v1/pipelines/p1':
return httpx.Response(200, json={'code': 0, 'data': {'pipeline': {'uuid': 'p1'}}})
if request.url.path.endswith('/extensions') and request.method == 'GET':
return httpx.Response(200, json={'code': 0, 'data': {'bound_plugins': []}})
return httpx.Response(200, json={'code': 0, 'data': {}})
client = ApplicationAPIClient(
'http://langbot.test',
api_key='test-key',
transport=httpx.MockTransport(handler),
)
try:
assert client.list_pipelines() == [{'uuid': 'p1'}]
assert client.get_pipeline('p1') == {'uuid': 'p1'}
assert client.get_pipeline_extensions('p1') == {'bound_plugins': []}
client.update_pipeline_extensions(
'p1',
bound_plugins=[],
bound_mcp_servers=[],
bound_skills=[],
bound_mcp_resources=[],
enable_all_plugins=False,
enable_all_mcp_servers=False,
enable_all_skills=False,
mcp_resource_agent_read_enabled=False,
)
finally:
client.close()
assert [method for method, _path, _body in calls] == ['GET', 'GET', 'GET', 'PUT']
assert all(body == b'' for _method, _path, body in calls[:3])
@@ -0,0 +1,9 @@
from langbot.pkg.api.context import ExecutionContext, RequestContext
from langbot.pkg.api.http.context import ExecutionContext as HTTPExecutionContext
from langbot.pkg.api.http.context import RequestContext as HTTPRequestContext
def test_application_context_types_are_http_independent_and_compatibly_reexported():
assert RequestContext is HTTPRequestContext
assert ExecutionContext is HTTPExecutionContext
assert RequestContext.__module__ == 'langbot.pkg.api.context'