Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
46f08bc
fix(mcp): use Zod fromJSONSchema for MCP tool schemas
mattzcarey Feb 20, 2026
9f2be18
feat: codemode to work with mcp tools
mattzcarey Feb 21, 2026
009133a
test: consolidate MCP and AI SDK tool type generation tests
mattzcarey Feb 21, 2026
52c047a
fix: TypeScript errors in MCP client and codemode tests
mattzcarey Feb 21, 2026
d425c18
fix: preserve jsonSchema property for backwards compatibility
mattzcarey Feb 21, 2026
99c4021
Add schema comparison tests for jsonSchema() vs fromJSONSchema()
mattzcarey Feb 21, 2026
50eddcd
Add schema performance profiler for jsonSchema vs fromJSONSchema
mattzcarey Feb 21, 2026
03c0050
feat(codemode): add comprehensive schema conversion tests and error h…
mattzcarey Feb 21, 2026
50b72a8
chore: remove temporary profiler and comparison test files
mattzcarey Feb 21, 2026
ab8475a
revert: remove unnecessary escape in testing.md
mattzcarey Feb 21, 2026
c7817fb
refactor: keep jsonSchema wrapper in MCP, handle conversion in codemode
mattzcarey Feb 21, 2026
31b4f1b
perf: direct JSON Schema to TypeScript conversion (~6µs vs ~210µs)
mattzcarey Feb 21, 2026
26c924b
test: simplify tests to focus on our code, not zod-to-ts
mattzcarey Feb 21, 2026
df39106
refactor: remove duplicate sanitizeToolName tests
mattzcarey Feb 21, 2026
b15afa3
style: format
mattzcarey Feb 21, 2026
af3a478
fix: harden JSON Schema to TypeScript converter for production use
mattzcarey Feb 23, 2026
4630041
fix: nullable on $ref, empty enum, and add improvements.md
mattzcarey Feb 23, 2026
1eb2e27
chore: add changeset for schema converter hardening
mattzcarey Feb 23, 2026
5b280e2
fix: remove improvements.md, add format keyword as JSDoc hint
mattzcarey Feb 23, 2026
3e4c7c5
fix: address review feedback for schema converter correctness
mattzcarey Feb 23, 2026
33152f6
Merge branch 'main' into feat/mcp-tools-zod-schema
mattzcarey Feb 23, 2026
0ee5368
Map boolean JSON schemas to TS types
threepointone Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/harden-schema-converter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cloudflare/codemode": patch
"agents": patch
---

Harden JSON Schema to TypeScript converter for production use

