7 min read

Custom MCP tool review: a hands-on security checklist

Use this custom MCP tool review checklist to inspect inputs, outbound requests, process identity, logs, approvals, and access removal before agent use.

Custom MCP tool review: a hands-on security checklist

Custom MCP tools deserve the same review you would give a small production integration with access to your workstation, network, and credentials. The fact that an agent calls the tool through MCP does not reduce its authority. It often makes sloppy authority easier to exercise repeatedly.

I have seen the recurring failure: a developer reads a tool description, sees a useful name such as deploy_preview or search_docs, and grants it access because it appears local. Later, the implementation turns a model-supplied string into a URL, a shell argument, or a recursive file read. The useful tool was never the security boundary. The implementation and the credential path were.

The Model Context Protocol specification describes tools as functions exposed by a server for a client to discover and call. It also makes an uncomfortable fact explicit: tool execution is model-controlled. A client may put a human in the approval loop, but a tool author must still assume that arguments can be surprising, excessive, or pointed at the wrong target. Review the tool before an agent gets a chance to be inventive.

Start with the authority map, not the README

A custom MCP tool is acceptable only when you can draw a short, concrete path from the agent request to the effect it produces. Start by writing down what the tool can read, where it can send data, what it can change, and which credential or operating-system identity makes that possible.

Do this before reading implementation details. It gives you a standard for judging the code instead of letting a pleasant description set the standard. A tool called get_build_status may read a local config file, call a hosted API, write a cache, and invoke a command-line helper. Each action has a different failure mode.

Use a small authority map like this:

PartRecordWhy it matters
Agent inputExact tool fields and maximum sizesShows what the model can influence
Local readsPaths, environment variables, config filesReveals accidental data collection
Local writesCache, workspace, git state, temporary filesFinds persistent side effects
NetworkHostnames, ports, methods, redirectsDefines exfiltration and request risk
ProcessesExecutable path, arguments, child processesFinds shell injection and inherited access
CredentialsCredential name, scope, storage, revocation ownerMakes removal possible
ResultsData returned to the agentPrevents secrets from coming back through the tool

Do not write "the internet" in the network row or "developer credentials" in the credential row. Those labels mean the review has not begun. Name the host, the API route family, the account or token, and the person or system that can revoke it.

This exercise also separates two things teams routinely blur: a tool's advertised purpose and its actual authority. create_issue sounds narrow. A function that accepts an arbitrary base URL, arbitrary headers, and arbitrary request body has the authority of a generic HTTP client. Review the latter, not the label.

The input schema must reduce choices

An MCP input schema should constrain the agent to the operation you mean to permit. It should not act as a decorative type definition around a free-form command channel.

The MCP specification uses JSON Schema for tool input schemas. That helps clients present arguments and helps implementations validate them, but JSON Schema is not enforcement unless the server rejects invalid values before it performs work. Treat the schema as the first gate and server-side validation as the second.

This is a reviewable schema for a tool that fetches the status of a known build:

{
  "name": "get_build_status",
  "description": "Return the status for one build in the approved CI project.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "required": ["build_id"],
    "properties": {
      "build_id": {
        "type": "string",
        "pattern": "^[A-Z]{2,8}-[0-9]{1,10}$",
        "maxLength": 20
      },
      "include_logs": {
        "type": "boolean",
        "default": false
      }
    }
  }
}

It makes several choices for the agent. It cannot select a host. It cannot attach headers. It cannot pass a shell command. It cannot send undeclared fields because additionalProperties is false. The build identifier has a bounded format, which makes downstream use less dangerous and easier to log.

Now compare it with the shape that causes trouble:

{
  "name": "request",
  "inputSchema": {
    "type": "object",
    "properties": {
      "url": {"type": "string"},
      "method": {"type": "string"},
      "headers": {"type": "object"},
      "body": {}
    }
  }
}

That is an HTTP client with a friendly handle. If it has a bearer token, it can send that token or data from an agent-controlled prompt to an arbitrary endpoint. People keep this design because it saves time while prototyping. It remains a bad production boundary even if the tool name sounds specific.

Test input handling with values that alter parsing rather than values that merely look malformed. Try a duplicate identifier field, an unexpected field, a large string, Unicode lookalikes, a newline, and a value that is syntactically valid but points outside the intended business domain. If an input becomes a path, require a relative identifier and resolve it against a fixed directory. If it becomes an API filter, use structured parameters rather than concatenating query strings.

Never hand an argument to a shell merely because you validated the schema. Use an argument array with a fixed executable path. This is safer:

subprocess.run(
    ["/usr/local/bin/buildctl", "status", "--id", build_id],
    check=True,
    text=True,
    capture_output=True,
    env={"PATH": "/usr/bin:/bin"}
)

