mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-21 01:40:58 +00:00
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:
@@ -0,0 +1,341 @@
|
||||
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { env } from "node:process";
|
||||
|
||||
const secretRe = /(?:authorization|bearer|token|secret|password|api[_-]?key|jwt|oauth)\s*[:=]\s*["']?[^"',\s]+/gi;
|
||||
|
||||
export function redact(text) {
|
||||
return String(text ?? "")
|
||||
.replace(secretRe, (match) => match.replace(/[:=]\s*["']?.*$/, "=[redacted]"))
|
||||
.replace(/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]")
|
||||
.replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]");
|
||||
}
|
||||
|
||||
export function timestampSlug(date = new Date()) {
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function localIsoWithOffset(date = new Date()) {
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absolute = Math.abs(offsetMinutes);
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = pad(date.getMonth() + 1);
|
||||
const dd = pad(date.getDate());
|
||||
const hh = pad(date.getHours());
|
||||
const mi = pad(date.getMinutes());
|
||||
const ss = pad(date.getSeconds());
|
||||
const ms = String(date.getMilliseconds()).padStart(3, "0");
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}.${ms}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
|
||||
}
|
||||
|
||||
export function evidencePaths(caseId) {
|
||||
const runId = env.LBS_RUN_ID || `${timestampSlug()}-${caseId}`;
|
||||
const evidenceDir = resolve(env.LBS_EVIDENCE_DIR || join("reports", "evidence", runId));
|
||||
return {
|
||||
runId,
|
||||
evidenceDir,
|
||||
consoleLog: join(evidenceDir, "console.log"),
|
||||
networkLog: join(evidenceDir, "network.log"),
|
||||
screenshot: join(evidenceDir, "screenshot.png"),
|
||||
automationResultJson: join(evidenceDir, "automation-result.json"),
|
||||
resultJson: join(evidenceDir, "result.json"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureEvidence(paths) {
|
||||
await mkdir(paths.evidenceDir, { recursive: true });
|
||||
await appendFile(paths.consoleLog, "", "utf8");
|
||||
await appendFile(paths.networkLog, "", "utf8");
|
||||
}
|
||||
|
||||
export async function pathExists(path) {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendLine(path, line) {
|
||||
await appendFile(path, `[${localIsoWithOffset()}] ${redact(line)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function writeResult(paths, result) {
|
||||
const text = `${JSON.stringify(result, null, 2)}\n`;
|
||||
if (paths.automationResultJson) await writeFile(paths.automationResultJson, text, "utf8");
|
||||
if (paths.resultJson && paths.resultJson !== paths.automationResultJson) {
|
||||
await writeFile(paths.resultJson, text, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEnvFiles(paths = ["skills/.env", "skills/.env.local"]) {
|
||||
for (const path of paths) {
|
||||
let text = "";
|
||||
try {
|
||||
text = await readFile(path, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const equals = trimmed.indexOf("=");
|
||||
if (equals <= 0) continue;
|
||||
const key = trimmed.slice(0, equals).trim();
|
||||
const value = trimmed.slice(equals + 1).trim().replace(/^["']|["']$/g, "");
|
||||
if (!(key in env)) env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRecoveryKey(repo = env.LANGBOT_REPO || "../LangBot") {
|
||||
const configPath = resolve(repo, "data/config.yaml");
|
||||
const config = await readFile(configPath, "utf8");
|
||||
const match = config.match(/^\s*recovery_key:\s*['"]?([^'"\s#]+)['"]?\s*$/m);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
export async function apiJson(backendUrl, path, { method = "GET", token = "", body } = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(`${backendUrl.replace(/\/$/, "")}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
json: await response.json().catch(() => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkBackendToken(backendUrl, token) {
|
||||
if (!token) {
|
||||
return { authenticated: false, http_status: 0, code: null, reason: "No token." };
|
||||
}
|
||||
const response = await apiJson(backendUrl, "/api/v1/user/check-token", { token });
|
||||
const code = response.json.code ?? null;
|
||||
const authenticated = response.status < 400 && code === 0;
|
||||
return {
|
||||
authenticated,
|
||||
http_status: response.status,
|
||||
code,
|
||||
reason: authenticated ? "Token accepted by backend." : response.json.msg || "Backend rejected token.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetAndAuthLocalUser({ backendUrl, user, password, recoveryKey = "" }) {
|
||||
const key = recoveryKey || await readRecoveryKey();
|
||||
if (!key) throw new Error("Could not read recovery_key from LangBot config.");
|
||||
|
||||
const reset = await apiJson(backendUrl, "/api/v1/user/reset-password", {
|
||||
method: "POST",
|
||||
body: {
|
||||
user,
|
||||
recovery_key: key,
|
||||
new_password: password,
|
||||
},
|
||||
});
|
||||
if (reset.status >= 400 || reset.json.code !== 0) {
|
||||
throw new Error(reset.json.msg || `Password reset failed with HTTP ${reset.status}.`);
|
||||
}
|
||||
|
||||
const auth = await apiJson(backendUrl, "/api/v1/user/auth", {
|
||||
method: "POST",
|
||||
body: { user, password },
|
||||
});
|
||||
const token = auth.json.data?.token || "";
|
||||
if (auth.status >= 400 || auth.json.code !== 0 || !token) {
|
||||
throw new Error(auth.json.msg || `Auth failed with HTTP ${auth.status}.`);
|
||||
}
|
||||
|
||||
const check = await checkBackendToken(backendUrl, token);
|
||||
if (!check.authenticated) {
|
||||
throw new Error(check.reason || "Authenticated token failed backend token check.");
|
||||
}
|
||||
|
||||
return { token, check };
|
||||
}
|
||||
|
||||
export async function setBrowserToken(page, frontendUrl, token) {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("token", value);
|
||||
}, token);
|
||||
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate((value) => localStorage.setItem("token", value), token);
|
||||
}
|
||||
|
||||
export async function verifyBrowserToken(page, backendUrl) {
|
||||
return await page.evaluate(async (baseUrl) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
return { authenticated: false, http_status: 0, code: null, reason: "No localStorage token." };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/user/check-token`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const json = await response.json().catch(() => ({}));
|
||||
const code = json.code ?? null;
|
||||
const authenticated = response.status < 400 && code === 0;
|
||||
return {
|
||||
authenticated,
|
||||
http_status: response.status,
|
||||
code,
|
||||
reason: authenticated ? "Token accepted by backend." : json.msg || "Backend rejected token.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
http_status: 0,
|
||||
code: null,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
}, backendUrl);
|
||||
}
|
||||
|
||||
export function exitCode(status) {
|
||||
if (status === "pass") return 0;
|
||||
if (status === "blocked" || status === "env_issue") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function loadPlaywright() {
|
||||
try {
|
||||
return await import("playwright");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Playwright is not installed. Install it in this repo with `npm install --save-dev playwright`, then run `npx playwright install chromium`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBrowser(paths) {
|
||||
const { chromium } = await loadPlaywright();
|
||||
const headed = env.LBS_HEADED === "1";
|
||||
const launchOptions = {
|
||||
headless: !headed,
|
||||
};
|
||||
if (env.LANGBOT_CHROMIUM_EXECUTABLE && await pathExists(env.LANGBOT_CHROMIUM_EXECUTABLE)) {
|
||||
launchOptions.executablePath = env.LANGBOT_CHROMIUM_EXECUTABLE;
|
||||
}
|
||||
|
||||
let browser;
|
||||
let context;
|
||||
if (env.LANGBOT_BROWSER_PROFILE) {
|
||||
context = await chromium.launchPersistentContext(resolve(env.LANGBOT_BROWSER_PROFILE), {
|
||||
...launchOptions,
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
} else {
|
||||
browser = await chromium.launch(launchOptions);
|
||||
context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||
}
|
||||
const page = context.pages()[0] || await context.newPage();
|
||||
|
||||
page.on("console", (message) => {
|
||||
appendLine(paths.consoleLog, `[${message.type()}] ${message.text()}`).catch(() => {});
|
||||
});
|
||||
page.on("pageerror", (error) => {
|
||||
appendLine(paths.consoleLog, `[pageerror] ${error.message}`).catch(() => {});
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
appendLine(paths.networkLog, `[requestfailed] ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`).catch(() => {});
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() < 400) return;
|
||||
appendLine(paths.networkLog, `[response] ${response.status()} ${response.url()}`).catch(() => {});
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
context,
|
||||
async close() {
|
||||
await context.close();
|
||||
if (browser) await browser.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function safeScreenshot(page, path) {
|
||||
try {
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
} catch {
|
||||
// Screenshot evidence is useful, but a screenshot failure should not hide the real test result.
|
||||
}
|
||||
}
|
||||
|
||||
export async function gotoFrontend(page) {
|
||||
const frontendUrl = env.LANGBOT_FRONTEND_URL;
|
||||
if (!frontendUrl) {
|
||||
throw new Error("LANGBOT_FRONTEND_URL is not configured.");
|
||||
}
|
||||
await page.goto(frontendUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
export function isLoginUrl(url) {
|
||||
return /\/login(?:[/?#]|$)/.test(url);
|
||||
}
|
||||
|
||||
export async function bodyText(page) {
|
||||
return await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
|
||||
}
|
||||
|
||||
export function countOccurrences(haystack, needle) {
|
||||
if (!needle) return 0;
|
||||
return String(haystack).split(needle).length - 1;
|
||||
}
|
||||
|
||||
export async function clickFirstVisible(page, labels, timeout = 2_000) {
|
||||
for (const label of labels) {
|
||||
const roleButton = page.getByRole("button", { name: label }).first();
|
||||
if (await roleButton.isVisible({ timeout }).catch(() => false)) {
|
||||
await roleButton.click();
|
||||
return label;
|
||||
}
|
||||
|
||||
const roleLink = page.getByRole("link", { name: label }).first();
|
||||
if (await roleLink.isVisible({ timeout }).catch(() => false)) {
|
||||
await roleLink.click();
|
||||
return label;
|
||||
}
|
||||
|
||||
const text = page.getByText(label, { exact: false }).first();
|
||||
if (await text.isVisible({ timeout }).catch(() => false)) {
|
||||
await text.click();
|
||||
return label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fillFirstTextInput(page, value) {
|
||||
const candidates = [
|
||||
page.getByRole("textbox").last(),
|
||||
page.locator("textarea").last(),
|
||||
page.locator("[contenteditable=true]").last(),
|
||||
page.locator("input[type=text]").last(),
|
||||
];
|
||||
|
||||
for (const locator of candidates) {
|
||||
if (!await locator.isVisible({ timeout: 2_000 }).catch(() => false)) continue;
|
||||
await locator.fill(value).catch(async () => {
|
||||
await locator.click();
|
||||
await locator.pressSequentially(value);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function waitForVisibleText(page, text, timeout = 20_000) {
|
||||
await page.getByText(text, { exact: false }).last().waitFor({ state: "visible", timeout });
|
||||
}
|
||||
Reference in New Issue
Block a user