Skip to content

itx: McpClient — first-party MCP client capability (loopback ref)#1441

Merged
jonastemplestein merged 4 commits into
mainfrom
itx-mcp-client
Jun 10, 2026
Merged

itx: McpClient — first-party MCP client capability (loopback ref)#1441
jonastemplestein merged 4 commits into
mainfrom
itx-mcp-client

Conversation

@jonastemplestein

@jonastemplestein jonastemplestein commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Stacks on the merged CapTarget kernel (#1436). Part of the codemode rip-out sequence (itx-next.md §4).

What

The first-party half of the §1 litmus test: MCP is a client implementation, not a transport — so it's an ordinary loopback-dialable RPC target, parameterized per server:

await itx.caps.define({
  invoke: "path-call",
  name: "docs",
  target: {
    type: "rpc",
    worker: { type: "loopback" },
    entrypoint: "McpClient",
    props: {
      serverUrl: "https://docs.example.com/mcp",
      headers: { authorization: 'Bearer getSecret({ key: "DOCS_TOKEN" })' },
    },
  },
});

await itx.docs.listTools();
await itx.docs.someToolName({ ... });
  • All transport HTTP rides project egress via the cap's own itx handle (the MCP SDK's custom-fetch option), so header values may carry getSecret() placeholders — substitution happens in the Project DO, the credential never exists in the isolate (Law 5).
  • Stateless by design: connect → call → close per invocation. When handshake latency matters, the same class becomes a durable-object ref without changing callers (the connection-caching pattern of the old OutboundMcpFromOurClientCapability DO).
  • listTools() is an ordinary call, not a special discovery protocol — same convention the codemode provider used.
  • Added to DIALABLE_LOOPBACKS; result shaping reuses the existing outbound-mcp-from-our-client-capability-core helpers.

In the upcoming codemode-drop PR this replaces connectToMcpServer and createOutboundMcpFromOurClientToolProviderRegistration.

Testing

typecheck / lint / format / unit green. New e2e test (itx.e2e.test.ts) defines an McpClient cap, lists tools, and calls the first one — gated on OS_E2E_MCP_SERVER_URL or MOCK_PROVIDER_BASE_URL (skips when no MCP server is reachable).

🤖 Generated with Claude Code


Note

Medium Risk
Expands dialable loopback surface and routes all MCP HTTP through project egress; behavior is gated by existing allowlists and attribution checks but affects outbound networking for defined caps.

Overview
Adds a first-party McpClient loopback RPC capability so projects can define remote MCP servers as ordinary path-call caps (listTools() plus dotted tool calls). Each invocation is connect → call → close; transport HTTP is forced through itx.fetch (project egress) so header secrets resolve in the Project DO, and tool listing/execution reuse the existing outbound MCP core helpers.

McpClient is allowlisted in DIALABLE_LOOPBACKS and exported from the main worker. Several itx entrypoints switch from Reflect.get(this, "ctx") to this.ctx.props. E2E gains a skipped-when-no-server test that defines an McpClient cap and exercises list/call, plus createdProjectIds cleanup for the AI bindings test project.

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

Environment Config Lease

No active environment config lease.

OS

Status: released
Commit: 236953d
Preview: https://os.iterate-preview-5.com
Summary: Preview app released.
Workflow run
Updated: 2026-06-10T13:56:08.912Z

Comment thread apps/os/src/itx/caps/mcp-client.ts Outdated
jonastemplestein and others added 2 commits June 10, 2026 13:35
The first-party half of the litmus test (itx-next.md §1): MCP is a client
implementation, not a transport. McpClient is a loopback-dialable
path-call entrypoint, parameterized per server by props
{ serverUrl, headers }. All transport HTTP rides the project egress pipe
via the cap's own itx handle, so headers may carry getSecret()
placeholders (Law 5) — the credential never exists in the isolate.

Stateless by design: connect → call → close per invocation. When
handshake latency matters, the same class becomes a durable-object ref
without changing callers (the old OutboundMcpFromOurClientCapability DO
pattern).

listTools() is an ordinary call, not a discovery protocol. Added to
DIALABLE_LOOPBACKS; e2e test gated on OS_E2E_MCP_SERVER_URL /
MOCK_PROVIDER_BASE_URL.

Replaces (in the upcoming codemode drop): connectToMcpServer +
createOutboundMcpFromOurClientToolProviderRegistration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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.

There are 2 total unresolved issues (including 1 from previous review).

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

Comment thread apps/os/src/itx/caps/mcp-client.ts
Bugbot findings on #1441: the SDK's custom fetch can receive a URL (which
itx.fetch would pass through un-stringified, skipping egress) or a Request
plus separate init (whose headers must merge before secret substitution
sees them) — build a real Request first. And connect() now runs inside the
try so a partial handshake still hits close().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jonastemplestein

Copy link
Copy Markdown
Contributor Author

Bugbot findings addressed in 24c632b: the transport's custom fetch now builds a real Request before handing off to itx.fetch (URL inputs were bypassing stringification and a separate init could drop headers before secret substitution), and client.connect moved inside the try so a partial handshake still hits close().

