Skip to content

mcpx: exec should run any shebang, and resolve file scripts the same way local or remote #1919

Open

Description

@dezren39

Relocated from dezren39/darwin-nix#106 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).

Parent: #175

Summary

Two limits on what mcpx exec and mcpx run will accept. First, they run
TypeScript and nothing else: there is no way to run a shell script, a Python
one-liner, or anything with a shebang, on any surface. Second, /v1/exec
accepts a file field but resolves it as a raw path on the daemon's
filesystem, while the CLI resolves a script name against a search path — so a
named script is a local-only concept and the two sides disagree about what a
"file" is.

Original notes

  • can exec call bash across the modes?
    • nearly just allow any shebang
      • drop the user in <user_config>||||pwd
      • if not shebang or --shell||--bash||--shell python3||--#!/usr/bin/python
      • then configured typescript
    • ./mcpx -exec
  • any exec local or remote should allow file scripts, server looks where it finds scripts and tries to find, same as local.

Current state

Nothing runs but TypeScript

runner.Detect returns one of three JavaScript runtimes and runner.Run
spawns it. There is no shell, no interpreter selection, no shebang handling
anywhere — rg -n "shebang|#!|--shell" across internal/cli and
internal/adapter finds only mcpx completion bash.

The nearest existing thing is script.launcher = none (runner.LauncherNone,
internal/runner/runner.go:493-497), documented as "the escape hatch for a
script that wants the runtime and none of the harness". Since #75 it, and
--launcher/--no-launcher/--export/--allow-repeat/--no-capture-console,
apply to exec snippets as well as run files (#74 closed). It removes mcpx's
shim; it does not change the interpreter.

Related but different: paths.adapters wraps an existing CLI as an MCP
server
, so a shell tool can be reached from a script. That is "call out to a
command", not "this script is a shell script".

file means two different things

  • CLI, local: resolveScript(arg) (internal/cli/scripts.go) — an argument
    that looks like a path is used verbatim; otherwise the name is looked up on
    the script search path (every .config/mcpx/scripts and .mcpx/scripts from
    the working directory up, then the user directory, with paths.scripts and
    the null splice), trying .ts, .mts, .js, .mjs. A miss produces a
    good error listing every directory searched and the mkdir to fix it.
  • CLI, remote: execOnDaemon (internal/cli/exec.go:448) calls
    resolveScript locally, reads the file, and sends its text. So a named
    script works remotely — by being resolved here and shipped as source.
    --export is refused with an explicit message because it needs a module on
    the runtime's disk (exec.go:453-458; only for files — on an inline snippet
    --export is not refused and not sent). The other exec flags are not refused,
    they are dropped: runScript hands off to execOnDaemon (internal/cli/commands.go:556-559)
    before --launcher, --no-launcher, the phase flags, --typecheck, --env,
    --no-capture-console and --allow-repeat are read (:573-843), and
    execsvc.Options has no field for most of them (internal/execsvc/execsvc.go:101-124).
    Update Rust crate proc-macro2 to 1.0.81 - autoclosed #75 closed that class for the local path only. Part 4 below moves resolution
    to the daemon; it should carry these flags on the wire or refuse them, as it
    already refuses --export.
  • /v1/exec and MCP: execReq.File (internal/daemon/routes_exec.go) is
    passed through execsvc.Request{File: …} into runner.Options.File
    (internal/execsvc/execsvc.go:327), and the runner does filepath.Abs and
    runs it (internal/runner/runner.go:293).
    No search path, no name resolution, no error naming where it looked. A
    caller that is not the mcpx CLI — the opencode plugin, a remote agent, an MCP
    client — can therefore only name absolute or cwd-relative paths on the
    daemon's machine.

docs/exec.md says "A remote run sends the script's text, not its path",
which is true of the CLI and not true of the endpoint. That is the gap the
user's second bullet names.

Approaches

Approach A — least recommended: a --shell/--bash flag pair on exec

Add --shell, --bash, --python flags that swap the interpreter.

Why not: it is a fixed list that will never be the right fixed list — the user's
own note already escalates from --shell to --bash to --shell python3 to
--#!/usr/bin/python within one line, which is the flag set discovering it
wants to be a string. Every addition is a new flag on two commands, a new
ExecOptions field and a new MCP tool parameter. The project has a settings
registry precisely so that one declaration reaches every surface; a flag family
bypasses it. It also leaves the file-resolution half of this issue untouched.

Approach B — middle: an script.interpreter setting plus shebang detection

