feat: MCP server + in-repo skills (agent-friendly platform) (#2269)

* feat(api): support global API key from config.yaml (api.global_api_key)

Accept a config-defined global API key anywhere a web-UI key is accepted
(X-API-Key / Bearer), with no login session and no DB record. Useful for
automated deployments and AI agents (HTTP API + MCP). Defaults to empty
(disabled); does not require the lbk_ prefix.

- templates/config.yaml: add api.global_api_key with security notes
- service/apikey.py: verify_api_key checks global key first (constant-time)
- docs/API_KEY_AUTH.md: document the global key + security guidance
- tests: cover global-key match, prefix-free, fallback-to-db, disabled

* feat(mcp): expose LangBot management as an MCP server at /mcp

Add an MCP (Model Context Protocol) server so external AI agents can manage a
LangBot instance. Reuses the same API-key auth as the HTTP API (including the
config.yaml global API key).

- pkg/api/mcp/server.py: FastMCP server wrapping the service layer; 21 curated
  tools across system/bots/pipelines/models/knowledge/mcp-servers/skills
- pkg/api/mcp/mount.py: ASGI dispatcher fronting Quart; authenticates /mcp
  requests with an API key, runs the streamable-HTTP session manager lifespan
- controller/main.py: serve the wrapped ASGI app via hypercorn (was run_task)
- web: new 'MCP' tab in the API integration dialog showing endpoint, auth, and
  client config; i18n for 8 locales
- tests/manual/mcp_smoke.py: e2e check (401 unauth, list tools, call tools)

Tool surface is intentionally curated (not all ~25 route groups) to keep the
agent surface small, safe, and maintainable. Extend deliberately.

* feat(skills): add in-repo skills/ as the single source of truth

Migrate the agent skills + QA/e2e test harness from the (now archived)
langbot-app/langbot-skills repo into LangBot/skills/, and add four new skills.

Migrated:
- langbot-plugin-dev, langbot-testing (e2e), langbot-env-setup,
  langbot-skills-maintenance, langbot-eba-adapter-dev
- the bin/lbs CLI (src/, test/, scripts/, schemas/, qa-agent-docs/)

New:
- langbot-dev      core backend + web development
- langbot-deploy   Docker/K8s deployment + config.yaml + global API key
- langbot-mcp-ops  operating the LangBot MCP server (/mcp)
- langbot-space-ops operating the Space marketplace MCP server

- src/cli.ts repoRoot(): recognize the skills assets root (skills.index.json +
  bin/lbs) so the CLI works when nested inside the LangBot repo
- README.md: unified skill catalog; skills.index.json regenerated

Parity with source verified: bin/lbs validate + node test suite match the
source repo (only the uncommitted .lbpkg build-artifact fixture differs).

* docs(agents): document agent-facing surfaces + API/MCP/skills sync rule

* docs(readme): add 'Built for AI Agents' section across all locales

Highlight MCP server, in-repo skills (single source of truth), AGENTS.md
sync rule, and llms.txt. Cross-link LangBot Space MCP marketplace.

* style(mcp): fix ruff format + prettier lint in MCP server and API panel

* style(web): prettier format MCP i18n locale entries

* docs(skills): note MCP instance control in dev/testing skills

All development-guidance skills now point to the LangBot instance MCP
server (/mcp) and the Space marketplace MCP server, reusing API keys.
This commit is contained in:
Junyan Chin
2026-06-20 15:14:47 +08:00
committed by GitHub
parent 91906d73be
commit e9dd584792
214 changed files with 25227 additions and 31 deletions
+34 -1
View File
@@ -17,6 +17,7 @@ from .groups import platform as groups_platform
from .groups import pipelines as groups_pipelines
from .groups import knowledge as groups_knowledge
from .groups import resources as groups_resources
from ...mcp.mount import MCPMount
importutil.import_modules_in_pkg(groups)
importutil.import_modules_in_pkg(groups_provider)
@@ -39,6 +40,10 @@ class HTTPController:
# Set maximum content length to prevent large file uploads
self.quart_app.config['MAX_CONTENT_LENGTH'] = group.MAX_FILE_SIZE
# MCP server (mounted at /mcp, see ..mcp.mount). Built lazily in
# initialize() so the service layer is ready.
self.mcp_mount: MCPMount | None = None
async def initialize(self) -> None:
# Register custom error handler for file size limit
@self.quart_app.errorhandler(RequestEntityTooLarge)
@@ -52,6 +57,12 @@ class HTTPController:
await self.register_routes()
# Build the MCP server and start its session-manager lifespan in the
# background so the streamable-HTTP transport is ready to serve.
self.mcp_mount = MCPMount(self.ap)
await self.mcp_mount.start_session_manager()
self.ap.logger.info('LangBot MCP server mounted at /mcp (API-key authenticated).')
async def run(self) -> None:
if True:
@@ -61,7 +72,7 @@ class HTTPController:
async def exception_handler(*args, **kwargs):
try:
await self.quart_app.run_task(*args, **kwargs)
await self._run_task(*args, **kwargs)
except Exception as e:
self.ap.logger.error(f'Failed to start HTTP service: {e}')
@@ -77,6 +88,28 @@ class HTTPController:
# await asyncio.sleep(5)
async def _run_task(self, host: str, port: int, shutdown_trigger) -> None:
"""Serve the Quart app, fronted by the MCP dispatcher at /mcp.
Mirrors Quart.run_task() but wraps the ASGI app so MCP requests are
intercepted before Quart's router. Falls back to plain Quart if the
MCP mount failed to build for any reason.
"""
from hypercorn.config import Config as HyperConfig
from hypercorn.asyncio import serve as hypercorn_serve
config = HyperConfig()
config.access_log_format = '%(h)s %(r)s %(s)s %(b)s %(D)s'
config.accesslog = '-'
config.bind = [f'{host}:{port}']
config.errorlog = config.accesslog
asgi_app = self.quart_app
if self.mcp_mount is not None:
asgi_app = self.mcp_mount.wrap(self.quart_app)
await hypercorn_serve(asgi_app, config, shutdown_trigger=shutdown_trigger)
async def register_routes(self) -> None:
@self.quart_app.route('/healthz')
async def healthz():
+19 -2
View File
@@ -51,8 +51,25 @@ class ApiKeyService:
return self.ap.persistence_mgr.serialize_model(apikey.ApiKey, key)
async def verify_api_key(self, key: str) -> bool:
"""Verify if an API key is valid"""
if not isinstance(key, str) or not key.startswith('lbk_'):
"""Verify if an API key is valid.
A key is accepted if it matches the global API key configured in
``config.yaml`` (``api.global_api_key``) — which requires no login
session and no database record — or if it matches a key created via
the web UI (stored in the database, prefixed with ``lbk_``).
"""
if not isinstance(key, str) or not key:
return False
# 1. Global API key from config.yaml (no DB lookup, no login state).
# Note: config completion only backfills top-level keys, so existing
# installs may not have this key — access it defensively.
global_api_key = self.ap.instance_config.data.get('api', {}).get('global_api_key', '')
if global_api_key and secrets.compare_digest(key, global_api_key):
return True
# 2. Web-UI-created keys are stored in the database and prefixed lbk_.
if not key.startswith('lbk_'):
return False
result = await self.ap.persistence_mgr.execute_async(
+14
View File
@@ -0,0 +1,14 @@
"""LangBot MCP (Model Context Protocol) server.
This package exposes a subset of LangBot's HTTP service API as MCP tools so
that external AI agents can manage a LangBot instance through the MCP
protocol. The MCP server reuses the same API-key authentication as the HTTP
API (including the global API key from ``config.yaml``).
See ``server.py`` for the tool surface and ``mount.py`` for the ASGI
integration with the Quart HTTP app.
"""
from .server import LangBotMCPServer
__all__ = ['LangBotMCPServer']
+112
View File
@@ -0,0 +1,112 @@
"""ASGI integration: serve the LangBot MCP server alongside the Quart HTTP app.
The Quart app and the MCP server are both ASGI apps. We front them with a small
dispatcher ASGI callable:
- Requests whose path is (or is under) ``/mcp`` are authenticated with a
LangBot API key (reusing ``apikey_service.verify_api_key``, which also
accepts the global API key from ``config.yaml``) and then handed to the
FastMCP Starlette app.
- Every other request goes to the Quart app unchanged.
The FastMCP streamable-HTTP transport requires its session manager's lifespan
to be running. Rather than rely on the dispatcher receiving ASGI lifespan
events (Quart owns those), we explicitly run the session manager in a background
task managed by LangBot's task manager.
"""
from __future__ import annotations
import contextlib
import typing
from .server import LangBotMCPServer
if typing.TYPE_CHECKING:
from ...core import app as app_module
# JSON-RPC-ish 401 body returned before the MCP app is reached.
_UNAUTHORIZED_BODY = b'{"error":"unauthorized","message":"A valid LangBot API key is required for MCP access."}'
def _extract_api_key(headers: list[tuple[bytes, bytes]]) -> str:
"""Pull an API key from ASGI headers (X-API-Key or Authorization: Bearer)."""
header_map = {k.lower(): v for k, v in headers}
api_key = header_map.get(b'x-api-key', b'').decode('latin-1').strip()
if api_key:
return api_key
auth = header_map.get(b'authorization', b'').decode('latin-1').strip()
if auth.lower().startswith('bearer '):
return auth[7:].strip()
return ''
class MCPMount:
"""Owns the MCP server and produces the dispatcher ASGI app."""
MCP_PATH_PREFIX = '/mcp'
def __init__(self, ap: app_module.Application) -> None:
self.ap = ap
self.server = LangBotMCPServer(ap)
self._mcp_asgi = self.server.streamable_http_app()
self._lifespan_cm: typing.Any = None
async def start_session_manager(self) -> None:
"""Run the MCP session manager lifespan in the background.
StreamableHTTPSessionManager.run() is a one-shot async context manager
(it may only be entered once). We keep it open for the process lifetime;
it is torn down when the event loop stops.
"""
cm = self.server.session_manager.run()
self._lifespan_cm = cm
await cm.__aenter__()
async def stop_session_manager(self) -> None:
if self._lifespan_cm is not None:
with contextlib.suppress(Exception):
await self._lifespan_cm.__aexit__(None, None, None)
self._lifespan_cm = None
def _is_mcp_path(self, path: str) -> bool:
return path == self.MCP_PATH_PREFIX or path.startswith(self.MCP_PATH_PREFIX + '/')
def wrap(self, quart_asgi: typing.Callable) -> typing.Callable:
"""Return a dispatcher ASGI app fronting ``quart_asgi``."""
mcp_asgi = self._mcp_asgi
verify_api_key = self.ap.apikey_service.verify_api_key
is_mcp_path = self._is_mcp_path
async def dispatcher(scope, receive, send): # type: ignore[no-untyped-def]
# Pass through non-HTTP scopes (lifespan, websocket) to Quart so its
# own startup/shutdown and websocket routes keep working.
if scope['type'] != 'http' or not is_mcp_path(scope.get('path', '')):
await quart_asgi(scope, receive, send)
return
# Authenticate MCP HTTP requests with a LangBot API key.
api_key = _extract_api_key(scope.get('headers', []))
authorized = False
if api_key:
with contextlib.suppress(Exception):
authorized = await verify_api_key(api_key)
if not authorized:
await send(
{
'type': 'http.response.start',
'status': 401,
'headers': [
(b'content-type', b'application/json'),
(b'www-authenticate', b'Bearer'),
],
}
)
await send({'type': 'http.response.body', 'body': _UNAUTHORIZED_BODY})
return
await mcp_asgi(scope, receive, send)
return dispatcher
+204
View File
@@ -0,0 +1,204 @@
"""LangBot MCP server definition.
Wraps a curated subset of LangBot's HTTP service API as MCP tools. Tools call
the existing service layer directly (not the HTTP API over the network), so the
MCP surface stays aligned with the API by construction.
IMPORTANT: when you add, remove, or change an HTTP API endpoint that should be
agent-accessible, update the corresponding MCP tool here AND the skills under
``skills/`` (see AGENTS.md). The MCP tool surface and the API must stay aligned.
Scope (first version): core read operations plus the most common writes for
bots, pipelines, LLM/embedding models, knowledge bases, MCP servers, skills,
and read-only system info. This intentionally does NOT expose every one of the
~25 HTTP route groups — that keeps the agent surface small, safe, and
maintainable. Extend deliberately.
"""
from __future__ import annotations
import json
import typing
from mcp.server.fastmcp import FastMCP
if typing.TYPE_CHECKING:
from ...core import app as app_module
INSTRUCTIONS = """\
This MCP server manages a LangBot instance. LangBot is an LLM-native instant
messaging bot platform. Use these tools to inspect and manage bots, pipelines,
models, knowledge bases, MCP servers, and skills.
Authentication uses a LangBot API key (web-UI-created `lbk_...` key or the
global API key from config.yaml), passed as the `X-API-Key` header or
`Authorization: Bearer <key>`.
Prefer the `list_*` / `get_*` tools to discover resources before mutating. All
identifiers are UUIDs unless noted. Mutating tools take JSON objects matching
the same shape as the LangBot HTTP API request bodies.
"""
def _dump(value: typing.Any) -> str:
"""Serialize a tool result to a compact JSON string for the agent."""
return json.dumps(value, ensure_ascii=False, default=str)
class LangBotMCPServer:
"""Builds and owns the FastMCP instance for LangBot."""
def __init__(self, ap: app_module.Application) -> None:
self.ap = ap
# Stateless HTTP so the server does not need sticky sessions behind a
# load balancer; json_response keeps responses simple (no SSE stream
# required for unary tool calls).
self.mcp = FastMCP(
name='LangBot',
instructions=INSTRUCTIONS,
stateless_http=True,
json_response=True,
)
self._register_tools()
# ------------------------------------------------------------------ #
# Tool registration
# ------------------------------------------------------------------ #
def _register_tools(self) -> None:
ap = self.ap
mcp = self.mcp
# ----- System (read-only) -------------------------------------- #
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
async def get_system_info() -> str:
version = None
try:
version = ap.ver_mgr.get_current_version()
except Exception:
pass
data = {
'version': version,
'edition': ap.instance_config.data.get('system', {}).get('edition'),
'instance_id': ap.instance_config.data.get('system', {}).get('instance_id'),
}
return _dump(data)
# ----- Bots ---------------------------------------------------- #
@mcp.tool(description='List all messaging-platform bots. Secrets are redacted.')
async def list_bots() -> str:
return _dump(await ap.bot_service.get_bots(include_secret=False))
@mcp.tool(description='Get a single bot by its UUID. Secrets are redacted.')
async def get_bot(bot_uuid: str) -> str:
return _dump(await ap.bot_service.get_bot(bot_uuid, include_secret=False))
@mcp.tool(
description=(
'Create a bot. `bot_data` is a JSON object matching the LangBot '
'POST /api/v1/platform/bots body (e.g. name, adapter, config). '
'Returns the new bot UUID.'
)
)
async def create_bot(bot_data: dict) -> str:
return _dump({'uuid': await ap.bot_service.create_bot(bot_data)})
@mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.')
async def update_bot(bot_uuid: str, bot_data: dict) -> str:
await ap.bot_service.update_bot(bot_uuid, bot_data)
return _dump({'ok': True})
@mcp.tool(description='Delete a bot by UUID.')
async def delete_bot(bot_uuid: str) -> str:
await ap.bot_service.delete_bot(bot_uuid)
return _dump({'ok': True})
# ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
return _dump(await ap.pipeline_service.get_pipelines())
@mcp.tool(description='Get a single pipeline by UUID.')
async def get_pipeline(pipeline_uuid: str) -> str:
return _dump(await ap.pipeline_service.get_pipeline(pipeline_uuid))
@mcp.tool(
description=(
'Create a pipeline. `pipeline_data` matches the LangBot POST '
'/api/v1/pipelines body. Returns the new pipeline UUID.'
)
)
async def create_pipeline(pipeline_data: dict) -> str:
return _dump({'uuid': await ap.pipeline_service.create_pipeline(pipeline_data)})
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
await ap.pipeline_service.update_pipeline(pipeline_uuid, pipeline_data)
return _dump({'ok': True})
@mcp.tool(description='Delete a pipeline by UUID.')
async def delete_pipeline(pipeline_uuid: str) -> str:
await ap.pipeline_service.delete_pipeline(pipeline_uuid)
return _dump({'ok': True})
# ----- Models -------------------------------------------------- #
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
async def list_llm_models() -> str:
return _dump(await ap.llm_model_service.get_llm_models(include_secret=False))
@mcp.tool(description='Get a single LLM model by UUID.')
async def get_llm_model(model_uuid: str) -> str:
return _dump(await ap.llm_model_service.get_llm_model(model_uuid))
@mcp.tool(description='List all configured embedding models.')
async def list_embedding_models() -> str:
return _dump(await ap.embedding_models_service.get_embedding_models())
@mcp.tool(description='List all model providers (OpenAI-compatible, Anthropic, etc.).')
async def list_model_providers() -> str:
return _dump(await ap.provider_service.get_providers())
# ----- Knowledge bases ----------------------------------------- #
@mcp.tool(description='List all knowledge bases (RAG).')
async def list_knowledge_bases() -> str:
return _dump(await ap.knowledge_service.get_knowledge_bases())
@mcp.tool(description='Get a single knowledge base by UUID.')
async def get_knowledge_base(kb_uuid: str) -> str:
return _dump(await ap.knowledge_service.get_knowledge_base(kb_uuid))
@mcp.tool(
description=('Retrieve (semantic search) from a knowledge base. Returns the matched chunks for `query`.')
)
async def retrieve_knowledge_base(kb_uuid: str, query: str) -> str:
return _dump(await ap.knowledge_service.retrieve_knowledge_base(kb_uuid, query))
# ----- MCP servers (LangBot as MCP client) --------------------- #
@mcp.tool(
description=(
'List external MCP servers registered in LangBot (the servers LangBot itself connects to as a client).'
)
)
async def list_mcp_servers() -> str:
return _dump(await ap.mcp_service.get_mcp_servers())
# ----- Skills -------------------------------------------------- #
@mcp.tool(description='List installed skills.')
async def list_skills() -> str:
return _dump(await ap.skill_service.list_skills())
@mcp.tool(description='Get a single skill by name.')
async def get_skill(skill_name: str) -> str:
return _dump(await ap.skill_service.get_skill(skill_name))
# ------------------------------------------------------------------ #
# ASGI app
# ------------------------------------------------------------------ #
def streamable_http_app(self): # type: ignore[no-untyped-def]
"""Return the Starlette ASGI app serving MCP over streamable HTTP at /mcp."""
return self.mcp.streamable_http_app()
@property
def session_manager(self): # type: ignore[no-untyped-def]
"""Expose the session manager so its lifespan can be run by the host."""
return self.mcp.session_manager