MCP server progress notification security
The MCP 2025-03-26 specification defines notifications/progress for servers to report progress on long-running tool operations. The client includes a _meta.progressToken in its tool call request; the server sends progress notifications using that token to associate updates with the correct operation. The progress token is a client-issued identifier, but the server is responsible for routing notifications correctly and for not leaking information about operation state through the granularity of its progress updates. Four SA-MCP-PRG patterns cover the progress notification security surface.
Progress notifications take the form: { method: "notifications/progress", params: { progressToken, progress, total } }. The progressToken is the value from the original request’s _meta.progressToken. progress and total are optional numeric values representing current and total units of work. When progress === total, the operation is typically considered complete. In multi-tenant MCP servers where multiple sessions share a process, progress token handling must be scoped to the originating session.
SA-MCP-PRG-001: predictable progress tokens enable cross-session operation tracking
MCP clients generate progress tokens and include them in requests. Servers route progress notifications back to the originating session by matching the token. When the server stores a mapping of progressToken → sessionId and the client-side token generation is predictable (sequential integers, timestamps), an attacker who can observe or enumerate tokens can track other sessions’ long-running operations: knowing the token lets them correlate operation start and completion across sessions in a shared server environment.
// VULNERABLE CLIENT: generates predictable progress tokens
let nextToken = 1;
async function callToolWithProgress(toolName, args) {
const progressToken = nextToken++; // 1, 2, 3 — predictable across all sessions
const result = await client.request({
method: 'tools/call',
params: { name: toolName, arguments: args },
_meta: { progressToken }
});
return result;
}
// Attacker in another session: token 1 belongs to first request ever made
// Token 2 to the second, etc. — cross-session operation correlation is trivial
// VULNERABLE: timestamp-derived token
const progressToken = Date.now(); // 1730351000000 — millisecond precision
// Any other client can observe their own token timing and estimate adjacent sessions' tokens
// SECURE CLIENT: cryptographically random progress token
import { randomBytes } from 'node:crypto';
async function callToolWithProgress(toolName, args) {
const progressToken = randomBytes(16).toString('hex'); // 128-bit unguessable
return client.request({
method: 'tools/call',
params: { name: toolName, arguments: args },
_meta: { progressToken }
});
}
// SERVER SIDE: progress token routing must be session-scoped
// VULNERABLE: server routes by token only — any session that knows the token receives the progress
const progressMap = new Map(); // token → sessionId
server.on('toolCall', (req, context) => {
if (req._meta?.progressToken) {
progressMap.set(req._meta.progressToken, context.sessionId);
}
});
// SECURE: validate that progress notification recipient matches originating session
async function sendProgress(token, progress, total) {
const ownerSessionId = progressMap.get(token);
// Only send to the session that issued the token
if (ownerSessionId) {
await sessions.get(ownerSessionId).sendNotification('notifications/progress',
{ progressToken: token, progress, total });
}
}
// DETECTION (SkillAudit):
// Scan client-side code for progress token generation
const weakProgressTokenPatterns = [
/progressToken\s*=\s*\w+\+\+/, // sequential counter
/Date\.now\(\)/, // timestamp
/Math\.random\(\)/, // weak random
/progressToken\s*=\s*\d+/ // literal integer
];
// Any match → SA-MCP-PRG-001 HIGH
Progress token predictability enables operation timing correlation: In a multi-tenant hosted MCP server, knowing that operation token 1000 belongs to session A and token 1001 to session B tells the attacker those operations were initiated in sequence. Combined with server response timing, this leaks information about other users’ activity rates and operation durations — a timing side-channel over the progress notification pathway.
SA-MCP-PRG-002: fine-grained progress updates leak server internal state
The progress and total fields in progress notifications are numeric. A server that sends fine-grained progress updates (e.g., progress: 47, total: 1000) directly exposes internal state to the client: the total number of records being processed, the total file size in bytes, or the number of iterations in a batch job. In multi-user scenarios, even progress updates for the current user’s operation can reveal sensitive system parameters (e.g., total: 847 users in a user enumeration job).
// VULNERABLE: progress total exposes record count
async function processAllUsers(progressToken) {
const users = await db.query('SELECT id FROM users');
const total = users.length; // 847 users — leaked to client via progress total
for (let i = 0; i < users.length; i++) {
await processUser(users[i]);
if (progressToken) {
await server.sendNotification('notifications/progress', {
progressToken,
progress: i + 1,
total: total // ← client now knows there are 847 total users in the system
});
}
}
}
// ALSO VULNERABLE: total = file size in bytes
async function processFile(filePath, progressToken) {
const stat = await fs.stat(filePath);
const totalBytes = stat.size; // exact file size leaked as progress total
// Process in chunks, reporting byte-level progress
let bytesProcessed = 0;
for await (const chunk of readFileChunks(filePath)) {
bytesProcessed += chunk.length;
await server.sendNotification('notifications/progress', {
progressToken,
progress: bytesProcessed,
total: totalBytes // ← exact file size exposed
});
}
}
// SECURE: use percentage-based progress (0-100) instead of raw counts
async function processAllUsers(progressToken) {
const users = await db.query('SELECT id FROM users');
const total = users.length; // never sent to client
for (let i = 0; i < users.length; i++) {
await processUser(users[i]);
if (progressToken) {
const pct = Math.floor((i + 1) / total * 100);
await server.sendNotification('notifications/progress', {
progressToken,
progress: pct,
total: 100 // always 100 — no raw count leaked
});
}
}
}
// DETECTION (SkillAudit):
// Flag: total value derived from database query result count or file stat
const progressSenders = findProgressNotificationSenders();
for (const sender of progressSenders) {
if (totalDerivedFromQueryResult(sender)) → HIGH // SA-MCP-PRG-002
if (totalIsFileSizeInBytes(sender)) → MEDIUM // SA-MCP-PRG-002
}
Progress totals are an unintended enumeration oracle: Developers use raw record counts as progress totals because it’s the most natural unit. But sending total: 847 in a user processing job tells the client exactly how many users are in the system — the same information that would require database access to obtain directly. The fix is a one-line change to percentage-based reporting, but it’s a common oversight because developers focus on the progress value (the changing one) and not the total (the constant one).
total field derived from database record count, file size in bytes, or other internal metric: client receives exact system parameter values as side-channel. Detection: total set to query result count or file stat value → HIGH.SA-MCP-PRG-003: forged progress notification creates false completion signal
MCP progress notifications are server-to-client messages. In the MCP transport layer, an on-path attacker (e.g., a compromised MCP proxy, a malicious intermediary, or an adversarial MCP server co-located with the target in a multi-server setup) can inject a forged notifications/progress message with progress === total to make the client believe a long-running operation has completed when it has not. If the client takes action on perceived completion (e.g., reads the result, closes the operation context, or proceeds to a next step), the actual result may be incomplete or missing.
// ATTACK SCENARIO: forged progress message injected by adversarial proxy
// Legitimate progress stream:
// { progressToken: 'abc123', progress: 10, total: 100 }
// { progressToken: 'abc123', progress: 50, total: 100 }
// [attacker injects]: { progressToken: 'abc123', progress: 100, total: 100 }
// Client sees 100% and reads result → result is incomplete (only 50% processed)
// Legitimate { progress: 100, total: 100 } never arrives from server
// VULNERABLE CLIENT: acts on progress === total without server confirmation
client.onNotification('notifications/progress', async (params) => {
updateProgressBar(params.progress, params.total);
if (params.progress === params.total) {
// Assumes operation complete based on progress notification alone
const result = await client.request({ method: 'tools/call/result', params: { token: params.progressToken } });
displayResult(result);
}
// No verification that the server actually completed the operation
});
// SECURE CLIENT: use progress for UI only; await the actual tool response
// The tool call promise is the authoritative completion signal
const toolResult = await client.request({
method: 'tools/call',
params: { name: 'long_operation', arguments: {} },
_meta: { progressToken }
});
// toolResult resolves only when the server sends the final CallToolResult response
// Progress notifications are for UI updates only — not the completion signal
// DETECTION (SkillAudit):
// Flag: progress === total check used to trigger result-reading logic
const progressHandler = findProgressNotificationHandler();
if (triggersActionOnProgressCompletion(progressHandler)) → MEDIUM // SA-MCP-PRG-003
// Verify: tool call result is awaited from the RPC response, not inferred from progress
Progress completion is advisory, not authoritative: The MCP specification is clear that the actual completion signal for a tool call is the CallToolResult JSON-RPC response, not a progress notification. Progress notifications with progress === total are a UI convenience. Clients that use them as the completion trigger are brittle under message loss, reordering, or injection, and can be exploited to cause premature result processing.
progress === total in progress notification as the operation completion signal rather than awaiting the CallToolResult response: forged progress completion triggers premature result processing. Detection: result-reading logic triggered by progress === total check → MEDIUM.SA-MCP-PRG-004: progress token reuse across requests creates cross-operation confusion
MCP servers that do not strictly associate progress tokens with single requests allow token reuse across multiple operations in the same session, or fail to invalidate tokens after operation completion. A client that reuses a progress token (by design or bug) in a second tool call, combined with a server that does not validate token uniqueness, creates ambiguity: both operations emit progress notifications on the same token, and the client’s progress tracking is confused between the two operations.
// VULNERABLE CLIENT: reuses progress tokens
let currentToken = 'my-progress-token'; // same token for all tool calls
async function runOperation(toolName) {
return client.request({
method: 'tools/call',
params: { name: toolName, arguments: {} },
_meta: { progressToken: currentToken } // ← same token every time
});
}
// Server receives two concurrent calls with the same progressToken
// Progress from operation A and operation B are indistinguishable
// Client progress bar shows combined progress from both operations
// VULNERABLE SERVER: does not validate token uniqueness
const activeTokens = new Map();
server.on('toolCall', async (req, context) => {
const token = req._meta?.progressToken;
if (token && activeTokens.has(token)) {
// Token already in use — should reject, but server proceeds
activeTokens.set(token, context.sessionId); // overwrites existing entry
}
// Both operations now emit progress on the same token → UI confusion
});
// SECURE SERVER: reject duplicate active progress tokens
server.on('toolCall', async (req, context) => {
const token = req._meta?.progressToken;
if (token) {
if (activeTokens.has(token)) {
throw new McpError(ErrorCode.InvalidParams,
`Progress token '${token}' is already in use for another active operation`);
}
activeTokens.set(token, context.sessionId);
// Remove when operation completes
req.onComplete(() => activeTokens.delete(token));
}
});
// SECURE CLIENT: always generate fresh token per request
import { randomBytes } from 'node:crypto';
async function runOperation(toolName) {
return client.request({
method: 'tools/call',
params: { name: toolName, arguments: {} },
_meta: { progressToken: randomBytes(16).toString('hex') }
});
}
// DETECTION (SkillAudit):
// Flag: server does not check for duplicate active progress tokens on tool call receipt
const toolCallHandler = findToolCallHandler();
if (!hasActiveTokenDeduplication(toolCallHandler)) → MEDIUM // SA-MCP-PRG-004
// Flag: client-side token generation reuses a fixed string or variable
if (clientReusesSameProgressToken()) → HIGH // SA-MCP-PRG-004
Token reuse is most dangerous in concurrent operation scenarios: A user who launches two long-running operations in quick succession (e.g., two parallel file processing tools) may accidentally reuse progress tokens if the client generates them non-randomly. The result is a progress UI that combines updates from both operations, making it impossible to tell which operation has finished. In automated workflows, this can cause an orchestrator to proceed with partial results believing the full operation is complete.
How SkillAudit detects SA-MCP-PRG patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-MCP-PRG-001 | Static: progress token generation scanned for sequential counters, timestamps, Math.random | HIGH |
| SA-MCP-PRG-002 | Static: progress total value source — flag if set from DB query result count or file stat size | HIGH |
| SA-MCP-PRG-003 | Static: client progress handler scanned for action-triggering logic on progress === total | MEDIUM |
| SA-MCP-PRG-004 | Static: server tool call handler scanned for active token uniqueness check; client scanned for token reuse | HIGH |
Run a SkillAudit scan on your MCP server’s progress notification implementation: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-PRG patterns and 200+ additional MCP security checks.