One setting, script.interpreter, whose value is a command line: empty means
"use the configured language/runtime", anything else is argv the script path is
appended to (bash, python3 -u, /usr/bin/env -S deno run -A). Plus: if the
source or file begins with #!, honour it — on a file by making it executable
and running it directly, on a snippet by writing it to a temporary file with
the shebang intact.

--shell and --bash become aliases for script.interpreter=sh and =bash if
they are wanted at all, which keeps the user's spelling available without the
combinatorics. KindString already exists; FlagAliases already exists; the
value reaches CLI, env, config, /v1/settings and MCP for free.

This answers "can exec call bash across the modes?" — yes, everywhere, because
it is a setting and settings are already everywhere. It stops short of two
things: it does not say what the working directory should be (the user's
"drop the user in <user_config>||<config>||pwd"), and it has no story for what
the harness means when there is no TypeScript client — a bash script gets no
tools, no emit, no artifact, and mcpx becomes a slower bash -c.

Approach C — recommended: shebang-first dispatch, with the harness offered rather than assumed, and one script-resolution rule for every surface

Three parts.

1. Dispatch order. Exactly the order the user wrote, made explicit:

rank condition result
1 source or file starts with #! run under that interpreter, verbatim
2 script.interpreter is set (or --shell, --interpreter) that command line
3 script.language (see the Python sibling issue) or the file extension that language's runtime
4 otherwise the configured TypeScript runtime — today's behaviour, unchanged

Unset defaults mean nothing changes for anyone. A shebang is self-describing,
travels with the script text, and therefore works identically for a local run, a
remote exec and a named script — which is the property that makes it the right
primary rule rather than a flag.

2. The harness is opt-in per interpreter, and its absence is stated. A
non-JS interpreter gets, at minimum: MCPX_ENDPOINT, MCPX_SESSION,
MCPX_SOCKET, MCPX_RUNTIME and the script's args in the environment, so a
bash script can curl the daemon; and stdout/stderr/exit code handled by
the same execsvc machinery, so frames, logs, artifacts-by-reference and
timeouts work unchanged. What it does not get is the generated client, and mcpx
should say so once rather than let the author discover it. This is the same
posture runner.Permissions takes about bun and node having no sandbox, and
preflight.TypeCheck takes about type checking — state the limitation, do not
pretend.

A shell script that can reach $MCPX_ENDPOINT with curl is genuinely useful
and costs almost nothing to enable, because the daemon already binds a
loopback HTTP endpoint for exactly this reason (a unix socket is not portable
across runtimes — docs/dependencies.md).

3. Working directory becomes a named policy, not an accident. The user's
<user_config>||<config>||pwd is asking for a rule. Propose script.cwd with
values caller | script | config | workdir:

  • caller (default) — today's behaviour and runner.Run's stated intent: "a
    relative path in a script should mean what it means on the command line".
  • script — the directory the script file lives in, which is what a named
    script in .config/mcpx/scripts usually wants.
  • config — the nearest mcpx config directory, the user's <config>.
  • workdir — the content-addressed exec workdir, i.e. isolated.

This matters more once shebangs exist, because a shell script's relative paths
are far more load-bearing than a TypeScript one's, and ExecOptions.cwd already
crosses the wire so the remote case has somewhere to put the answer.

4. One resolution rule, both sides. Move resolveScript out of
internal/cli into a package internal/execsvc can call, and have the daemon
resolve file the same way — search path, null splice, extension list, and
the same error that names every directory it looked in. Then:

On ./mcpx -exec: the most useful reading is mcpx as a shebang interpreter —
#!/usr/bin/env -S mcpx run at the top of a .ts file, making a script
directly executable while still getting the client, the launcher and the tool
namespaces. It should be listed as done-when.

Corrected 2026-09-30 (this paragraph said mcpx exec and called it a two-line
change):

  • The interpreter must be mcpx run, not mcpx exec. The kernel appends the
    file's path, and exec takes source text, so mcpx exec ./x.ts would run the
    string ./x.ts as TypeScript.
  • With run it probably works already. A file script is imported unchanged by
    the launcher (runner.go:292-310), and all three runtimes skip a line-1
    hashbang in an imported module. Verified here with deno 2.9.6, bun 1.3.11 and
    node 24.20.0: a module starting #!/usr/bin/env -S mcpx run, imported by
    another, loads and runs. Not yet verified end to end through mcpx. The work is
    a test, not a feature.
  • A snippet starting with #! does fail under exec. The prelude is prepended
    (runner.go:282), and a hashbang is only legal on line 1 (a #! on line 2 is
    a syntax error on all three). That is part 1's snippet case, not this one.