This is a review failure:

subprocess.run(f"buildctl status --id {build_id}", shell=True)

The first form still needs validation, error handling, and a trustworthy executable. It does not ask a shell to reinterpret model-controlled punctuation.

Every outbound request needs a fixed destination

A custom MCP tool should own a small set of destinations, and its code should reject every other destination before opening a connection. Allowlisting a hostname in documentation is useless if the request code accepts an arbitrary URL.

Review outbound traffic at two levels. First, inspect source for HTTP libraries, WebSocket clients, DNS lookups, package installers, telemetry SDKs, webhook libraries, and any helper process that can connect elsewhere. Second, watch a real run. Static inspection finds intended paths. Runtime observation catches a dependency that phones home or a configuration value that changed the target.

A safe client builds a URL from fixed pieces and encodes only the identifier:

from urllib.parse import quote

BASE = "https://ci.example.internal/api/builds/"
url = BASE + quote(build_id, safe="")
response = client.get(url, timeout=10, follow_redirects=False)

The important control is not quote. The fixed origin is. If your client follows redirects, a trusted origin can return a redirect to an untrusted host. Disable redirects unless the tool checks each redirect destination against the same allowlist.

This matters for internal services too. A tool that accepts http://host/path can be tricked into reaching local admin services or metadata endpoints that an agent cannot directly access. Blocking public hosts does not solve that. You need a positive list of approved origins and a rule that refuses literal IP addresses or private destinations unless the tool explicitly needs them.

Capture traffic in a disposable test environment. On macOS, lsof gives a quick first view of current network sockets:

lsof -nP -iTCP -sTCP:ESTABLISHED -c python

The output shape identifies a process, its user, file descriptor, and remote endpoint:

COMMAND   PID  USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
python   8421  alex   12u  IPv4 0x...             0t0  TCP 10.0.0.8:51244->203.0.113.20:443 (ESTABLISHED)

Replace python with the actual process name, then repeat while you invoke one tool operation. This command is not a complete traffic audit. Short connections can disappear before you inspect them. It is still good at exposing an unexpected long-lived connection or a helper you did not know existed.

Review request payloads with equal care. A tool may correctly call one approved API while dumping an entire repository diff, environment variable, or agent transcript into a query parameter. Restrict outbound fields in code. Build the payload from named values the operation needs, rather than serializing a whole object received from the agent.

Process identity is part of the permission

You cannot approve a tool use responsibly if you cannot tell which executable requested it. The process name alone is weak evidence because any process can choose a familiar-looking name.

Record the complete launch command, executable path, version, working directory, parent process, and the user account. If macOS code signing applies, inspect the signature authority as well. codesign can show the identity claims macOS sees:

codesign -dv --verbose=4 /absolute/path/to/mcp-server 2>&1 | grep -E 'Identifier|TeamIdentifier|Authority'

Typical output has fields such as:

Identifier=com.example.mcpserver
Authority=Developer ID Application: Example Developer
TeamIdentifier=ABCDE12345

Those fields are evidence, not a permission decision by themselves. A signed binary can still be the wrong binary for this job, and an unsigned internal script is not automatically malicious. The review asks whether the path, owner, source, and identity match the tool you expected to run.

Then inspect the process tree during an invocation:

ps -axo pid,ppid,user,command | grep -E 'mcp-server|sp-ssh|node|python'

You want a boring answer: the agent client launches the MCP server, and the server starts only the helper processes you expected. Be suspicious when the server launches a shell, a package manager, an interpreter from a mutable project directory, or a background process that outlives the session.

A common failure looks harmless in a configuration file:

{
  "command": "npx",
  "args": ["-y", "some-mcp-package"]
}

This can fetch or change code at launch time depending on local cache state and package resolution. It makes a review of yesterday's source less meaningful than teams assume. Pin the executable or package version, install it through a controlled process, and launch a known local path. If the tool needs updates, make updates an explicit review event rather than an invisible side effect of starting an agent.

Process identity also includes inherited environment. A server launched from a developer shell can inherit cloud tokens, source-control tokens, proxy settings, and a broad PATH. Print a sanitized inventory in test runs, or launch with a minimal environment. Do not log secret values. Log variable names that affect behavior and confirm that the tool does not need ambient credentials it was never meant to use.

Logs must reconstruct actions without repeating secrets

Keep SSH keys outside prompts
Use the bundled sp-ssh helper for SSH commands without giving private keys to the agent.

A useful audit record answers who ran the tool, under which process, with what sanitized arguments, against which destination, and with what result. A line reading tool call succeeded answers none of the questions you will have when an agent sends data somewhere surprising.

Separate operational logs from secret-bearing debug output. Operators need enough detail to investigate. They do not need bearer tokens, authorization headers, private keys, raw agent transcripts, or full response bodies copied into a text file.

For each invocation, write fields similar to these:

{
  "time": "2025-03-08T14:22:11Z",
  "session_id": "run_7c2f",
  "process": "/opt/tools/build-mcp",
  "tool": "get_build_status",
  "argument_summary": {"build_id": "CI-4812", "include_logs": false},
  "destination": "ci.example.internal",
  "decision": "approved",
  "result": "success",
  "request_id": "c4e8..."
}

The example uses an argument summary on purpose. A summary should preserve identifiers and bounded fields that help investigation, while redacting or hashing sensitive values. If an operation legitimately sends a document, log its byte count and a content digest where that helps correlation. Do not put the document itself in the journal just to make debugging convenient.

The OpenTelemetry logging model is useful here even if you do not adopt OpenTelemetry. It distinguishes event body from attributes and emphasizes structured fields for filtering and correlation. Apply that idea locally: make destination, operation, outcome, and process identity machine-readable. A pile of prose log lines becomes useless the first time you need to answer whether the agent sent the same request ten times.

Also inspect error paths. Many tools redact successful requests but print a complete request object when an API returns an error. Force a 401, a timeout, malformed JSON, and a failed DNS lookup. Read every emitted line. Debug output is where credentials usually escape.

Sallyport keeps a Sessions journal for agent runs and an Activity journal for individual calls, both projected from a write-blind encrypted, hash-chained audit log. That design is useful when you need a local record beyond the tool author's own logs, but it does not make a broad tool safe. The tool still needs narrow inputs and known destinations.

Approval prompts cannot repair broad authority

Human approval is a useful brake only when the thing you approve has a comprehensible scope. A card that says an unrecognized process wants to use a credential tells you something important. It does not tell you whether a generic request tool will send a repository file to a host chosen by a model five seconds later.

Keep the approval unit close to the authority unit. A read-only status token and a production deployment token should not sit behind one approval because they have different consequences. A tool that reads a release record should not quietly gain the ability to create one because both operations happen to use the same API.

The Model Context Protocol specification recommends that clients obtain user consent before invoking tools. That is sound guidance, but consent has a failure mode: people approve repetitive, poorly described prompts until the prompt stops carrying information. Do not solve prompt fatigue by approving a whole category of actions forever. Fix the tool boundary that produces vague or excessive prompts.

Sallyport's decision ladder puts a locked vault ahead of all actions, asks for per-session authorization by default, and can require a decision for every use of a specific credential. Use per-call approval for credentials whose use you would want to inspect each time, such as a deployment or write-capable API credential. Do not use it as an excuse to give a generic HTTP tool that credential.

A good approval test has a sentence behind it: "This signed process, launched from this path, may use this credential to read status from this service for this run." If you cannot say that honestly, deny the request and go back to the authority map.

Test the failure paths before you trust success

Give MCP tools a gateway
The bundled sp mcp shim gives MCP-capable agents a controlled route to external actions.

A tool that works on the happy path has not passed a security review. You need to see how it behaves when inputs are wrong, its credential is absent, the network points somewhere unexpected, and its helper process fails.

Run the tool in a test account or isolated project with a credential that has the smallest practical scope. Use test data that resembles real data enough to exercise serialization and size limits, but never feed production secrets into an unreviewed tool just to see what happens.

Use this five-part test sequence:

  1. Call the tool with one valid request and capture its process tree, outbound destination, and audit record.
  2. Send an undeclared field, a maximum-length value, a value containing line breaks, and a valid-looking identifier outside the permitted project. The server should reject each before making a request.
  3. Force an unapproved host through every available input and configuration route. Include redirects if the tool uses HTTP. The tool should refuse it and record the refusal without exposing sensitive input.
  4. Remove or revoke the credential while the server remains running, then repeat the valid request. Confirm that the next action fails rather than succeeding from a hidden cache or inherited environment.
  5. Kill the parent agent process and check for surviving server or helper processes. A background worker that retains access after the agent exits needs a clear reason and a separate review.

Keep the evidence with the tool version: the manifest, source revision or package digest, commands used, observed endpoints, sample redacted audit record, and the person who accepted the remaining risks. This is not paperwork for its own sake. Without versioned evidence, a later package update changes the tool and everyone assumes the old review still applies.

One failure deserves special attention. Suppose a document-search tool accepts repository_path and calls a helper using a shell string. Normal requests work. An agent later receives an instruction embedded in a ticket to search a path containing shell punctuation. The helper executes a second command under the developer's account, reads a credential file, and the tool sends the result to its otherwise approved search endpoint. Each component did what its author expected. The composition failed because the schema allowed a path, the shell reinterpreted it, and the outbound payload accepted arbitrary helper output. Test chains, not isolated functions.

Access removal needs more than deleting a config entry

Stop passing tokens through MCP
Sallyport injects bearer, basic, or custom-header credentials directly into approved HTTP calls.

Removing an MCP server from an agent configuration stops the ordinary launch path. It does not revoke a token copied into a cache, end a server that is still running, remove an SSH key from an agent process, or invalidate access at the remote service.

Plan removal when you grant access. The credential owner should know where to revoke it, the tool should use a distinct credential where possible, and the operator should know which process and local files to remove. Shared developer tokens turn a simple removal into an incident because you cannot tell which use belongs to the tool.

For a tool that uses HTTP, revoke or disable the remote token first. Then stop the MCP server and any child helpers, remove the tool's local credential reference, and delete its launch configuration. Finally, rerun the same request and retain the denied result. For SSH, remove the relevant public key at the remote account or repository, terminate local helper processes, and inspect agent configuration for alternate identities.

Do not confuse a local vault lock with remote revocation. A lock stops future use through that vault while it remains locked. It cannot retract data already sent, invalidate a token stored elsewhere, or stop an unrelated process that inherited a credential earlier.

Make removal testable before an incident. Add a short runbook entry with the remote revocation location, expected denial response, server command, config location, and the log fields that confirm the attempt failed. If the tool owner cannot provide that entry, they have not finished the integration.

A narrow tool earns repeatable trust

The tools that survive review are usually boring in the best way. They accept a few typed fields, connect to one known service, run a known executable when they need one, return only the result the agent needs, and leave a record someone can inspect later.

Broad tools feel flexible because they shift design decisions onto the agent at runtime. They also turn a single approval into authority over destinations, data, and commands that nobody reviewed. Keep flexibility in the code you own and expose a narrow operation to the agent.

Before approving a custom tool, try to remove one input, one destination, one credential scope, or one child process. If nobody can explain why that item must remain, remove it. The review gets easier, and so does the eventual incident response.

FAQ

What should I inspect before connecting an MCP tool to an agent?

Treat a custom MCP tool as code that receives instructions chosen by a model, not as a harmless extension. Review its schema, every network destination, every child process, its identity, its logs, and how you remove its access. A clean README is not evidence for any of those things.

Does MCP make a tool safe by default?

No. MCP describes how clients and servers communicate; it does not certify a tool's implementation or its destinations. A tool can speak MCP correctly and still send prompts, files, or credentials somewhere you did not approve.

Are JSON schemas enough to secure MCP tool inputs?

A tool schema can narrow the inputs a model may supply, but it cannot prove the tool uses those inputs safely. Reject vague catch-all fields such as query, options, or arbitrary JSON when the operation has a finite set of legitimate parameters. Validate again in the implementation, because schemas do not stop a compromised client from sending malformed requests.

Can a local MCP server still exfiltrate data?

A local stdio MCP server may still make outbound HTTP calls, invoke package managers, read your home directory, or start child processes. Local changes the transport between client and server, not the authority the server inherits. Inspect both the launch command and the code paths it can reach.

How do I verify an MCP server process identity?

Ask what executable actually owns the network connection and which user account launches it. A signed package name is weaker evidence than a known path, a recorded version, a digest where available, and a process tree you can reproduce. Do not approve an identity you cannot explain to another engineer.

What should MCP tool audit logs contain?

Audit records need enough context to reconstruct an action: session or process identity, time, tool name, sanitized arguments, destination, result category, and approval outcome. Logs that only say a tool was called cannot answer where data went. Keep secrets and full sensitive payloads out of ordinary log output.

How do I revoke an MCP tool's access safely?

Use a separate credential or access grant for each tool where the remote service permits it. Removing the tool from a configuration only stops the normal launch path; it does not revoke a copied token or an already running process. Revoke the remote credential, terminate the process, remove the local grant, and verify with a denied test.

When is it reasonable to allow an agent to use a custom MCP tool?

It can be reasonable for narrow, read-only operations against a known destination, with a bounded schema and meaningful logs. It is a poor fit for broad shell execution, arbitrary URLs, recursive file reads, or tools that mix unrelated powers under one name. Convenience is not a reason to give a model ambient authority.

Do approval prompts make a risky MCP tool safe?

It often helps, but it does not replace inspection. The first call tells you which process is requesting access, while per-call approval limits individual uses of a sensitive credential. A tool that can send arbitrary data to arbitrary hosts still needs a narrower design before approval becomes useful.

How can I test an MCP tool without trusting it first?

Run the tool with representative hostile-looking inputs and capture the outbound traffic and child-process activity. Check that invalid URLs, unexpected fields, shell metacharacters, oversized values, and missing parameters fail closed. Then repeat the test after revoking its credential to prove that removal works.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov