From 47b5a94c73dbe706348299fa759f2d8a369d6dde Mon Sep 17 00:00:00 2001 From: Swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:22:47 -0700 Subject: [PATCH] skill: name the MCP tool before the shell verb on the first-call surfaces (#1425) The skill description, the orient and directive hooks, and the block that `axiomcode install` writes spelled every call as a shell command, with the MCP tool in parentheses or not named at all. install pre-approves only the MCP tools, so the first call an agent made was the one left behind a permission prompt, and in a headless session it was denied. Each of those surfaces now names axiomcode_ first and keeps the shell form after it, for a host where the MCP server is absent. The Gemini copy of the skill is regenerated. tests/mcp_first.py checks the order and the shell fallback on every surface; on 0.1.3 it fails 18 of 34 checks. Headless runs, 6 tasks on 4 Java projects and a fixture: the first axiomcode call is the MCP tool in 16 of 18 runs where MCP is listed (0 of 18 before), and still Bash in 6 of 6 runs with no MCP server. --- plugins/axiomcode/hooks/direct.py | 12 ++- plugins/axiomcode/hooks/orient.py | 18 ++-- plugins/axiomcode/skills/axiomcode/SKILL.md | 2 +- .../axiomcode/scripts/axiomcode-install | 11 +- skills/axiomcode/SKILL.md | 2 +- tests/README.md | 3 + tests/mcp_first.py | 101 ++++++++++++++++++ 7 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 tests/mcp_first.py diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 297c9bc3d..9d8b1dc65 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -31,13 +31,17 @@ # the shell commands that are the search or read this directive competes with SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep|find|fd|cat|head|tail|sed|awk|less)\b') +# The MCP tools are named first and the shell verbs second (#1425): `axiomcode install` pre-approves only the tools, +# so an agent that takes the first spelling it reads should be taking the one it is already allowed to call. FULL = ( "graph: this repository has a resolved call graph, and it answers the question this search is asking.\n" - " `axiomcode impact ` — everything a change to that declaration reaches, each entry labelled\n" - " with how certain it is; finds callers that never spell the name (an interface, an override, a\n" + " the axiomcode_impact tool (targets=[]) — everything a change to that declaration reaches, each entry\n" + " labelled with how certain it is; finds callers that never spell the name (an interface, an override, a\n" " callback, DI, a config key), which is what a grep for the name cannot do.\n" - " `axiomcode path ` — how one thing reaches another. `axiomcode context \"\"` — where a\n" - " task's words land. `axiomcode changed` / `test-impact` — what an edit moved and which tests reach it.\n" + " axiomcode_path — how one thing reaches another. axiomcode_context — where a task's words land.\n" + " axiomcode_changed / axiomcode_test_impact — what an edit moved and which tests reach it.\n" + " Without those tools, the same verbs from the shell: `axiomcode impact `, `axiomcode path `,\n" + " `axiomcode context \"\"`, `axiomcode changed`, `axiomcode test-impact`.\n" " Search raw files after that, to read or to change specific lines. If you hand this to a subagent,\n" " carry this line into its prompt — exploration done in a child is exploration done without the graph." ) diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index dbaf5511b..a3241ff06 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -174,19 +174,23 @@ def names_code(prompt, db): # rather than restating that there was a match, which told the reader nothing about WHICH match _, _, hits = rest.partition('<- ') print(f" {path}" + (f" <- {hits.strip()}" if hits.strip() else '')) - print(' `axiomcode context "" --in ` (the axiomcode_context tool) ranks the files and ' - 'declarations inside it; call it directly, no skill needs loading first.') + print(' the axiomcode_context tool with in_path= ranks the files and declarations inside it; call it ' + 'directly, no skill needs loading first (without that tool: `axiomcode context "" --in `).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: print(" " + l[:150]) # the first call, named: an agent that only has the plugin otherwise spends two turns loading the skill and then - # the tool schemas before it asks anything (#1202) + # the tool schemas before it asks anything (#1202). The MCP tool comes first and the shell form second (#1425): + # `axiomcode install` pre-approves only the tools, so a first call spelled as a shell command is the one call the + # install left behind a permission prompt, and headless it is simply denied. if 'how it runs —' in out: # a how-question: the flow is the answer's spine, and the call that returns it with each step's code is the # one to make — named here so no turn goes to loading the skill or the tool schemas first - print(' next: `axiomcode context "" --source --from ` returns the call flow with ' - "each step's code; call it directly, no skill needs loading first.") + print(" next: the axiomcode_context tool with source=True, from_= returns the call flow with each " + "step's code; call it directly, no skill needs loading first (without that tool: " + '`axiomcode context "" --source --from `).') else: - print(' a starting point, not a conclusion: next, `axiomcode impact --in ` (the axiomcode_impact tool) ' - 'for what a change reaches; call it directly, no skill needs loading first.') + print(' a starting point, not a conclusion: next, the axiomcode_impact tool with targets=[], in_path= ' + 'for what a change reaches; call it directly, no skill needs loading first (without that tool: ' + '`axiomcode impact --in `).') diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 793aebb83..bcb292c1f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: `axiomcode context "" --source` for how something works (the call flow with each step's code; `--from ` when you know where it begins), `axiomcode impact ` for what a change reaches, `axiomcode path ` for how A reaches B. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_= when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "" --source`, `axiomcode impact `, `axiomcode path `. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index 182e00a80..111cfa642 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -35,12 +35,15 @@ END = '' BLOCK = """ ## Finding code in this repository -This repository has a resolved call graph. Ask it FIRST — from the shell, or through the +This repository has a resolved call graph. Ask it FIRST, through the `mcp__plugin_axiomcode_axiomcode__axiomcode_*` tools; no skill needs loading: - axiomcode context "" # where the work is, when you have no name yet - axiomcode impact # what a change reaches: contract, users, tests - axiomcode path # how A reaches B + axiomcode_context task="" # where the work is, when you have no name yet + axiomcode_impact targets=[""] # what a change reaches: contract, users, tests + axiomcode_path from_="" to="" # how A reaches B + +Only when those tools are not in your list, the same from the shell: `axiomcode context ""`, +`axiomcode impact `, `axiomcode path `. **Trust the answer.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line) — do not re-derive it by grepping or opening the other files it names. Every diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index eb73fe93a..2f60e2f02 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: `axiomcode context "" --source` for how something works (the call flow with each step's code; `--from ` when you know where it begins), `axiomcode impact ` for what a change reaches, `axiomcode path ` for how A reaches B. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_= when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "" --source`, `axiomcode impact `, `axiomcode path `. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode diff --git a/tests/README.md b/tests/README.md index d353f75a5..916a1f059 100644 --- a/tests/README.md +++ b/tests/README.md @@ -27,6 +27,9 @@ One check needs no graph and is its own script: python3 tests/manifests.py every agent's manifest (Claude, Codex, Cursor, Gemini) names the same plugin and points at files that exist, the way that agent resolves them, and Gemini's skill and Cursor's rule are current copies + python3 tests/mcp_first.py the description, the install block and the orient and directive hooks name the + MCP tool before the shell verb, and still carry the shell verb for a host + without the server (#1425; indexes one case, so it needs the engine) python3 tests/hosts.py each hook tells Cursor and Gemini CLI what it tells the original host, in their own event names and output shape (indexes one case, so it needs the engine) python3 tests/enrich_budget.py what a Read or a Grep adds to a session is capped: a declaration annotated once, diff --git a/tests/mcp_first.py b/tests/mcp_first.py new file mode 100644 index 000000000..39499ab56 --- /dev/null +++ b/tests/mcp_first.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""tests/mcp_first.py — every surface an agent reads before its first call names the MCP tool first (#1425). + +`axiomcode install` pre-approves the plugin's MCP tools and nothing else. The skill's description, the +prompt-time orientation and the pre-search directive used to spell the call as a shell command, with the tool +in parentheses or not at all, so an agent took the shell spelling; under the install's permissions that first +call is the one that stops for a prompt, and in a headless session it is denied outright. + +Each check is one surface: the tool's name comes before the shell form of the same verb. The control on every +surface is the other half: the shell form is still there, after it. A host without the MCP server, or a session +where the server did not start, has nothing else to call, and the skill has to keep working there. + + python3 tests/mcp_first.py indexes one case, so it needs the engine, as hosts.py does +""" +import json, os, re, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUG = os.path.join(ROOT, 'plugins', 'axiomcode') +HOOKS = os.path.join(PLUG, 'hooks') +SCRIPTS = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts') +AX = os.path.join(SCRIPTS, 'axiomcode') +CASE = os.path.join(ROOT, 'tests', 'cases', 'java', 'injected-into-a-field') + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def tool_first(surface, text, verbs): + """for each verb: the MCP tool is named, the shell form is named, and the tool comes first""" + for v in verbs: + tool, shell = f"axiomcode_{v.replace('-', '_')}", f"`axiomcode {v}" + t, s = text.find(tool), text.find(shell) + check(f'{surface}: {v} is named as the {tool} tool', t >= 0, text[:200]) + check(f'{surface}: {v} keeps its shell form, for a host without the MCP server', s >= 0, text[:200]) + if t >= 0 and s >= 0: + check(f'{surface}: {tool} comes before `axiomcode {v}`', t < s, f'tool at {t}, shell at {s}') + + +def fire(hook, ev): + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), + capture_output=True, text=True, timeout=120) + out = r.stdout.strip() + try: + return r.returncode, json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): + return r.returncode, out + + +# 1. the description, which is the one part of the skill every session sees +skill = open(os.path.join(PLUG, 'skills', 'axiomcode', 'SKILL.md'), encoding='utf-8').read() +m = re.search(r'^description: >-\n(.*?)\n---', skill, re.S | re.M) +check('SKILL.md has a description block', bool(m)) +if m: + tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('context', 'impact', 'path')) + +# 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants +r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], + capture_output=True, text=True, timeout=30) +check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) +tool_first('install block', r.stdout, ('context', 'impact', 'path')) + +# 3. the directive before the first search; it needs only a graph file to exist +with tempfile.TemporaryDirectory() as repo: + os.makedirs(os.path.join(repo, '.axiomcode', 'out')) + open(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite'), 'w').close() + rc, said = fire('direct.py', {'hook_event_name': 'PreToolUse', 'tool_name': 'Grep', + 'tool_input': {'pattern': 'f'}, 'cwd': repo, 'session_id': 'm1'}) + check('direct: the first search hears the directive', rc == 0 and bool(said), f'rc={rc}') + tool_first('direct', said, ('impact', 'path', 'context', 'changed', 'test-impact')) + +# 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question +with tempfile.TemporaryDirectory() as work: + repo = os.path.join(work, 'case') + shutil.copytree(CASE, repo) + b = subprocess.run(['bash', AX, 'index', repo], capture_output=True, text=True, timeout=900) + built = os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')) + check('the case indexes', built, (b.stdout + b.stderr)[-300:]) + if built: + rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o1', + 'prompt': 'What breaks if I change Repo.find and its callers?'}) + check('orient: a change question is oriented', rc == 0 and 'next,' in said, said[:300]) + tool_first('orient (change)', said, ('impact',)) + rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o2', + 'prompt': 'How does Consumer.go work, step by step?'}) + check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) + tool_first('orient (how)', said, ('context',)) + +# 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be +# fired; the order is checked in the source line that prints it. +src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() +i = src.find("the axiomcode_context tool with in_path=") +check('orient (scope refused): its hint is still in the source', i >= 0) +if i >= 0: + tool_first('orient (scope refused)', src[i:src.find('\n', src.find("')", i))], ('context',)) + +print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') +sys.exit(1 if fails else 0)