Skip to content
Merged
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
62 changes: 45 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ AI Agent
CodeMode MCP Server
├─ search(code) → runs JS with preprocessed OpenAPI spec
│ → all $refs resolved inline, only essential fields kept
│ → ref-resolved paths view with only essential fields kept
│ → agent discovers endpoints, schemas, parameters
└─ execute(code) → runs JS with injected request client
Expand Down Expand Up @@ -136,6 +136,16 @@ Each tool call gets a fresh sandbox with no state carried over between calls.
| `allowedHeaders` | `string[]` | `undefined` | Header whitelist. When unset, a blocklist strips `Authorization`, `Cookie`, `Host`, `X-Forwarded-*`, `Proxy-*`. |
| `maxRefDepth` | `number` | `50` | Max `$ref` resolution depth |

Successful spec preprocessing is cached. Concurrent searches share an in-flight async spec provider, while a failed preparation is retried by a later search.

Data-only input first has a 100,000-node structural inspection limit. Built-in
executors additionally reject cycles, `bigint`, and custom `toJSON` hooks, then
preflight the fully expanded transport shape before marshalling it into a
sandbox. Shared aliases count once per transport occurrence, with limits of
500,000 expanded occurrences and 10 MiB of encoded input. Custom executors still
receive the alias-preserving structured clone and remain responsible for their
own transport limits.

#### `SandboxOptions`

| Option | Type | Default | Description |
Expand Down Expand Up @@ -174,7 +184,7 @@ Clean up sandbox resources.

### Inside `search`

The `spec` global is the preprocessed OpenAPI spec with all `$ref` pointers resolved inline:
The `spec` global is the preprocessed OpenAPI paths view; resolvable `$ref` pointers are expanded inline:

