Skip to content

Commit 13187d7

Browse files
committed
feat(mcp): code mode discovery surface
Make Code Mode self-describing so clients/agents discover the pattern on connect without external docs. (d) Server name bumped to "CreateOS MCP (Code Mode v2)" — visible in initialize.serverInfo.name. (a) Richer tool descriptions for search / execute / pollJob: api shape (api.<group>.<operationId>, api.raw escape hatch), ApiError contract (status/body/path), result envelope (sync/async/error), explicit limits, inline examples. (e) tools[].Meta = {mode:"code", sandbox:"workerd", version:"v2"} on each Code Mode tool. Lets orchestrators flag Code Mode tools programmatically. (b) Resources (pull-on-demand, zero cost until requested): code-mode://intro — workflow + auth + sandbox + limits code-mode://api-shape — api proxy + ApiError contract + operationId discovery snippet (c) Prompts: code-mode/deploy-example — single execute() that deploys + waits, with pollJob fallback code-mode/api-discovery — 4 search() snippets that walk you from keyword to operationId Token surface: 476 -> 896 tokens, well under the 1500 gate. count-tokens.ts updated to match shipped descriptions.
1 parent 483ec80 commit 13187d7

4 files changed

Lines changed: 354 additions & 22 deletions

File tree

codemode/discovery.go

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package codemode
2+
3+
import (
4+
"context"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
)
8+
9+
// MCP Resources + Prompts that expose Code Mode usage to clients.
10+
// Tools/list alone tells the model the names; these give it the
11+
// patterns. Pulled on-demand — zero token cost until requested.
12+
13+
const introContent = `# CreateOS MCP — Code Mode v2
14+
15+
This server exposes 10 tools:
16+
17+
Native fast-path (7):
18+
GetQuotas, GetSupportedProjectTypes, CheckProjectUniqueName,
19+
CreateProject, UploadDeploymentBase64Files, GetDeployment,
20+
CancelDeployment
21+
22+
Code Mode (3):
23+
search(code) — read-only spec introspection in a JS sandbox
24+
execute(code) — typed CreateOS API calls in a JS sandbox
25+
pollJob(jobId) — drain long-running execute jobs
26+
27+
The full ~100-endpoint API is reachable via execute. Use search first
28+
to discover an endpoint, then execute to call it.
29+
30+
## Workflow
31+
32+
1. search: 'async () => Object.keys(spec.paths).filter(p => p.includes("project"))'
33+
2. search: 'async () => spec.paths["/v1/projects"]' // inspect schema
34+
3. execute: 'async () => (await api.projects.listProjects({limit:5})).data'
35+
36+
If execute exceeds 90s it returns {status:"running", jobId}; loop
37+
pollJob(jobId) until status != "running".
38+
39+
## Auth
40+
41+
Provide X-Api-Key or Authorization: Bearer <token> on the MCP request.
42+
For stdio transport, set CREATEOS_API_KEY or CREATEOS_BEARER env vars.
43+
44+
## Sandbox
45+
46+
workerd V8 isolate. No fs, no env, no ambient fetch. Only:
47+
spec — OpenAPI document, $refs resolved, descriptions stripped
48+
api — typed proxy (execute mode only)
49+
console — log/info/warn/error captured into result.logs
50+
sleep — async, capped at 60s per call
51+
52+
## Limits
53+
54+
search: 5s timeout
55+
execute: 90s sync window, 600s wall cap, 50 concurrent
56+
jobs: 30 min TTL after terminal, 500 total cap
57+
code: 1 MB
58+
result: 64 KB (oversized truncated with __truncated:true)
59+
`
60+
61+
const apiShapeContent = `# Code Mode — api shape
62+
63+
## Typed accessor
64+
65+
api.<group>.<operationId>(args)
66+
67+
group = first OpenAPI tag for the operation (lowercase, alnum_).
68+
operationId = exact operationId from the spec.
69+
args = single object. Path params by name; query params by name;
70+
body inferred from remaining keys, or pass {body:{...}}
71+
explicitly.
72+
73+
Example:
74+
api.projects.getProject({id:"p_abc"})
75+
api.deployments.createDeployment({projectId:"p_abc", body:{...}})
76+
77+
## Escape hatch
78+
79+
api.raw(method, path, {query, headers, body})
80+
81+
Use when an operation lacks an operationId or you need a custom call.
82+
Path MUST be server-relative (starts with "/"); dot-segments rejected.
83+
84+
## Return shape
85+
86+
Success: { status, headers, data } data = parsed JSON or raw text.
87+
Failure: throws ApiError {
88+
name: "ApiError",
89+
status: number, // backend HTTP status
90+
body: parsed | string,
91+
path: request path,
92+
}
93+
94+
Catch in user code if you want to inspect failures without rejecting:
95+
96+
try { return (await api.foo.listFoo()).data; }
97+
catch (e) { return { kind: e.name, status: e.status, body: e.body }; }
98+
99+
## Discovering operationIds
100+
101+
search: 'async () => Object.entries(spec.paths).flatMap(([p,m]) =>
102+
Object.entries(m).filter(([k]) => k !== "parameters")
103+
.map(([method, op]) => ({ path:p, method, op:op.operationId, tags:op.tags }))
104+
)'
105+
106+
## Forbidden
107+
108+
- Dynamic code evaluation primitives are not exposed; user code is
109+
compiled to a static ESM module.
110+
- fetch — only api/api.raw reach the backend; no other network.
111+
- Set-Cookie / Authorization / X-Api-Key in api.raw headers — stripped.
112+
`
113+
114+
func newCodeModeResource(uri, name, content string) (mcp.Resource, ResourceHandlerFunc) {
115+
r := mcp.NewResource(
116+
uri, name,
117+
mcp.WithMIMEType("text/markdown"),
118+
mcp.WithResourceDescription("Code Mode reference. Pull on demand."),
119+
)
120+
body := content
121+
handler := func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
122+
return []mcp.ResourceContents{
123+
mcp.TextResourceContents{
124+
URI: uri,
125+
MIMEType: "text/markdown",
126+
Text: body,
127+
},
128+
}, nil
129+
}
130+
return r, handler
131+
}
132+
133+
// ResourceHandlerFunc mirrors server.ResourceHandlerFunc to avoid an import
134+
// cycle on the server package; identical signature.
135+
type ResourceHandlerFunc func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error)
136+
137+
// NewIntroResource exposes the high-level Code Mode usage doc.
138+
func NewIntroResource() (mcp.Resource, ResourceHandlerFunc) {
139+
return newCodeModeResource("code-mode://intro", "Code Mode intro + workflow", introContent)
140+
}
141+
142+
// NewAPIShapeResource exposes the api proxy / ApiError contract.
143+
func NewAPIShapeResource() (mcp.Resource, ResourceHandlerFunc) {
144+
return newCodeModeResource("code-mode://api-shape", "Code Mode api shape + ApiError contract", apiShapeContent)
145+
}
146+
147+
// Prompts ---------------------------------------------------------------
148+
149+
const deployExamplePromptText = `You are using CreateOS MCP Code Mode v2.
150+
Goal: deploy a project from an upload and wait for the deployment URL.
151+
152+
Pattern (single execute call):
153+
154+
execute({code: 'async () => {
155+
const { data: dep } = await api.deployments.createDeployment({
156+
projectId: PROJECT_ID,
157+
body: { source: "upload" },
158+
});
159+
for (let i = 0; i < 20; i++) {
160+
await sleep(30000);
161+
const { data: s } = await api.deployments.getDeployment({ id: dep.id });
162+
if (s.status === "deployed") return s.url;
163+
if (s.status === "failed") throw new Error(s.error ?? "deploy failed");
164+
}
165+
throw new Error("deploy did not complete in 10 min");
166+
}'})
167+
168+
If the call exceeds 90s, the result is {status:"running", jobId}. Loop:
169+
170+
pollJob({jobId: "j_..."})
171+
172+
until status != "running". Each pollJob blocks up to ~90s.
173+
174+
Replace PROJECT_ID with the real value from CreateProject (native tool)
175+
or api.projects.listProjects().`
176+
177+
const apiDiscoveryPromptText = `You are using CreateOS MCP Code Mode v2.
178+
Goal: find which endpoints + parameters are relevant to a task.
179+
180+
Use search (no network, fast):
181+
182+
// 1. Find paths matching a keyword
183+
search({code: 'async () => Object.keys(spec.paths).filter(p => /domain/i.test(p))'})
184+
185+
// 2. Inspect the operations on a path
186+
search({code: 'async () => spec.paths["/v1/domains"]'})
187+
188+
// 3. Get the input schema of a specific operation
189+
search({code: 'async () => spec.paths["/v1/domains"].post.requestBody'})
190+
191+
// 4. List all operationIds grouped by tag (= api.<tag>.<operationId>)
192+
search({code: 'async () => {
193+
const out = {};
194+
for (const [path, methods] of Object.entries(spec.paths)) {
195+
for (const [m, op] of Object.entries(methods)) {
196+
if (m === "parameters" || !op.operationId) continue;
197+
const tag = (op.tags?.[0] ?? "default");
198+
(out[tag] ||= []).push({op: op.operationId, method: m.toUpperCase(), path});
199+
}
200+
}
201+
return out;
202+
}'})
203+
204+
Then call execute with api.<tag>.<operationId>(args).`
205+
206+
func NewDeployExamplePrompt() (mcp.Prompt, PromptHandlerFunc) {
207+
p := mcp.NewPrompt(
208+
"code-mode/deploy-example",
209+
mcp.WithPromptDescription("Code Mode pattern: single execute() that deploys + waits, with pollJob fallback."),
210+
)
211+
handler := func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
212+
return &mcp.GetPromptResult{
213+
Description: "Deploy + wait pattern using Code Mode execute() and pollJob().",
214+
Messages: []mcp.PromptMessage{
215+
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(deployExamplePromptText)),
216+
},
217+
}, nil
218+
}
219+
return p, handler
220+
}
221+
222+
func NewAPIDiscoveryPrompt() (mcp.Prompt, PromptHandlerFunc) {
223+
p := mcp.NewPrompt(
224+
"code-mode/api-discovery",
225+
mcp.WithPromptDescription("Code Mode pattern: 4 search() snippets that walk you from keyword to operationId."),
226+
)
227+
handler := func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
228+
return &mcp.GetPromptResult{
229+
Description: "Discover the right operationId via search().",
230+
Messages: []mcp.PromptMessage{
231+
mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(apiDiscoveryPromptText)),
232+
},
233+
}, nil
234+
}
235+
return p, handler
236+
}
237+
238+
// PromptHandlerFunc mirrors server.PromptHandlerFunc.
239+
type PromptHandlerFunc func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error)

codemode/tools.go

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,36 @@ const searchInputSchema = `{
3434
"properties": {
3535
"code": {
3636
"type": "string",
37-
"description": "JavaScript async arrow function. Available globals: spec (CreateOS OpenAPI, $refs resolved), console, sleep. Return any JSON-serialisable value."
37+
"description": "Async arrow fn. Globals: spec, console, sleep. Example: 'async () => Object.entries(spec.paths).filter(([p]) => p.includes(\"deploy\")).map(([p,m]) => ({path:p, ops:Object.keys(m)}))'."
3838
}
3939
},
4040
"required": ["code"]
4141
}`
4242

43+
const searchToolDescription = `Code Mode: introspect the CreateOS OpenAPI spec from a sandboxed JS arrow fn.
44+
45+
When: discover endpoints/params/schemas BEFORE calling execute. No network in this mode.
46+
47+
Globals: spec ($refs resolved, descriptions stripped), console, sleep(ms).
48+
Result envelope: {status:"done"|"error", result, logs} | {status:"error", errorKind:"userCode"|"timeout"|"infra", error, stack}.
49+
Limits: 5s timeout, 1MB code, 64KB result.
50+
51+
See resource code-mode://intro and prompt code-mode/api-discovery.`
52+
53+
func toolMeta() *mcp.Meta {
54+
return &mcp.Meta{
55+
AdditionalFields: map[string]any{
56+
"mode": "code",
57+
"sandbox": "workerd",
58+
"version": "v2",
59+
},
60+
}
61+
}
62+
4363
func NewSearchTool() mcp.Tool {
44-
return mcp.NewToolWithRawSchema(
45-
"search",
46-
"Search the CreateOS OpenAPI spec. Write a JS async arrow function that reads `spec` and returns matches. No network access in this mode. Use to discover endpoints, parameters, and shapes before calling execute().",
47-
[]byte(searchInputSchema),
48-
)
64+
t := mcp.NewToolWithRawSchema("search", searchToolDescription, []byte(searchInputSchema))
65+
t.Meta = toolMeta()
66+
return t
4967
}
5068

5169
type Handler struct {
@@ -88,18 +106,24 @@ const pollJobInputSchema = `{
88106
"properties": {
89107
"jobId": {
90108
"type": "string",
91-
"description": "Job id returned by execute() when status was running."
109+
"description": "j_<uuid> from a prior execute() that returned status:'running'."
92110
}
93111
},
94112
"required": ["jobId"]
95113
}`
96114

115+
const pollJobToolDescription = `Code Mode: drain a long-running execute() job.
116+
117+
When: a prior execute() returned {status:"running", jobId}. Loop until status != "running".
118+
Blocks up to ~90s per call (long-poll).
119+
120+
Result envelope: {status:"running", jobId, logsSoFar} | {status:"done", result, logs} | {status:"error", errorKind:"userCode"|"timeout"|"infra"|"jobMissing", error, stack}.
121+
Wall cap on the underlying job: 600s. Jobs evicted 30 min after terminal.`
122+
97123
func NewPollJobTool() mcp.Tool {
98-
return mcp.NewToolWithRawSchema(
99-
"pollJob",
100-
"Poll a long-running execute() job. Blocks up to ~90s; returns {status: 'running'|'done'|'error', ...}. Loop until status != 'running'.",
101-
[]byte(pollJobInputSchema),
102-
)
124+
t := mcp.NewToolWithRawSchema("pollJob", pollJobToolDescription, []byte(pollJobInputSchema))
125+
t.Meta = toolMeta()
126+
return t
103127
}
104128

105129
func (h *Handler) PollJob(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -131,18 +155,36 @@ const executeInputSchema = `{
131155
"properties": {
132156
"code": {
133157
"type": "string",
134-
"description": "JavaScript async arrow function. Available globals: spec, api, console, sleep. api.<group>.<operationId>(args) calls the CreateOS API with caller auth. api.raw(method, path, opts) is an escape hatch. Throws ApiError on non-2xx. Return any JSON-serialisable value."
158+
"description": "Async arrow fn. Globals: spec, api, console, sleep. Example: 'async () => { const {data} = await api.projects.listProjects({limit:5}); return data; }'. Or escape hatch: 'async () => (await api.raw(\"POST\",\"/v1/x\",{body:{...}})).body'."
135159
}
136160
},
137161
"required": ["code"]
138162
}`
139163

164+
const executeToolDescription = `Code Mode: run JS against the CreateOS API in a fresh V8 isolate. Auth forwarded from caller.
165+
166+
API shape:
167+
api.<group>.<operationId>(args) typed call; group = first OpenAPI tag, operationId from spec.
168+
api.raw(method, path, {query, headers, body}) escape hatch for unmapped routes.
169+
Path params come from args by name: api.projects.getProject({id:"..."}).
170+
Query/body inferred from operation; pass {body:{...}} explicitly to override.
171+
172+
Errors: non-2xx throws ApiError {name, status, body, path}. Catch in user code if you want to handle them.
173+
174+
Result envelope:
175+
sync -> {status:"done", result, logs}
176+
>90s -> {status:"running", jobId} -> loop pollJob(jobId)
177+
failure -> {status:"error", errorKind:"userCode"|"timeout"|"infra"|"capacity", error, stack, logs}
178+
179+
Limits: 90s sync window, 600s wall cap, 1MB code, 64KB result, 50 concurrent jobs.
180+
Sandbox: no fs, no env, no ambient fetch. Only api/console/sleep/spec.
181+
182+
See resource code-mode://api-shape and prompt code-mode/deploy-example.`
183+
140184
func NewExecuteTool() mcp.Tool {
141-
return mcp.NewToolWithRawSchema(
142-
"execute",
143-
"Execute JavaScript against the CreateOS API. Single sandbox call may chain multiple api calls. Returns either {status:'done', result, logs} or {status:'running', jobId} (long-running ops, polled via pollJob).",
144-
[]byte(executeInputSchema),
145-
)
185+
t := mcp.NewToolWithRawSchema("execute", executeToolDescription, []byte(executeInputSchema))
186+
t.Meta = toolMeta()
187+
return t
146188
}
147189

148190
func (h *Handler) Execute(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {

0 commit comments

Comments
 (0)