add tab to modify mcp_config.json in front end

This commit is contained in:
2026-02-13 11:22:47 +08:00
parent 2523703df0
commit cf2aea2d26
4 changed files with 409 additions and 216 deletions

View File

@@ -4,10 +4,12 @@ import {
deleteGraphConfig, deleteGraphConfig,
getGraphConfig, getGraphConfig,
getGraphDefaultConfig, getGraphDefaultConfig,
getMcpToolConfig,
listAvailableGraphs, listAvailableGraphs,
listGraphConfigs, listGraphConfigs,
listPipelines, listPipelines,
stopPipeline, stopPipeline,
updateMcpToolConfig,
upsertGraphConfig, upsertGraphConfig,
} from "./api/frontApis"; } from "./api/frontApis";
import type { import type {
@@ -37,6 +39,8 @@ type LaunchCredentials = {
authKeyMasked: string; authKeyMasked: string;
}; };
type ActiveTab = "agents" | "mcp";
const DEFAULT_ENTRY_POINT = "fastapi_server/server_dashscope.py"; const DEFAULT_ENTRY_POINT = "fastapi_server/server_dashscope.py";
const DEFAULT_LLM_NAME = "qwen-plus"; const DEFAULT_LLM_NAME = "qwen-plus";
const DEFAULT_PORT = 8100; const DEFAULT_PORT = 8100;
@@ -118,6 +122,7 @@ function toEditable(
} }
export default function App() { export default function App() {
const [activeTab, setActiveTab] = useState<ActiveTab>("agents");
const [graphs, setGraphs] = useState<string[]>([]); const [graphs, setGraphs] = useState<string[]>([]);
const [configItems, setConfigItems] = useState<GraphConfigListItem[]>([]); const [configItems, setConfigItems] = useState<GraphConfigListItem[]>([]);
const [running, setRunning] = useState<PipelineRunInfo[]>([]); const [running, setRunning] = useState<PipelineRunInfo[]>([]);
@@ -126,6 +131,9 @@ export default function App() {
const [editor, setEditor] = useState<EditableAgent | null>(null); const [editor, setEditor] = useState<EditableAgent | null>(null);
const [statusMessage, setStatusMessage] = useState<string>(""); const [statusMessage, setStatusMessage] = useState<string>("");
const [launchCredentials, setLaunchCredentials] = useState<LaunchCredentials | null>(null); const [launchCredentials, setLaunchCredentials] = useState<LaunchCredentials | null>(null);
const [mcpConfigPath, setMcpConfigPath] = useState<string>("");
const [mcpConfigRaw, setMcpConfigRaw] = useState<string>("");
const [mcpToolKeys, setMcpToolKeys] = useState<string[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const configKeySet = useMemo( const configKeySet = useMemo(
@@ -208,6 +216,16 @@ export default function App() {
} }
}, [selectedId, configKeySet]); }, [selectedId, configKeySet]);
useEffect(() => {
if (activeTab !== "mcp") {
return;
}
if (mcpConfigRaw) {
return;
}
reloadMcpConfig().catch(() => undefined);
}, [activeTab]);
async function selectExisting(item: GraphConfigListItem): Promise<void> { async function selectExisting(item: GraphConfigListItem): Promise<void> {
const id = makeAgentKey(item.pipeline_id, item.prompt_set_id); const id = makeAgentKey(item.pipeline_id, item.prompt_set_id);
setSelectedId(id); setSelectedId(id);
@@ -321,6 +339,37 @@ export default function App() {
} }
} }
async function reloadMcpConfig(): Promise<void> {
setBusy(true);
setStatusMessage("Loading MCP config...");
try {
const resp = await getMcpToolConfig();
setMcpConfigPath(resp.path || "");
setMcpConfigRaw(resp.raw_content || "");
setMcpToolKeys(resp.tool_keys || []);
setStatusMessage("MCP config loaded.");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function saveMcpConfig(): Promise<void> {
setBusy(true);
setStatusMessage("Saving MCP config...");
try {
const resp = await updateMcpToolConfig({ raw_content: mcpConfigRaw });
setMcpConfigPath(resp.path || "");
setMcpToolKeys(resp.tool_keys || []);
setStatusMessage("MCP config saved.");
} catch (error) {
setStatusMessage((error as Error).message);
} finally {
setBusy(false);
}
}
async function saveConfig(): Promise<void> { async function saveConfig(): Promise<void> {
if (!editor) { if (!editor) {
return; return;
@@ -507,243 +556,303 @@ export default function App() {
} }
} }
const showSidebar = activeTab === "agents";
return ( return (
<div className="app"> <div className={`app ${showSidebar ? "" : "full-width"}`}>
<aside className="sidebar"> {showSidebar ? (
<div className="sidebar-header"> <aside className="sidebar">
<h2>Agents</h2> <div className="sidebar-header">
<button onClick={addDraftAgent} disabled={busy}> <h2>Agents</h2>
+ New <button onClick={addDraftAgent} disabled={busy}>
</button> + New
</div>
<div className="agent-list">
{rows.map((row) => (
<button
key={row.id}
className={`agent-item ${selectedId === row.id ? "selected" : ""}`}
onClick={() => {
if (row.isDraft) {
const selectedDraft = draftAgents.find((d) => d.id === row.id) || null;
setSelectedId(row.id);
setEditor(selectedDraft);
return;
}
const item = visibleConfigItems.find(
(x) => makeAgentKey(x.pipeline_id, x.prompt_set_id) === row.id
);
if (item) {
selectExisting(item);
}
}}
>
<span>{row.label}</span>
<small>{row.graphId}</small>
</button> </button>
))} </div>
{rows.length === 0 ? <p className="empty">No agents configured yet.</p> : null} <div className="agent-list">
</div> {rows.map((row) => (
</aside> <button
key={row.id}
className={`agent-item ${selectedId === row.id ? "selected" : ""}`}
onClick={() => {
if (row.isDraft) {
const selectedDraft = draftAgents.find((d) => d.id === row.id) || null;
setSelectedId(row.id);
setEditor(selectedDraft);
return;
}
const item = visibleConfigItems.find(
(x) => makeAgentKey(x.pipeline_id, x.prompt_set_id) === row.id
);
if (item) {
selectExisting(item);
}
}}
>
<span>{row.label}</span>
<small>{row.graphId}</small>
</button>
))}
{rows.length === 0 ? <p className="empty">No agents configured yet.</p> : null}
</div>
</aside>
) : null}
<main className="content"> <main className="content">
<header className="content-header"> <header className="content-header">
<h1>Agent Configuration</h1> <h1>Agent Manager</h1>
<div className="header-actions"> <div className="tabs">
<button onClick={saveConfig} disabled={busy || !editor}> <button
Save type="button"
className={`tab-button ${activeTab === "agents" ? "active" : ""}`}
onClick={() => setActiveTab("agents")}
disabled={busy}
>
Agents
</button> </button>
<button onClick={runSelected} disabled={busy || !editor}> <button
Run type="button"
</button> className={`tab-button ${activeTab === "mcp" ? "active" : ""}`}
<button onClick={stopSelected} disabled={busy || !editor}> onClick={() => setActiveTab("mcp")}
Stop disabled={busy}
</button> >
<button onClick={deleteSelected} disabled={busy || !editor}> MCP Config
Delete
</button> </button>
</div> </div>
</header> </header>
{statusMessage ? <p className="status">{statusMessage}</p> : null} {statusMessage ? <p className="status">{statusMessage}</p> : null}
{launchCredentials ? ( {activeTab === "agents" ? (
<div className="launch-credentials"> <div className="tab-pane">
<h3>Access Credentials (shown once)</h3> <div className="header-actions">
<div> <button onClick={saveConfig} disabled={busy || !editor}>
<strong>URL:</strong>{" "} Save
<a href={launchCredentials.url} target="_blank" rel="noreferrer"> </button>
{launchCredentials.url} <button onClick={runSelected} disabled={busy || !editor}>
</a> Run
<button </button>
type="button" <button onClick={stopSelected} disabled={busy || !editor}>
onClick={() => copyText(launchCredentials.url, "URL")} Stop
disabled={busy} </button>
> <button onClick={deleteSelected} disabled={busy || !editor}>
Copy URL Delete
</button> </button>
</div> </div>
<div>
<strong>{launchCredentials.authType} key:</strong> {launchCredentials.authKey}
<button
type="button"
onClick={() => copyText(launchCredentials.authKey, "auth key")}
disabled={busy}
>
Copy Key
</button>
</div>
<div>
<strong>Header:</strong> <code>{authHeaderValue}</code>
<button
type="button"
onClick={() => copyText(authHeaderValue, "auth header")}
disabled={busy}
>
Copy Header
</button>
</div>
<p className="empty">
Stored after launch as masked value: {launchCredentials.authKeyMasked}
</p>
</div>
) : null}
{!editor ? ( {launchCredentials ? (
<div className="empty-panel"> <div className="launch-credentials">
<p>Select an agent from the left or create a new one.</p> <h3>Access Credentials (shown once)</h3>
</div> <div>
) : ( <strong>URL:</strong>{" "}
<section className="form-grid"> <a href={launchCredentials.url} target="_blank" rel="noreferrer">
<label> {launchCredentials.url}
Agent Type (graph_id) </a>
<select <button
value={editor.graphId} type="button"
onChange={(e) => changeGraph(e.target.value)} onClick={() => copyText(launchCredentials.url, "URL")}
disabled={busy} disabled={busy}
> >
{graphs.map((graph) => ( Copy URL
<option key={graph} value={graph}> </button>
{graph}
</option>
))}
</select>
</label>
{graphArchImage && (
<div className="graph-arch-section">
<h3>Graph Architecture</h3>
<div className="graph-arch-image-container">
<img
src={graphArchImage}
alt={`${editor.graphId} architecture diagram`}
className="graph-arch-image"
/>
</div> </div>
<div>
<strong>{launchCredentials.authType} key:</strong> {launchCredentials.authKey}
<button
type="button"
onClick={() => copyText(launchCredentials.authKey, "auth key")}
disabled={busy}
>
Copy Key
</button>
</div>
<div>
<strong>Header:</strong> <code>{authHeaderValue}</code>
<button
type="button"
onClick={() => copyText(authHeaderValue, "auth header")}
disabled={busy}
>
Copy Header
</button>
</div>
<p className="empty">
Stored after launch as masked value: {launchCredentials.authKeyMasked}
</p>
</div> </div>
)} ) : null}
<label> {!editor ? (
pipeline_id <div className="empty-panel">
<input <p>Select an agent from the left or create a new one.</p>
value={editor.pipelineId} </div>
onChange={(e) => updateEditor("pipelineId", e.target.value)} ) : (
placeholder="example: routing-agent-1" <section className="form-grid">
disabled={busy} <label>
/> Agent Type (graph_id)
</label> <select
value={editor.graphId}
onChange={(e) => changeGraph(e.target.value)}
disabled={busy}
>
{graphs.map((graph) => (
<option key={graph} value={graph}>
{graph}
</option>
))}
</select>
</label>
<label> {graphArchImage && (
prompt_set_id <div className="graph-arch-section">
<input value={editor.promptSetId || "(assigned on save)"} readOnly /> <h3>Graph Architecture</h3>
</label> <div className="graph-arch-image-container">
<img
<label> src={graphArchImage}
tool_keys (comma separated) alt={`${editor.graphId} architecture diagram`}
<input className="graph-arch-image"
value={editor.toolKeys.join(", ")} />
onChange={(e) => updateEditor("toolKeys", parseToolCsv(e.target.value))}
placeholder="tool_a, tool_b"
disabled={busy}
/>
</label>
<label>
port
<input
type="number"
min={1}
value={editor.port}
onChange={(e) => updateEditor("port", Number(e.target.value))}
disabled={busy}
/>
</label>
<label>
api_key
<input
type="password"
value={editor.apiKey}
onChange={(e) => updateEditor("apiKey", e.target.value)}
placeholder="Enter provider API key"
disabled={busy}
/>
{editor.apiKey ? (
<small className="empty">Preview: {maskSecretPreview(editor.apiKey)}</small>
) : null}
</label>
<label>
llm_name
<input
value={editor.llmName}
onChange={(e) => updateEditor("llmName", e.target.value)}
disabled={busy}
/>
</label>
<div className="prompt-section">
<h3>Prompts</h3>
{Object.keys(editor.prompts).length === 0 ? (
<p className="empty">No prompt keys returned from backend.</p>
) : (
Object.entries(editor.prompts).map(([key, value]) => (
<label key={key}>
{key}
<textarea
value={value}
onChange={(e) => updatePrompt(key, e.target.value)}
rows={4}
disabled={busy}
/>
</label>
))
)}
</div>
<div className="run-info">
<h3>Running Instances</h3>
{selectedRuns.length === 0 ? (
<p className="empty">No active runs for this agent.</p>
) : (
selectedRuns.map((run) => (
<div key={run.run_id} className="run-card">
<div>
<strong>run_id:</strong> {run.run_id}
</div>
<div>
<strong>pid:</strong> {run.pid}
</div>
<div>
<strong>url:</strong>{" "}
<a href={run.url} target="_blank" rel="noreferrer">
{run.url}
</a>
</div>
<div>
<strong>auth:</strong> {run.auth_header_name} Bearer {run.auth_key_masked}
</div> </div>
</div> </div>
)) )}
)}
<label>
pipeline_id
<input
value={editor.pipelineId}
onChange={(e) => updateEditor("pipelineId", e.target.value)}
placeholder="example: routing-agent-1"
disabled={busy}
/>
</label>
<label>
prompt_set_id
<input value={editor.promptSetId || "(assigned on save)"} readOnly />
</label>
<label>
tool_keys (comma separated)
<input
value={editor.toolKeys.join(", ")}
onChange={(e) => updateEditor("toolKeys", parseToolCsv(e.target.value))}
placeholder="tool_a, tool_b"
disabled={busy}
/>
</label>
<label>
port
<input
type="number"
min={1}
value={editor.port}
onChange={(e) => updateEditor("port", Number(e.target.value))}
disabled={busy}
/>
</label>
<label>
api_key
<input
type="password"
value={editor.apiKey}
onChange={(e) => updateEditor("apiKey", e.target.value)}
placeholder="Enter provider API key"
disabled={busy}
/>
{editor.apiKey ? (
<small className="empty">Preview: {maskSecretPreview(editor.apiKey)}</small>
) : null}
</label>
<label>
llm_name
<input
value={editor.llmName}
onChange={(e) => updateEditor("llmName", e.target.value)}
disabled={busy}
/>
</label>
<div className="prompt-section">
<h3>Prompts</h3>
{Object.keys(editor.prompts).length === 0 ? (
<p className="empty">No prompt keys returned from backend.</p>
) : (
Object.entries(editor.prompts).map(([key, value]) => (
<label key={key}>
{key}
<textarea
value={value}
onChange={(e) => updatePrompt(key, e.target.value)}
rows={4}
disabled={busy}
/>
</label>
))
)}
</div>
<div className="run-info">
<h3>Running Instances</h3>
{selectedRuns.length === 0 ? (
<p className="empty">No active runs for this agent.</p>
) : (
selectedRuns.map((run) => (
<div key={run.run_id} className="run-card">
<div>
<strong>run_id:</strong> {run.run_id}
</div>
<div>
<strong>pid:</strong> {run.pid}
</div>
<div>
<strong>url:</strong>{" "}
<a href={run.url} target="_blank" rel="noreferrer">
{run.url}
</a>
</div>
<div>
<strong>auth:</strong> {run.auth_header_name} Bearer {run.auth_key_masked}
</div>
</div>
))
)}
</div>
</section>
)}
</div>
) : (
<section className="mcp-config-section tab-pane">
<div className="mcp-config-header">
<h3>Edit MCP Tool Options</h3>
<div className="header-actions">
<button type="button" onClick={reloadMcpConfig} disabled={busy}>
Reload
</button>
<button type="button" onClick={saveMcpConfig} disabled={busy}>
Save
</button>
</div>
</div> </div>
<p className="empty">
This tab edits <code>configs/mcp_config.json</code> directly (comments supported).
</p>
{mcpConfigPath ? (
<p className="empty">
File: <code>{mcpConfigPath}</code>
</p>
) : null}
<p className="empty">
Tool options detected: {mcpToolKeys.length ? mcpToolKeys.join(", ") : "(none)"}
</p>
<textarea
className="mcp-config-editor"
value={mcpConfigRaw}
onChange={(e) => setMcpConfigRaw(e.target.value)}
rows={18}
spellCheck={false}
disabled={busy}
/>
</section> </section>
)} )}
</main> </main>

View File

@@ -4,6 +4,9 @@ import type {
GraphConfigReadResponse, GraphConfigReadResponse,
GraphConfigUpsertRequest, GraphConfigUpsertRequest,
GraphConfigUpsertResponse, GraphConfigUpsertResponse,
McpToolConfigResponse,
McpToolConfigUpdateRequest,
McpToolConfigUpdateResponse,
PipelineCreateRequest, PipelineCreateRequest,
PipelineCreateResponse, PipelineCreateResponse,
PipelineListResponse, PipelineListResponse,
@@ -85,6 +88,19 @@ export function deleteGraphConfig(
}); });
} }
export function getMcpToolConfig(): Promise<McpToolConfigResponse> {
return fetchJson("/v1/tool-configs/mcp");
}
export function updateMcpToolConfig(
payload: McpToolConfigUpdateRequest
): Promise<McpToolConfigUpdateResponse> {
return fetchJson("/v1/tool-configs/mcp", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function createPipeline( export function createPipeline(
payload: PipelineCreateRequest payload: PipelineCreateRequest
): Promise<PipelineCreateResponse> { ): Promise<PipelineCreateResponse> {

View File

@@ -24,6 +24,10 @@ body {
min-height: 100vh; min-height: 100vh;
} }
.app.full-width {
grid-template-columns: 1fr;
}
.sidebar { .sidebar {
border-right: 1px solid #dbe2ea; border-right: 1px solid #dbe2ea;
background: #ffffff; background: #ffffff;
@@ -94,6 +98,21 @@ button:disabled {
margin: 0; margin: 0;
} }
.tabs {
display: flex;
gap: 8px;
}
.tab-button {
min-width: 120px;
}
.tab-button.active {
background: #edf3ff;
border-color: #4d7ef3;
color: #1a4fc5;
}
.header-actions { .header-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -139,6 +158,10 @@ button:disabled {
margin-top: 30px; margin-top: 30px;
} }
.tab-pane {
margin-top: 12px;
}
.form-grid { .form-grid {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -206,6 +229,35 @@ button:disabled {
padding: 10px; padding: 10px;
} }
.mcp-config-section {
background: #f7fbff;
border: 1px solid #d7e6f6;
border-radius: 10px;
padding: 12px;
}
.mcp-config-header {
align-items: center;
display: flex;
justify-content: space-between;
gap: 12px;
}
.mcp-config-header h3 {
margin: 0;
}
.mcp-config-editor {
border: 1px solid #c9d4e2;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
margin-top: 8px;
padding: 10px;
resize: vertical;
width: 100%;
}
.empty { .empty {
color: #687788; color: #687788;
margin: 6px 0; margin: 6px 0;

View File

@@ -85,3 +85,19 @@ export type PipelineStopResponse = {
status: string; status: string;
}; };
export type McpToolConfigResponse = {
path: string;
raw_content: string;
tool_keys: string[];
};
export type McpToolConfigUpdateRequest = {
raw_content: string;
};
export type McpToolConfigUpdateResponse = {
status: string;
path: string;
tool_keys: string[];
};