[FEATURE] Declare local scripts and CLIs as first-class tools, without an MCP server (reopen of #31165)

Status Open
Maintainer reply None cached
Activity 0 comments · opened Sep 25, 2026

Preflight Checklist

  • [x] I have searched existing requests and this feature hasn't been requested yet
  • [x] This is a single feature request (not multiple features)

Problem Statement

Today there are exactly three ways to give Claude Code a capability, and none of them fits the most common case: a local script or CLI that already does the job.

  • MCP server — gets everything right (structured params, deferred loading, per-tool permissions) but requires writing, installing, and supervising a server process whose only job is to shell out to a command that already exists.
  • Bash — zero setup, but no discoverability, no typed inputs, and permissions are pattern-matched against command strings rather than scoped per capability.
  • Skills — teach the model how to call the CLI in prose. They are instructions, not tools: no schema, no argument validation, no per-capability permission, and the model has to compose the command string correctly every time.

The gap has real cost. In my own setup (a personal knowledge vault driven entirely by Claude Code), the local-script path is strictly better than MCP on context and latency, so I wrote a permission hook that denies the first call to any Atlassian MCP tool and hands back the equivalent acli or local-Python invocation. That hook exists purely because there is no supported way to register python3 _shared/atlassian_api.py search --jql '...' as a tool with a schema. I am hand-building, in a hook, what a tool definition should give me declaratively.

This was filed before as #31165 and closed NOT_PLANNED by the staleness bot with no maintainer response, then locked. The bot's own closing message asks for a new issue, so here it is, with a different use case.

Proposed Solution

Let a script or CLI invocation be declared as a tool in settings (settings.json, or a tools/ directory alongside skills/ and agents/), and have Claude Code treat it exactly like an MCP tool — typed params, deferred loading, per-tool permission — while executing it as a subprocess.

{
  "tools": {
    "jira_search": {
      "exec": ["python3", "_shared/atlassian_api.py", "search", "--jql", "{jql}"],
      "description": "Search JIRA across all projects with a JQL query. Returns key, summary, status, assignee.",
      "params": {
        "jql": { "type": "string", "required": true, "description": "JQL query string" },
        "limit": { "type": "integer", "default": 25 }
      },
      "permission": "auto",
      "output": "text"
    },
    "obsidian_open": {
      "exec": ["open", "obsidian://advanced-uri?vault=Second%20Brain&filepath={path}"],
      "description": "Open a vault file in Obsidian. Side effect only; returns nothing.",
      "params": { "path": { "type": "string", "required": true } },
      "permission": "auto",
      "output": "none"
    },
    "jira_transition": {
      "exec": ["acli", "jira", "workitem", "transition", "--key", "{key}", "--status", "{status}"],
      "description": "Move a JIRA work item to a new status.",
      "params": {
        "key": { "type": "string", "required": true },
        "status": { "type": "string", "required": true }
      },
      "permission": "ask"
    }
  }
}

Requirements that make this worth having over Bash:

  1. Arguments are passed as a list, never interpolated into a shell string. {param} substitutes into an argv slot. No quoting bugs, no injection surface from model-generated values.
  2. Deferred loading, same as MCP tools — the schema costs nothing until the tool is searched for or called.
  3. Per-tool permission (auto / ask / deny), so a read tool runs freely and a write tool prompts, without writing Bash allow-patterns that try to distinguish them by regex.
  4. Non-zero exit is a tool error, with stderr returned as the error message, so the model can retry intelligently instead of parsing a failure out of stdout.
  5. A "output": "none" mode for fire-and-forget side effects. Some tools exist only to cause something to happen outside the session — open a file in an editor, focus a window, ring a bell. Their output is always empty and their exit code is always 0. Feeding that back into the transcript costs a full model turn for information content of zero. A tool declared "output": "none" should run and return nothing, with the model's turn unaffected. (This overlaps #93575, which asks for user-pressed action buttons that run without a model turn; the same "don't wake the model" primitive serves both.)
  6. Works in the desktop app, not just the terminal CLI — the desktop app is where MCP setup friction is highest, since there is no interactive claude mcp flow in a Code-tab session.

Alternative Solutions

  • Wrap each CLI in an MCP server. Works, and is what most people do, but it means a supervised process per integration, a second language runtime, and a schema maintained in two places. For a three-command CLI this is all overhead and no benefit.
  • Hooks on Bash. A PreToolUse hook can gate or rewrite commands — that is exactly what I do today — but hooks see a command string, not typed arguments. They can block, they cannot make the tool discoverable or validated.
  • Skills. Good for teaching a workflow, wrong shape for a single callable operation. A skill that says "run acli jira workitem transition --key X --status Y" is a tool definition written in English and enforced by nothing.

Priority

Medium - Would be very helpful

Feature Category

Configuration and settings

Use Case Example

  1. My vault ships several local helpers: a JIRA/Confluence API wrapper, a claim-resolver that rewrites a generated page, and a recency-cache generator.
  2. Each is invoked dozens of times a week, always through Bash, always with the model reconstructing the argument string from a skill's prose.
  3. When it gets an argument wrong, the failure surfaces as a shell error the model has to interpret, rather than a schema violation it could have avoided.
  4. With declarative tools, each helper becomes jira_search, resolve_claims, refresh_now_cache — typed, deferred, individually permissioned.
  5. The read tools run without prompting; the two that write to disk prompt every time. No regex over command strings, no MCP server, no hook.

Additional Context

The distance between "I have a working CLI" and "Claude Code can use it well" should be a block of JSON, not a server. Skills and hooks have both become first-class, file-based, discoverable extension points over the last year; tools are the one extension point that still requires running a process.

The "output": "none" point above is the one I would most like considered even if the rest is declined. Opening a file in Obsidian via the Advanced URI plugin is a one-line open command with no output, and it is the single most frequent action in my workflow. Today the agent emits it as a fenced bash block so the desktop app renders a Run button — and pressing that button sends an empty result back into the session, waking the model over the full conversation context to react to nothing. My workaround is a rule in my own instructions telling the agent to produce no reply at all when it sees one of those results. That works, and it is still a wasted turn: the cost is paid on the press, not on the reply.

Prior art: #31165 (closed NOT_PLANNED by the staleness bot, no maintainer reply, now locked).

View original on GitHub ↗