Skip to content

codemode: support dotted provider namespaces and dynamic remote providers#2

Closed
jonastemplestein wants to merge 4 commits intomainfrom
feat/codemode-dotted-provider-paths
Closed

codemode: support dotted provider namespaces and dynamic remote providers#2
jonastemplestein wants to merge 4 commits intomainfrom
feat/codemode-dotted-provider-paths

Conversation

@jonastemplestein
Copy link
Copy Markdown

@jonastemplestein jonastemplestein commented Apr 27, 2026

Why this exists

The main goal of this follow-up is to make remote tool hosts usable from codemode with as little codemode surgery as possible — especially:

  • a Durable Object instance acting as a tool server
  • an HTTP/RPC-backed tool host
  • an MCP-ish remote endpoint where the tool surface may be known only by the remote side

The key constraint was: do not redesign codemode around a whole new remote-provider abstraction if a much smaller runtime-first change can unlock the use case.

In other words, this PR tries to answer:

How do we let codemode call mcp.someServer.files.read(...) when the actual implementation lives in a remote server / DO instance, without forcing codemode itself to eagerly know every tool up front?

What changed

This PR adds two small but important capabilities:

  1. dotted provider namespaces
  2. a dynamic provider escape hatch for runtime dispatch

Together they allow code like this inside codemode-generated sandbox code:

await mcp.someServer.files.read({ path: "/tmp/x" });
await mcp.someServer.search.docs({ query: "workers rpc" });

where codemode does not need a static local tools: Record<string, Tool> for every callable leaf.

Instead, the provider can forward the attempted call to a remote host at runtime.

New API, up front

1) Dedicated dynamic entrypoint

import { dynamicTools } from "@cloudflare/codemode/dynamic";

I intentionally moved this off the root codemode entrypoint so the mainstream/static codemode API stays conservative, and the runtime-first escape hatch is opt-in.

2) Dynamic provider authoring

import { createCodeTool } from "@cloudflare/codemode/ai";
import { dynamicTools } from "@cloudflare/codemode/dynamic";

const codeTool = createCodeTool({
  tools: [
    dynamicTools({
      name: "mcp.someServer",
      types: `
      declare const mcp: {
        someServer: {
          files: {
            read(input: { path: string }): Promise<string>;
          };
          search: {
            docs(input: { query: string }): Promise<{ hits: string[] }>;
          };
        };
      }
      `.trim(),
      callTool: async (name, args) => {
        // name === "files.read" or "search.docs"
        // args === [{ path: "/tmp/x" }] / [{ query: "workers rpc" }]
        return await remoteCall(name, args);
      }
    })
  ],
  executor
});

That is the primary intended shape.

Example: Durable Object provider

This is the motivating case I had in mind while shaping the PR.

const codeTool = createCodeTool({
  tools: [
    dynamicTools({
      name: "mcp.repo42",
      types: `
      declare const mcp: {
        repo42: {
          issues: {
            list(input: { state?: "open" | "closed" }): Promise<unknown>;
          };
          files: {
            read(input: { path: string }): Promise<string>;
          };
        };
      }
      `.trim(),
      callTool: async (name, args) => {
        const stub = env.REMOTE_TOOLS_DO.get(env.REMOTE_TOOLS_DO.idFromName("repo42"));
        return await stub.callTool(name, args);
      }
    })
  ],
  executor
});

Then codemode-generated code can do:

async () => {
  const readme = await mcp.repo42.files.read({ path: "README.md" });
  const issues = await mcp.repo42.issues.list({ state: "open" });
  return { readme, issues };
}

At runtime codemode forwards:

  • "files.read" + [{ path: "README.md" }]
  • "issues.list" + [{ state: "open" }]

into the DO instance.

That is the whole point: remote/DO-backed providers can be used without teaching codemode a whole new discovery or transport framework first.

Design summary

A. Dotted tool names are already path-like

The earlier dotted-tool-path work made codemode understand that tool names like:

  • files.read
  • internal.sample.ping

should behave like nested sandbox access and nested ambient type declarations.

This PR extends the same idea one level higher to provider names.

So now a provider name like:

name: "mcp.someServer"

means sandbox code can access:

mcp.someServer.*

B. Dynamic providers trade enumeration for runtime dispatch

Static providers still look like:

{ name, tools: { ... } }

Dynamic providers instead look like:

{ name, callTool, types }

This is the explicit “trust me, try it at runtime” escape hatch.

If sandbox code attempts:

mcp.someServer.foo.bar(1, 2)

codemode forwards:

  • tool name: "foo.bar"
  • args: [1, 2]

to the provider’s callTool() hook.

