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(