MCP has more than one sensitive-data boundary
An MCP server can move data in both directions. Treat each direction separately:
| Surface | Risk | pyveil channel |
|---|---|---|
| Tool arguments | A model attempts to send credentials into a tool | tool.call.arguments |
| Tool results | An API or database returns PII or hidden tokens | tool.call.result |
| Resource content | Files, records, or logs enter agent context | mcp.resource.content |
Create one scoped redactor for the server
from pyveil import Veil
veil = Veil.high(
secret=b"tenant-secret-from-your-secret-manager",
scope="tenant-123/mcp-server",
)
Use separate instances or scopes when tenants or runs must not share stable placeholder identities.
Block credential-like tool arguments
The default high policy blocks authorization headers, API keys, private keys, JWTs, and token-like values before model-controlled tools execute.
from pyveil import BlockedSensitiveData, Channel
def run_mcp_tool(name: str, arguments: dict) -> object:
try:
safe = veil.redact_data(
arguments,
channel=Channel.TOOL_CALL_ARGUMENTS,
)
except BlockedSensitiveData as exc:
raise PermissionError(exc.summary()) from exc
return dispatch_tool(name, safe.data)
Do not catch the exception and then run the tool with the original arguments. A block is a refusal decision, not a warning.
Redact tool results before returning them to the model
from pyveil.integrations.mcp import redact_tool_result
raw_result = {
"owner": "alice@example.com",
"callback": "https://example.test/cb?access_token=demo-only-token",
}
safe_result = redact_tool_result(raw_result, veil)
return safe_result
The helper uses tool.call.result. You can call veil.redact_data(...) directly when your server needs a custom channel or policy.
Redact resource content at read time
from pyveil.integrations.mcp import redact_resource
def read_resource_for_agent(uri: str) -> object:
raw = resource_store.read(uri)
safe = redact_resource(raw, veil)
return safe
Apply the boundary after authorization and retrieval, but before content is serialized into an MCP response. This avoids storing a second redacted copy while still keeping raw data out of agent context.
Test the response object the agent receives
def test_mcp_resource_hides_email():
raw = {"text": "Owner alice@example.com"}
result = veil.redact_data(raw, channel="mcp.resource.content")
assert "alice@example.com" not in str(result.data)
assert "[EMAIL:" in result.data["text"]
assert result.findings[0].path == "/text"
assert result.findings[0].raw is None
Also test the block path for tool arguments and ensure logs contain only exc.summary(), never the original payload.
What this MCP boundary does not do
- It does not authorize the caller or resource. Run normal auth checks first.
- It does not stop prompt injection or malicious tool selection.
- It does not discover every unknown name or address. Add known-value rules or an upstream NER layer.
- It does not provide reversible unmasking to the model.
Do not expose an "unmask" MCP tool. That turns the redaction layer into a model-accessible secret oracle.