Skip to content

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

Open
jonastemplestein wants to merge 12 commits intomainfrom
feat/codemode-dynamic-remote-providers-clean
Open

codemode: support dotted provider namespaces and dynamic remote providers#3
jonastemplestein wants to merge 12 commits intomainfrom
feat/codemode-dynamic-remote-providers-clean

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

Open in Devin Review

Note

Medium Risk
Updates core execution and type-generation paths to support dotted provider namespaces and dynamic runtime dispatch, which could affect tool routing and sandbox proxy behavior. Risk is mitigated by added validation for namespace conflicts and expanded test coverage for new execution paths.

Overview
Adds an opt-in @cloudflare/codemode/dynamic entrypoint with a dynamicTools() helper for providers that resolve tool calls at runtime via a callTool(name, args) hook instead of a static tools record.

Extends DynamicWorkerExecutor to support dotted provider namespaces (e.g. mcp.someServer.*) by creating nested sandbox globals, validating/normalizing provider paths, rejecting prefix-conflicting namespaces, and dispatching calls by provider path key. ToolDispatcher now parses arguments uniformly and falls back to a provider-level callTool when no static tool match exists.

Updates AI/TanStack integrations and type generators to handle dynamic providers and dotted provider namespaces, including new insertDeclTree() logic so emitted ambient declarations match nested provider paths. Packaging/build is updated to ship the new entrypoint and standalone build config, and tests/snapshots are extended to cover dotted namespaces and dynamic dispatch behavior.

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

cursor[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@jonastemplestein
Copy link
Copy Markdown
Author

Addressed the review comments.

Two follow-ups landed:

  1. Reject ambiguous provider prefix conflicts up front

    • e.g. mcp and mcp.someServer can no longer coexist silently
    • executor now throws an explicit error explaining that the current provider-construction shape cannot safely represent that overlap
    • the error text also says the longer-term direction should likely be something more oRPC-like (a provider tree / nested client model) rather than continuing to stretch the current flat provider shape
  2. Avoid empty dotted-namespace crashes in declaration emission

    • empty dotted provider/type trees are now skipped rather than materializing empty intermediate nodes that later crash during emit

This keeps the current PR minimal while being honest that the API shape has limits, and it explicitly points toward the more principled nested-client direction for later.

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 6d110bd. Configure here.

typeBlocks.push(types);
const resolved: ResolvedProvider = { name, fns: extractFns(filtered) };
if (provider.positionalArgs) resolved.positionalArgs = true;
if (staticProvider.positionalArgs) resolved.positionalArgs = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate declare const for sibling dotted providers

Medium Severity

When two or more static providers share the same root via dotted names (e.g., "mcp.serverA" and "mcp.serverB"), each provider independently generates its own declare const mcp: { ... } type block containing only its own tools. These are concatenated into the LLM prompt, producing duplicate declare const mcp declarations—invalid TypeScript that can't be merged. The model may only see one provider's tools, causing it to miss the other's. The runtime correctly handles this via globalThis and ??=, but the type-generation loop doesn't merge declaration trees across providers that resolve to the same root identifier.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6d110bd. Configure here.

@jonastemplestein
Copy link
Copy Markdown
Author

Added a small follow-up for pnpm git-subdirectory installs:

  • packages/codemode/package.json now has "prepare": "pnpm build"

This lets consumers install packages/codemode directly from this monorepo via pnpm git dependencies like:

{
  "dependencies": {
    "@cloudflare/codemode": "github:iterate/agents#<ref>&path:/packages/codemode"
  }
}

so the package builds its dist/ artifacts during install instead of requiring a prepacked tarball or registry publish.

Validated with local build + repo check after the change.

@jonastemplestein
Copy link
Copy Markdown
Author

Added the remaining package-local build tooling needed for pnpm git-subdirectory installs.

packages/codemode now carries the build-time devDependencies its prepare script needs when installed in isolation from this monorepo:

{
  "oxfmt": "^0.46.0",
  "tsdown": "^0.21.9",
  "tsx": "^4.21.0",
  "typescript": "^6.0.3"
}

That means this should now work as a direct pnpm dependency from the fork:

{
  "dependencies": {
    "@cloudflare/codemode": "github:iterate/agents#<ref>&path:/packages/codemode"
  }
}

without relying on the monorepo root devDependencies being present during prepare.

Validated again with local package build and full repo check after the change.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 27, 2026

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@3

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@3

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@3

hono-agents

npm i https://pkg.pr.new/hono-agents@3

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@3

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@3

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@3

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@3

commit: 7c0735e

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