This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit e605c61ee16f4cce7787b8b279af4379249204cf Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 11:05:09 2026 +0800 feat(mcp): add a server from a template or a pasted JSON block --- web/src/components/agent-config/add-mcp-dialog.tsx | 452 +++++++++++++++------ .../components/agent-config/mcp-preset-picker.tsx | 167 ++++++++ web/src/lib/mcp.ts | 436 ++++++++++++++++++++ web/src/locales/en/data.json | 25 ++ web/src/locales/zh/data.json | 25 ++ 5 files changed, 992 insertions(+), 113 deletions(-) diff --git a/web/src/components/agent-config/add-mcp-dialog.tsx b/web/src/components/agent-config/add-mcp-dialog.tsx index 273ecca..7c46eae 100644 --- a/web/src/components/agent-config/add-mcp-dialog.tsx +++ b/web/src/components/agent-config/add-mcp-dialog.tsx @@ -13,13 +13,17 @@ // limitations under the License. import * as React from "react"; +import {ChevronLeft, ExternalLink, Plug} from "lucide-react"; import i18next from "i18next"; import * as AgentConfigBackend from "@/backend/AgentConfigBackend"; import * as Setting from "@/Setting"; +import {ProviderIcon} from "@/components/ProviderIcon"; import {ActionBadge} from "@/components/agent-config/action-badge"; +import {McpPresetInputs, McpPresetPicker} from "@/components/agent-config/mcp-preset-picker"; import {TargetPicker} from "@/components/agent-config/target-picker"; import {Field} from "@/components/shared/form-dialog"; +import {CodeText} from "@/components/shared/misc"; import {SimpleSelect} from "@/components/shared/simple-select"; import {MessageAlert} from "@/components/ui/alert"; import {Button} from "@/components/ui/button"; @@ -33,16 +37,34 @@ import { } from "@/components/ui/dialog"; import {Input} from "@/components/ui/input"; import {Switch} from "@/components/ui/switch"; +import {Tabs, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {TagsInput} from "@/components/ui/tags-input"; import {Textarea} from "@/components/ui/textarea"; import {counted} from "@/lib/agent-configs"; -import {parsePairs} from "@/lib/pairs"; +import {draftFromPreset, parseMcpJson, presetFilled, type McpDraft, type McpPreset} from "@/lib/mcp"; +import {formatPairs, parsePairs} from "@/lib/pairs"; import type {AgentConfigInventory, AgentConfigPlanItem, McpTransport} from "@/types"; +/** Which of the three ways of describing a server the dialog is showing. */ +type Channel = "preset" | "form" | "json"; + +const jsonExample = `{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp"] + } + } +}`; + /** * Setting up one MCP server from Gateway: it is written into every agent picked * here, in that agent's own format and file, so the agents that should share a * server are configured once instead of one file at a time. + * + * The three tabs are the three ways a server is described in the wild - picked + * from a catalogue, typed out field by field, or pasted as the JSON block its + * own page hands out - and all of them end up as the same request. */ export function AddMcpDialog({ open, @@ -57,6 +79,10 @@ export function AddMcpDialog({ source: AgentConfigInventory; onDone: () => void; }) { + const [channel, setChannel] = React.useState<Channel>("preset"); + const [preset, setPreset] = React.useState<McpPreset | null>(null); + const [values, setValues] = React.useState<Record<string, string>>({}); + const [json, setJson] = React.useState(""); const [name, setName] = React.useState(""); const [transport, setTransport] = React.useState<McpTransport>("stdio"); const [command, setCommand] = React.useState(""); @@ -67,6 +93,7 @@ export function AddMcpDialog({ const [targets, setTargets] = React.useState<string[]>([]); const [overwrite, setOverwrite] = React.useState(false); const [result, setResult] = React.useState<AgentConfigPlanItem[] | null>(null); + const [named, setNamed] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(""); @@ -78,6 +105,10 @@ export function AddMcpDialog({ React.useEffect(() => { if (open) { + setChannel("preset"); + setPreset(null); + setValues({}); + setJson(""); setName(""); setTransport("stdio"); setCommand(""); @@ -88,43 +119,126 @@ export function AddMcpDialog({ setTargets(defaultTarget === "" ? [] : [defaultTarget]); setOverwrite(false); setResult(null); + setNamed(false); setError(""); } }, [open, defaultTarget]); + const parsed = React.useMemo(() => parseMcpJson(json), [json]); + + // A pasted block names its server, so the name field follows what was pasted + // until the block itself changes again. + const pastedName = parsed.servers.length === 1 ? parsed.servers[0].name : ""; + React.useEffect(() => { + if (pastedName !== "") { + setName(pastedName); + } + }, [pastedName]); + + const formDraft = (): McpDraft => ({ + name: name.trim(), + transport: transport, + command: transport === "stdio" ? command.trim() : "", + args: transport === "stdio" ? args : [], + env: transport === "stdio" ? parsePairs(env) : {}, + url: transport === "http" ? url.trim() : "", + headers: transport === "http" ? parsePairs(headers) : {}, + }); + + // What would be written, in the tab the dialog is on. The name typed above + // wins over the one a preset or a pasted block came with, so a second copy of + // the same server is a rename rather than an overwrite. + const drafts = (): McpDraft[] => { + if (channel === "preset") { + return preset === null ? [] : [{...draftFromPreset(preset, values), name: name.trim()}]; + } + if (channel === "json") { + return parsed.servers.length === 1 + ? [{...parsed.servers[0], name: name.trim()}] + : parsed.servers.map(server => ({...server, name: server.name.trim()})); + } + return [formDraft()]; + }; + + const complete = (draft: McpDraft) => + draft.name !== "" && (draft.transport === "http" ? draft.url !== "" : draft.command !== ""); + + const pending = drafts(); + const ready = targets.length > 0 && pending.length > 0 && pending.every(complete) && + (channel !== "preset" || preset === null || presetFilled(preset, values)); + + // Picking a preset only fills the fields in; nothing is written until the tab + // it lands on is submitted, so the command it will run stays reviewable. + const pick = (picked: McpPreset) => { + setPreset(picked); + setValues({}); + setName(picked.key); + setTransport(picked.transport); + setError(""); + }; + + const fillForm = (draft: McpDraft) => { + setName(draft.name); + setTransport(draft.transport); + setCommand(draft.command); + setArgs(draft.args); + setEnv(formatPairs(draft.env)); + setUrl(draft.url); + setHeaders(formatPairs(draft.headers)); + }; + + // Moving to the fields carries whatever the other tab has, so a preset or a + // pasted block can be adjusted rather than retyped. + const switchChannel = (next: Channel) => { + const carried = drafts(); + if (next === "form" && channel !== "form" && carried.length === 1) { + fillForm(carried[0]); + } + setChannel(next); + setError(""); + }; + const toggleTarget = (agentId: string) => { setTargets(previous => previous.includes(agentId) ? previous.filter(item => item !== agentId) : [...previous, agentId], ); }; - const ready = - name.trim() !== "" && - targets.length > 0 && - (transport === "http" ? url.trim() !== "" : command.trim() !== ""); - const add = () => { + const sending = drafts(); setBusy(true); setError(""); - AgentConfigBackend.addAgentConfigMcp({ - owner: source.owner, - to: targets, - name: name.trim(), - transport: transport, - command: transport === "stdio" ? command.trim() : undefined, - args: transport === "stdio" ? args : undefined, - env: transport === "stdio" ? parsePairs(env) : undefined, - url: transport === "http" ? url.trim() : undefined, - headers: transport === "http" ? parsePairs(headers) : undefined, - overwrite: overwrite, - }) - .then(res => { - if (res.status !== "ok") { - setError(res.msg || i18next.t("agentConfig:Failed to add this MCP server")); - return; - } - - const written = res.data ?? []; + setNamed(sending.length > 1); + + // One request per server, in order, so a block naming several of them is + // still reported server by server and agent by agent. + sending + .reduce( + (chain, draft) => + chain.then(items => + AgentConfigBackend.addAgentConfigMcp({ + owner: source.owner, + to: targets, + name: draft.name, + transport: draft.transport, + command: draft.transport === "stdio" ? draft.command : undefined, + args: draft.transport === "stdio" ? draft.args : undefined, + env: draft.transport === "stdio" ? draft.env : undefined, + url: draft.transport === "http" ? draft.url : undefined, + headers: draft.transport === "http" ? draft.headers : undefined, + overwrite: overwrite, + }).then(res => { + if (res.status !== "ok") { + throw new Error( + res.msg || i18next.t("agentConfig:Failed to add this MCP server"), + ); + } + return [...items, ...(res.data ?? [])]; + }), + ), + Promise.resolve([] as AgentConfigPlanItem[]), + ) + .then(written => { const done = written.filter(item => item.action === "create" || item.action === "overwrite").length; const failed = written.filter(item => item.action === "failed").length; setResult(written); @@ -146,115 +260,222 @@ export function AddMcpDialog({ const nameOf = (agentId: string) => candidates.find(inventory => inventory.agentId === agentId)?.name ?? agentId; + const jsonErrors: Record<string, string> = { + notJson: i18next.t("agentConfig:This is not valid JSON"), + notServers: i18next.t("agentConfig:No MCP server in this JSON"), + noCommand: i18next.t("agentConfig:A server here has no command or URL"), + }; + + // Browsing the catalogue is a screen of its own: the targets and the button + // belong to the server that has not been picked yet. + const browsing = result === null && channel === "preset" && preset === null; + const single = pending.length === 1 ? pending[0] : null; + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-h-[85vh] gap-4 sm:max-w-2xl"> + <DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-2xl"> <DialogHeader> <DialogTitle>{i18next.t("agentConfig:Add MCP server")}</DialogTitle> <DialogDescription>{i18next.t("agentConfig:Add MCP server hint")}</DialogDescription> </DialogHeader> - <div className="flex flex-col gap-4 overflow-y-auto"> - <Field label={i18next.t("general:Name")} htmlFor="mcp-name" required> - <Input - id="mcp-name" - value={name} - placeholder="context7" - disabled={result !== null} - onChange={event => setName(event.target.value)} - /> - </Field> - - <Field label={i18next.t("agentConfig:Transport")}> - <SimpleSelect - value={transport} - disabled={result !== null} - options={[ - {label: i18next.t("agentConfig:Local command"), value: "stdio"}, - {label: i18next.t("agentConfig:HTTP endpoint"), value: "http"}, - ]} - onChange={value => setTransport(value as McpTransport)} - /> - </Field> - - {transport === "stdio" ? ( + {result === null ? ( + <Tabs value={channel} onValueChange={value => switchChannel(value as Channel)}> + <TabsList className="w-full"> + <TabsTrigger value="preset">{i18next.t("agentConfig:From a template")}</TabsTrigger> + <TabsTrigger value="form">{i18next.t("agentConfig:Fill in by hand")}</TabsTrigger> + <TabsTrigger value="json">{i18next.t("agentConfig:Paste JSON")}</TabsTrigger> + </TabsList> + </Tabs> + ) : null} + + <div className="scrollbar-thin -mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1 py-0.5"> + {browsing ? <McpPresetPicker onPick={pick} /> : null} + + {result === null && channel === "preset" && preset !== null ? ( <> - <Field - label={i18next.t("agentConfig:Command")} - htmlFor="mcp-command" - hint={i18next.t("agentConfig:Command hint")} - required - > - <Input - id="mcp-command" - value={command} - placeholder="npx" - disabled={result !== null} - onChange={event => setCommand(event.target.value)} - /> - </Field> - <Field label={i18next.t("agentConfig:Arguments")}> - <TagsInput - value={args} - placeholder="-y, @upstash/context7-mcp" - disabled={result !== null} - onChange={setArgs} - /> + <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3"> + <span className="flex min-w-0 items-center gap-2 text-sm font-medium"> + <ProviderIcon + baseUrl={preset.website} + size={16} + fallback={<Plug className="size-4 shrink-0" />} + /> + {preset.label} + <a + href={preset.website} + target="_blank" + rel="noreferrer" + className="text-muted-foreground hover:text-foreground" + > + <ExternalLink className="size-3.5" /> + </a> + </span> + <Button type="button" variant="ghost" size="sm" onClick={() => setPreset(null)}> + <ChevronLeft className="size-4" /> + {i18next.t("agentConfig:Pick another")} + </Button> + </div> + + <McpPresetInputs + preset={preset} + values={values} + onChange={(key, value) => setValues(previous => ({...previous, [key]: value}))} + /> + + <Field label={i18next.t("general:Name")} htmlFor="mcp-name" required> + <Input id="mcp-name" value={name} onChange={event => setName(event.target.value)} /> </Field> + + {single === null ? null : ( + <Field label={i18next.t("agentConfig:This runs")}> + <CodeText> + {single.transport === "http" + ? single.url + : [single.command, ...single.args].join(" ")} + </CodeText> + </Field> + )} + </> + ) : null} + + {result === null && channel === "json" ? ( + <> <Field - label={i18next.t("agentConfig:Environment")} - htmlFor="mcp-env" - hint={i18next.t("agentConfig:Pairs hint")} + label={i18next.t("agentConfig:Configuration JSON")} + htmlFor="mcp-json" + hint={i18next.t("agentConfig:Paste JSON hint")} + error={jsonErrors[parsed.error]} > <Textarea - id="mcp-env" - value={env} - rows={2} - placeholder="API_KEY=..." - disabled={result !== null} - onChange={event => setEnv(event.target.value)} + id="mcp-json" + value={json} + rows={10} + spellCheck={false} + placeholder={jsonExample} + className="font-mono text-xs" + onChange={event => setJson(event.target.value)} /> </Field> + + {parsed.servers.length === 1 ? ( + <Field label={i18next.t("general:Name")} htmlFor="mcp-name" required> + <Input id="mcp-name" value={name} onChange={event => setName(event.target.value)} /> + </Field> + ) : null} + + {parsed.servers.length > 1 ? ( + <Field label={i18next.t("agentConfig:Servers in this JSON")}> + <ul className="divide-y rounded-md border"> + {parsed.servers.map(server => ( + <li key={server.name} className="flex items-center gap-3 px-3 py-1.5 text-sm"> + <span className="shrink-0 font-medium">{server.name}</span> + <span className="text-muted-foreground truncate font-mono text-xs"> + {server.transport === "http" ? server.url : [server.command, ...server.args].join(" ")} + </span> + </li> + ))} + </ul> + </Field> + ) : null} </> - ) : ( + ) : null} + + {result === null && channel === "form" ? ( <> - <Field label={i18next.t("agentConfig:URL")} htmlFor="mcp-url" required> + <Field label={i18next.t("general:Name")} htmlFor="mcp-name" required> <Input - id="mcp-url" - value={url} - placeholder="https://mcp.example.com/mcp" - disabled={result !== null} - onChange={event => setUrl(event.target.value)} + id="mcp-name" + value={name} + placeholder="context7" + onChange={event => setName(event.target.value)} /> </Field> - <Field - label={i18next.t("agentConfig:Headers")} - htmlFor="mcp-headers" - hint={i18next.t("agentConfig:Pairs hint")} - > - <Textarea - id="mcp-headers" - value={headers} - rows={2} - placeholder="Authorization=Bearer ..." - disabled={result !== null} - onChange={event => setHeaders(event.target.value)} + + <Field label={i18next.t("agentConfig:Transport")}> + <SimpleSelect + value={transport} + options={[ + {label: i18next.t("agentConfig:Local command"), value: "stdio"}, + {label: i18next.t("agentConfig:HTTP endpoint"), value: "http"}, + ]} + onChange={value => setTransport(value as McpTransport)} /> </Field> + + {transport === "stdio" ? ( + <> + <Field + label={i18next.t("agentConfig:Command")} + htmlFor="mcp-command" + hint={i18next.t("agentConfig:Command hint")} + required + > + <Input + id="mcp-command" + value={command} + placeholder="npx" + onChange={event => setCommand(event.target.value)} + /> + </Field> + <Field label={i18next.t("agentConfig:Arguments")}> + <TagsInput value={args} placeholder="-y, @upstash/context7-mcp" onChange={setArgs} /> + </Field> + <Field + label={i18next.t("agentConfig:Environment")} + htmlFor="mcp-env" + hint={i18next.t("agentConfig:Pairs hint")} + > + <Textarea + id="mcp-env" + value={env} + rows={2} + placeholder="API_KEY=..." + onChange={event => setEnv(event.target.value)} + /> + </Field> + </> + ) : ( + <> + <Field label={i18next.t("agentConfig:URL")} htmlFor="mcp-url" required> + <Input + id="mcp-url" + value={url} + placeholder="https://mcp.example.com/mcp" + onChange={event => setUrl(event.target.value)} + /> + </Field> + <Field + label={i18next.t("agentConfig:Headers")} + htmlFor="mcp-headers" + hint={i18next.t("agentConfig:Pairs hint")} + > + <Textarea + id="mcp-headers" + value={headers} + rows={2} + placeholder="Authorization=Bearer ..." + onChange={event => setHeaders(event.target.value)} + /> + </Field> + </> + )} </> + ) : null} + + {browsing ? null : ( + <Field label={i18next.t("agentConfig:Add to")} hint={i18next.t("agentConfig:Add to hint")}> + <TargetPicker + candidates={candidates} + kind="mcp" + selected={targets} + onToggle={toggleTarget} + disabled={result !== null} + /> + </Field> )} - <Field label={i18next.t("agentConfig:Add to")} hint={i18next.t("agentConfig:Add to hint")}> - <TargetPicker - candidates={candidates} - kind="mcp" - selected={targets} - onToggle={toggleTarget} - disabled={result !== null} - /> - </Field> - - {result === null ? ( + {result === null && !browsing ? ( <label className="flex items-center gap-2 text-sm"> <Switch checked={overwrite} onCheckedChange={setOverwrite} /> <span>{i18next.t("agentConfig:Replace items that already exist")}</span> @@ -270,7 +491,10 @@ export function AddMcpDialog({ key={`${item.agentId}/${item.name}`} className="flex items-center justify-between gap-3 px-3 py-1.5 text-sm" > - <span className="truncate">{nameOf(item.agentId)}</span> + <span className="truncate"> + {nameOf(item.agentId)} + {named ? <span className="text-muted-foreground"> · {item.name}</span> : null} + </span> <span className="flex shrink-0 items-center gap-2"> {item.reason ? <span className="text-muted-foreground text-xs">{item.reason}</span> : null} <ActionBadge action={item.action} /> @@ -285,9 +509,11 @@ export function AddMcpDialog({ <Button variant="outline" onClick={() => onOpenChange(false)}> {result === null ? i18next.t("general:Cancel") : i18next.t("general:Close")} </Button> - {result === null ? ( + {result === null && !browsing ? ( <Button onClick={add} disabled={busy || !ready} loading={busy}> - {i18next.t("agentConfig:Add MCP server")} + {pending.length > 1 + ? i18next.t("agentConfig:Add {count} servers").replace("{count}", String(pending.length)) + : i18next.t("agentConfig:Add MCP server")} </Button> ) : null} </DialogFooter> diff --git a/web/src/components/agent-config/mcp-preset-picker.tsx b/web/src/components/agent-config/mcp-preset-picker.tsx new file mode 100644 index 0000000..69cd8c0 --- /dev/null +++ b/web/src/components/agent-config/mcp-preset-picker.tsx @@ -0,0 +1,167 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from "react"; +import {Plug, Search} from "lucide-react"; +import i18next from "i18next"; + +import {ProviderIcon} from "@/components/ProviderIcon"; +import {Field} from "@/components/shared/form-dialog"; +import {PasswordInput} from "@/components/shared/password-input"; +import {Input} from "@/components/ui/input"; +import { + mcpCategories, + mcpPresets, + presetSummary, + type McpCategory, + type McpInputKind, + type McpPreset, +} from "@/lib/mcp"; + +export function categoryTitle(category: McpCategory) { + return { + essential: i18next.t("agentConfig:Everyday servers"), + dev: i18next.t("agentConfig:Development"), + data: i18next.t("agentConfig:Databases and data"), + web: i18next.t("agentConfig:Search and the web"), + work: i18next.t("agentConfig:Team tools"), + }[category]; +} + +function PresetCard({preset, onPick}: {preset: McpPreset; onPick: (preset: McpPreset) => void}) { + return ( + <button + type="button" + onClick={() => onPick(preset)} + className="hover:border-primary hover:bg-accent/40 flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors" + > + <span className="flex w-full min-w-0 items-center gap-2 text-sm font-medium"> + <ProviderIcon baseUrl={preset.website} size={16} fallback={<Plug className="size-4 shrink-0" />} /> + <span className="truncate">{preset.label}</span> + </span> + <span className="text-muted-foreground w-full truncate font-mono text-xs">{presetSummary(preset)}</span> + </button> + ); +} + +/** + * The catalogue half of adding a server: the servers people actually install, + * offered by name so that a working install is a click and a key rather than a + * command line copied out of a README. + */ +export function McpPresetPicker({onPick}: {onPick: (preset: McpPreset) => void}) { + const [query, setQuery] = React.useState(""); + + const needle = query.trim().toLowerCase(); + const matches = (preset: McpPreset) => + needle === "" || + preset.label.toLowerCase().includes(needle) || + preset.key.includes(needle) || + presetSummary(preset).toLowerCase().includes(needle); + + const groups = mcpCategories + .map(category => ({ + category: category, + presets: mcpPresets.filter(preset => preset.category === category).filter(matches), + })) + .filter(group => group.presets.length > 0); + + return ( + <div className="grid gap-4"> + <div className="relative"> + <Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" /> + <Input + value={query} + onChange={event => setQuery(event.target.value)} + placeholder={i18next.t("agentConfig:Search servers")} + className="pl-9" + /> + </div> + + {groups.map(group => ( + <div key={group.category} className="grid gap-2"> + <span className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> + {categoryTitle(group.category)} + </span> + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> + {group.presets.map(preset => ( + <PresetCard key={preset.key} preset={preset} onPick={onPick} /> + ))} + </div> + </div> + ))} + + {groups.length === 0 ? ( + <p className="text-muted-foreground py-4 text-center text-sm"> + {i18next.t("agentConfig:No server matches")} + </p> + ) : null} + </div> + ); +} + +function inputLabel(kind: McpInputKind) { + return { + key: i18next.t("agentConfig:API key"), + token: i18next.t("agentConfig:Access token"), + path: i18next.t("agentConfig:Path"), + url: i18next.t("agentConfig:Connection URL"), + id: i18next.t("agentConfig:Workspace ID"), + }[kind]; +} + +/** The one or two values a picked preset still needs, and nothing else. */ +export function McpPresetInputs({ + preset, + values, + disabled, + onChange, +}: { + preset: McpPreset; + values: Record<string, string>; + disabled?: boolean; + onChange: (key: string, value: string) => void; +}) { + return ( + <> + {(preset.inputs ?? []).map(input => { + const secret = input.kind === "key" || input.kind === "token"; + const Control = secret ? PasswordInput : Input; + return ( + <Field + key={input.key} + label={inputLabel(input.kind)} + htmlFor={`mcp-input-${input.key}`} + required + hint={ + secret ? ( + <a href={preset.website} target="_blank" rel="noreferrer" className="hover:underline"> + {i18next.t("agentConfig:Where to get it")} + </a> + ) : null + } + > + <Control + id={`mcp-input-${input.key}`} + value={values[input.key] ?? ""} + placeholder={input.placeholder} + disabled={disabled} + onChange={event => onChange(input.key, event.target.value)} + /> + </Field> + ); + })} + </> + ); +} diff --git a/web/src/lib/mcp.ts b/web/src/lib/mcp.ts new file mode 100644 index 0000000..c0de144 --- /dev/null +++ b/web/src/lib/mcp.ts @@ -0,0 +1,436 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type {McpTransport} from "@/types"; + +/** One server as the Add dialog holds it, whichever way it was filled in. */ +export interface McpDraft { + name: string; + transport: McpTransport; + command: string; + args: string[]; + env: Record<string, string>; + url: string; + headers: Record<string, string>; +} + +export function emptyDraft(): McpDraft { + return {name: "", transport: "stdio", command: "", args: [], env: {}, url: "", headers: {}}; +} + +/** What the catalogue groups servers by: the job they are installed to do. */ +export const mcpCategories = ["essential", "dev", "data", "web", "work"] as const; +export type McpCategory = (typeof mcpCategories)[number]; + +/** The handful of things a preset asks for, each with its own label. */ +export type McpInputKind = "key" | "token" | "path" | "url" | "id"; + +/** A value the server will not run without, substituted for {{key}} below. */ +export interface McpPresetInput { + key: string; + kind: McpInputKind; + placeholder?: string; +} + +export interface McpPreset { + key: string; + label: string; + category: McpCategory; + transport: McpTransport; + /** The vendor's own page, for the icon and for where a key comes from. */ + website: string; + command?: string; + args?: string[]; + env?: Record<string, string>; + url?: string; + headers?: Record<string, string>; + inputs?: McpPresetInput[]; +} + +/** + * The servers offered by name instead of by command line. Every entry is the + * install its own documentation gives, with the parts that differ per machine — + * a folder, a key — left as {{...}} for the wizard to ask for. + */ +export const mcpPresets: McpPreset[] = [ + { + key: "filesystem", + label: "Filesystem", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "{{path}}"], + inputs: [{key: "path", kind: "path", placeholder: "/Users/me/projects"}], + }, + { + key: "fetch", + label: "Fetch", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + command: "uvx", + args: ["mcp-server-fetch"], + }, + { + key: "memory", + label: "Memory", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-memory"], + }, + { + key: "sequential-thinking", + label: "Sequential Thinking", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-sequential-thinking"], + }, + { + key: "time", + label: "Time", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/time", + command: "uvx", + args: ["mcp-server-time"], + }, + { + key: "git", + label: "Git", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + command: "uvx", + args: ["mcp-server-git", "--repository", "{{path}}"], + inputs: [{key: "path", kind: "path", placeholder: "/Users/me/projects/repo"}], + }, + { + key: "everything", + label: "Everything", + category: "essential", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers/tree/main/src/everything", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + }, + { + key: "github", + label: "GitHub", + category: "dev", + transport: "http", + website: "https://github.com/github/github-mcp-server", + url: "https://api.githubcopilot.com/mcp/", + headers: {Authorization: "Bearer {{token}}"}, + inputs: [{key: "token", kind: "token", placeholder: "github_pat_..."}], + }, + { + key: "context7", + label: "Context7", + category: "dev", + transport: "stdio", + website: "https://context7.com", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }, + { + key: "playwright", + label: "Playwright", + category: "dev", + transport: "stdio", + website: "https://github.com/microsoft/playwright-mcp", + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + }, + { + key: "chrome-devtools", + label: "Chrome DevTools", + category: "dev", + transport: "stdio", + website: "https://github.com/ChromeDevTools/chrome-devtools-mcp", + command: "npx", + args: ["-y", "chrome-devtools-mcp@latest"], + }, + { + key: "deepwiki", + label: "DeepWiki", + category: "dev", + transport: "http", + website: "https://deepwiki.com", + url: "https://mcp.deepwiki.com/mcp", + }, + { + key: "sentry", + label: "Sentry", + category: "dev", + transport: "http", + website: "https://docs.sentry.io/product/sentry-mcp/", + url: "https://mcp.sentry.dev/mcp", + }, + { + key: "supabase", + label: "Supabase", + category: "dev", + transport: "stdio", + website: "https://supabase.com/docs/guides/getting-started/mcp", + command: "npx", + args: ["-y", "@supabase/mcp-server-supabase@latest"], + env: {SUPABASE_ACCESS_TOKEN: "{{token}}"}, + inputs: [{key: "token", kind: "token", placeholder: "sbp_..."}], + }, + { + key: "figma", + label: "Figma", + category: "dev", + transport: "stdio", + website: "https://github.com/GLips/Figma-Context-MCP", + command: "npx", + args: ["-y", "figma-developer-mcp", "--stdio"], + env: {FIGMA_API_KEY: "{{key}}"}, + inputs: [{key: "key", kind: "key", placeholder: "figd_..."}], + }, + { + key: "cloudflare-docs", + label: "Cloudflare Docs", + category: "dev", + transport: "http", + website: "https://developers.cloudflare.com/agents/model-context-protocol/", + url: "https://docs.mcp.cloudflare.com/mcp", + }, + { + key: "sqlite", + label: "SQLite", + category: "data", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite", + command: "uvx", + args: ["mcp-server-sqlite", "--db-path", "{{path}}"], + inputs: [{key: "path", kind: "path", placeholder: "/Users/me/data.db"}], + }, + { + key: "postgres", + label: "PostgreSQL", + category: "data", + transport: "stdio", + website: "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-postgres", "{{url}}"], + inputs: [{key: "url", kind: "url", placeholder: "postgresql://localhost/mydb"}], + }, + { + key: "huggingface", + label: "Hugging Face", + category: "data", + transport: "http", + website: "https://huggingface.co/settings/mcp", + url: "https://huggingface.co/mcp", + }, + { + key: "brave-search", + label: "Brave Search", + category: "web", + transport: "stdio", + website: "https://brave.com/search/api/", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-brave-search"], + env: {BRAVE_API_KEY: "{{key}}"}, + inputs: [{key: "key", kind: "key", placeholder: "BSA..."}], + }, + { + key: "tavily", + label: "Tavily", + category: "web", + transport: "stdio", + website: "https://app.tavily.com", + command: "npx", + args: ["-y", "tavily-mcp"], + env: {TAVILY_API_KEY: "{{key}}"}, + inputs: [{key: "key", kind: "key", placeholder: "tvly-..."}], + }, + { + key: "exa", + label: "Exa", + category: "web", + transport: "http", + website: "https://exa.ai", + url: "https://mcp.exa.ai/mcp", + }, + { + key: "firecrawl", + label: "Firecrawl", + category: "web", + transport: "stdio", + website: "https://www.firecrawl.dev", + command: "npx", + args: ["-y", "firecrawl-mcp"], + env: {FIRECRAWL_API_KEY: "{{key}}"}, + inputs: [{key: "key", kind: "key", placeholder: "fc-..."}], + }, + { + key: "notion", + label: "Notion", + category: "work", + transport: "http", + website: "https://developers.notion.com/docs/mcp", + url: "https://mcp.notion.com/mcp", + }, + { + key: "linear", + label: "Linear", + category: "work", + transport: "http", + website: "https://linear.app/docs/mcp", + url: "https://mcp.linear.app/mcp", + }, + { + key: "slack", + label: "Slack", + category: "work", + transport: "stdio", + website: "https://api.slack.com/apps", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-slack"], + env: {SLACK_BOT_TOKEN: "{{token}}", SLACK_TEAM_ID: "{{team}}"}, + inputs: [ + {key: "token", kind: "token", placeholder: "xoxb-..."}, + {key: "team", kind: "id", placeholder: "T01234567"}, + ], + }, + { + key: "stripe", + label: "Stripe", + category: "work", + transport: "http", + website: "https://docs.stripe.com/mcp", + url: "https://mcp.stripe.com", + }, +]; + +function fill(text: string, values: Record<string, string>) { + return text.replace(/\{\{(\w+)\}\}/g, (match, key: string) => + values[key] === undefined ? match : values[key].trim(), + ); +} + +function fillPairs(pairs: Record<string, string> | undefined, values: Record<string, string>) { + return Object.fromEntries(Object.entries(pairs ?? {}).map(([key, value]) => [key, fill(value, values)])); +} + +/** The preset with what the wizard asked for put in, ready to be written. */ +export function draftFromPreset(preset: McpPreset, values: Record<string, string>): McpDraft { + return { + name: preset.key, + transport: preset.transport, + command: fill(preset.command ?? "", values), + args: (preset.args ?? []).map(arg => fill(arg, values)), + env: fillPairs(preset.env, values), + url: fill(preset.url ?? "", values), + headers: fillPairs(preset.headers, values), + }; +} + +export function presetFilled(preset: McpPreset, values: Record<string, string>) { + return (preset.inputs ?? []).every(input => (values[input.key] ?? "").trim() !== ""); +} + +/** What a preset runs, for the line under its name in the catalogue. */ +export function presetSummary(preset: McpPreset) { + return preset.transport === "http" ? (preset.url ?? "") : [preset.command, ...(preset.args ?? [])].join(" "); +} + +const serverMaps = ["mcpServers", "servers", "mcp_servers"]; +const httpTypes = ["http", "streamable-http", "streamablehttp", "sse"]; + +function entryDraft(name: string, entry: Record<string, unknown>): McpDraft | null { + const url = String(entry.url ?? entry.httpUrl ?? entry.serverUrl ?? "").trim(); + const command = String(entry.command ?? "").trim(); + const type = String(entry.type ?? entry.transport ?? "").toLowerCase(); + const http = httpTypes.includes(type) || (url !== "" && command === ""); + if (http ? url === "" : command === "") { + return null; + } + + const strings = (value: unknown) => + Object.fromEntries( + Object.entries((value ?? {}) as Record<string, unknown>).map(([key, item]) => [key, String(item ?? "")]), + ); + + return { + name: name, + transport: http ? "http" : "stdio", + command: http ? "" : command, + args: http || !Array.isArray(entry.args) ? [] : entry.args.map(arg => String(arg)), + env: http ? {} : strings(entry.env), + url: url, + headers: http ? strings(entry.headers) : {}, + }; +} + +function isEntry(value: unknown) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const entry = value as Record<string, unknown>; + return ["command", "url", "httpUrl", "serverUrl", "type", "args"].some(field => entry[field] !== undefined); +} + +/** + * The block a server's own README hands out, read as the servers it declares. + * Every shape that block comes in is accepted — the mcpServers wrapper Claude + * writes, the servers one VS Code writes, a bare name-to-entry map, or a single + * unwrapped entry — because which one a page shows is not the reader's choice. + */ +export function parseMcpJson(text: string): {servers: McpDraft[]; error: string} { + const trimmed = text.trim(); + if (trimmed === "") { + return {servers: [], error: ""}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return {servers: [], error: "notJson"}; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {servers: [], error: "notServers"}; + } + + const root = parsed as Record<string, unknown>; + const wrapper = serverMaps.find(field => typeof root[field] === "object" && root[field] !== null); + if (wrapper === undefined && isEntry(root)) { + const draft = entryDraft("", root); + return draft === null ? {servers: [], error: "noCommand"} : {servers: [draft], error: ""}; + } + + const map = (wrapper === undefined ? root : root[wrapper]) as Record<string, unknown>; + const entries = Object.entries(map).filter(([, value]) => isEntry(value)); + if (entries.length === 0) { + return {servers: [], error: "notServers"}; + } + + const servers: McpDraft[] = []; + for (const [name, value] of entries) { + const draft = entryDraft(name, value as Record<string, unknown>); + if (draft === null) { + return {servers: [], error: "noCommand"}; + } + servers.push(draft); + } + return {servers: servers, error: ""}; +} diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 4f35e95..3f6baad 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -271,11 +271,15 @@ "agentConfig": { "1 file": "1 file", "1 skill in this source": "1 skill in this source", + "A server here has no command or URL": "A server here has no command or URL", + "API key": "API key", + "Access token": "Access token", "Add MCP server": "Add MCP server", "Add MCP server hint": "Gateway writes the server into each agent's own configuration file, in that agent's own format. Restart a session that is already running to pick it up.", "Add source": "Add source", "Add to": "Add to", "Add to hint": "Every agent picked here gets the same server.", + "Add {count} servers": "Add {count} servers", "Added to 1 agent": "Added to 1 agent", "Added to {done} agents": "Added to {done} agents", "Added to {done}, {failed} failed": "Added to {done} agents, {failed} failed", @@ -291,6 +295,8 @@ "Choose a .zip": "Choose a .zip", "Command": "Command", "Command hint": "The executable the agent spawns, e.g. npx or uvx.", + "Configuration JSON": "Configuration JSON", + "Connection URL": "Connection URL", "Contents": "Contents", "Copied": "Copied", "Copied 1 item": "Copied 1 item", @@ -309,6 +315,7 @@ "Copy {count} skills": "Copy {count} skills", "Copy {count} to other agents": "Copy {count} to other agents", "Credentials are masked here": "Credentials are masked in this view. A copy carries the real values.", + "Databases and data": "Databases and data", "Delete description": "This removes it from the agent's own configuration, here:", "Delete for good description": "This erases it from the recycle bin, and it cannot be restored afterwards.", "Delete for good?": "Delete for good?", @@ -317,6 +324,7 @@ "Delete this skill?": "Delete this skill?", "Delete undo hint": "It goes to Gateway's recycle bin first, so this can be undone.", "Deleted": "Deleted", + "Development": "Development", "Different version here": "A different version is here", "Display name, optional": "Display name, optional", "Diverged detail": "The source has changed and so has this copy. Updating replaces what was changed here, and puts it in the recycle bin.", @@ -326,6 +334,7 @@ "Empty the recycle bin?": "Empty the recycle bin?", "Endpoint": "Endpoint", "Environment": "Environment variables", + "Everyday servers": "Everyday servers", "Failed": "Failed", "Failed to add this MCP server": "Failed to add this MCP server", "Failed to add this source": "This source could not be added", @@ -343,9 +352,11 @@ "Failed to save these instructions": "Failed to save these instructions", "Failed to update": "Failed to update", "Fetch again": "Fetch again", + "Fill in by hand": "Fill in by hand", "From": "From", "From a plugin detail": "Shipped by the {plugin} plugin. Uninstall the plugin to remove it.", "From a project detail": "Kept in the project at {project}, not in the agent's own skills directory.", + "From a template": "From a template", "Gateway does not know where this agent keeps these": "Gateway does not know where this agent keeps these", "GitHub repository": "GitHub repository", "GitHub source hint": "owner/repo, or the address of a repository, a branch or a folder inside one.", @@ -377,11 +388,13 @@ "Moved to the recycle bin": "Moved to the recycle bin", "New": "New", "Newer version here": "A newer version is here", + "No MCP server in this JSON": "No MCP server in this JSON", "No MCP servers yet": "This agent has no MCP servers yet", "No agents found": "No agents found on this machine", "No agents found detail": "Gateway reads the configuration of the agents installed where it runs. Inside a container it sees the container's own filesystem, not the host's.", "No instructions yet": "This agent has no instructions yet", "No other agent on this machine can receive these": "No other agent on this machine can receive these.", + "No server matches": "No server matches", "No skills yet": "This agent has no skills yet", "No sources yet": "No sources yet. Add a GitHub repository, an archive or a folder to install from.", "Not installed": "config only", @@ -396,6 +409,10 @@ "Out of date detail": "{agents} hold a newer version of this.", "Page description": "The skills, MCP servers and instructions each agent on this machine keeps in its own configuration files. Compare them side by side, and copy them from one agent to another.", "Pairs hint": "One KEY=VALUE per line.", + "Paste JSON": "Paste JSON", + "Paste JSON hint": "Paste the block a server's own page hands out, with or without the mcpServers wrapper around it.", + "Path": "Path", + "Pick another": "Pick another", "Pick the agents to copy into": "pick the agents to copy into", "Prompt": "Prompt", "Prompt editor hint": "What this agent is told before every session. It is saved to the agent's own file, and a session already running has to be restarted to pick it up.", @@ -415,8 +432,11 @@ "Restore as a replacement?": "Restore as a replacement?", "Restored": "Restored", "Same version": "The same version", + "Search and the web": "Search and the web", "Search by name": "Search by name", + "Search servers": "Search servers", "Select all": "Select all", + "Servers in this JSON": "Servers in this JSON", "Shared with": "The same files are read by {agents}.", "Skill": "Skill", "Skills": "Skills", @@ -427,7 +447,10 @@ "Source removed": "Source removed", "Sources": "Sources", "Subfolder": "Subfolder, optional", + "Team tools": "Team tools", "This file is read-only for Gateway": "Gateway does not write this agent's configuration file", + "This is not valid JSON": "This is not valid JSON", + "This runs": "This runs", "This source holds no skills": "Nothing in this source has a SKILL.md in it.", "Transport": "Transport", "URL": "URL", @@ -445,6 +468,8 @@ "Version": "Version", "Version detail": "A digest of the skill's files. Two agents holding the same digest hold the same version.", "View": "View", + "Where to get it": "Where to get it", + "Workspace ID": "Workspace ID", "and {count} more folders": "and {count} more folders", "{count} skills in this source": "{count} skills in this source", "{count} skills, fetched {when}": "{count} skills, fetched {when}", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index a16150a..33d5b4a 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -271,11 +271,15 @@ "agentConfig": { "1 file": "1 个文件", "1 skill in this source": "该来源共 1 个技能", + "A server here has no command or URL": "其中有服务器既没有命令也没有 URL", + "API key": "API Key", + "Access token": "访问令牌", "Add MCP server": "添加 MCP 服务器", "Add MCP server hint": "Gateway 会按每个 Agent 自己的格式,把这个服务器写进它自己的配置文件。已经在运行的会话需要重启才会读到。", "Add source": "添加来源", "Add to": "添加到", "Add to hint": "这里选中的每个 Agent 都会得到同一个服务器。", + "Add {count} servers": "添加 {count} 个服务器", "Added to 1 agent": "已添加到 1 个 Agent", "Added to {done} agents": "已添加到 {done} 个 Agent", "Added to {done}, {failed} failed": "已添加到 {done} 个 Agent,{failed} 个失败", @@ -291,6 +295,8 @@ "Choose a .zip": "选择 .zip 文件", "Command": "命令", "Command hint": "Agent 要启动的可执行文件,例如 npx 或 uvx。", + "Configuration JSON": "配置 JSON", + "Connection URL": "连接地址", "Contents": "内容", "Copied": "复制于", "Copied 1 item": "已复制 1 项", @@ -309,6 +315,7 @@ "Copy {count} skills": "复制 {count} 个技能", "Copy {count} to other agents": "把 {count} 项复制到其他 Agent", "Credentials are masked here": "此视图中的凭据已被遮蔽。复制时携带的是真实值。", + "Databases and data": "数据库与数据", "Delete description": "这会把它从该 Agent 自己的配置中删除,位置:", "Delete for good description": "将从回收站中彻底删除,之后无法恢复。", "Delete for good?": "彻底删除?", @@ -317,6 +324,7 @@ "Delete this skill?": "删除这个技能?", "Delete undo hint": "会先移入网关的回收站,可以随时撤销。", "Deleted": "已删除", + "Development": "开发", "Different version here": "这里是另一个版本", "Display name, optional": "显示名称,可选", "Diverged detail": "来源已更新,本地也改过。更新会覆盖本地修改,被覆盖的版本会放进回收站。", @@ -326,6 +334,7 @@ "Empty the recycle bin?": "清空回收站?", "Endpoint": "端点", "Environment": "环境变量", + "Everyday servers": "常用服务器", "Failed": "失败", "Failed to add this MCP server": "添加 MCP 服务器失败", "Failed to add this source": "无法添加该来源", @@ -343,9 +352,11 @@ "Failed to save these instructions": "保存提示词失败", "Failed to update": "更新失败", "Fetch again": "重新拉取", + "Fill in by hand": "手动填写", "From": "从", "From a plugin detail": "由插件 {plugin} 提供,卸载该插件才能移除。", "From a project detail": "保存在项目 {project} 中,不在智能体自己的 skills 目录里。", + "From a template": "从模板", "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent 把这些配置放在哪里", "GitHub repository": "GitHub 仓库", "GitHub source hint": "填写 owner/repo,或仓库、分支、子目录的网址。", @@ -377,11 +388,13 @@ "Moved to the recycle bin": "已移入回收站", "New": "新增", "Newer version here": "这里是更新的版本", + "No MCP server in this JSON": "这段 JSON 里没有 MCP 服务器", "No MCP servers yet": "该 Agent 还没有 MCP 服务器", "No agents found": "本机没有找到 Agent", "No agents found detail": "Gateway 只读取它自己所在机器上安装的 Agent 的配置。在容器里运行时,它看到的是容器自己的文件系统,而不是宿主机的。", "No instructions yet": "这个 Agent 还没有提示词", "No other agent on this machine can receive these": "本机没有其他 Agent 可以接收这些内容。", + "No server matches": "没有匹配的服务器", "No skills yet": "该 Agent 还没有技能", "No sources yet": "还没有来源。添加一个 GitHub 仓库、压缩包或本地目录。", "Not installed": "仅配置", @@ -396,6 +409,10 @@ "Out of date detail": "{agents} 上的版本比这里新。", "Page description": "本机每个 Agent 自己配置文件里的技能、MCP 服务器和提示词。可以并排比较,并从一个 Agent 复制到另一个。", "Pairs hint": "每行一个 KEY=VALUE。", + "Paste JSON": "粘贴 JSON", + "Paste JSON hint": "粘贴服务器文档里给出的配置片段,带 mcpServers、servers 包装或者单条配置都可以。", + "Path": "路径", + "Pick another": "换一个", "Pick the agents to copy into": "选择要复制到的 Agent", "Prompt": "提示词", "Prompt editor hint": "每次会话开始前这个 Agent 会读到的内容。保存后写入 Agent 自己的文件,已经在运行的会话需要重启才会生效。", @@ -415,8 +432,11 @@ "Restore as a replacement?": "覆盖恢复?", "Restored": "已恢复", "Same version": "版本相同", + "Search and the web": "搜索与网页", "Search by name": "按名称搜索", + "Search servers": "搜索服务器", "Select all": "全选", + "Servers in this JSON": "这段 JSON 里的服务器", "Shared with": "{agents} 读取的是同一批文件。", "Skill": "技能", "Skills": "技能", @@ -427,7 +447,10 @@ "Source removed": "来源已移除", "Sources": "来源", "Subfolder": "子目录,可选", + "Team tools": "团队协作", "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件", + "This is not valid JSON": "这不是合法的 JSON", + "This runs": "将会运行", "This source holds no skills": "该来源中没有包含 SKILL.md 的目录。", "Transport": "连接方式", "URL": "URL", @@ -445,6 +468,8 @@ "Version": "版本", "Version detail": "技能文件内容的摘要。两个智能体的摘要相同,说明版本相同。", "View": "查看", + "Where to get it": "去哪里获取", + "Workspace ID": "工作区 ID", "and {count} more folders": "另有 {count} 个目录", "{count} skills in this source": "该来源共 {count} 个技能", "{count} skills, fetched {when}": "{count} 个技能,拉取于 {when}", --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