C. types remains model-facing prompt material

I originally explored async/lazy types, but after review that version turned out to be too invasive and not worth the complexity.

So the final compromise is:

  • keep the historical field name types
  • treat it honestly as LLM-facing documentation / declaration text
  • keep it synchronous in this minimal design

That keeps codemode’s eager description assembly intact and avoids large refactors in tool.ts / tanstack-ai.ts.

Why this is intentionally minimal

I specifically did not add:

  • a new remote transport layer
  • discovery protocols inside codemode
  • async prompt-doc loading
  • a generic nested authoring tree API
  • any opinionated MCP/HTTP/DO wrapper abstraction

All of those may be reasonable later, but they are not required to unblock the real use case.

The smallest useful thing is:

  1. let providers have dotted namespaces
  2. let some providers resolve tool calls dynamically at runtime
  3. keep the prompt/docs side simple

That is enough to make a remote server or Durable Object instance usable as a codemode provider today.

Detailed implementation notes

1) Executor runtime now supports dotted provider namespaces

DynamicWorkerExecutor now builds sandbox globals recursively for dotted provider names.

So a provider named:

mcp.someServer

creates nested proxy access rather than a single flat identifier.

2) Runtime dispatch supports dynamic providers

ResolvedProvider can now carry either:

  • static extracted functions (fns)
  • or a provider-level callTool(name, args) hook

ToolDispatcher checks static functions first, then falls back to the dynamic provider hook.

3) Ambient declarations support dotted provider namespaces

Type-generation paths now emit nested declarations for dotted provider namespaces so the prompt-side model view matches runtime access.

Example output shape:

declare const mcp: {
  someServer: {
    files: {
      read: (input: FilesReadInput) => Promise<FilesReadOutput>;
    };
  };
};

4) Prefix conflicts still work

The previous dotted-tool-path work also introduced $call when a name is both:

  • callable itself
  • and a namespace prefix

That behavior is preserved.

5) Dedicated entrypoint for dynamic behavior

dynamicTools() now lives at:

@cloudflare/codemode/dynamic

instead of the root package export.

This keeps the root package surface narrower and makes the dynamic/runtime-first behavior feel intentionally opt-in.

Review-driven changes / corrections

This PR changed shape significantly during review.

The original version tried to do too much, especially around async/lazy types. Review feedback was right that this created both:

  • real bugs
  • unnecessary invasiveness

I explicitly corrected the following:

Fixed: discarded prompt description bug

The async-doc experiment accidentally computed the final description too late and discarded it, leaving the raw {{types}} placeholder visible to the model.

That design was removed.

Fixed: dotted namespace emit bug

Nested provider declaration output was briefly dropping intermediate segments like someServer. The declaration tree helpers and callers were corrected so emitted prompt declarations now match runtime access.

Fixed: dead runtime conditional

A leftover no-op conditional in the executor proxy path was removed.

Reduced scope substantially

I removed the async/lazy prompt-doc machinery entirely and went back to the smallest shape that actually serves the remote/DO provider use case.

Files of interest

Primary implementation files:

  • packages/codemode/src/executor.ts
  • packages/codemode/src/dynamic-tools.ts
  • packages/codemode/src/dynamic.ts
  • packages/codemode/src/resolve.ts
  • packages/codemode/src/tool.ts
  • packages/codemode/src/tanstack-ai.ts
  • packages/codemode/src/tool-types.ts
  • packages/codemode/src/json-schema-types.ts
  • packages/codemode/src/type-tree.ts
  • packages/codemode/src/utils.ts

Tests:

  • packages/codemode/src/tests/dynamic-tools.test.ts
  • packages/codemode/src/tests/executor.test.ts
  • packages/codemode/src/tests/tool-types.test.ts
  • packages/codemode/src/tests/utils.test.ts

Packaging / exports:

  • packages/codemode/package.json
  • packages/codemode/scripts/build.ts

Before / after mental model

Before

Codemode was best at providers that could eagerly produce:

{ name, tools: Record<string, Tool> }

That is awkward for remote systems where the real implementation lives elsewhere, especially per-instance systems like Durable Objects.

After

Codemode still fully supports the static shape, but now also supports:

dynamicTools({
  name: "mcp.someServer",
  types: "...prompt-facing docs...",
  callTool: async (name, args) => { ... }
})

That is enough to bridge codemode into remote tool servers with very little new machinery.

Scope / non-goals

This PR is not trying to solve every remote-tools problem.

It does not provide:

  • discovery
  • caching
  • transport retries
  • auth/session handshakes
  • remote schema syncing
  • first-class MCP client orchestration

Those are intentionally left to the caller / provider implementation.

