mcp: added path config, register at /api path by default to work with frameworks

This commit is contained in:
dswbx
2025-08-30 14:06:13 +02:00
parent d898018b49
commit 24eefa5357
10 changed files with 52 additions and 27 deletions

View File

@@ -24,6 +24,7 @@ export const serverConfigSchema = $object(
}), }),
mcp: s.strictObject({ mcp: s.strictObject({
enabled: s.boolean({ default: false }), enabled: s.boolean({ default: false }),
path: s.string({ default: "/api/system/mcp" }),
}), }),
}, },
{ {

View File

@@ -60,8 +60,9 @@ export class SystemController extends Controller {
register(app: App) { register(app: App) {
app.server.route("/api/system", this.getController()); app.server.route("/api/system", this.getController());
const config = app.modules.get("server").config;
if (!this.app.modules.get("server").config.mcp.enabled) { if (!config.mcp.enabled) {
return; return;
} }
@@ -97,7 +98,7 @@ export class SystemController extends Controller {
explainEndpoint: true, explainEndpoint: true,
}, },
endpoint: { endpoint: {
path: "/mcp", path: config.mcp.path as any,
// @ts-ignore // @ts-ignore
_init: isNode() ? { duplex: "half" } : {}, _init: isNode() ? { duplex: "half" } : {},
}, },

View File

@@ -13,6 +13,14 @@ const Warning = ({ className, ...props }: IconProps) => (
/> />
); );
const Err = ({ className, ...props }: IconProps) => (
<TbAlertCircle
{...props}
className={twMerge("dark:text-red-300 text-red-700 cursor-help", className)}
/>
);
export const Icon = { export const Icon = {
Warning, Warning,
Err,
}; };

View File

@@ -0,0 +1,16 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { useBknd } from "ui/client/bknd";
const clients = new Map<string, McpClient>();
export function getClient(opts: McpClientConfig) {
if (!clients.has(JSON.stringify(opts))) {
clients.set(JSON.stringify(opts), new McpClient(opts));
}
return clients.get(JSON.stringify(opts))!;
}
export function useMcpClient() {
const { config } = useBknd();
return getClient({ url: window.location.origin + config.server.mcp.path });
}

View File

@@ -17,6 +17,7 @@ export default function ToolsMcp() {
const setFeature = useMcpStore((state) => state.setFeature); const setFeature = useMcpStore((state) => state.setFeature);
const content = useMcpStore((state) => state.content); const content = useMcpStore((state) => state.content);
const openSidebar = appShellStore((store) => store.toggleSidebar("default")); const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
const mcpPath = config.server.mcp.path;
if (!config.server.mcp.enabled) { if (!config.server.mcp.enabled) {
return ( return (
@@ -39,7 +40,7 @@ export default function ToolsMcp() {
<TbWorld /> <TbWorld />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<span className="block truncate text-sm font-mono leading-none"> <span className="block truncate text-sm font-mono leading-none">
{window.location.origin + "/mcp"} {window.location.origin + mcpPath}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, useTransition } from "react"; import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { getClient, getTemplate } from "./utils"; import { getTemplate } from "./utils";
import { useMcpStore } from "./state"; import { useMcpStore } from "./state";
import { AppShell } from "ui/layouts/AppShell"; import { AppShell } from "ui/layouts/AppShell";
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb"; import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
@@ -10,9 +10,11 @@ import { Field, Form } from "ui/components/form/json-schema-form";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { appShellStore } from "ui/store"; import { appShellStore } from "ui/store";
import { Icon } from "ui/components/display/Icon";
import { useMcpClient } from "./hooks/use-mcp-client";
export function Sidebar({ open, toggle }) { export function Sidebar({ open, toggle }) {
const client = getClient(); const client = useMcpClient();
const closeSidebar = appShellStore((store) => store.closeSidebar("default")); const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
const tools = useMcpStore((state) => state.tools); const tools = useMcpStore((state) => state.tools);
const setTools = useMcpStore((state) => state.setTools); const setTools = useMcpStore((state) => state.setTools);
@@ -20,11 +22,18 @@ export function Sidebar({ open, toggle }) {
const content = useMcpStore((state) => state.content); const content = useMcpStore((state) => state.content);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null);
try {
const res = await client.listTools(); const res = await client.listTools();
if (res) setTools(res.tools); if (res) setTools(res.tools);
} catch (e) {
console.error(e);
setError(String(e));
}
setLoading(false); setLoading(false);
}, []); }, []);
@@ -39,6 +48,7 @@ export function Sidebar({ open, toggle }) {
toggle={toggle} toggle={toggle}
renderHeaderRight={() => ( renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center"> <div className="flex flex-row gap-2 items-center">
{error && <Icon.Err title={error} className="size-5 pointer-events-auto" />}
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none"> <span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
{tools.length} {tools.length}
</span> </span>
@@ -88,7 +98,7 @@ export function Content() {
const [result, setResult] = useState<object | null>(null); const [result, setResult] = useState<object | null>(null);
const historyVisible = useMcpStore((state) => state.historyVisible); const historyVisible = useMcpStore((state) => state.historyVisible);
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible); const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
const client = getClient(); const client = useMcpClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null); const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema = const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0; content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;

View File

@@ -1,17 +1,5 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { Draft2019 } from "json-schema-library"; import { Draft2019 } from "json-schema-library";
const clients = new Map<string, McpClient>();
export function getClient(
{ url, ...opts }: McpClientConfig = { url: window.location.origin + "/mcp" },
) {
if (!clients.has(String(url))) {
clients.set(String(url), new McpClient({ url, ...opts }));
}
return clients.get(String(url))!;
}
export function getTemplate(schema: object) { export function getTemplate(schema: object) {
if (!schema || schema === undefined || schema === null) return undefined; if (!schema || schema === undefined || schema === null) return undefined;

View File

@@ -24,7 +24,7 @@ bknd includes a fully featured MCP server that can be used to interact with the
<source src="/content/mcp/v0.17_mcp_o.mp4" type="video/mp4" /> <source src="/content/mcp/v0.17_mcp_o.mp4" type="video/mp4" />
</video> </video>
Once enabled, you can access the MCP UI at `/mcp` or choose "MCP" from the top right user menu. Once enabled, you can access the MCP UI at `/mcp`, or choose "MCP" from the top right user menu.
## Enable MCP ## Enable MCP
@@ -54,7 +54,7 @@ The implementation is closely following the [MCP spec 2025-06-18](https://modelc
import { McpClient } from "bknd/utils"; import { McpClient } from "bknd/utils";
const client = new McpClient({ const client = new McpClient({
url: "http://localhost:1337/mcp", url: "http://localhost:1337/api/system/mcp",
}); });
``` ```
@@ -128,7 +128,7 @@ Pasting the following config into your Cursor `~/.cursor/mcp.json` file is the r
{ {
"mcpServers": { "mcpServers": {
"bknd": { "bknd": {
"url": "http://localhost:1337/mcp" "url": "http://localhost:1337/api/system/mcp"
} }
} }
} }
@@ -155,7 +155,7 @@ Add this to your VS Code MCP config. See [VS Code MCP docs](https://code.visuals
"servers": { "servers": {
"bknd": { "bknd": {
"type": "http", "type": "http",
"url": "http://localhost:1337/mcp" "url": "http://localhost:1337/api/system/mcp"
} }
} }
} }
@@ -188,7 +188,7 @@ When using the Streamable HTTP transport, you can pass the `Authorization` heade
```typescript ```typescript
const client = new McpClient({ const client = new McpClient({
url: "http://localhost:1337/mcp", url: "http://localhost:1337/api/system/mcp",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },

View File

@@ -5,7 +5,7 @@
* We're using separate files, so that "wrangler" doesn't get bundled with your worker. * We're using separate files, so that "wrangler" doesn't get bundled with your worker.
*/ */
import { withPlatformProxy } from "bknd/adapter/cloudflare"; import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy";
import config from "./config.ts"; import config from "./config.ts";
export default withPlatformProxy(config); export default withPlatformProxy(config);

View File

@@ -2,7 +2,7 @@ import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare";
import { syncTypes } from "bknd/plugins"; import { syncTypes } from "bknd/plugins";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
const isDev = !import.meta.env.PROD; const isDev = import.meta.env && !import.meta.env.PROD;
export default { export default {
d1: { d1: {