Comment thread apps/os/src/itx/caps/mcp-client.ts Outdated
this.ctx.props is properly typed by WorkerEntrypoint's second generic
(GmailCapability already used it directly); the cast dance was vestigial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jonastemplestein added a commit that referenced this pull request Jun 10, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jonastemplestein jonastemplestein merged commit b86d710 into main Jun 10, 2026
9 checks passed
@jonastemplestein jonastemplestein deleted the itx-mcp-client branch June 10, 2026 13:54
jonastemplestein added a commit that referenced this pull request Jun 10, 2026
Stacked on #1441 (McpClient). Part of the codemode rip-out sequence
(itx-next.md §4).

## What

The two-event execution record that replaces codemode's six-event
protocol:

- `events.iterate.com/itx/execution-requested` `{ executionId, code,
vars, context }`
- `events.iterate.com/itx/execution-completed` `{ executionId, ok,
result | error+stack, durationMs, context }`

Both land on the owning project's `/itx` stream (D9-consistent: context
id in the payload).

**Record-only mode**: the events are the durable history, not the
transport — callers get the outcome from the return value/HTTP response,
and everything between the two events is invisible to the stream.
Appends are best-effort, matching the registry's audit posture. Recorded
results are bounded (64k chars, truncated with a preview beyond that);
the full value still returns to the caller.

Mechanically: the loader harness moves out of `fetch.ts` into a shared
`src/itx/run.ts` (`runItxScript()`), `/api/itx/run` becomes a thin
resolve-and-delegate shim, and the response now includes the
`executionId` for correlation. Global-context scripts record no events
(no owning project stream).

The shared runner is the function the upcoming codemode-drop PR points
the inbound-MCP `exec_js` and agent execution paths at.

## Testing

typecheck / lint / format green. New e2e test runs a script via
`/api/itx/run` and asserts both events land on the `/itx` stream with
the returned `executionId` and the correct outcome payload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches dynamic code execution and stream audit payloads; auth stays
at the existing `/api/itx/run` boundary, but execution records and API
response shape change for all script runs.
> 
> **Overview**
> Introduces **record-only** script execution auditing: each
project-context run appends **`execution-requested`** then
**`execution-completed`** on the project **`/itx`** stream (replacing
codemode’s six-event protocol), while callers still get the outcome from
the HTTP/return value.
> 
> The loader harness moves from **`fetch.ts`** into shared
**`runItxScript()`** in **`run.ts`**; **`POST /api/itx/run`** only
resolves access and delegates. Responses now include **`executionId`**
(and on errors too) for correlation. The runner captures isolate
**console** lines, **truncates** large results on the stream (64k),
always emits **completed** even on loader failures, and skips stream
writes for **global** (admin-only) scripts.
> 
> A new e2e test runs a script via **`/api/itx/run`** and asserts both
events match the returned **`executionId`** and result.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f43fee8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- CLOUDFLARE_PREVIEW -->
## Environment Config Lease
<!-- CLOUDFLARE_PREVIEW_STATE -->
<!--
{
  "apps": {
    "os": {
      "appDisplayName": "OS",
      "appSlug": "os",
      "status": "deployed",
      "updatedAt": "2026-06-10T14:04:25.235Z",
      "headSha": "f43fee8b69ae70c6889cdd236fd568c1eab9b671",
      "message": null,
      "publicUrl": "https://os.iterate-preview-7.com",
"runUrl": "https://github.com/iterate/iterate/actions/runs/27281391307",
      "shortSha": "f43fee8"
    },
    "semaphore": {
      "appDisplayName": "Semaphore",
      "appSlug": "semaphore",
      "status": "deployed",
      "updatedAt": "2026-06-10T14:00:32.897Z",
      "headSha": "f43fee8b69ae70c6889cdd236fd568c1eab9b671",
      "message": null,
      "publicUrl": "https://semaphore.iterate-preview-7.com",
"runUrl": "https://github.com/iterate/iterate/actions/runs/27281391307",
      "shortSha": "f43fee8"
    }
  },
  "environmentConfigLease": {
    "dopplerConfig": "preview_7",
    "leasedUntil": 1781103456147,
    "leaseId": "2790f185-d4dc-4d47-8791-eab94eb3ac31",
    "slug": "preview-7",
    "type": "environment-config-lease"
  }
}
-->
<!-- /CLOUDFLARE_PREVIEW_STATE -->
Lease: `preview-7`
Doppler config: `preview_7`
Type: `environment-config-lease`
Leased until: 2026-06-10T14:57:36.147Z

### OS
Status: deployed
Commit: `f43fee8`
Preview: https://os.iterate-preview-7.com
[Workflow
run](https://github.com/iterate/iterate/actions/runs/27281391307)
Updated: 2026-06-10T14:04:25.235Z

### Semaphore
Status: deployed
Commit: `f43fee8`
Preview: https://semaphore.iterate-preview-7.com
[Workflow
run](https://github.com/iterate/iterate/actions/runs/27281391307)
Updated: 2026-06-10T14:00:32.897Z
<!-- /CLOUDFLARE_PREVIEW -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
jonastemplestein added a commit that referenced this pull request Jun 10, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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