Overview

The ExecuteScript MCP tool runs the C# code stored in an executable Code MeshNode through the in-process Microsoft.DotNet.Interactive kernel and returns a status envelope. Agents use it to trigger data imports, run assertion harnesses, or execute any ad-hoc C# against the live mesh — all without a browser click.

Side effects are committed. Calls to mesh.CreateNode, mesh.UpdateNode, and blob writes all happen before ExecuteScript returns.

MCP Client Claude / Cursor ExecuteScript MCP Tool Code MeshNode isExecutable: true Kernel DotNet.Interactive ActivityLog live progress call fetch code submit log entries status response

ExecuteScript flow: the MCP client calls the tool, which fetches and submits the Code node's C# to the kernel; the kernel streams log entries to an ActivityLog node and returns the final status.

When to use it

Not every task calls for ExecuteScript. A quick decision table:

Use ExecuteScript when… Prefer a different tool when…
Running a data import (xlsx / CSV → MeshNodes) Tool-level CRUD — use Create / Update / Patch / Delete
One-shot assertion harness ("check this calculation is green") Anything conversational — let the agent reason directly
Triggering a scheduled job by hand Rendering a view — use RenderArea
Reflection work that reads a NodeType's compiled assembly Reading a single node — use Get

Scripts are full C# — #r "nuget:..." directives work, the kernel's Mesh global exposes the hub's service provider, and Rx operators compose cleanly.

Making a Code node executable

Execution is opt-in per node. Set CodeConfiguration.IsExecutable = true in the node's content:

{
  "id": "ImportLargeClaims",
  "namespace": "Systemorph/FutuRe/EuropeRe/AcmeSubmission2025/Script",
  "name": "Import Large Claims",
  "nodeType": "Code",
  "content": {
    "code": "Console.WriteLine(\"hello\"); 1+1",
    "language": "csharp",
    "isExecutable": true
  }
}

The default is false, so existing Code nodes stay read-only until you explicitly flip the flag. When isExecutable is true, the portal's Content view surfaces a Run button alongside the Edit button.

Calling ExecuteScript from MCP

Pass the node path and an optional timeout:

// Tool call from your MCP client (Claude Code / Cursor / etc.)
{
  "name": "ExecuteScript",
  "arguments": {
    "path": "@Systemorph/FutuRe/EuropeRe/AcmeSubmission2025/Script/ImportLargeClaims",
    "timeoutSeconds": 120
  }
}

The path follows the same Unified Content Reference rules as every other MCP tool: the leading @ is stripped, @/ prefixes resolve to absolute paths, and relative paths are resolved against the current chat context.

Response shape

The reply is the dispatch acknowledgement, not the run's result. On success it carries the activityPath the owning hub actually created, which you then poll or subscribe for the run's live messages and its terminal status.

{
  "status": "Dispatched",        // or "Error"
  "path": "Systemorph/FutuRe/EuropeRe/AcmeSubmission2025/Script/ImportLargeClaims",
  "submissionId": "…",           // stable id for this run
  "activityPath": "…/_Activity/…",// the ActivityLog node to watch
  "message": "Script dispatched. Poll `get @…` for live messages and final status."
}
Status Meaning
Dispatched The owning hub accepted the submission and created the run's ActivityLog. Watch activityPath for Running → Succeeded / Warning / Failed.
Error The run did not start, or it is not known whether it started. errorType says which — read the table below before deciding whether a retry is safe.

errorType — and whether a retry is safe

Every Error reply carries an errorType, so a caller can always branch on it. Two of the conditions are decidable before anything is dispatched, and are decided that way — from one bounded read of the target node, with the caller's own timeoutSeconds as the ceiling:

errorType Meaning Side effects What to do
NodeNotFound There is no readable node at that path. None — nothing was dispatched. Check the path. A denied read reports the same way on purpose — saying "denied" would disclose that a gated node exists there.
NotExecutable The node exists but carries no CodeConfiguration, or carries one with isExecutable: false. None — nothing was dispatched. Point at a Code node, or set isExecutable: true on it.
DispatchRefused The owning hub was reached and said no (unreadable node, activity creation refused, a fault while starting the run). None — the hub creates no ActivityLog on any refusal path. message carries the hub's own verdict; the hub has logged the full cause.
NoActivityPath The hub accepted the submission but returned no activity path. 🚨 Possible — the run may be under way, it simply cannot be observed. Check the Code node's activity history before re-running; a blind retry can run the script twice.
(an exception type name) The dispatch itself failed — undeliverable, or the acknowledgement never arrived within the budget. 🚨 Unknown — "no acknowledgement" is not "not delivered". message and detail carry the type and stack. Check the activity history before retrying.

🚨 "Error" does not universally mean "nothing happened." The two pre-flight verdicts and a hub refusal are genuinely side-effect free. The last two are not: an acknowledgement that never came back says nothing about whether the submission landed, so a retry can run the script a second time.

🚨 The pre-flight refuses only on a DEFINITIVE answer. A read that reaches no verdict inside its budget is unknown, not absent — so it never refuses; the dispatch proceeds exactly as it would have, and the owning hub gets the last word. That asymmetry is what makes the check safe to add: it can turn a guaranteed failure into a fast one, never a would-be success into an error. See CQRS and Content Access for the same rule at the read layer.

Watching progress via the ActivityLog

Scripts emit live updates through the standard logger:

Log.LogInformation("Fetched {Bytes} bytes. Parsing...", bytes.Length);
Log.LogWarning("Row {Row} skipped: {Reason}", i, reason);
Log.LogError("Import failed: {Message}", ex.Message);

Each call appends a message to the run's ActivityLog MeshNode. The ExecuteScriptResponse carries the log's path in the activityLog field. Clients subscribe to that path via workspace.GetMeshNodeStream(activityLogPath) and see each ActivityLog.Messages entry arrive in real time — the same shape used by Thread streams.

When the script finishes, ActivityLog.Status flips to Succeeded / Warning / Failed — the terminal signal UIs and agents watch for.

To poll the log from MCP:

{ "name": "Get", "arguments": { "path": "<activityLog-path-from-ExecuteScript-response>" } }

Each run gets its own ActivityLog node. Previous runs remain browsable under the Code node's activity history — no replacement, no bleed between submissions.

Authoring scripts that agents can run

A few rules of thumb learned from the scripts that ship with the FutuRe demo:

1. Reach hub-level services via Mesh.ServiceProvider. The kernel's script context exposes Mesh. Call Mesh.ServiceProvider.GetRequiredService<IMeshService>() or <IContentService> as needed. The root hub is the one the kernel's sub-hub descends from, so the full production DI surface is available.

2. Avoid await on hub-reachable services in hot paths. Scripts run on the kernel's action block. await meshService.CreateNodeAsync inside a loop serialises the hub. Prefer meshService.CreateNode(node).Subscribe(...) — it returns IObservable<MeshNode>, not Task<MeshNode>.

3. Push external Task-returning primitives onto an IIoPool. For blob reads:

var pool = Mesh.ServiceProvider.GetRequiredService<IoPoolRegistry>().Get(IoPoolNames.Blob);
pool.Invoke(ct => contentService.GetContentAsync(..., ct)).Subscribe(...);

The pool runs the call off the kernel's action block with ConfigureAwait(false), and bounds how many such calls are in flight at once.

🚨 Never Observable.FromAsync. It looks like the same thing and is not: a bare FromAsync runs the function's synchronous prologue on the subscribing thread — the kernel's action block, when you subscribe mid-script — and applies no concurrency bound at all. That is the deadlock-and-exhaustion class IIoPool exists to remove, which is why Observable.FromAsync is forbidden everywhere in src/ outside IoPool itself. Use Invoke for a Task<T> leaf, InvokeBlocking for a sync-blocking or CPU leaf, InvokeStream for an IAsyncEnumerable<T>. See Controlled IO Pooling.

4. Log liberally. Log.LogInformation(...) / LogWarning(...) / LogError(...) append to the run's ActivityLog. Agents and users watching the log have no other window into what the script is doing — tell them.

5. Let the ActivityLog status speak for you. On a clean run the log's Status ends at Succeeded; a LogWarning flips it to Warning; an exception or LogError flips to Failed. Consumers watch that field for the terminal signal — no need for synthetic DONE / FAIL markers.

Typical agent flow

1. Search for the script:
   Search("nodeType:Code name:*import*", basePath="@Systemorph/FutuRe/EuropeRe")

2. Confirm it's executable:
   Get("@Systemorph/FutuRe/EuropeRe/AcmeSubmission2025/Script/ImportLargeClaims")
   → check content.isExecutable == true

3. Run it:
   ExecuteScript(path=..., timeoutSeconds=120)
   → {status: "Executed", ...}

4. Verify side effects:
   Search("nodeType:Systemorph/FutuRe/LargeLoss",
          basePath="@Systemorph/FutuRe/EuropeRe/AcmeSubmission2025/Claims")
   → should now return the created claim nodes

5. (Optional) Fetch the ActivityLog for the human-readable trace:
   Get(<activityLog path from the ExecuteScript response>)

Security

ExecuteScript runs C# with the full permissions of the authenticated caller. Scripts are mesh nodes and participate in the same row-level security checks as any other edit — an agent without Update rights on the target namespace cannot create children there, even from a script.

Anyone who can write a Code node with IsExecutable=true has full server-side code execution. Treat that permission accordingly.

Do not paste secrets into scripts. Scripts are stored verbatim in the mesh and versioned. Pull credentials from a proper secret store or a scoped IConfiguration surface instead.

Limitations

Quick demo

The cell below shows what a minimal executable Code node's C# looks like when it runs in the kernel. This is the same execution environment your scripts run in:

var rows = new[]
{
    new { Step = 1, Action = "Search", Tool = "Search(\"nodeType:Code name:*import*\")" },
    new { Step = 2, Action = "Verify", Tool = "Get(\"@.../ImportLargeClaims\")" },
    new { Step = 3, Action = "Run",    Tool = "ExecuteScript(path=..., timeoutSeconds=120)" },
    new { Step = 4, Action = "Check",  Tool = "Search(\"nodeType:LargeLoss ...\")" },
};

MeshWeaver.Layout.Controls.Stack
    .WithView(MeshWeaver.Layout.Controls.Markdown("### Typical agent flow for `ExecuteScript`"))
    .WithView(MeshWeaver.Layout.Controls.Html(
        "<table style='width:100%;border-collapse:collapse'>" +
        "<tr><th style='text-align:left;padding:6px 8px;border-bottom:2px solid #ccc'>Step</th>" +
        "<th style='text-align:left;padding:6px 8px;border-bottom:2px solid #ccc'>Action</th>" +
        "<th style='text-align:left;padding:6px 8px;border-bottom:2px solid #ccc'>MCP Tool Call</th></tr>" +
        string.Concat(rows.Select(r =>
            $"<tr><td style='padding:6px 8px;border-bottom:1px solid #eee'>{r.Step}</td>" +
            $"<td style='padding:6px 8px;border-bottom:1px solid #eee'><b>{r.Action}</b></td>" +
            $"<td style='padding:6px 8px;border-bottom:1px solid #eee;font-family:monospace'>{r.Tool}</td></tr>")) +
        "</table>"))
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.