```javascript
// Find endpoints by tag
Expand All @@ -190,16 +200,14 @@ async () => {
return results;
}

// Get endpoint with requestBody schema (refs are already resolved)
// Get endpoint with requestBody schema (resolvable refs are expanded)
async () => {
const op = spec.paths['/v1/products']?.post;
return { summary: op?.summary, requestBody: op?.requestBody };
}

// Spec metadata
// Spec shape
async () => ({
title: spec.info.title,
version: spec.info.version,
endpoints: Object.keys(spec.paths).length,
})
```
Expand Down Expand Up @@ -257,9 +265,9 @@ async () => {

CodeMode automatically preprocesses your OpenAPI spec before passing it to the search sandbox:

- **`$ref` resolution** — all `$ref` pointers are resolved inline (circular refs become `{ $circular: ref }`)
- **Field extraction** — only essential fields kept per operation: `summary`, `description`, `tags`, `operationId`, `parameters`, `requestBody`, `responses`
- **Metadata preserved** — `info`, `servers`, and `components.schemas` are kept alongside processed paths
- **`$ref` resolution** — resolvable `$ref` pointers are expanded inline; circular refs become `{ $circular: ref }`, and refs beyond `maxRefDepth` become `{ $circular: ref, $reason: "max depth exceeded" }`
- **Field extraction** — only essential fields kept per operation: `summary`, `description`, `tags`, `parameters`, `requestBody`, `responses`
- **Output shape** — only `{ paths }` is passed to search; `info`, `servers`, and `components` are omitted because operation fields and resolved references are represented in the paths view

You can also use the preprocessing utilities directly:

Expand Down Expand Up @@ -315,17 +323,37 @@ const codemode = new CodeMode({

Implement the `Executor` interface to use your own sandbox:

CodeMode passes a fresh structured clone of the processed spec to each `search()` call, so mutations made by a custom executor cannot change later searches.

CodeMode calls `executeData()` for `search()` and `executeWithCapabilities()` for `execute()`. Implement the latter when the custom runtime needs to expose the request capability; the legacy `execute()` method remains available for direct executor use.

```typescript
import { CodeMode, type Executor, type ExecuteResult } from '@robinbraemer/codemode';
import {
CodeMode,
emptyExecuteStats,
type CapabilityManifest,
type ExecuteResult,
type Executor,
} from '@robinbraemer/codemode';

class MyExecutor implements Executor {
async execute(code: string, globals: Record<string, unknown>): Promise<ExecuteResult> {
// `code` is an async arrow function as a string: "async () => { ... }"
// `globals` contains named values to inject:
// - plain data (objects, arrays, primitives) → read-only values
// - functions → callable host functions
// - objects with function values → namespace with callable methods
return { result: ..., logs: [] };
async executeData(_code: string, _input: Record<string, unknown>): Promise<ExecuteResult> {
// Run data-only code in the sandbox.
return { result: undefined, stats: emptyExecuteStats() };
}

async executeWithCapabilities(
_code: string,
_input: Record<string, unknown>,
_capabilities: CapabilityManifest,
): Promise<ExecuteResult> {
// Run code with declared capabilities, such as `{namespace}.request()`.
return { result: undefined, stats: emptyExecuteStats() };
}

async execute(_code: string, _globals: Record<string, unknown>): Promise<ExecuteResult> {
// Legacy direct-executor entrypoint.
return { result: undefined, stats: emptyExecuteStats() };
}

dispose() { /* clean up */ }
Expand Down
10 changes: 5 additions & 5 deletions packages/codemode/examples/petstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,6 @@ function step(label: string, result: { content: { text: string }[]; isError?: bo
step(
"Search: API overview",
await codemode.search(`async () => ({
title: spec.info.title,
version: spec.info.version,
servers: spec.servers,
endpointCount: Object.keys(spec.paths).length,
endpoints: Object.keys(spec.paths),
})`),
Expand All @@ -100,7 +97,7 @@ step(
"Search: Pet endpoints",
await codemode.search(`async () => {
return Object.entries(spec.paths)
.filter(([p]) => p.startsWith('/pet'))
.filter(([p]) => p.startsWith('/api/v3/pet'))
.flatMap(([path, methods]) =>
Object.entries(methods)
.filter(([m]) => ['get','post','put','delete'].includes(m))
Expand All @@ -116,7 +113,10 @@ step(
// 3. Search: Inspect Pet schema
step(
"Search: Pet schema",
await codemode.search(`async () => spec.components?.schemas?.Pet`),
await codemode.search(`async () => ({
getPet: spec.paths['/api/v3/pet/{petId}']?.get?.responses,
createPet: spec.paths['/api/v3/pet']?.post?.requestBody,
})`),
);

// 4. Execute: Find available pets
Expand Down
2 changes: 1 addition & 1 deletion packages/codemode/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/codemode",
"version": "0.4.0",
"version": "0.4.1",
"description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.",
"type": "module",
"main": "./dist/index.js",
Expand Down
51 changes: 40 additions & 11 deletions packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { createExecutor } from "./executor/auto.js";
import {
dataOnlyViolationError,
findDataOnlyTransportViolation,
} from "./executor/data-only.js";
import { DEFAULT_MAX_CODE_BYTES } from "./limits.js";
import {
createRequestBridge,
Expand Down Expand Up @@ -91,6 +95,7 @@ export class CodeMode {

// Cached processed spec & context for tool descriptions
private processedSpec: Record<string, unknown> | null = null;
private processedSpecPromise: Promise<Record<string, unknown>> | null = null;
private specContext: { tags: string[]; endpointCount: number } | null = null;

constructor(options: CodeModeOptions) {
Expand Down Expand Up @@ -158,15 +163,24 @@ export class CodeMode {
/**
* Execute a search against the OpenAPI spec.
* The code runs in a sandbox with `spec` available as a global.
* All $refs are pre-resolved inline.
* Resolvable $refs are expanded inline; circular and max-depth refs remain
* marked in the processed search view.
*/
async search(code: string): Promise<ToolCallResult> {
const sizeError = this.validateCodeSize(code);
if (sizeError) return sizeError;
const executor = await this.getExecutor();
const spec = await this.getProcessedSpec();
const input = { spec };
const violation = findDataOnlyTransportViolation(input);
if (violation) {
return this.formatResult({
result: undefined,
error: dataOnlyViolationError(violation),
});
}

const result = await executor.executeData(code, { spec });
const result = await executor.executeData(code, { spec: structuredClone(spec) });

return this.formatResult(result);
}
Expand Down Expand Up @@ -227,21 +241,36 @@ export class CodeMode {
}

/**
* Get the processed spec (refs resolved, fields extracted).
* Get the processed spec (resolvable refs expanded, fields extracted).
* Caches the result after first call.
*/
private async getProcessedSpec(): Promise<Record<string, unknown>> {
if (this.processedSpec) return this.processedSpec;

const raw = await this.resolveSpec();
this.processedSpec = processSpec(raw, this.options.maxRefDepth);

// Extract context for tool descriptions
const tags = extractTags(raw);
const endpointCount = Object.keys(raw.paths ?? {}).length;
this.specContext = { tags, endpointCount };
if (!this.processedSpecPromise) {
const processedSpecPromise = this.resolveSpec().then((raw) => {
const processedSpec = processSpec(raw, this.options.maxRefDepth);

// Extract context for tool descriptions
const tags = extractTags(raw);
const endpointCount = Object.keys(raw.paths ?? {}).length;
this.specContext = { tags, endpointCount };
this.processedSpec = processedSpec;
if (this.processedSpecPromise === processedSpecPromise) {
this.processedSpecPromise = null;
}

return processedSpec;
});
this.processedSpecPromise = processedSpecPromise;
void processedSpecPromise.catch(() => {
if (this.processedSpecPromise === processedSpecPromise) {
this.processedSpecPromise = null;
}
});
}

return this.processedSpec;
return this.processedSpecPromise;
}

private async getExecutor(): Promise<Executor> {
Expand Down
Loading