Open questions

  • Does a shebang override script.runtime silently, or warn when both are
    specified and disagree? Silently winning is the usual Unix rule; a warning is
    more in keeping with this project's habits.
  • Shebang on a snippet: mcpx exec '#!/bin/bash\necho hi' must be written to
    a file to run. Where — the content-addressed exec workdir, subject to the same
    pruning? And does it get the +x bit, or is the interpreter parsed out and
    invoked directly (safer, and works where the workdir is noexec)?
  • script.permissions is meaningless for a shell script. Warn, ignore, or
    refuse when a non-default permission profile is set?
  • Should the daemon's script search path be the same setting as the CLI's
    (paths.scripts) with its own scope, or a distinct paths.daemonScripts? A
    daemon resolving names from the invoking user's home directory is a different
    thing from resolving them from its own.
  • Does internal/diagnose's static tool-call scan simply not run for non-JS
    interpreters, or does it report "not analysable for this interpreter"?
  • Does a shebang'd script still get --typecheck? Under Approach C's
    script.check, onUnsupported already has the vocabulary for this.

Ease · Impact · Related

Ease M (dispatch and cwd policy) / L (shared script resolution across CLI and
daemon) · Impact high — it turns exec from "run TypeScript" into "run a
script", and it closes a real CLI-vs-/v1 divergence of the kind #76 tracks.

Depends on / related: #76 (CLI ↔ /v1 parity), #48 (harness-free plugin
core; a plugin with no binary is the caller that needs daemon-side
resolution), #44 (closed, fixed in #53 — the plugin no longer needs the
binary), #75 (merged: exec honours its flags locally; the remote path is still
open, above), #5 (paths.config, the config-directory chain script.cwd=config
would follow), #40 (closed, fixed in #57 — ExecOptions), #7 (launcher, which a
non-JS interpreter bypasses), and siblings #100 (runtime selection;
script.checkOnUnsupported), #105 (Python parity; dispatch rank 3) and #107
(flake execution environments; exec.env composes on the interpreter seam).

Security/auth deferred; see tracker.

Activity

  1. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    History carried over from dezren39/darwin-nix#106 (verbatim, oldest first):


    @dezren39 · 2026-09-30T07:21:44Z

    Integration pass. Placed in the DAG rooted at #181.


    @dezren39 · 2026-09-30T12:06:57Z

    Issues pass (2026-09-30, at 05c78b2).


    @dezren39 · 2026-10-28T21:48:42Z

    Triage verdict (task 2136, repo-health-nix-pilot): RELOCATED — confirmed unimplemented feature; its code surface moved to developing-today/code. No fix packet in this repo.

    Verified against current main at 224c7b738e88c7a8e5d406987a7d88257e229db2:

    • Bump h2 from 0.3.19 to 0.3.24 in /sources/pavex/examples/realworld #106 is OPEN with labels enhancement, design — no hard-stop labels. Parent tracker fix(id): make Nix E2E check reproducible #175 is OPEN.
    • The limits it names are as stated: mcpx exec/run accept TypeScript only — no shebang scripts (shell, Python one-liners), and file scripts do not resolve identically local vs remote. That behaviour lives in mcpx's runner/CLI script-resolution code.
    • That code no longer lives in dezren39/nix: no pkgs/mcpx at HEAD (404); this repo's mcpx-nixbuild.yml states the source "lives only in developing-today/code now" (migration commit a03494f3090b there). Checking the new home for prior implementation: in developing-today/code, shebang under pkgs/mcpx appears only in docs/ideas.md — an ideas page, not runner code — so this is not already implemented there either.

    Route: no small_fix packet in dezren39/nix — there is no exec/runner code here to change. The feature routes with the source in developing-today/code, referencing #106 and parent #175; per the cross-repo rule, do not re-file it there. #106 stays open on the nix side until implemented there or transferred.

  2. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  3. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 29, 2026
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

    • Agent: queue worker (repo-worker-code), lane D
    • Session / ID: queue task 2814
    • Found via: queue claim
    • Task: Triage relocated mcpx exec shebang / file-resolution issue: dedupe against existing code issues, confirm scope against current pkgs/mcpx on main, enqueue implementation follow-up(s) — no implementation in this task.
    • Expectation: effort s; done = triage verdict posted with dedupe result and per-part live/fixed status, follow-up packet(s) enqueued (or split).
    • Links: queue task 2814; re-home tracker mcpx: re-home the open mcpx backlog from dezren39/nix, with this repo as the single source #309
  5. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

    Triage verdict — live on main, no duplicate, split into #2167 + #2168

    Dedupe (no duplicate): searched open and closed issues for "shebang", "exec interpreter", "daemon script search path", and "shebang exec". This issue is the only holder of both halves. Siblings are distinct subjects, not duplicates: #1917 (Python as a first-class language — dispatch rank 3, referenced by this issue), #1921 (flake refs as programs/environments), #1889 (runtimes/languages tracker).

    Scope confirmed against current pkgs/mcpx on main (GitHub API, 2026-10-29) — both halves are still live:

    • Nothing runs but TypeScript: internal/runner/runner.go Detect still returns only the three JS runtimes; a search for "shebang" across the package's Go code finds none (only ci/*.sh scripts and docs); no script.interpreter setting exists.
    • file means two different things: resolveScript still lives only in internal/cli/scripts.go (plus its CLI tests); internal/execsvc passes req.File straight into runner.Options.File with no search path, no name resolution, and no searched-directories error — exactly the divergence filed.

    Split (queue dedupe keys are per-issue, so the two slices got their own issues):

    This issue stays open as the parent: it holds the full analysis and the open questions, which the two split issues point back to. Its queue/* labels are removed because its work now lives on #2167/#2168, each carrying queue/queued. No implementation was done in this triage task, per its scope.

  6. 10 remaining items

  7. removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 4, 2026
  8. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    event: picked-up -> parked
    task: 8725 (triage, repo-health-code-pilot; worker repo-worker-code; lane 8ac61616-3815-4e7b-b33d-840a3829154a)
    by: openchamber-scheduled-lane
    at: 2026-11-04T04:00:15Z

    Fresh revalidation: #1919 is OPEN and remains correctly split into #2167 (landed via merged PR #2611) and #2168 (still in flight via open PR #2586). The prior fix task #3202 is done. PR #2586 is authored by dezren39 (Drew), with an optional test check failing and unresolved lower-severity bot threads; this lane did not inspect or change the PR. Per the Drew-ownership hard stop, task 8725 is parked for 30 minutes. Resume after #2586 merges/closes or ownership changes, then revalidate the parent/child state and route only remaining work.

    Labels: added work/assigned, queue/parked, and status/parked; removed queue/queued and status/needs-triage. No PR or branch was touched.

  9. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    event: picked-up -> parked
    task: 8725 (triage, repo-health-code-pilot; worker repo-worker-code; lane code-t, execution lane direct)
    by: repo-queue-worker lane T

    Fresh revalidation: issue #1919 is OPEN; its split stands (#2167 done via PR #2611 merged; #2168 carried by PR #2586). PR #2586 is still OPEN and authored by dezren39 (Drew-owned) — not touched, no competing work. Parking task 8725 for 30 minutes; resume by rechecking PR #2586: if merged, the #1919 triage completes against the split; if closed unmerged, #2168's work reopens to the queue.

  10. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

    Fresh evidence (2026-11-04 ~05:32Z): issue #1919 OPEN. PR #2586 is still OPEN, non-draft, author dezren39 — Drew-owned, a hard stop: not touched. Child issue #2168 (the part PR #2586 carries) is OPEN; child issue #2167 is CLOSED (its PR #2611 merged). Nothing else about the split changed since the last check ~35 minutes ago.

  11. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    event: picked-up -> parked
    task: 8725 (triage, repo-health-code-pilot; worker repo-worker-code; lane 02cafe76-eafd-4d0b-aaa6-2b7a737d68dc)
    by: openchamber-scheduled-lane
    at: 2026-11-04T06:05:30Z

    Fresh revalidation: issue #1919 remains OPEN. Child #2167 is CLOSED via merged PR #2611; child #2168 remains OPEN and is carried by PR #2586, still OPEN and authored by dezren39 (Drew-owned hard stop). This lane did not touch the PR or create a branch/worktree. Task 8725 is parked for 30 minutes; resume after #2586 merges/closes or ownership changes, then revalidate and route only remaining work.

    Labels: queue/parked and status/parked remain set with work/assigned; the issue already reflected the parked state. No implementation performed.

  12. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 4, 2026
  13. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    task: 8725
    kind: triage
    state: done (triaged, routed to existing owner)
    lane: code-h (direct execution)
    verdict: #1919 is still live ONLY through its script-resolution half. Fresh gh 2026-11-04T06:50Z:

  14. added
    status/triagedTriaged and routed (see agent note)
    and removed
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    on Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestsource/creatorFiled by the new-issue-creator jobstatus/triagedTriaged and routed (see agent note)task/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions