In Part 1 we stood up a bare security-auditor agent: a system prompt, a model, and the two built-in capabilities the Claude Agent SDK gives you out of the box: Read and Grep. That is enough to walk a repository and reason about it, and for a surprising number of tasks it is genuinely sufficient. But it has a ceiling, and you hit that ceiling the moment you ask the agent to do something precise: enumerate every hardcoded credential in a file, decide whether a pinned dependency is a known-bad version, or confirm that no HTTP client in the codebase disables certificate verification. Those are not reasoning problems. They are deterministic checks, and deterministic checks belong in code.
This part gives the agent eyes: purpose-built sensors it can call, so the model is not left to eyeball source and hope. We will build three custom tools with the SDK's @tool decorator, package them into an in-process MCP server with create_sdk_mcp_server, wire them into the auditor's options, and watch the model decide which tool to reach for on a per-file basis. The architectural shift is simple to state and easy to get wrong: the model orchestrates, the tools do the precise work.
One standing caveat before any code. The Claude Agent SDK is young and its surface evolves between releases. Decorator signatures, the MCP helper names, and the options object have all moved in the past. Treat everything here as the shape of the solution and confirm the exact symbols against the current SDK documentation for the version you have installed. The patterns are stable; the spellings drift.
Why Read and Grep aren't enough
Consider what you are actually asking when you say "find the hardcoded secrets in this file." A large language model can pattern-match a string that looks like an AWS key, and most of the time it will. But "most of the time" is precisely the property you cannot ship in a security tool. The model has no guarantee of completeness, it cannot compute Shannon entropy to catch a high-randomness token that does not match a known prefix, and its output is a narrative rather than a structured result you can pipe into triage. Worse, it costs tokens to read a file the model then has to scan character by character in its head.
A twenty-line regex-and-entropy scanner does this perfectly, every time, for a fraction of the cost, and it returns the same answer on the same input, which is what you want from a security control. The same logic applies to parsing requirements.txt against a list of known-bad versions, or flagging verify=False in an HTTP call. These are mechanical. Handing them to the model is both less reliable and more expensive than handing them to a function.
So the division of labour becomes clear. The model is good at judgement: deciding which files warrant which checks, correlating a flagged secret with how it is used downstream, distinguishing a test fixture from a production credential, and writing the human-readable finding. The tools are good at facts: here is the line, here is the snippet, here is the version, here is the byte that fails the check. You want the model spending its tokens on the part only it can do.
Every check you can express as a deterministic function is a check you should take away from the model. The model's scarce resource is judgement; do not spend it on work a regular expression does better.
The Claude Agent SDK custom-tool model
A custom tool in the SDK is three things bolted together: a name and description the model reads when deciding whether to call it, an input schema that tells the model what arguments to supply, and an async handler that does the work and returns a result in the SDK's expected shape. The @tool decorator binds the first two to the third.
The return shape matters and trips people up: a tool returns a dictionary with a content key holding a list of content blocks, each a dict with a type and the payload. For text results that is {"content": [{"type": "text", "text": "..."}]}. This is the same content-block structure the model itself speaks, which is why the result drops cleanly back into the conversation.
Once you have a set of tools, you do not register them individually. You collect them into an in-process MCP server with create_sdk_mcp_server and pass that server to the agent's options. Two words there deserve unpacking. MCP is the Model Context Protocol, the open standard for exposing tools and resources to a model. In-process means the server runs inside your Python process rather than as a separate program the SDK talks to over stdio or HTTP. A conventional MCP server is an external process (a database connector, a GitHub bridge, a remote service) that the SDK spawns or dials out to. An in-process (SDK) server is just your own functions, registered locally, with no transport, no subprocess, and no serialisation boundary to cross. For tools that are tightly coupled to your application's logic, like a bespoke secret scanner, in-process is faster, simpler to deploy, and easier to test. Reach for an external MCP server when the capability genuinely lives elsewhere; reach for in-process when it is your own code.
The last piece is exposure. Defining and registering a tool does not let the model use it; you must also list it in allowed_tools. Tools from an MCP server are namespaced as mcp__<server>__<tool>, so a tool named scan_secrets on a server named audit is referenced as mcp__audit__scan_secrets. That naming convention is the wiring you have to get exactly right.
Building the three scanners
Each tool follows the same skeleton: decorate an async function with a name, a one-line description written for the model to read, and a schema. Keep each one focused on a single class of finding and return enough context (file, line, a snippet) that a later triage pass (Part 3) has something to work with.
The first scans a file for hardcoded secrets, combining a small set of high-precision patterns with an entropy heuristic to catch random-looking tokens that no pattern anticipates:
import math
import re
from claude_agent_sdk import tool
# High-precision patterns: known prefixes and obvious credential assignments.
_SECRET_PATTERNS = [
("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
("Slack token", re.compile(r"xox[baprs]-[0-9A-Za-z-]{10,}")),
("Private key block", re.compile(r"-----BEGIN (?:RSA |EC )?PRIVATE KEY-----")),
("Assigned credential", re.compile(
r"(?i)(?:password|passwd|secret|api_?key|token)\s*[:=]\s*['\"]([^'\"]{8,})['\"]")),
]
def _shannon_entropy(s: str) -> float:
if not s:
return 0.0
counts = {c: s.count(c) for c in set(s)}
return -sum((n / len(s)) * math.log2(n / len(s)) for n in counts.values())
@tool(
"scan_secrets",
"Scan a file for hardcoded secrets and return matches with line numbers",
{"path": str},
)
async def scan_secrets(args):
path = args["path"]
findings = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.readlines()
except OSError as exc:
return {"content": [{"type": "text", "text": f"Could not read {path}: {exc}"}]}
for lineno, line in enumerate(lines, start=1):
for label, pattern in _SECRET_PATTERNS:
if pattern.search(line):
findings.append(f"L{lineno} [{label}]: {line.strip()[:120]}")
# Entropy fallback: long, high-randomness tokens that match no pattern.
for token in re.findall(r"[A-Za-z0-9+/=_-]{20,}", line):
if _shannon_entropy(token) > 4.0:
findings.append(f"L{lineno} [high-entropy token]: {token[:40]}...")
if not findings:
return {"content": [{"type": "text", "text": f"No secrets found in {path}."}]}
body = "\n".join(findings)
return {"content": [{"type": "text", "text": f"{len(findings)} potential secret(s) in {path}:\n{body}"}]}
Note that this returns evidence rather than a verdict. The entropy fallback deliberately over-flags (a base64 asset hash will trip it) because the agent's job downstream is to separate signal from noise, and a missed secret is far more costly than a false positive the model can dismiss. We tune that trade-off properly in Part 3.
The second tool looks for the handful of ways Python code quietly switches off TLS verification. These are short, unambiguous, and individually catastrophic:
@tool(
"check_tls_config",
"Flag disabled TLS/certificate verification in a Python source file",
{"path": str},
)
async def check_tls_config(args):
path = args["path"]
checks = [
("verify=False passed to a request", re.compile(r"verify\s*=\s*False")),
("unverified SSL context", re.compile(r"ssl\._create_unverified_context")),
("default HTTPS context disabled", re.compile(
r"ssl\._create_default_https_context\s*=\s*ssl\._create_unverified_context")),
("hostname check disabled", re.compile(r"check_hostname\s*=\s*False")),
("cert requirements disabled", re.compile(r"CERT_NONE")),
("weak hash for security use", re.compile(r"hashlib\.(?:md5|sha1)\(")),
]
findings = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.readlines()
except OSError as exc:
return {"content": [{"type": "text", "text": f"Could not read {path}: {exc}"}]}
for lineno, line in enumerate(lines, start=1):
for label, pattern in checks:
if pattern.search(line):
findings.append(f"L{lineno} [{label}]: {line.strip()[:120]}")
if not findings:
return {"content": [{"type": "text", "text": f"No TLS/crypto misconfig in {path}."}]}
body = "\n".join(findings)
return {"content": [{"type": "text", "text": f"TLS/crypto issues in {path}:\n{body}"}]}
The third parses a requirements.txt and flags pinned versions that appear on a known-bad list. In production that list comes from an advisory feed; here a small static map keeps the example self-contained and the logic legible:
_KNOWN_BAD = {
"cryptography": {"max_bad": "3.2", "note": "CVE-2020-25659 padding oracle; upgrade to >=3.3"},
"pyyaml": {"max_bad": "5.3", "note": "CVE-2020-1747 arbitrary code via full_load; upgrade to >=5.4"},
"requests": {"max_bad": "2.19.1", "note": "CVE-2018-18074 credential leak on redirect; upgrade to >=2.20"},
}
def _version_tuple(v: str):
return tuple(int(p) for p in re.findall(r"\d+", v))
@tool(
"check_dependencies",
"Parse a requirements.txt and flag pinned versions with known vulnerabilities",
{"path": str},
)
async def check_dependencies(args):
path = args["path"]
findings = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.readlines()
except OSError as exc:
return {"content": [{"type": "text", "text": f"Could not read {path}: {exc}"}]}
for lineno, raw in enumerate(lines, start=1):
line = raw.split("#", 1)[0].strip()
match = re.match(r"^([A-Za-z0-9._-]+)\s*==\s*([0-9][0-9A-Za-z.\-]*)", line)
if not match:
continue
name, version = match.group(1).lower(), match.group(2)
bad = _KNOWN_BAD.get(name)
if bad and _version_tuple(version) <= _version_tuple(bad["max_bad"]):
findings.append(f"L{lineno} {name}=={version}: {bad['note']}")
if not findings:
return {"content": [{"type": "text", "text": f"No known-bad pinned versions in {path}."}]}
body = "\n".join(findings)
return {"content": [{"type": "text", "text": f"Vulnerable dependencies in {path}:\n{body}"}]}
Wiring the tools into the agent
With the three handlers defined, assembly is short. Collect them into an in-process server, list their namespaced names in allowed_tools alongside the built-in Read and Grep, and give the model a system prompt that tells it the tools exist and how to use them. Then drive the whole thing from ClaudeSDKClient:
import asyncio
from claude_agent_sdk import (
create_sdk_mcp_server,
ClaudeAgentOptions,
ClaudeSDKClient,
)
server = create_sdk_mcp_server(
name="audit",
version="1.0.0",
tools=[scan_secrets, check_tls_config, check_dependencies],
)
SYSTEM_PROMPT = """You are a code security auditor. Walk the repository and,
for each file, choose the appropriate scanning tool:
- scan_secrets for any source or config file that might hold credentials
- check_tls_config for Python files making network or crypto calls
- check_dependencies for requirements.txt or similar manifests
Use Read and Grep to locate candidate files. Do not guess at findings the
tools can determine; call the tool and report what it returns, with file
and line references."""
options = ClaudeAgentOptions(
model="claude-opus-4-8",
system_prompt=SYSTEM_PROMPT,
mcp_servers={"audit": server},
allowed_tools=[
"mcp__audit__scan_secrets",
"mcp__audit__check_tls_config",
"mcp__audit__check_dependencies",
"Read",
"Grep",
],
)
async def main():
async with ClaudeSDKClient(options=options) as client:
await client.query(
"Audit the ./service directory. For each file, run the relevant "
"scanner and summarise every finding with its file path and line."
)
async for message in client.receive_response():
print(message)
if __name__ == "__main__":
asyncio.run(main())
The key registered in mcp_servers, "audit", is the same token that appears in every mcp__audit__* name. Change one and you must change all of them; a mismatch here surfaces as the model never being able to call a tool it can plainly see described. If you prefer a one-shot invocation over the streaming client, the top-level query helper takes the same options object and is convenient for scripted runs.
A run, and how the model chooses
Point the auditor at a small service directory and the orchestration becomes visible. The model uses Grep and Read to find candidates, then calls the right scanner for each file type, without being told file by file which tool to use. Trimmed, a session looks like this:
$ python auditor.py
[tool] Grep pattern="." path="./service" -> client.py, settings.py, requirements.txt
[tool] mcp__audit__scan_secrets {"path": "./service/settings.py"}
-> 2 potential secret(s) in ./service/settings.py:
L14 [Assigned credential]: API_KEY = "sk_live_4eC39H...redacted"
L15 [high-entropy token]: ZmFrZS1zZWNyZXQtdmFsdWUtZm9y...
[tool] mcp__audit__check_tls_config {"path": "./service/client.py"}
-> TLS/crypto issues in ./service/client.py:
L22 [verify=False passed to a request]: resp = requests.get(url, verify=False)
L31 [weak hash for security use]: token = hashlib.md5(payload).hexdigest()
[tool] mcp__audit__check_dependencies {"path": "./service/requirements.txt"}
-> Vulnerable dependencies in ./service/requirements.txt:
L3 requests==2.18.0: CVE-2018-18074 credential leak on redirect; upgrade to >=2.20
Audit summary
-------------
HIGH settings.py:14 Hardcoded live API key committed to source.
HIGH client.py:22 TLS verification disabled on outbound request.
MED client.py:31 MD5 used to derive a token; not collision-resistant.
MED requirements.txt:3 requests 2.18.0 carries a known credential-leak CVE.
The model picked scan_secrets for the settings file, check_tls_config for the HTTP client, and check_dependencies for the manifest, then folded the deterministic tool output into a severity-ranked summary. That summary is the part worth a language model; the line numbers underneath it are the part worth a function. Notice too that the entropy match on line 15 made it into the tool output but the model treated it cautiously in the summary. That is exactly the judgement layer we wanted to preserve.
Designing tools the model can actually use
The agent only ever sees a tool's name, description, and schema until the moment it calls it. Those three strings are your entire interface to the model's decision-making, so write them deliberately:
- Name and describe for selection. The model chooses a tool by reading its description against the task at hand. "Flag disabled TLS/certificate verification in a Python source file" tells the model precisely when to reach for it; "tls tool" tells it nothing. Be specific about what the tool does and what kind of input it expects.
- Type the inputs. A schema of
{"path": str}constrains the model to supply a string path and nothing else, which makes calls reliable and your handler simpler. Loose or absent schemas invite malformed arguments. - Keep the scope tight. Three focused scanners beat one mega-tool with a
modeflag. Focused tools are easier for the model to select correctly, easier for you to test in isolation, and easier to reason about when one misfires. - Return enough context to triage. File, line, and a snippet turn a finding into something actionable. A bare "found a secret" forces the model to go re-read the file, burning the tokens the tool was meant to save.
- Stay read-only and idempotent. Every tool here reads and reports; none mutate the repository. Calling one twice yields the same answer and changes nothing. That property is what makes it safe for an autonomous agent to call them freely.
A security note before we go further
Before moving on, consider what we have just built, because the next four parts lean on it. These tools read repository content, and the agent driving them is reasoning over untrusted code, which may have been written to mislead a reviewer, human or otherwise. A comment that says # nosec: reviewed and safe is not evidence; a variable named NOT_A_SECRET is not exoneration. Our scanners ignore such hints by design, and the model should be prompted to as well.
The deeper principle is least privilege. We deliberately exposed only read-only, idempotent tools and an allowed_tools list that grants nothing destructive: no shell, no write, no network egress. An auditor has no business deleting files or making outbound calls, so it is not given the means to. As the agent gains autonomy in later parts, that discipline becomes the difference between a tool that finds problems and a tool that becomes one. We give this its full treatment in Part 6, where we sandbox the agent and reason carefully about its blast radius; for now, just note that keeping the tool surface as small as possible is deliberate.
With the agent's eyes installed, Part 3 turns to the quality of what it sees: how to take the deliberately noisy output of these scanners and produce findings a senior engineer will actually trust: high-signal, low-false-positive, and triaged by severity. That is where orchestration and judgement earn their keep.