# Testing and Security Workflows

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Development Workflows
Source: https://www.devtutorials.ai/learn/mcp/dev-workflows/testing-and-security-workflows

> Testing and security through MCP: test-runner and secret-scanner tools, unit and contract tests for MCP servers, end-to-end checks, and securing dev-tool servers with scopes, guards and least privilege.

Two of the most valuable things an assistant can do for developers are "run the tests and tell me what broke" and "check this for leaked secrets." Both need tools: the assistant cannot run a test suite or scan a repository by thinking about it. The servers that provide those tools are code too, so they need their own tests and their own security.

**The main idea:** MCP connects assistants to **testing** and **security** workflows through tools such as `run_tests` and `scan_secrets` that return **structured, actionable** results. Test MCP servers at three levels: **unit tests** for each handler, **contract tests** that catch accidental changes to tool names, schemas, and annotations, and **end-to-end** checks with the Inspector or a real host. Secure dev-tool servers with **least-privilege scopes per tool**, guards in code, masked secrets, and authorization checked on every call.

## A Real-World Example

Omar's team connects its assistant to the dev workbench. On Monday:

- He asks, "Run the tests and tell me what failed." The assistant calls `run_tests`, gets two structured failures, and explains them: the login button cannot be found by role (a **frontend accessibility** bug), and `GET /orders` returns 200 for another user's ID (a **backend authorization** bug).
- He asks, "Did I leak anything in this branch?" `scan_secrets` finds a payment key in `config/payments.ts` and reports only `acme...` and the file. The assistant reminds him to rotate the key, not just delete it.

On Tuesday, a teammate "improves" the server by marking `delete_branch` as non-destructive. The server's **contract test** fails in CI: the annotation changed. The host would have started offering "Always allow" for a destructive tool. The change is reverted before release.

**In simple words:** let tools run the tests and scans, and test and lock down the tool servers like any other code.

## What You Will Learn

In this lesson, you will learn:

1. How a test-runner tool supports frontend and backend debugging.
2. How a secret-scanner tool supports security work.
3. How to unit-test MCP tool handlers.
4. How contract tests protect tool schemas and annotations.
5. How to secure dev-tool servers with scopes and guards.

Use the dev workbench server in the MCP Lab: `run_tests` and `scan_secrets` are real tools in it, and the Inspector shows every message. The Raw request tab's protected server (`/secure/mcp`) shows how scopes and `403 insufficient_scope` work over HTTP (see **Remote MCP and Authorization**).

## Testing Workflows

### Run the Tests Through a Tool

A test-runner tool should return **structured** results: counts and a list of failures with names and messages. The model can then group failures and explain them.

```js runnable title="Sort failures by area"
// A test-runner tool returns structured results the model can act on.
const result = {
  passed: 3,
  failed: [
    {
      name: "LoginForm submits with the keyboard",
      error: "Unable to find role 'button' with name 'Sign in'",
    },
    {
      name: "GET /orders rejects another user's id",
      error: "expected 403, received 200",
    },
  ],
};

for (const f of result.failed) {
  const area = /role|label|button/.test(f.error)
    ? "frontend/accessibility"
    : /\b(40\d|50\d)\b/.test(f.error)
      ? "backend/authorization"
      : "logic";
  console.log(`${area.padEnd(24)} ${f.name}`);
}
console.log(`${result.passed} passed, ${result.failed.length} failed`);
```

Expected output:

```text
frontend/accessibility   LoginForm submits with the keyboard
backend/authorization    GET /orders rejects another user's id
3 passed, 2 failed
```

Keep test output **short** in the tool result. Return the failing tests and their first error line, not 2,000 lines of logs. If the model needs more, provide a second tool or a resource (`ci://runs/{id}/log`) it can read on demand.

## Security Workflows

### Scan for Secrets

A secret-scanner tool is an exact pattern-matching check: it is perfect for code, but it is not a substitute for a model. It reports the file location and a **safe prefix** only.

```js runnable title="Scan files and mask the findings"
// A secret scanner tool: find keys, report locations, never echo the full value.
const files = {
  "config/payments.ts": "export const PAY_KEY = 'acme_live_4f9XyZ81QwErTy00';",
  "src/App.tsx": "export default function App() { return null; }",
  "scripts/deploy.sh": "export TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123",
};
const patterns = [
  ["payment key", /acme_live_[A-Za-z0-9]{8,}/g],
  ["GitHub-style token", /ghp_[A-Za-z0-9]{20,}/g],
];
for (const [path, text] of Object.entries(files)) {
  for (const [label, re] of patterns) {
    for (const m of text.matchAll(re)) {
      console.log(`${path}: ${label} ${m[0].slice(0, 4)}...`);
    }
  }
}
```

Expected output:

```text
config/payments.ts: payment key acme...
scripts/deploy.sh: GitHub-style token ghp_...
```

Other security tools that fit MCP well are dependency audits, static analysis results, and policy checks. Each should be **read-only**, return structured findings, and never echo secrets.

## Testing MCP Servers

### Unit Tests for Handlers

Each tool handler is a function. Test it directly, without a model, including refusal cases.

```js runnable title="Unit-test a tool handler"
// Unit-test a tool handler directly, without any model in the loop.
function runQuery({ sql }) {
  if (!/^\s*select\b/i.test(sql) || /;\s*\S/.test(sql)) {
    return { isError: true, content: [{ type: "text", text: "Refused" }] };
  }
  return { content: [{ type: "text", text: "3 rows" }] };
}

const cases = [
  [{ sql: "SELECT 1" }, false],
  [{ sql: "DROP TABLE orders" }, true],
  [{ sql: "select 1; delete from users" }, true],
];
for (const [input, expectError] of cases) {
  const r = runQuery(input);
  const ok = Boolean(r.isError) === expectError;
  console.log(`${ok ? "PASS" : "FAIL"} ${JSON.stringify(input)} -> ${r.content[0].text}`);
}
```

Expected output:

```text
PASS {"sql":"SELECT 1"} -> 3 rows
PASS {"sql":"DROP TABLE orders"} -> Refused
PASS {"sql":"select 1; delete from users"} -> Refused
```

### Contract Tests for the Tool List

Hosts make decisions from your tool list: names, required arguments, and annotations. An accidental change can break hosts or weaken safety. Snapshot the list and compare it on every change.

```js runnable title="Detect tool-list changes"
// Contract test: the tool list should not change shape by accident.
const snapshot = {
  run_query: { required: ["sql"], readOnlyHint: true },
  delete_branch: { required: ["name"], readOnlyHint: false, destructiveHint: true },
};
const current = {
  run_query: { required: ["sql", "database"], readOnlyHint: true },
  delete_branch: { required: ["name"], readOnlyHint: false, destructiveHint: false },
};

for (const [tool, before] of Object.entries(snapshot)) {
  const now = current[tool];
  const changes = Object.keys(before).filter(
    (k) => JSON.stringify(before[k]) !== JSON.stringify(now[k]),
  );
  console.log(
    `${tool.padEnd(14)} ${changes.length ? "CHANGED: " + changes.join(", ") : "same"}`,
  );
}
```

Expected output:

```text
run_query      CHANGED: required
delete_branch  CHANGED: destructiveHint
```

| Change | Why it matters |
| --- | --- |
| A new required argument | Existing callers start failing |
| `destructiveHint` true → false | Hosts may stop asking for confirmation |
| `readOnlyHint` false → true | Hosts may run a write tool without asking |
| A renamed tool | Saved approvals and prompts stop matching |

### End-to-End Checks

Finally, connect a real client: the **MCP Inspector**, a test host, or the lab. Check discovery, a successful call, a refused call, progress, cancellation, and authorization errors.

## Securing Dev-Tool Servers

### Scopes per Tool

Developer tools range from harmless (read tokens) to dangerous (delete branches). Give each tool a **scope**, and check the token's scopes on **every** call.

```js runnable title="Check scopes per tool"
// Least privilege for a dev-tools server: scopes per tool, checked on every call.
const toolScopes = {
  get_design_tokens: "design:read",
  run_query: "db:read",
  run_tests: "ci:run",
  create_branch: "repo:write",
  delete_branch: "repo:admin",
};
const token = { scopes: ["design:read", "db:read", "ci:run", "repo:write"] };

for (const [tool, scope] of Object.entries(toolScopes)) {
  const ok = token.scopes.includes(scope);
  console.log(
    `${tool.padEnd(18)} needs ${scope.padEnd(12)} ${ok ? "allowed" : "403 insufficient_scope"}`,
  );
}
```

Expected output:

```text
get_design_tokens  needs design:read  allowed
run_query          needs db:read      allowed
run_tests          needs ci:run       allowed
create_branch      needs repo:write   allowed
delete_branch      needs repo:admin   403 insufficient_scope
```

### Security Checklist for Dev-Tool Servers

- **Read-only by default**; write tools are separate, clearly annotated, and scoped.
- **Guards in code** (single SELECT, allowed paths, branch-name patterns), not only in descriptions.
- **Least-privilege credentials** for every backend system (read-only DB user, scoped repo token).
- **Never return secrets**; mask findings and redact logs.
- **Validate inputs** with schemas and reject paths such as `../../etc/passwd`.
- **Treat tool inputs from the model as untrusted**: they may come from prompt-injected content.
- **Log** every write call with the user, arguments, and result.

A test-runner tool **executes code**. If it accepts arbitrary commands or file paths, a prompt-injected request could run anything on the CI machine. Accept only a test filter, run in an isolated container with no production credentials, and time out long runs.

## Common mistakes

- **Dumping full logs into tool results.** Return failures and first error lines.
- **Echoing secrets in scan results.** Show a prefix and the location.
- **Testing servers only through a model.** Unit-test handlers directly.
- **No contract tests.** Annotation and schema changes slip through.
- **One scope for all tools.** Scope each tool, and check on every call.
- **Test runners that accept any command.** Accept filters only, in isolation.

## Check Your Understanding

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- **Test-runner** and **secret-scanner** tools bring testing and security workflows into the assistant.
- Return **structured, short, actionable** results, and **mask** secrets.
- Test servers with **unit tests** (handlers), **contract tests** (names, schemas, annotations), and **end-to-end** checks.
- Secure dev-tool servers with **scopes per tool**, **guards in code**, least-privilege credentials, and logging.
- Treat **tool inputs as untrusted**, and isolate anything that executes code.

This completes the Development Workflows chapter. Return to the **MCP Reference** page any time for message shapes, error codes, and checklists.