- Add depth and circular reference guards to prevent stack overflows on recursive or deeply nested schemas
- Add `$ref` resolution for internal JSON Pointers (`#/definitions/...`, `#/$defs/...`, `#`)
- Add tuple support (`prefixItems` for JSON Schema 2020-12, array `items` for draft-07)
- Add OpenAPI 3.0 `nullable: true` support across all schema branches
- Fix string escaping in enum/const values, property names (control chars, U+2028/U+2029), and JSDoc comments (`*/`)
- Add per-tool error isolation in `generateTypes()` so one malformed schema cannot crash the pipeline
- Guard missing `inputSchema` in `getAITools()` with a fallback to `{ type: "object" }`
- Add per-tool error isolation in `getAITools()` so one bad MCP tool does not break the entire tool set
1 change: 1 addition & 0 deletions examples/codemode/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare namespace Cloudflare {
LOADER: WorkerLoader;
AI: Ai;
OPENAI_API_KEY: string;
HOST?: string;
Codemode: DurableObjectNamespace<import("./src/server").Codemode>;
}
}
Expand Down
242 changes: 235 additions & 7 deletions examples/codemode/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
CheckCircleIcon,
CircleNotchIcon,
BrainIcon,
CaretDownIcon
CaretDownIcon,
PlugsConnectedIcon,
PlusIcon,
CubeIcon,
SpinnerGapIcon
} from "@phosphor-icons/react";
import {
ModeToggle,
Expand All @@ -37,6 +41,12 @@ import {
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import type { ExecutorType } from "./server";

interface McpTool {
serverId: string;
name: string;
description?: string;
}

interface ToolPart {
type: string;
toolCallId?: string;
Expand Down Expand Up @@ -249,23 +259,59 @@ function SettingsPanel({
executor,
onExecutorChange,
loading,
onClose
onClose,
mcpTools,
onAddMcp,
onRemoveMcp,
onRefreshMcpTools,
mcpLoading
}: {
executor: ExecutorType;
onExecutorChange: (e: ExecutorType) => void;
loading: boolean;
onClose: () => void;
mcpTools: McpTool[];
onAddMcp: (url: string, name?: string) => Promise<void>;
onRemoveMcp: (serverId: string) => Promise<void>;
onRefreshMcpTools: () => void;
mcpLoading: boolean;
}) {
const [mcpUrl, setMcpUrl] = useState("");
const [mcpName, setMcpName] = useState("");
const [addingMcp, setAddingMcp] = useState(false);

const handleAddMcp = async () => {
if (!mcpUrl.trim()) return;
setAddingMcp(true);
try {
await onAddMcp(mcpUrl.trim(), mcpName.trim() || undefined);
setMcpUrl("");
setMcpName("");
} finally {
setAddingMcp(false);
}
};

// Group MCP tools by server
const toolsByServer = mcpTools.reduce(
(acc, tool) => {
if (!acc[tool.serverId]) acc[tool.serverId] = [];
acc[tool.serverId].push(tool);
return acc;
},
{} as Record<string, McpTool[]>
);

return (
<>
<button
type="button"
className="fixed inset-0 bg-black/40 z-40"
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40"
onClick={onClose}
aria-label="Close settings"
/>
<aside className="fixed top-0 right-0 bottom-0 w-[360px] max-w-[90vw] bg-kumo-base border-l border-kumo-line z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-kumo-line">
<aside className="fixed top-0 right-0 bottom-0 w-[400px] max-w-[90vw] bg-kumo-base border-l border-kumo-line z-50 flex flex-col shadow-2xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-kumo-line bg-gradient-to-r from-kumo-base to-kumo-elevated">
<Text variant="heading3">Settings</Text>
<Button
variant="ghost"
Expand All @@ -278,12 +324,13 @@ function SettingsPanel({
</div>

<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
{/* Executor Section */}
<div>
<span className="text-xs font-semibold text-kumo-secondary mb-2 block uppercase tracking-wider">
Executor
</span>
<select
className="w-full px-3 py-2 bg-kumo-elevated border border-kumo-line rounded-lg text-kumo-default text-sm outline-none focus:ring-2 focus:ring-kumo-ring"
className="w-full px-3 py-2 bg-kumo-elevated border border-kumo-line rounded-lg text-kumo-default text-sm outline-none focus:ring-2 focus:ring-kumo-ring transition-all"
value={executor}
onChange={(e) => onExecutorChange(e.target.value as ExecutorType)}
disabled={loading}
Expand All @@ -301,9 +348,138 @@ function SettingsPanel({
</div>
</div>

{/* MCP Servers Section */}
<div className="relative">
<div className="absolute -inset-3 bg-gradient-to-br from-orange-500/5 via-transparent to-amber-500/5 rounded-2xl -z-10" />
<div className="flex items-center gap-2 mb-3">
<PlugsConnectedIcon
size={16}
className="text-orange-500"
weight="duotone"
/>
<span className="text-xs font-semibold text-kumo-secondary uppercase tracking-wider">
MCP Servers
</span>
</div>

<div className="space-y-3 p-3 bg-kumo-elevated/50 rounded-xl border border-kumo-line">
<div className="space-y-2">
<input
type="text"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://docs.mcp.cloudflare.com/mcp"
className="w-full px-3 py-2.5 bg-kumo-base border border-kumo-line rounded-lg text-kumo-default text-sm outline-none focus:ring-2 focus:ring-orange-500/30 focus:border-orange-500/50 transition-all placeholder:text-kumo-inactive"
disabled={addingMcp}
/>
<input
type="text"
value={mcpName}
onChange={(e) => setMcpName(e.target.value)}
placeholder="Server name (optional)"
className="w-full px-3 py-2 bg-kumo-base border border-kumo-line rounded-lg text-kumo-default text-xs outline-none focus:ring-2 focus:ring-orange-500/30 focus:border-orange-500/50 transition-all placeholder:text-kumo-inactive"
disabled={addingMcp}
/>
<Button
variant="secondary"
size="sm"
onClick={handleAddMcp}
disabled={!mcpUrl.trim() || addingMcp}
loading={addingMcp}
icon={<PlusIcon size={14} />}
className="w-full !bg-gradient-to-r !from-orange-500/10 !to-amber-500/10 hover:!from-orange-500/20 hover:!to-amber-500/20 !border-orange-500/30"
>
Connect MCP Server
</Button>
</div>

{Object.keys(toolsByServer).length > 0 && (
<div className="pt-3 border-t border-kumo-line space-y-3">
<div className="flex items-center justify-between">
<Text size="xs" variant="secondary" bold>
Connected Servers
</Text>
<button
type="button"
onClick={onRefreshMcpTools}
disabled={mcpLoading}
className="text-xs text-kumo-secondary hover:text-kumo-default transition-colors flex items-center gap-1"
>
{mcpLoading ? (
<SpinnerGapIcon size={12} className="animate-spin" />
) : (
"Refresh"
)}
</button>
</div>
{Object.entries(toolsByServer).map(([serverId, tools]) => (
<div
key={serverId}
className="bg-kumo-base rounded-lg border border-kumo-line overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-orange-500/10 to-transparent border-b border-kumo-line">
<CubeIcon
size={14}
className="text-orange-500"
weight="duotone"
/>
<span className="truncate flex-1">
<Text size="xs" bold>
{serverId}
</Text>
</span>
<Badge variant="secondary" className="text-[10px]">
{tools.length} tools
</Badge>
<button
type="button"
onClick={() => onRemoveMcp(serverId)}
className="p-1 text-kumo-inactive hover:text-red-500 transition-colors"
title="Remove server"
>
<XIcon size={12} />
</button>
</div>
<div className="divide-y divide-kumo-line max-h-32 overflow-y-auto">
{tools.map((tool) => (
<div
key={`${tool.serverId}-${tool.name}`}
className="px-3 py-1.5 hover:bg-kumo-elevated transition-colors"
>
<span className="text-[11px] font-mono text-orange-400/90 block">
{tool.name}
</span>
{tool.description && (
<span className="text-[10px] text-kumo-secondary line-clamp-1">
{tool.description}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
)}

{Object.keys(toolsByServer).length === 0 && (
<div className="text-center py-4">
<PlugsConnectedIcon
size={24}
className="text-kumo-inactive mx-auto mb-2"
/>
<Text size="xs" variant="secondary">
No MCP servers connected
</Text>
</div>
)}
</div>
</div>

{/* Available Functions Section */}
<div>
<span className="text-xs font-semibold text-kumo-secondary mb-2 block uppercase tracking-wider">
Available Functions
Built-in Functions
</span>
<div className="border border-kumo-line rounded-lg overflow-hidden divide-y divide-kumo-line">
{TOOLS.map((tool) => (
Expand Down Expand Up @@ -333,6 +509,8 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [mcpTools, setMcpTools] = useState<McpTool[]>([]);
const [mcpLoading, setMcpLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);

const agent = useAgent({
Expand All @@ -357,6 +535,51 @@ function App() {
[agent]
);

const refreshMcpTools = useCallback(async () => {
setMcpLoading(true);
try {
const tools = await agent.call("listMcpTools", []);
setMcpTools(tools as McpTool[]);
} catch (err) {
console.error("Failed to list MCP tools:", err);
} finally {
setMcpLoading(false);
}
}, [agent]);

const handleAddMcp = useCallback(
async (url: string, name?: string) => {
try {
await agent.call("addMcp", [url, name]);
// Refresh tools list after adding server
await refreshMcpTools();
} catch (err) {
console.error("Failed to add MCP server:", err);
throw err;
}
},
[agent, refreshMcpTools]
);

const handleRemoveMcp = useCallback(
async (serverId: string) => {
try {
await agent.call("removeMcp", [serverId]);
await refreshMcpTools();
} catch (err) {
console.error("Failed to remove MCP server:", err);
}
},
[agent, refreshMcpTools]
);

// Load MCP tools when settings panel opens
useEffect(() => {
if (settingsOpen && isConnected) {
refreshMcpTools();
}
}, [settingsOpen, isConnected, refreshMcpTools]);

const send = useCallback(() => {
const text = input.trim();
if (!text || isStreaming) return;
Expand Down Expand Up @@ -543,6 +766,11 @@ function App() {
onExecutorChange={handleExecutorChange}
loading={isStreaming}
onClose={() => setSettingsOpen(false)}
mcpTools={mcpTools}
onAddMcp={handleAddMcp}
onRemoveMcp={handleRemoveMcp}
onRefreshMcpTools={refreshMcpTools}
mcpLoading={mcpLoading}
/>
)}
</div>
Expand Down
40 changes: 38 additions & 2 deletions examples/codemode/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,38 @@ export class Codemode extends AIChatAgent<Env> {

@callable({ description: "Get tool type definitions" })
getToolTypes() {
return generateTypes(this.tools);
// Merge local tools with MCP tools for type generation
const mcpTools = this.mcp.getAITools();
const allTools = { ...this.tools, ...mcpTools };
return generateTypes(allTools);
}

@callable({ description: "Add an MCP server to get additional tools" })
async addMcp(url: string, name?: string) {
const serverName = name || `mcp-${Date.now()}`;
// Use HOST if provided, otherwise it will be derived from the request
// For @callable methods (WebSocket RPC), there's no request context,
// so HOST must be set in wrangler.jsonc vars for production
await this.addMcpServer(serverName, url, {
callbackHost: this.env.HOST
});
return { success: true, name: serverName };
}

@callable({ description: "List connected MCP servers and their tools" })
listMcpTools() {
const tools = this.mcp.listTools();
return tools.map((t) => ({
serverId: t.serverId,
name: t.name,
description: t.description
}));
}

@callable({ description: "Remove an MCP server" })
async removeMcp(serverId: string) {
await this.mcp.removeServer(serverId);
return { success: true, removed: serverId };
}

createExecutor(): Executor {
Expand All @@ -72,8 +103,13 @@ export class Codemode extends AIChatAgent<Env> {
const workersai = createWorkersAI({ binding: this.env.AI });

const executor = this.createExecutor();

// Merge local tools with MCP tools
const mcpTools = this.mcp.getAITools();
const allTools = { ...this.tools, ...mcpTools };

const codemode = createCodeTool({
tools: this.tools,
tools: allTools,
executor
});

Expand Down
Loading
Loading