Unverified 提交 f4305a37 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add MCP HTTP Header support (@gkrdy) (#2365)

Original PR: https://github.com/dyad-sh/dyad/pull/2328 Authored by @gkrdy - thank you! --------- Co-authored-by: 's avatargkrdy <girishkathirdy@gmail.com> Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 4b2f0b5a
ALTER TABLE `mcp_servers` ADD `headers_json` text;
\ No newline at end of file
差异被折叠。
......@@ -162,6 +162,13 @@
"when": 1768924462154,
"tag": "0022_loving_wendigo",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1769188144685,
"tag": "0023_pale_red_hulk",
"breakpoints": true
}
]
}
\ No newline at end of file
import path from "path";
import { spawn } from "child_process";
import { testSkipIfWindows } from "./helpers/test_helper";
import { expect } from "@playwright/test";
......@@ -33,7 +34,6 @@ testSkipIfWindows("mcp - call calculator", async ({ po }) => {
await po.sendPrompt("[call_tool=calculator_add]", {
skipWaitForCompletion: true,
});
// Wait for consent dialog to appear
const alwaysAllowButton = po.page.getByRole("button", {
name: "Always allow",
......@@ -48,3 +48,97 @@ testSkipIfWindows("mcp - call calculator", async ({ po }) => {
await po.sendPrompt("[dump]");
await po.snapshotServerDump("all-messages");
});
testSkipIfWindows("mcp - call calculator via http", async ({ po }) => {
const httpMcpServerPath = path.join(
__dirname,
"..",
"testing",
"fake-http-mcp-server.mjs",
);
console.log("Starting HTTP MCP server at:", httpMcpServerPath);
const httpServerProcess = spawn("node", [httpMcpServerPath], {
env: { ...process.env, PORT: "3002" },
stdio: "pipe",
});
// Wait for the HTTP server to be ready by checking stdout for the ready message
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("HTTP MCP server failed to start within timeout"));
}, 10000);
httpServerProcess.stdout?.on("data", (data: Buffer) => {
console.log("HTTP MCP server stdout:", data.toString());
if (data.toString().includes("HTTP MCP server running")) {
clearTimeout(timeout);
resolve();
}
});
httpServerProcess.stderr?.on("data", (data: Buffer) => {
console.error("HTTP MCP server stderr:", data.toString());
});
httpServerProcess.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
try {
await po.setUp();
await po.goToSettingsTab();
await po.page.getByRole("button", { name: "Tools (MCP)" }).click();
// Fill in server name
await po.page
.getByRole("textbox", { name: "My MCP Server" })
.fill("testing-mcp-server");
await po.page.getByTestId("mcp-transport-select").selectOption("http");
const urlInput = po.page.getByPlaceholder("http://localhost:3000");
await expect(urlInput).toBeVisible();
await urlInput.fill("http://localhost:3002/mcp");
await po.page.getByRole("button", { name: "Add Server" }).click();
// Wait for the server to be created and the "Add Header" button to become visible
const addHeaderButton = po.page.getByRole("button", { name: "Add Header" });
await expect(addHeaderButton).toBeVisible({ timeout: 10000 });
await addHeaderButton.click();
await po.page.getByRole("textbox", { name: "Key" }).fill("Authorization");
await po.page.getByRole("textbox", { name: "Value" }).fill("testValue1");
await po.page.getByRole("button", { name: "Save" }).click();
await po.goToSettingsTab();
await po.page.getByRole("button", { name: "Tools (MCP)" }).click();
await po.goToAppsTab();
await po.selectChatMode("agent");
await po.sendPrompt("[call_tool=calculator_add]", {
skipWaitForCompletion: true,
});
const alwaysAllowButton = po.page.getByRole("button", {
name: "Allow once",
});
await expect(alwaysAllowButton).toBeVisible();
await po.snapshotMessages();
await alwaysAllowButton.click();
await po.page.getByRole("button", { name: "Approve" }).click();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("all-messages");
} finally {
// Clean up: kill the HTTP server process
httpServerProcess.kill();
await new Promise<void>((resolve) => {
httpServerProcess.on("exit", () => resolve());
// Force kill after 2 seconds if it doesn't exit gracefully
setTimeout(() => {
httpServerProcess.kill("SIGKILL");
resolve();
}, 2000);
});
}
});
- paragraph: "[call_tool=calculator_add]"
- img
- text: Tool Call
- img
- text: testing-mcp-server calculator_add
- img
- text: less than a minute ago
\ No newline at end of file
===
role: system
message:
You are an AI App Builder Agent. Your role is to analyze app development requests and gather all necessary information before the actual coding phase begins.
## Core Mission
Determine what tools, APIs, data, or external resources are needed to build the requested application. Prepare everything needed for successful app development without writing any code yourself.
## Tool Usage Decision Framework
### Use Tools When The App Needs:
- **External APIs or services** (payment processing, authentication, maps, social media, etc.)
- **Real-time data** (weather, stock prices, news, current events)
- **Third-party integrations** (Firebase, Supabase, cloud services)
- **Current framework/library documentation** or best practices
### Use Tools To Research:
- Available APIs and their documentation
- Authentication methods and implementation approaches
- Database options and setup requirements
- UI/UX frameworks and component libraries
- Deployment platforms and requirements
- Performance optimization strategies
- Security best practices for the app type
### When Tools Are NOT Needed
If the app request is straightforward and can be built with standard web technologies without external dependencies, respond with:
**"Ok, looks like I don't need any tools, I can start building."**
This applies to simple apps like:
- Basic calculators or converters
- Simple games (tic-tac-toe, memory games)
- Static information displays
- Basic form interfaces
- Simple data visualization with static data
## Critical Constraints
- ABSOLUTELY NO CODE GENERATION
- **Never write HTML, CSS, JavaScript, TypeScript, or any programming code**
- **Do not create component examples or code snippets**
- **Do not provide implementation details or syntax**
- **Do not use <dyad-write>, <dyad-edit>, <dyad-add-dependency> OR ANY OTHER <dyad-*> tags**
- Your job ends with information gathering and requirement analysis
- All actual development happens in the next phase
## Output Structure
When tools are used, provide a brief human-readable summary of the information gathered from the tools.
When tools are not used, simply state: **"Ok, looks like I don't need any tools, I can start building."**
===
role: user
message: [call_tool=calculator_add]
===
role: assistant
message: <dyad-mcp-tool-call server="testing-mcp-server" tool="calculator_add">
{"a":1,"b":2}
</dyad-mcp-tool-call>
<dyad-mcp-tool-result server="testing-mcp-server" tool="calculator_add">
{"content":[{"type":"text","text":"3"}],"isError":false}
</dyad-mcp-tool-result>
<dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
<dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
===
role: user
message: [dump]
\ No newline at end of file
===
role: system
message:
You are an AI App Builder Agent. Your role is to analyze app development requests and gather all necessary information before the actual coding phase begins.
## Core Mission
Determine what tools, APIs, data, or external resources are needed to build the requested application. Prepare everything needed for successful app development without writing any code yourself.
## Tool Usage Decision Framework
### Use Tools When The App Needs:
- **External APIs or services** (payment processing, authentication, maps, social media, etc.)
- **Real-time data** (weather, stock prices, news, current events)
- **Third-party integrations** (Firebase, Supabase, cloud services)
- **Current framework/library documentation** or best practices
### Use Tools To Research:
- Available APIs and their documentation
- Authentication methods and implementation approaches
- Database options and setup requirements
- UI/UX frameworks and component libraries
- Deployment platforms and requirements
- Performance optimization strategies
- Security best practices for the app type
### When Tools Are NOT Needed
If the app request is straightforward and can be built with standard web technologies without external dependencies, respond with:
**"Ok, looks like I don't need any tools, I can start building."**
This applies to simple apps like:
- Basic calculators or converters
- Simple games (tic-tac-toe, memory games)
- Static information displays
- Basic form interfaces
- Simple data visualization with static data
## Critical Constraints
- ABSOLUTELY NO CODE GENERATION
- **Never write HTML, CSS, JavaScript, TypeScript, or any programming code**
- **Do not create component examples or code snippets**
- **Do not provide implementation details or syntax**
- **Do not use <dyad-write>, <dyad-edit>, <dyad-add-dependency> OR ANY OTHER <dyad-*> tags**
- Your job ends with information gathering and requirement analysis
- All actual development happens in the next phase
## Output Structure
When tools are used, provide a brief human-readable summary of the information gathered from the tools.
When tools are not used, simply state: **"Ok, looks like I don't need any tools, I can start building."**
===
role: user
message: [call_tool=calculator_add_2]
===
role: assistant
message: <dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
<dyad-write path="file1.txt">
A file (2)
</dyad-write>
More
EOM
===
role: user
message: [dump]
\ No newline at end of file
......@@ -18,15 +18,15 @@ import { AddMcpServerDeepLinkData } from "@/ipc/deep_link_data";
type KeyValue = { key: string; value: string };
function parseEnvJsonToArray(
envJson?: Record<string, string> | string | null,
function parseJsonToArray(
json?: Record<string, string> | string | null,
): KeyValue[] {
if (!envJson) return [];
if (!json) return [];
try {
const obj =
typeof envJson === "string"
? (JSON.parse(envJson) as unknown as Record<string, string>)
: (envJson as Record<string, string>);
typeof json === "string"
? (JSON.parse(json) as unknown as Record<string, string>)
: (json as Record<string, string>);
return Object.entries(obj).map(([key, value]) => ({
key,
value: String(value ?? ""),
......@@ -36,7 +36,7 @@ function parseEnvJsonToArray(
}
}
function arrayToEnvObject(envVars: KeyValue[]): Record<string, string> {
function arrayToJsonObject(envVars: KeyValue[]): Record<string, string> {
const env: Record<string, string> = {};
for (const { key, value } of envVars) {
if (key.trim().length === 0) continue;
......@@ -45,20 +45,22 @@ function arrayToEnvObject(envVars: KeyValue[]): Record<string, string> {
return env;
}
function EnvVarsEditor({
serverId,
envJson,
function KeyValueEditor({
id,
json,
disabled,
onSave,
isSaving,
itemLabel = "Environment Variable",
}: {
serverId: number;
envJson?: Record<string, string> | null;
id: number;
json?: Record<string, string> | null;
disabled?: boolean;
onSave: (envVars: KeyValue[]) => Promise<void>;
isSaving: boolean;
itemLabel?: string;
}) {
const initial = useMemo(() => parseEnvJsonToArray(envJson), [envJson]);
const initial = useMemo(() => parseJsonToArray(json), [json]);
const [envVars, setEnvVars] = useState<KeyValue[]>(initial);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingKeyValue, setEditingKeyValue] = useState("");
......@@ -69,7 +71,7 @@ function EnvVarsEditor({
React.useEffect(() => {
setEnvVars(initial);
}, [serverId, initial]);
}, [id, initial]);
const saveAll = async (next: KeyValue[]) => {
await onSave(next);
......@@ -82,7 +84,7 @@ function EnvVarsEditor({
return;
}
if (envVars.some((e) => e.key === newKey.trim())) {
showError("Environment variable with this key already exists");
showError(`${itemLabel} with this key already exists`);
return;
}
const next = [...envVars, { key: newKey.trim(), value: newValue.trim() }];
......@@ -90,7 +92,7 @@ function EnvVarsEditor({
setNewKey("");
setNewValue("");
setIsAddingNew(false);
showSuccess("Environment variables saved");
showSuccess(`${itemLabel}s saved`);
};
const handleEdit = (kv: KeyValue) => {
......@@ -110,7 +112,7 @@ function EnvVarsEditor({
(e) => e.key === editingKeyValue.trim() && e.key !== editingKey,
)
) {
showError("Environment variable with this key already exists");
showError(`${itemLabel} with this key already exists`);
return;
}
const next = envVars.map((e) =>
......@@ -122,7 +124,7 @@ function EnvVarsEditor({
setEditingKey(null);
setEditingKeyValue("");
setEditingValue("");
showSuccess("Environment variables saved");
showSuccess(`${itemLabel}s saved`);
};
const handleCancelEdit = () => {
......@@ -134,7 +136,7 @@ function EnvVarsEditor({
const handleDelete = async (key: string) => {
const next = envVars.filter((e) => e.key !== key);
await saveAll(next);
showSuccess("Environment variables saved");
showSuccess(`${itemLabel}s saved`);
};
return (
......@@ -142,10 +144,10 @@ function EnvVarsEditor({
{isAddingNew ? (
<div className="space-y-3 p-3 border rounded-md bg-muted/50">
<div className="space-y-2">
<Label htmlFor={`env-new-key-${serverId}`}>Key</Label>
<Label htmlFor={`env-new-key-${id}`}>Key</Label>
<Input
id={`env-new-key-${serverId}`}
placeholder="e.g., PATH"
id={`env-new-key-${id}`}
placeholder={itemLabel === "Header" ? "Key" : "e.g., PATH"}
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
autoFocus
......@@ -153,10 +155,12 @@ function EnvVarsEditor({
/>
</div>
<div className="space-y-2">
<Label htmlFor={`env-new-value-${serverId}`}>Value</Label>
<Label htmlFor={`env-new-value-${id}`}>Value</Label>
<Input
id={`env-new-value-${serverId}`}
placeholder="e.g., /usr/local/bin"
id={`env-new-value-${id}`}
placeholder={
itemLabel === "Header" ? "Value" : "e.g., /usr/local/bin"
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
disabled={disabled || isSaving}
......@@ -193,14 +197,14 @@ function EnvVarsEditor({
disabled={disabled}
>
<Plus size={14} />
Add Environment Variable
Add {itemLabel}
</Button>
)}
<div className="space-y-2">
{envVars.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No environment variables configured
No {itemLabel.toLowerCase()}s configured
</p>
) : (
envVars.map((kv) => (
......@@ -384,8 +388,10 @@ export function ToolsMcpSettings() {
/>
</div>
<div>
<Label>Transport</Label>
<Label htmlFor="mcp-transport-select">Transport</Label>
<select
id="mcp-transport-select"
data-testid="mcp-transport-select"
value={transport}
onChange={(e) => setTransport(e.target.value as Transport)}
className="w-full h-9 rounded-md border bg-transparent px-3 text-sm"
......@@ -466,15 +472,33 @@ export function ToolsMcpSettings() {
<div className="text-sm font-medium mb-2">
Environment Variables
</div>
<EnvVarsEditor
serverId={s.id}
envJson={s.envJson}
<KeyValueEditor
id={s.id}
json={s.envJson}
disabled={!s.enabled}
isSaving={!!isUpdatingServer}
onSave={async (pairs) => {
await updateServer({
id: s.id,
envJson: arrayToJsonObject(pairs),
});
}}
/>
</div>
)}
{s.transport === "http" && (
<div className="mt-3">
<div className="text-sm font-medium mb-2">Headers</div>
<KeyValueEditor
id={s.id}
json={s.headersJson}
disabled={!s.enabled}
isSaving={!!isUpdatingServer}
itemLabel="Header"
onSave={async (pairs) => {
await updateServer({
id: s.id,
envJson: arrayToEnvObject(pairs),
headersJson: arrayToJsonObject(pairs),
});
}}
/>
......
......@@ -220,6 +220,10 @@ export const mcpServers = sqliteTable("mcp_servers", {
string,
string
> | null>(),
headersJson: text("headers_json", { mode: "json" }).$type<Record<
string,
string
> | null>(),
url: text("url"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
......
......@@ -32,7 +32,16 @@ export function registerMcpHandlers() {
});
createTypedHandler(mcpContracts.createServer, async (_, params) => {
const { name, transport, command, args, envJson, url, enabled } = params;
const {
name,
transport,
command,
args,
envJson,
headersJson,
url,
enabled,
} = params;
// Handle args: can be string (JSON), array, or null/undefined
const parsedArgs = args
? typeof args === "string"
......@@ -45,6 +54,12 @@ export function registerMcpHandlers() {
? (JSON.parse(envJson) as Record<string, string>)
: envJson
: null;
// Handle headersJson: can be string (JSON), object, or null/undefined
const parsedHeadersJson = headersJson
? typeof headersJson === "string"
? (JSON.parse(headersJson) as Record<string, string>)
: headersJson
: null;
const result = await db
.insert(mcpServers)
.values({
......@@ -53,6 +68,7 @@ export function registerMcpHandlers() {
command: command || null,
args: parsedArgs,
envJson: parsedEnvJson,
headersJson: parsedHeadersJson,
url: url || null,
enabled: !!enabled,
})
......@@ -78,6 +94,12 @@ export function registerMcpHandlers() {
? JSON.parse(params.envJson)
: params.envJson
: null;
if (params.headersJson !== undefined)
update.headersJson = params.headersJson
? typeof params.headersJson === "string"
? JSON.parse(params.headersJson)
: params.headersJson
: null;
if (params.url !== undefined) update.url = params.url;
if (params.enabled !== undefined) update.enabled = !!params.enabled;
......
......@@ -20,6 +20,7 @@ export const McpServerSchema = z.object({
command: z.string().nullable(),
args: z.array(z.string()).nullable(),
envJson: z.record(z.string()).nullable(),
headersJson: z.record(z.string()).nullable(),
url: z.string().nullable(),
enabled: z.boolean(),
createdAt: z.date(),
......@@ -40,6 +41,10 @@ export const CreateMcpServerSchema = z.object({
.union([z.record(z.string()), z.string()])
.nullable()
.optional(),
headersJson: z
.union([z.record(z.string()), z.string()])
.nullable()
.optional(),
url: z.string().nullable().optional(),
enabled: z.boolean().optional(),
});
......@@ -54,6 +59,7 @@ export const McpServerUpdateSchema = z.object({
args: z.string().optional(),
cwd: z.string().optional(),
envJson: z.union([z.record(z.string()), z.string()]).optional(),
headersJson: z.union([z.record(z.string()), z.string()]).optional(),
url: z.string().optional(),
enabled: z.boolean().optional(),
});
......
......@@ -36,7 +36,12 @@ class McpManager {
});
} else if (s.transport === "http") {
if (!s.url) throw new Error("HTTP MCP requires url");
transport = new StreamableHTTPClientTransport(new URL(s.url as string));
const headers = s.headersJson ?? {};
transport = new StreamableHTTPClientTransport(new URL(s.url as string), {
requestInit: {
headers,
},
});
} else {
throw new Error(`Unsupported MCP transport: ${s.transport}`);
}
......
......@@ -48,3 +48,55 @@ Once connected, you should see the two tools listed:
- `calculator_add`
- `print_envs`
---
### Fake HTTP MCP server
This directory contains a minimal HTTP MCP server for local testing.
- **Tools**:
- **calculator_add**: adds two numbers. Inputs: `a` (number), `b` (number).
- **print_envs**: returns all environment variables visible to the server as pretty JSON.
### Requirements
- **Node 20+** (same as the repo engines)
- Uses Node.js built-in `http` module
### Launch
- **Via Node**:
```bash
node testing/fake-http-mcp-server.mjs
```
- **Via script**:
```bash
testing/run-fake-http-mcp-server.sh
```
### Configuration
- **Port**: defaults to `3002`, configurable via `PORT` environment variable
```bash
export PORT=3002
node testing/fake-http-mcp-server.mjs
```
### Integrating with Dyad (HTTP MCP)
When adding an HTTP MCP server in the app, use:
- **Name**: `testing-http-mcp-server` (or any name)
- **Transport**: `http`
- **URL**: `http://localhost:3002/mcp` (or your configured port)
- **Headers**: Optional. You can add custom headers (e.g., `Authorization: Bearer token`) if needed for testing.
Once connected, you should see the tools listed:
- `calculator_add`
- `print_envs`
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "node:http";
import { z } from "zod";
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3002;
const server = new McpServer({
name: "fake-http-mcp",
version: "0.1.0",
});
server.registerTool(
"calculator_add",
{
title: "Calculator Add",
description: "Add two numbers and return the sum",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => {
const sum = a + b;
return {
content: [{ type: "text", text: String(sum) }],
};
},
);
server.registerTool(
"print_envs",
{
title: "Print Envs",
description: "Print the environment variables received by the server",
inputSchema: {},
},
async () => {
const envObject = Object.fromEntries(
Object.entries(process.env).map(([key, value]) => [key, value ?? ""]),
);
const pretty = JSON.stringify(envObject, null, 2);
return {
content: [{ type: "text", text: pretty }],
};
},
);
// Create the StreamableHTTP transport
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
// Connect the server to the transport
await server.connect(transport);
// Create HTTP server
const httpServer = createServer(async (req, res) => {
// Only handle requests to /mcp endpoint
if (req.url !== "/mcp") {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
// Handle CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
});
res.end();
return;
}
// Set CORS headers
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
try {
// Let the transport handle body parsing (it uses raw-body internally)
await transport.handleRequest(req, res);
} catch (error) {
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: error.message }));
}
}
});
httpServer.listen(PORT, "0.0.0.0", () => {
console.log(`HTTP MCP server running on http://localhost:${PORT}/mcp`);
console.log(`Environment variables:`, Object.keys(process.env).length);
});
// Graceful shutdown
process.on("SIGINT", async () => {
console.log("\nShutting down server...");
await transport.close();
httpServer.close(() => {
console.log("Server closed");
process.exit(0);
});
});
process.on("SIGTERM", async () => {
console.log("\nShutting down server...");
await transport.close();
httpServer.close(() => {
console.log("Server closed");
process.exit(0);
});
});
#!/usr/bin/env bash
set -euo pipefail
# Launch the fake HTTP MCP server with Node.
# Usage: testing/run-fake-http-mcp-server.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_BIN="node"
exec "$NODE_BIN" "$SCRIPT_DIR/fake-http-mcp-server.mjs"
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论