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
+132
View File
@@ -0,0 +1,132 @@
"""End-to-end test: boot the real MCPMount on a port and drive it with an MCP client.
Exercises the ASGI dispatcher (auth + /mcp routing), the FastMCP streamable-HTTP
transport, and a real tool call against the (mocked) service layer.
Run: uv run --no-sync python tests/manual/mcp_smoke.py
"""
from __future__ import annotations
import asyncio
import contextlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
from hypercorn.asyncio import serve
from hypercorn.config import Config
from quart import Quart
from langbot.pkg.api.mcp.mount import MCPMount
PORT = 5399
GLOBAL_KEY = 'test-global-key-123'
def build_ap() -> SimpleNamespace:
ap = SimpleNamespace()
ap.instance_config = SimpleNamespace(
data={'api': {'global_api_key': GLOBAL_KEY}, 'system': {'edition': 'community', 'instance_id': 'inst-1'}}
)
ap.ver_mgr = SimpleNamespace(get_current_version=lambda: '4.5.0-test')
ap.logger = SimpleNamespace(info=print, error=print, warning=print)
# API key verification: reuse real logic shape (global key match)
async def verify_api_key(key: str) -> bool:
return bool(key) and key == GLOBAL_KEY
ap.apikey_service = SimpleNamespace(verify_api_key=verify_api_key)
ap.bot_service = SimpleNamespace(
get_bots=AsyncMock(return_value=[{'uuid': 'bot-1', 'name': 'Demo Bot', 'adapter': 'telegram'}])
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'pl-1', 'name': 'default'}]))
ap.llm_model_service = SimpleNamespace(get_llm_models=AsyncMock(return_value=[]))
ap.embedding_models_service = SimpleNamespace(get_embedding_models=AsyncMock(return_value=[]))
ap.provider_service = SimpleNamespace(get_providers=AsyncMock(return_value=[]))
ap.knowledge_service = SimpleNamespace(get_knowledge_bases=AsyncMock(return_value=[]))
ap.mcp_service = SimpleNamespace(get_mcp_servers=AsyncMock(return_value=[]))
ap.skill_service = SimpleNamespace(list_skills=AsyncMock(return_value=[{'name': 'demo-skill'}]))
return ap
async def run_server(mount: MCPMount, shutdown: asyncio.Event) -> None:
quart_app = Quart(__name__)
@quart_app.route('/healthz')
async def healthz():
return {'code': 0, 'msg': 'ok'}
config = Config()
config.bind = [f'127.0.0.1:{PORT}']
config.accesslog = None
asgi = mount.wrap(quart_app)
await serve(asgi, config, shutdown_trigger=shutdown.wait)
async def main() -> int:
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamablehttp_client
ap = build_ap()
mount = MCPMount(ap)
await mount.start_session_manager()
shutdown = asyncio.Event()
server_task = asyncio.create_task(run_server(mount, shutdown))
await asyncio.sleep(1.0) # let the server bind
url = f'http://127.0.0.1:{PORT}/mcp'
failures = []
# 1. Unauthorized request is rejected.
import httpx
async with httpx.AsyncClient() as client:
r = await client.post(url, json={'jsonrpc': '2.0', 'id': 1, 'method': 'ping'})
if r.status_code != 401:
failures.append(f'expected 401 without key, got {r.status_code}')
else:
print('PASS: unauthorized request rejected (401)')
# 2. Authorized MCP session: list tools + call two.
headers = {'X-API-Key': GLOBAL_KEY}
async with streamablehttp_client(url, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = [t.name for t in tools.tools]
print(f'PASS: listed {len(names)} tools')
for required in ('list_bots', 'get_system_info', 'list_skills'):
if required not in names:
failures.append(f'missing tool {required}')
res = await session.call_tool('list_bots', {})
text = res.content[0].text if res.content else ''
if 'Demo Bot' not in text:
failures.append(f'list_bots did not return expected data: {text!r}')
else:
print('PASS: list_bots returned bot data')
res2 = await session.call_tool('get_system_info', {})
text2 = res2.content[0].text if res2.content else ''
if '4.5.0-test' not in text2:
failures.append(f'get_system_info wrong: {text2!r}')
else:
print('PASS: get_system_info returned version')
shutdown.set()
with contextlib.suppress(Exception):
await asyncio.wait_for(server_task, timeout=5)
await mount.stop_session_manager()
if failures:
print('\nFAILURES:')
for f in failures:
print(' -', f)
return 1
print('\nALL MCP SMOKE CHECKS PASSED')
return 0
if __name__ == '__main__':
raise SystemExit(asyncio.run(main()))
@@ -255,16 +255,22 @@ class TestApiKeyServiceGetApiKey:
class TestApiKeyServiceVerifyApiKey:
"""Tests for verify_api_key method."""
@staticmethod
def _make_ap(db_key=None, global_api_key=''):
"""Build a mock Application with persistence + instance_config."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=db_key)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.instance_config = SimpleNamespace(data={'api': {'global_api_key': global_api_key}})
return ap
async def test_verify_api_key_valid(self):
"""Returns True for valid API key."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
key = Mock(spec=ApiKey)
mock_result = Mock()
mock_result.first = Mock(return_value=key)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap = self._make_ap(db_key=key)
service = ApiKeyService(ap)
@@ -277,12 +283,7 @@ class TestApiKeyServiceVerifyApiKey:
async def test_verify_api_key_invalid(self):
"""Returns False for invalid API key."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap = self._make_ap(db_key=None)
service = ApiKeyService(ap)
@@ -295,12 +296,7 @@ class TestApiKeyServiceVerifyApiKey:
async def test_verify_api_key_empty_string(self):
"""Returns False for empty key string."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap = self._make_ap(db_key=None)
service = ApiKeyService(ap)
@@ -313,12 +309,7 @@ class TestApiKeyServiceVerifyApiKey:
async def test_verify_api_key_unknown_key(self):
"""Returns False when the key is not present in persistence."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap = self._make_ap(db_key=None)
service = ApiKeyService(ap)
@@ -328,6 +319,70 @@ class TestApiKeyServiceVerifyApiKey:
# Verify
assert result is False
async def test_verify_global_api_key_match(self):
"""Returns True when key matches the config.yaml global API key (no DB lookup)."""
# Setup: no DB record, but a global key is configured
ap = self._make_ap(db_key=None, global_api_key='my-global-secret')
service = ApiKeyService(ap)
# Execute
result = await service.verify_api_key('my-global-secret')
# Verify: accepted purely on config match
assert result is True
# DB should not have been consulted for the global-key path
ap.persistence_mgr.execute_async.assert_not_called()
async def test_verify_global_api_key_no_prefix_required(self):
"""Global API key is accepted even without the lbk_ prefix."""
ap = self._make_ap(db_key=None, global_api_key='plainsecret123')
service = ApiKeyService(ap)
result = await service.verify_api_key('plainsecret123')
assert result is True
async def test_verify_global_api_key_mismatch_falls_back_to_db(self):
"""A non-matching key still falls through to the DB lookup."""
# Global key set, but request uses a different lbk_ key that IS in DB
key = Mock(spec=ApiKey)
ap = self._make_ap(db_key=key, global_api_key='my-global-secret')
service = ApiKeyService(ap)
result = await service.verify_api_key('lbk_db_key')
assert result is True
ap.persistence_mgr.execute_async.assert_called_once()
async def test_verify_empty_global_api_key_disabled(self):
"""An empty global_api_key must never authenticate an empty/blank request."""
ap = self._make_ap(db_key=None, global_api_key='')
service = ApiKeyService(ap)
# Empty request key is rejected, and a blank global key never matches
assert await service.verify_api_key('') is False
assert await service.verify_api_key(' ') is False
async def test_verify_api_key_missing_global_config_key(self):
"""Works even when api.global_api_key is absent (existing installs)."""
# instance_config without the global_api_key field at all
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
mock_result = Mock()
mock_result.first = Mock(return_value=None)
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.instance_config = SimpleNamespace(data={'api': {}})
service = ApiKeyService(ap)
result = await service.verify_api_key('lbk_some_key')
assert result is False
class TestApiKeyServiceDeleteApiKey:
"""Tests for delete_api_key method."""
+4 -2
View File
@@ -12,7 +12,8 @@ from langbot.pkg.api.http.service.apikey import ApiKeyService
@pytest.mark.parametrize('api_key', [None, 123, b'lbk_bytes', '', 'plain_key', ' LBK_bad', 'sk-lbk_fake'])
async def test_verify_api_key_rejects_non_lbk_keys_without_db_query(api_key):
persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr))
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
result = await service.verify_api_key(api_key)
@@ -32,7 +33,8 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(db_row, expected)
query_result = Mock()
query_result.first.return_value = db_row
persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=query_result))
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr))
instance_config = SimpleNamespace(data={'api': {'global_api_key': ''}})
service = ApiKeyService(SimpleNamespace(persistence_mgr=persistence_mgr, instance_config=instance_config))
result = await service.verify_api_key('lbk_valid_format')