Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/codemode-dynamic-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/codemode": patch
---

Add a `dynamicTools()` helper for runtime-resolved codemode providers, support dotted provider paths, and allow provider prompt documentation (`types`) to be loaded asynchronously.
5 changes: 5 additions & 0 deletions packages/codemode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
"import": "./dist/mcp.js",
"require": "./dist/mcp.js"
},
"./dynamic": {
"types": "./dist/dynamic.d.ts",
"import": "./dist/dynamic.js",
"require": "./dist/dynamic.js"
},
"./tanstack-ai": {
"types": "./dist/tanstack-ai.d.ts",
"import": "./dist/tanstack-ai.js",
Expand Down
8 changes: 7 additions & 1 deletion packages/codemode/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ async function main() {
await build({
clean: true,
dts: true,
entry: ["src/index.ts", "src/ai.ts", "src/mcp.ts", "src/tanstack-ai.ts"],
entry: [
"src/index.ts",
"src/ai.ts",
"src/mcp.ts",
"src/dynamic.ts",
"src/tanstack-ai.ts"
],
deps: {
skipNodeModulesBundle: true,
neverBundle: ["cloudflare:workers"]
Expand Down
81 changes: 81 additions & 0 deletions packages/codemode/src/dynamic-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ToolProvider, ToolProviderTypes } from "./executor";

/**
* A dynamic tool provider is the escape hatch for integrations that do not
* know their full tool surface ahead of time, or deliberately do not want to
* pay the cost of discovering it up front.
*
* Instead of contributing a static `tools` record, a dynamic provider accepts
* arbitrary dotted subpaths at runtime and decides for itself whether they are
* valid. In other words, if sandbox code evaluates
* `mcp.someServer.files.read({ path: "/tmp/x" })`, codemode forwards the
* trailing path `"files.read"` and the raw argument array to `callTool()`.
*
* The `types` field name is inherited from existing codemode providers, but for
* dynamic providers it should be read much more literally as *LLM-facing
* documentation*. Codemode does not parse, typecheck, or validate this text as
* TypeScript. The string is inserted verbatim into the prompt block shown to
* the model. That means it can contain declaration-like examples, prose API
* notes, usage conventions, or any other guidance that helps the model produce
* sensible calls.
*
* We intentionally keep this field synchronous in the minimal dynamic-provider
* design. Tool descriptions are assembled eagerly by the surrounding codemode
* integrations, and trying to sneak lazy/async prompt material through that
* path made the implementation much more invasive than the runtime feature
* justified. If we ever want remote discovery-backed docs, that should likely
* be a separate API with its own explicit lifecycle.
*/
export interface DynamicToolsOptions {
/**
* Namespace path exposed in sandbox code.
*
* Dotted names are allowed. For example, `name: "mcp.someServer"` makes the
* provider reachable as `mcp.someServer.*` in generated code.
*/
name?: string;

/**
* Runtime handler for tool calls under this namespace.
*
* Codemode forwards the full dotted subpath below `name` verbatim. If the
* model attempts `mcp.someServer.foo.bar(1, 2)`, this function receives
* `name === "foo.bar"` and `args === [1, 2]`.
*/
callTool: (name: string, args: unknown[]) => Promise<unknown>;

/**
* Optional model-facing documentation inserted into the codemode prompt.
*
* Despite the legacy field name, this does not need to be valid TypeScript.
* It is best thought of as provider documentation for the LLM: declaration
* snippets, examples, conventions, caveats, etc.
*/
types?: ToolProviderTypes;

/**
* Marks this provider as preferring positional-argument examples.
*
* The runtime hook always receives the raw argument array regardless; this
* flag only affects how surrounding codemode integrations think about the
* provider surface.
*/
positionalArgs?: boolean;
}

/**
* Construct a codemode provider whose tool surface is resolved dynamically at
* runtime instead of from a static `tools` record.
*
* The helper exists so we can keep the public API crisp and intentional. Users
* opt into the dynamic behavior explicitly instead of smuggling it through the
* generic `ToolProvider` shape.
*/
export function dynamicTools(options: DynamicToolsOptions): ToolProvider {
return {
name: options.name,
callTool: options.callTool,
types: options.types,
positionalArgs: options.positionalArgs
};
}
1 change: 1 addition & 0 deletions packages/codemode/src/dynamic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { dynamicTools, type DynamicToolsOptions } from "./dynamic-tools";
171 changes: 111 additions & 60 deletions packages/codemode/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { sanitizeToolPath } from "./utils";
import type { ToolDescriptors } from "./tool-types";
import type { ToolSet } from "ai";

export type ToolProviderTypes = string;

export interface ExecuteResult {
result: unknown;
error?: string;
Expand Down Expand Up @@ -53,15 +55,21 @@ export type ToolProviderTools = ToolDescriptors | ToolSet | SimpleToolRecord;
* // sandbox: github.listIssues(), shell.exec(), codemode.search()
* ```
*/
export interface ToolProvider {
export interface StaticToolProvider {
/** Namespace prefix in the sandbox (e.g. "state", "mcp"). Defaults to "codemode". */
name?: string;

/** Tools exposed as `namespace.toolName()` in the sandbox. */
tools: ToolProviderTools;

/** Type declarations for the LLM. Auto-generated from `tools` if omitted. */
types?: string;
/**
* Model-facing provider documentation inserted into the codemode prompt.
*
* The field name is historical: codemode does not typecheck this content.
* It may contain declaration-like snippets, prose documentation, examples,
* or other guidance for the LLM.
*/
types?: ToolProviderTypes;

/**
* When true, tools accept positional args instead of a single object arg.
Expand All @@ -73,6 +81,25 @@ export interface ToolProvider {
positionalArgs?: boolean;
}

/**
* Dynamic providers trade ahead-of-time tool enumeration for a single runtime
* hook that receives any attempted dotted subpath under the provider namespace.
*
* This is the explicit "trust me, try it at runtime" escape hatch. If sandbox
* code evaluates `mcp.someServer.foo.bar(1, 2)`, codemode forwards
* `"foo.bar"` and `[1, 2]` to `callTool()` and lets the provider decide
* whether that path is meaningful.
*/
export interface DynamicToolProvider {
name?: string;
callTool: (name: string, args: unknown[]) => Promise<unknown>;
types?: ToolProviderTypes;
positionalArgs?: boolean;
tools?: never;
}

export type ToolProvider = StaticToolProvider | DynamicToolProvider;

// ── ResolvedProvider ──────────────────────────────────────────────────

/**
Expand All @@ -82,6 +109,7 @@ export interface ToolProvider {
export interface ResolvedProvider {
name: string;
fns: Record<string, (...args: unknown[]) => Promise<unknown>>;
callTool?: (name: string, args: unknown[]) => Promise<unknown>;
positionalArgs?: boolean;
}

Expand Down Expand Up @@ -117,30 +145,44 @@ export interface Executor {
export class ToolDispatcher extends RpcTarget {
#fns: Record<string, (...args: unknown[]) => Promise<unknown>>;
#positionalArgs: boolean;
#callTool?: (name: string, args: unknown[]) => Promise<unknown>;

constructor(
fns: Record<string, (...args: unknown[]) => Promise<unknown>>,
positionalArgs = false
positionalArgs = false,
callTool?: (name: string, args: unknown[]) => Promise<unknown>
) {
super();
this.#fns = fns;
this.#positionalArgs = positionalArgs;
this.#callTool = callTool;
}

async call(name: string, argsJson: string): Promise<string> {
const fn = this.#fns[name];
if (!fn) {
return JSON.stringify({ error: `Tool "${name}" not found` });
}
try {
if (this.#positionalArgs) {
const args = argsJson ? JSON.parse(argsJson) : [];
const result = await fn(...(Array.isArray(args) ? args : [args]));
const parsed = argsJson ? JSON.parse(argsJson) : [];
const args = Array.isArray(parsed) ? parsed : [parsed];

const fn = this.#fns[name];
if (fn) {
if (this.#positionalArgs) {
const result = await fn(...args);
return JSON.stringify({ result });
}
const result = await fn(args[0] ?? {});
return JSON.stringify({ result });
}
const args = argsJson ? JSON.parse(argsJson) : {};
const result = await fn(args);
return JSON.stringify({ result });

// Dynamic providers intentionally do not predeclare their full tool
// surface. When a static match is missing, we give the provider-level
// hook the exact dotted path the sandbox attempted and let the remote side
// decide whether it is meaningful.
if (this.#callTool) {
const result = await this.#callTool(name, args);
return JSON.stringify({ result });
}

return JSON.stringify({ error: `Tool "${name}" not found` });
} catch (err) {
return JSON.stringify({
error: err instanceof Error ? err.message : String(err)
Expand Down Expand Up @@ -226,64 +268,63 @@ export class DynamicWorkerExecutor implements Executor {
const normalized = normalizeCode(code);
const timeoutMs = this.#timeout;

// Validate provider names.
// Provider names are no longer limited to a single identifier. A provider
// path like `mcp.someServer` should become nested objects in the sandbox,
// and each segment must remain stable across prompt generation, proxy
// creation, and dispatcher lookup.
const RESERVED_NAMES = new Set(["__dispatchers", "__logs"]);
const VALID_IDENT = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
const seenNames = new Set<string>();
const providerPaths = new Map<string, string[]>();
for (const provider of providers) {
if (RESERVED_NAMES.has(provider.name)) {
return {
result: undefined,
error: `Provider name "${provider.name}" is reserved`
};
}
if (!VALID_IDENT.test(provider.name)) {
return {
result: undefined,
error: `Provider name "${provider.name}" is not a valid JavaScript identifier`
};
const safePath = sanitizeToolPath(provider.name);
const pathParts = safePath.split(".");
for (const part of pathParts) {
if (RESERVED_NAMES.has(part)) {
return {
result: undefined,
error: `Provider name segment "${part}" is reserved`
};
}
}
if (seenNames.has(provider.name)) {
const providerKey = pathParts.join(".");
if (seenNames.has(providerKey)) {
return {
result: undefined,
error: `Duplicate provider name "${provider.name}"`
};
}
seenNames.add(provider.name);
seenNames.add(providerKey);
providerPaths.set(provider.name, pathParts);
}
Comment on lines 276 to 298
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Single-segment provider proxy shadows all longer-prefixed providers sharing the same root

When a single-segment provider (e.g., name: "mcp") and a multi-segment provider sharing the same root (e.g., name: "mcp.server") are both registered, the single-segment provider's recursive Proxy intercepts all property accesses via its get trap, making the multi-segment provider's proxy permanently unreachable.

Detailed walkthrough of the failure

The generated sandbox code for name: "mcp" does mcp = (() => { /* proxy A */ })(), creating a Proxy with a get trap that captures every property access. The code for name: "mcp.server" then does mcp.server = (() => { /* proxy B */ })() — this set falls through to proxy A's target (a function object) since there's no set trap, but when sandbox code later accesses mcp.server.tool(), proxy A's get trap intercepts "server", returning make(["server"]) which routes to the "mcp" dispatcher, not the "mcp.server" dispatcher.

A plausible scenario:

createCodeTool({
  tools: [
    { tools: defaultTools },          // name defaults to "codemode"
    dynamicTools({ name: "codemode.extra", callTool: hook }),
  ],
  executor,
});

The "codemode.extra" provider's tools are silently unreachable.

The validation loop at executor.ts:276-298 checks for exact duplicate providerKeys but not for prefix conflicts. A provider with key "mcp" and another with "mcp.server" both pass validation, producing silently broken runtime behavior.

Prompt for agents
The provider name validation loop in DynamicWorkerExecutor.execute() (executor.ts lines 276-298) checks for exact duplicate providerKeys but does not detect when one provider's path is a strict prefix of another's. For example, providers with names "mcp" and "mcp.server" both pass validation, but at runtime the single-segment proxy's get trap intercepts all property accesses, making the longer-prefixed provider unreachable.

The fix should add prefix conflict detection after the seenNames check. After building the seenNames set, iterate over all pairs of provider keys and check if any key is a strict prefix of another (i.e., key A equals the first N segments of key B). If so, return an error like 'Provider name "mcp" conflicts with "mcp.server" — a single-segment provider cannot coexist with providers that extend its namespace'.

A simple approach: after populating seenNames, for each providerKey, check if any other key in seenNames starts with providerKey + '.' (or vice versa). This would catch the prefix conflict.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


// Generate a recursive Proxy global for each provider namespace.
const proxyInits = providers.map((p) => {
if (p.positionalArgs) {
return (
` const ${p.name} = (() => {\n` +
` const make = (path = []) => new Proxy(async () => {}, {\n` +
` get: (_, key) => typeof key === "string" ? (key === "$call" ? make(path) : make([...path, key])) : undefined,\n` +
` apply: async (_, __, args) => {\n` +
` const resJson = await __dispatchers.${p.name}.call(path.join("."), JSON.stringify(args));\n` +
` const data = JSON.parse(resJson);\n` +
` if (data.error) throw new Error(data.error);\n` +
` return data.result;\n` +
` }\n` +
` });\n` +
` return make();\n` +
` })();`
);
}
return (
` const ${p.name} = (() => {\n` +
` const make = (path = []) => new Proxy(async () => {}, {\n` +
` get: (_, key) => typeof key === "string" ? (key === "$call" ? make(path) : make([...path, key])) : undefined,\n` +
` apply: async (_, __, args) => {\n` +
` const resJson = await __dispatchers.${p.name}.call(path.join("."), JSON.stringify(args[0] ?? {}));\n` +
` const data = JSON.parse(resJson);\n` +
` if (data.error) throw new Error(data.error);\n` +
` return data.result;\n` +
` }\n` +
` });\n` +
` return make();\n` +
const pathParts = providerPaths.get(p.name)!;
const providerKey = pathParts.join(".");
const root = pathParts[0]!;
const setupLines = [
` globalThis.${root} ??= {};`,
...pathParts.slice(1, -1).map((_, i) => {
const child = pathParts.slice(0, i + 2).join(".");
return ` ${child} ??= {};`;
})
];
const assignTarget = providerKey;
return [
...setupLines,
` ${assignTarget} = (() => {`,
` const make = (path = []) => new Proxy(async () => {}, {`,
` get: (_, key) => typeof key === "string" ? (key === "$call" ? make(path) : make([...path, key])) : undefined,`,
` apply: async (_, __, args) => {`,
` const resJson = await __dispatchers[${JSON.stringify(providerKey)}].call(path.join("."), JSON.stringify(args));`,
` const data = JSON.parse(resJson);`,
` if (data.error) throw new Error(data.error);`,
` return data.result;`,
` }`,
` });`,
` return make();`,
` })();`
);
].join("\n");
});

const executorModule = [
Expand Down Expand Up @@ -324,14 +365,24 @@ export class DynamicWorkerExecutor implements Executor {
// generators, so executor lookup stays aligned with the emitted paths.
const dispatchers: Record<string, ToolDispatcher> = {};
for (const provider of providers) {
const providerKey = providerPaths.get(provider.name)!.join(".");
if (provider.callTool) {
dispatchers[providerKey] = new ToolDispatcher(
{},
provider.positionalArgs,
provider.callTool
);
continue;
}

const sanitizedFns: Record<
string,
(...args: unknown[]) => Promise<unknown>
> = {};
for (const [name, fn] of Object.entries(provider.fns)) {
for (const [name, fn] of Object.entries(provider.fns ?? {})) {
sanitizedFns[sanitizeToolPath(name)] = fn;
}
dispatchers[provider.name] = new ToolDispatcher(
dispatchers[providerKey] = new ToolDispatcher(
sanitizedFns,
provider.positionalArgs
);
Expand Down
Loading