This PR only provides the minimal codemode runtime surface necessary so those systems can be plugged in cleanly.

Validation

cd packages/codemode
npx vitest run \
  src/tests/dynamic-tools.test.ts \
  src/tests/tool-types.test.ts \
  src/tests/executor.test.ts \
  src/tests/utils.test.ts

cd ../..
npm run build
npm run check

Note

Medium Risk
Touches core execution and dispatch logic (DynamicWorkerExecutor/ToolDispatcher) and prompt type-generation, which could break existing tool routing or namespaces if edge cases slip through. Changes are additive but affect widely used paths.

Overview
Adds an opt-in @cloudflare/codemode/dynamic entrypoint exposing dynamicTools() for providers whose tool surface is resolved at runtime via a callTool(name, args) hook.

Extends runtime execution to support dotted provider namespaces (e.g. mcp.someServer.*) by generating nested sandbox proxies and mapping dispatchers by sanitized dotted paths; ToolDispatcher now falls back to a provider-level callTool when no static tool match exists.

Updates provider resolution and prompt/type generation to match these namespaces: ToolProvider is now a static/dynamic union, types is treated as model-facing documentation, and type emitters (tool-types, json-schema-types, tanstack-ai) now graft tool declarations under dotted namespace roots using the new insertDeclTree() helper. Includes new/updated tests covering dynamic dispatch and dotted provider access.

Reviewed by Cursor Bugbot for commit 0c454cc. Bugbot is set up for automated code reviews on this repo. Configure here.

devin-ai-integration[bot]

This comment was marked as resolved.

cursor[bot]

This comment was marked as resolved.

@jonastemplestein
Copy link
Copy Markdown
Author

Took a second pass and reduced the scope substantially.

What changed:

  • kept dotted provider namespaces
  • kept the dynamicTools() runtime helper
  • kept dynamic runtime dispatch via callTool(name, args)
  • fixed the dotted-namespace declaration bug
  • fixed the discarded-description bug
  • removed the async/lazy types plumbing that had made the change much more invasive

So the PR is now focused on the minimal runtime-first behavior:

  • dynamicTools({ name: "mcp.someServer", types: string, callTool })
  • sandbox code can call mcp.someServer.foo.bar(...)
  • runtime forwards "foo.bar" plus the raw args array to callTool
  • provider types stays an eager string, which keeps prompt assembly aligned with the existing tool-definition model

I think this is a much better tradeoff and closer to the smallest upstreamable shape.

@jonastemplestein
Copy link
Copy Markdown
Author

Did one more polish pass:

  • moved dynamicTools() off the root codemode entrypoint
  • added a dedicated @cloudflare/codemode/dynamic entrypoint instead
  • kept the root package surface narrower and more conservative
  • clarified in the helper docs that types is intentionally synchronous in this minimal design

So the iterate-specific compromise is now:

  • mainstream/static codemode APIs stay where they were
  • the dynamic/runtime-first escape hatch is opt-in via a dedicated subpath export

@jonastemplestein jonastemplestein force-pushed the feat/codemode-dotted-provider-paths branch from 887593d to 262c572 Compare April 27, 2026 12:46
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 262c572. Configure here.

Comment thread examples/assistant/src/use-chats.ts Outdated
if (isWorkspaceChangeMessage(parsed)) {
setWorkspaceRevision((n) => n + 1);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unstable onMessage callback causes repeated WebSocket reconnections

Medium Severity

The onMessage handler passed to useAgent is an inline arrow function, creating a new reference on every render. Right next to it, onMcpUpdate is correctly wrapped in useCallback. If useAgent uses the callback identity in an effect dependency array (as is typical for React hooks managing connections), the unstable onMessage reference would cause the directory WebSocket to tear down and reconnect on every render, breaking sidebar state updates, MCP broadcasts, and workspace change notifications.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 262c572. Configure here.

@jonastemplestein jonastemplestein changed the title codemode: add dynamic tool providers codemode: support dotted provider namespaces and dynamic remote providers Apr 27, 2026
@jonastemplestein jonastemplestein force-pushed the feat/codemode-dotted-provider-paths branch from 262c572 to 0c454cc Compare April 27, 2026 12:58
@jonastemplestein
Copy link
Copy Markdown
Author

Superseded by a clean PR with the correct base / diff:

PR #2 ended up polluted by an incorrect rebase target and showed a large unrelated upstream diff. PR #3 is recreated from iterate/main and contains only the intended follow-up codemode change.

@jonastemplestein
Copy link
Copy Markdown
Author

Closing in favor of #3, which has the correct clean diff on top of iterate/main.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines 276 to 298
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);
}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant