Agents write good reports and dashboards. They also write good code. The gap is a runtime for that code after the conversation ends: query the warehouse or invoke an API, write the document, do it again on Monday, with no model in the loop and no warehouse login for the people reading the page. I embedded Starlark in mcp-data-platform to close that gap. This post is that embed, as it sits in internal/platform/scriptrun and internal/platform/scriptexec.
This is the sixth post in MCP by Design. The earlier notes covered Go, composition, steering, knowledge, and testing. This one is the interpreter. It is grounded in the open-source
txn2/mcp-data-platform, also available hosted as Plexara.
§Why Starlark
Starlark was designed at Google for Bazel, originally named Skylark. It is a dialect of Python: dynamically typed, garbage collected, first-class functions, the syntax every model has already read. Determinism and isolation are language rules, not a blocklist. There is no ambient clock, no randomness, no filesystem, no network. A script can affect the world only through bindings the host predeclares.
The alternatives fail that requirement in different ways. Embedding CPython gives you the real language, which is the problem: open, socket, datetime.now, random, subprocess, and a module system that will import whatever is on the path. JavaScript via goja is a language agents also write, but the sandbox is by omission. You strip Date, Math.random, promise scheduling, and prototype pollution, and then you do it again for the next ES feature. WASM via wazero is the strongest isolation, and the only option with a real memory cap, but the author is an agent that thinks in Python, not a guest toolchain. Starlark is the one where the proof ships with the language.
quadrantChart
title Isolation is a language property
x-axis Painful to author --> Agents already write it
y-axis Sandbox by omission --> Hermetic by construction
quadrant-1 embed this
quadrant-2 guest toolchain
quadrant-3 skip
quadrant-4 forever stripping
CPython: [0.18, 0.12]
goja: [0.80, 0.22]
wazero: [0.20, 0.92]
Starlark: [0.86, 0.90]
A marketing manager can already ask an agent for Python, Go, or C++. Then someone has to evaluate it, deploy it, give it credentials, and watch it fail at 07:00. That is an SDLC. The engine below is a smaller one: parse, dry-run, save, execute under captured roles, cap the run.
Readers of the resulting document do not need, or want, a warehouse query or a raw API. A public page should not have access to anything behind it. The script runs with the author’s reach. The page is bytes.
§FileOptions Are Not Bazel’s Defaults
starlark-go ships Bazel defaults. A .bzl file is a declaration loaded by other files, so top-level control flow and rebinding a top-level name are off. A managed script is a procedure executed once, top to bottom, loaded by nobody. The comment on fileOptions in scriptrun.go is the whole argument:
// fileOptions is the dialect every managed script is parsed and resolved under.
//
// while and recursion are OFF. Both are unbounded control flow whose cost
// cannot be read off the source, and a script that needs either is doing
// computation that belongs in SQL. This is the deliberate restrictiveness of
// the feature, not an oversight, and it is the only pair of switches here that
// is about safety.
//
// TopLevelControl and GlobalReassign are ON, and both defaults are inverted on
// purpose. Starlark's defaults come from Bazel, where a .bzl file is a
// DECLARATION loaded by other files: top-level control flow and rebinding a
// top-level name would make what a file declares depend on evaluation order. A
// managed script is the opposite — a procedure executed once, top to bottom, by
// one runner, loaded by nobody. Under the Bazel defaults an author could not
// write `total = 0` and then accumulate into it inside a loop without wrapping
// the whole script in a function, which is friction that buys no safety and no
// determinism.
var fileOptions = &syntax.FileOptions{
Set: true,
While: false,
TopLevelControl: true,
GlobalReassign: true,
LoadBindsGlobally: false,
Recursion: false,
}
§The Thread, the Step Cap, and the Watchdog
The interpreter has no context.Context. CPU is SetMaxExecutionSteps. Wall-clock is a goroutine that cancels the thread when the run context ends. starlark-go reports both as a generic EvalError, so Run records which one fired. This is scriptrun.Run, trimmed of the result packing:
func Run(ctx context.Context, opts Options) (*Result, error) {
opts = opts.withDefaults()
runCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
log := &logBuffer{limit: opts.MaxLogBytes}
host := &hostState{opts: opts, ctx: runCtx}
var overStep atomic.Bool
thread := &starlark.Thread{
Name: opts.Name,
Print: func(_ *starlark.Thread, msg string) { log.write(msg) },
}
thread.SetMaxExecutionSteps(opts.MaxSteps)
thread.OnMaxSteps = func(th *starlark.Thread) {
overStep.Store(true)
th.Cancel("too many steps")
}
done := make(chan struct{})
defer close(done)
go watchCancel(runCtx, thread, done)
_, execErr := starlark.ExecFileOptions(fileOptions, thread, opts.Name, opts.Source, predeclared(host))
result := &Result{
Log: log.string(),
LogTruncated: log.truncated,
Steps: thread.ExecutionSteps(),
Queries: host.queries,
Exports: host.exports,
}
if execErr != nil {
return result, classifyExecError(runCtx, execErr, overStep.Load(), opts.MaxSteps)
}
return result, nil
}
func watchCancel(ctx context.Context, thread *starlark.Thread, done <-chan struct{}) {
select {
case <-ctx.Done():
thread.Cancel(ctx.Err().Error())
case <-done:
}
}
A draft is DraftMaxSteps (2 million), DraftTimeout (60s), DraftMaxRows (5,000), DraftMaxResultBytes (8 MiB). A scheduled run is RunMaxSteps (20 million), RunTimeout (10 minutes), RunMaxRows (20,000), RunMaxResultBytes (32 MiB). MaxLogBytes is 64 KiB. script.MaxSourceBytes is 256 KiB. Scripts are glue.
OnMaxSteps sets the flag before Cancel because a script that burns its step budget often also exceeds the wall clock on the way out, and classifyExecError checks the flag first so the author gets “too expensive, move it into SQL” rather than “your query took too long.” Failures are deterministic. The caller must not retry a Starlark exception.
There is no hard memory cap. Neither starlark-go nor goja offers one. The mitigations are the step limit, the deadline, host-side result caps, one run at a time per replica, GOMEMLIMIT, and a worker-role flag so script execution can live on replicas that are not serving MCP. If isolation requirements harden, wazero is the escape hatch.
§Predeclare the Universe
Everything absent from the StringDict passed to ExecFileOptions is absent from the language. predeclared in scriptrun.go is the whole environment:
func predeclared(host *hostState) starlark.StringDict {
return starlark.StringDict{
"platform": &starlarkstruct.Module{
Name: "platform",
Members: starlark.StringDict{
"query": starlark.NewBuiltin(CapabilityQuery, host.query),
"export": starlark.NewBuiltin(CapabilityExport, host.export),
"publish_data": starlark.NewBuiltin(CapabilityPublishData, host.publishData),
"call": starlark.NewBuiltin(CapabilityCall, host.call),
},
},
"json": json.Module,
"date": dateModule,
"run": host.runValue(),
sumBuiltinName: sumBuiltin,
}
}
json is starlark-go’s module. date is a host module of YYYY-MM-DD operations in dates.go, because there is no datetime. sum is missing from Starlark’s universe; sum.go adds it. run is frozen in host.runValue so the script cannot rewrite the fire time:
rec := starlarkstruct.FromStringDict(starlark.String("run"), starlark.StringDict{
"run_id": starlark.String(h.opts.RunID),
"fire_time": starlark.String(h.opts.FireTime.UTC().Format(timeLayout)),
"params": params,
})
rec.Freeze()
return rec
“Yesterday” is date.add_days(date.of(run.fire_time), -1), a value pinned when the run was queued.
§Bind SQL. Do Not Concatenate It.
platform.query does not hand the warehouse a string the script built. host.query unpacks the arguments, runs them through bindSQL, and only then issues a tool call:
func (h *hostState) query(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var (
connection string
sql string
params *starlark.Dict
)
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"sql", &sql, "connection?", &connection, "params?", ¶ms); err != nil {
return nil, argErr(b, err)
}
bound, err := bindSQL(sql, params)
if err != nil {
return nil, argErr(b, err)
}
call := map[string]any{"sql": bound, "limit": h.opts.MaxRows}
if connection != "" {
call["connection"] = connection
}
out, err := h.opts.Caller.CallTool(h.ctx, toolQuery, call)
if err != nil {
return nil, argErr(b, err)
}
h.queries++
return h.queryResult(b.Name(), out)
}
bindSQL in bind.go is state-aware: a :name inside a string literal, a quoted identifier, or a comment is text, and :: is a cast. Every placeholder must have a value and every value must be used. A region named x' OR '1'='1 is a region name.
A truncated result is a failure, not a result. The row cap is pushed down as the query’s limit, so a length comparison against the returned slice can never fire. queryResult reads the tool’s own truncation flag first:
if truncated(out) {
return nil, fmt.Errorf("result of %s was truncated at %d rows; aggregate in SQL or narrow the query, because a partial result would silently change what this script computes",
name, len(rows))
}
Silently wrong is the outcome the determinism contract exists to exclude.
platform.call is the same Caller with the tool name left to the author. A weather-watch script in the ACME demo uses it for a forecast API and for patching the dashboard’s data island. Layout lives in the asset. The script rewrites #data and nothing else:
def forecast(site):
result = platform.call("api_invoke_endpoint", {
"connection": "nws",
"operation_id": "getGridpointForecast",
"path_params": {"office": site["office"], "grid": site["grid"]},
"purpose": "Refreshing the data center weather watch dashboard for one location.",
})
status = result.get("status")
if status != 200:
fail("NWS returned %s for %s (%s %s)" % (status, site["name"], site["office"], site["grid"]))
# ... compact periods into a panel ...
return panel
sites = [forecast(site) for site in SITES]
data = {
"source": "National Weather Service (api.weather.gov)",
"unit": "F",
"sites": sites,
}
platform.call("manage_asset", {
"action": "patch",
"asset_id": ASSET_ID,
"change_summary": "Hourly NWS forecast refresh",
"edits": [{"op": "replace_content", "selector": "#data", "text": json.encode(data)}],
})
No try. A non-200 is fail(...), which fails the run and records the reason. Dates and stamps that need a clock come from the payload or from run.fire_time.
§Host Calls Go Through the Server You Already Have
The tempting design is to bind host.query straight onto a warehouse client. Faster per call. It also means a second authorization path for user-authored code.
Every host binding goes through Caller:
type Caller interface {
CallTool(ctx context.Context, name string, args map[string]any) (map[string]any, error)
}
The production implementation is SessionCaller in session.go. It opens an in-memory MCP session against the fully assembled server, the same mcp.NewInMemoryTransports pattern the admin tool runner already uses:
func Connect(ctx context.Context, server *mcp.Server, label string) (Caller, func(), error) {
if server == nil {
return nil, nil, errors.New("script execution is unavailable on this deployment")
}
serverTransport, clientTransport := mcp.NewInMemoryTransports()
serverSession, err := server.Connect(ctx, serverTransport, nil)
if err != nil {
return nil, nil, fmt.Errorf("opening a script session: %w", err)
}
client := mcp.NewClient(&mcp.Implementation{Name: label, Version: "v1"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
_ = serverSession.Close()
return nil, nil, fmt.Errorf("opening a script session: %w", err)
}
return &SessionCaller{session: session}, func() {
_ = session.Close()
_ = serverSession.Close()
}, nil
}
Identity is already on ctx when Connect is called. A draft run carries the author’s own identity. A scheduled run carries script:<name> and the roles captured on the immutable version row at save. Connect establishes no identity of its own. Authentication, authorization, rate limiting, and audit then apply to a script’s query the same way they apply to an agent’s.
A script can never do unattended what the person who wrote it could not do. Standing authority after the author leaves is inherent to unattended automation. The controls are disable / deprecate / supersede, live persona resolution, and the audit trail.
§Validate Walks the Source Before It Runs
Parse is not execute. Validate uses starlark-go’s resolver, then a lexical pass over the raw source for Python instincts that either parse cleanly and are still wrong, or make the parser name a token instead of the mistake. From validate.go:
var sourcePatterns = []sourcePattern{
{
re: regexp.MustCompile(`(?m)^\s*(?:import\s+\w|from\s+\w+\s+import\b)`),
severity: SeverityError,
message: "`import` is not available",
hint: "There is no module system. Data comes from `platform.query`; `json` and `date` are already predeclared.",
},
{
re: regexp.MustCompile(`(?m)^\s*(?:try|except|finally)\s*:`),
severity: SeverityError,
message: "`try`/`except` does not exist",
hint: "Errors fail the run by design, so a failure is recorded rather than hidden. Check a value before using it, or stop deliberately with `fail(\"message\")`.",
},
{
re: regexp.MustCompile(`\bf"|\bf'`),
severity: SeverityWarning,
message: "f-strings are not supported",
hint: "Use `\"total: {}\".format(n)` or `\"total: %d\" % n`.",
},
{
re: regexp.MustCompile(`\b(?:datetime|time\.time|date\.today|now\(\))`),
severity: SeverityWarning,
message: "there is no clock in a script",
hint: "Reading a clock would make the run unreproducible. The fire time is pinned on `run.fire_time`; derive dates from it with `date.of(run.fire_time)` and the `date` helpers.",
},
}
The same pass reports literal tool names, connections, and destinations from the call sites. A computed tool name is dynamic_tools, not a quietly incomplete list. A destination the deployment does not declare is refused here, not after the queries have already run.
The dialect contract those hints implement is also the body of manage_script command=help, and the built-in knowledge page platform-writing-managed-scripts substitutes the same constant, so the tool, the page, and the validator cannot drift.
§The Document Is an Output
Output identity is the pair (script, output name). scriptexec stores that as an idempotency key on the portal asset, keyed by script ID rather than by script name, so a rename keeps the outputs and a later script of the same name cannot inherit them:
func (w *outputWriter) outputIdentityKey(name string) string {
return "script:" + w.script.ID + ":" + name
}
Each run writes a new version of that one asset. A year of weekday fires is one bookmark with a year of history.
platform.publish_data refreshes a marked region of an asset this script already publishes. PublishData in scriptexec/publish.go serializes the payload first, loads the current body through the same identity key, and splices through the same anchored-editing machinery manage_asset patch uses:
func (w *outputWriter) PublishData(ctx context.Context, req scriptrun.PublishRequest) (*scriptrun.ExportResult, error) {
payload, err := scriptrun.FormatDataPayload(req.Name, req.Data)
if err != nil {
return nil, err
}
asset, body, err := w.refreshTarget(ctx, req.Name)
if err != nil {
return nil, err
}
spliced, err := spliceDataRegion(req.Name, body, contenttype.Normalize(asset.ContentType), payload)
if err != nil {
return nil, err
}
version, err := w.writeRefreshedVersion(ctx, asset, spliced)
// ...
}
The selector is script.DataRegionSelector, #data. A document with no match, or more than one, refuses the publish rather than writing anywhere else. A draft run has no Exporter, so persistOrPreviewPublish serializes to measure and writes nothing. The size a draft reports is the size a platform run splices.
A public URL then serves bytes. There is no view-time warehouse query and no view-time API.
§Summary
The engine is a Go package. syntax.FileOptions inverted from Bazel because the file is a procedure. starlark.Thread with a step cap and a context watchdog, because the interpreter will not distinguish those two stops for you. A predeclared StringDict that is the entire outside world. bindSQL so the script never concatenates a statement. Host builtins that call Caller.CallTool over an in-memory session against the server you already assembled. A lexical pass for the Python the author will type. Output identity as script:<id>:<name> so a schedule versions one document. A #data island so a refresh does not destroy a layout edit.
That is internal/platform/scriptrun and internal/platform/scriptexec. The dialect is hermetic because Starlark is. The reach of a run is the author’s, because the host calls are ordinary tool calls. The audience holds a document. They do not get a warehouse query or an API.