Skip to main content
Back to Blog
AI Testing
2026-08-08

MCP Testing Resource Subscription Updates End to End

Master MCP testing resource subscription updates with wire-level checks for capabilities, invalidation, isolation, races, reconnects, and agent freshness.

MCP Testing Resource Subscription Updates End to End

MCP testing resource subscription updates means proving the complete invalidation loop: a server advertises subscription support, a client subscribes to one exact resource URI, the server emits notifications/resources/updated after that resource changes, and the client reads the resource again before using it. The notification is not the new resource body. A robust test therefore checks both the wire message and the refreshed content that reaches the agent.

Test the feature as a stateful protocol, not as an isolated callback. Cover authorization, URI matching, duplicate subscriptions, update bursts, unsubscribe, disconnect, and the race between a read and a later mutation. These cases expose stale-context failures that a happy-path test cannot. For the wider quality model, see the agentic AI testing guide. For transport, lifecycle, and server selection context, use the MCP servers for test automation guide.

Turn the Protocol Exchange Into an Observable Contract

The resource capability has two independent optional features. subscribe says the server supports resource-specific update notifications. listChanged says it can report changes to the set of available resources. A server can advertise one, both, or neither. Your test must not infer subscription support merely because resources/list works.

The stable 2025-06-18 schema documents resources/subscribe, resources/unsubscribe, and notifications/resources/updated at https://modelcontextprotocol.io/specification/2025-06-18/schema. Pin the protocol revision exercised by a compatibility suite, because draft behavior can evolve.

StageMessage or observationRequired assertionFailure meaning
InitializationServer capabilitiesresources.subscribe is explicitly trueClient must not offer subscriptions
SubscriptionRequest with one URISuccessful JSON-RPC resultServer accepted the requested target
MutationDomain action changes backing dataRevision or body actually changesTest stimulus is real
InvalidationUpdated notification with same URIMethod and URI are exactCorrect subscribers are told to refresh
RefreshClient sends resources/readRead occurs after notificationClient did not treat notice as payload
ConsumptionAgent-visible contentNew revision is usedFresh bytes reached the consumer
UnsubscribeUnsubscribe request succeedsLater mutation produces no noticeSubscription state was removed

A canonical happy-path transcript gives reviewers a shared oracle. This JSON Lines fixture is valid JSON one object per line, which is convenient for a transport recorder:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"qa-probe","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"resources":{"subscribe":true}},"serverInfo":{"name":"fixture-server","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":2,"method":"resources/subscribe","params":{"uri":"qa://runbooks/deploy"}}
{"jsonrpc":"2.0","id":2,"result":{}}
{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"qa://runbooks/deploy"}}
{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"qa://runbooks/deploy"}}
{"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"qa://runbooks/deploy","mimeType":"text/plain","text":"revision=2"}]}}

Assert direction as well as shape. Subscribe and read are client-to-server requests with IDs. The update is a server-to-client notification without an ID. A fixture that puts an ID on the notification can accidentally turn it into a request and make one implementation wait for a response that should never exist.

Build a State Model Before Writing Transport Tests

The smallest useful model has four pieces of state per connection: negotiated capability, authorized resource set, active subscription set, and the latest revision read by the client. Modeling these explicitly stops the test from confusing globally changed data with connection-local delivery.

Connection stateIncoming actionExpected next stateExpected output
Initialized, not subscribedSubscribe to allowed URIURI becomes activeEmpty success result
Initialized, active URIBacking resource changesSubscription stays activeOne update notification
Initialized, active URIRead same URIRevision advances locallyFresh resource contents
Initialized, active URIUnsubscribe same URIURI removedEmpty success result
Initialized, inactive URIUnrelated resource changesNo changeNo notification
DisconnectedAny later resource changeNo connection stateNo delivery on old channel

Here is a self-contained Node test for that state model. It deliberately stores subscribers by URI and connection ID, rather than using a global boolean.

import test from 'node:test';
import assert from 'node:assert/strict';

type Notice = { method: 'notifications/resources/updated'; params: { uri: string } };

class ResourceHub {
  private subscriptions = new Map<string, Set<string>>();
  private inboxes = new Map<string, Notice[]>();

  connect(connectionId: string): void {
    this.inboxes.set(connectionId, []);
  }

  subscribe(connectionId: string, uri: string): void {
    if (!this.inboxes.has(connectionId)) throw new Error('unknown connection');
    const members = this.subscriptions.get(uri) ?? new Set<string>();
    members.add(connectionId);
    this.subscriptions.set(uri, members);
  }

  update(uri: string): void {
    for (const connectionId of this.subscriptions.get(uri) ?? []) {
      this.inboxes.get(connectionId)?.push({
        method: 'notifications/resources/updated',
        params: { uri },
      });
    }
  }

  inbox(connectionId: string): Notice[] {
    return [...(this.inboxes.get(connectionId) ?? [])];
  }
}

test('notifies only the connection subscribed to the exact URI', () => {
  const hub = new ResourceHub();
  hub.connect('client-a');
  hub.connect('client-b');
  hub.subscribe('client-a', 'qa://runbooks/deploy');

  hub.update('qa://runbooks/deploy');

  assert.deepEqual(hub.inbox('client-a'), [{
    method: 'notifications/resources/updated',
    params: { uri: 'qa://runbooks/deploy' },
  }]);
  assert.deepEqual(hub.inbox('client-b'), []);
});

This is not a substitute for testing the SDK or transport. It is a fast executable oracle. Run the same transitions through stdio or Streamable HTTP and compare the observed messages with the model. When a wire test fails, the model tells you whether the bug belongs to subscription bookkeeping or message serialization.

Verify Capability Negotiation and Negative Behavior

The first dangerous assumption is that every resource server supports subscriptions. Test a client against capability variants and make unsupported behavior visible in the UI or agent trace. The client should fall back to an explicit read strategy if the product defines one. It should not send an undocumented subscription request and hope the server accepts it.

The following pure test validates the decision, including the important false and absent cases:

import test from 'node:test';
import assert from 'node:assert/strict';

type Capabilities = {
  resources?: { subscribe?: boolean; listChanged?: boolean };
};

function canSubscribe(capabilities: Capabilities): boolean {
  return capabilities.resources?.subscribe === true;
}

test('requires an explicit resource subscription capability', () => {
  assert.equal(canSubscribe({ resources: { subscribe: true } }), true);
  assert.equal(canSubscribe({ resources: { listChanged: true } }), false);
  assert.equal(canSubscribe({ resources: {} }), false);
  assert.equal(canSubscribe({}), false);
});

Also test lying capability declarations. If a server advertises subscribe: true but returns method-not-found for resources/subscribe, record a compatibility defect against the server. Do not silently relabel this as an unsupported server, because the initialization response is part of the contract. Conversely, a server that implements the request but does not advertise it creates an interoperability problem: conforming clients will never discover the feature.

Authorization belongs in this layer. Use two principals with different resource visibility. An unauthorized subscribe request must not become a side channel that confirms whether a secret URI exists. Assert the implementation's documented error behavior without requiring sensitive detail in the error message. Then mutate the secret resource and prove the unauthorized connection receives nothing.

Negative caseStimulusSafe resultSecurity assertion
Capability absentClient considers subscribeRequest is not sentNo speculative protocol use
Unknown URISubscribe to nonexistent URIDocumented error or safe handlingNo leaked metadata
Forbidden URILower-privilege principal subscribesAuthorization failureNo later update notice
Malformed URIInvalid value reaches handlerInvalid-params responseServer remains connected
Subscription repeatedSame connection subscribes twiceIdempotent set semantics or documented responseNo accidental fan-out
Unsubscribe inactive URICancel missing subscriptionStable documented behaviorOther subscriptions survive

Prove That an Update Causes a Fresh Read

The update notification carries a URI, not the new content. A client that merely updates an icon or reuses its cached resource can pass a notification-count assertion while feeding stale context to the model. Capture the causal sequence: notification observed, read issued, newer body stored, and only then next agent turn begins.

A small client-side cache makes that policy testable without a model call:

import test from 'node:test';
import assert from 'node:assert/strict';

type Reader = (uri: string) => Promise<string>;

class ResourceCache {
  private values = new Map<string, string>();

  constructor(private readonly read: Reader) {}

  async prime(uri: string): Promise<void> {
    this.values.set(uri, await this.read(uri));
  }

  async onUpdated(uri: string): Promise<void> {
    this.values.set(uri, await this.read(uri));
  }

  get(uri: string): string | undefined {
    return this.values.get(uri);
  }
}

test('re-reads the invalidated URI before exposing it', async () => {
  let revision = 1;
  const reads: string[] = [];
  const cache = new ResourceCache(async (uri) => {
    reads.push(uri);
    return `revision=${revision}`;
  });

  await cache.prime('qa://runbooks/deploy');
  revision = 2;
  await cache.onUpdated('qa://runbooks/deploy');

  assert.deepEqual(reads, ['qa://runbooks/deploy', 'qa://runbooks/deploy']);
  assert.equal(cache.get('qa://runbooks/deploy'), 'revision=2');
});

For an AI host, replace the final cache assertion with a deterministic prompt assembly assertion. Avoid asking an LLM whether the content looks fresh. Inspect the exact context bytes or a stable resource revision supplied to the model gateway. A model answer is probabilistic and can repeat old facts from prior messages even when the host refreshed correctly.

If the product keeps conversation memory, test both context layers. Updating a resource does not erase an earlier assistant message. Your oracle should state which wins when the new resource contradicts conversation history. That is an agent product decision, not an MCP transport guarantee.

Exercise Bursts, Coalescing, and Read-Update Races

Updates rarely arrive one at a time in production. A repository watcher can produce several filesystem events for one save. A database transaction can modify related records. An operator can publish revision 8 while a client is still reading revision 7. The quality goal is freshness and bounded work, not necessarily one read per notification.

Choose and document a client policy:

Burst policyBehaviorUseful assertionTradeoff
Read every noticeOne read per update messageRead count equals notice countSimple, potentially wasteful
Coalesce while pendingOne active read plus one trailing readConcurrent reads never exceed oneFresh final value with bounded load
DebounceRead after quiet intervalOne read after configured windowAdds intentional staleness
Revision-awareIgnore notice already represented locallyStored revision never decreasesRequires revision metadata in content or app layer

The following coalescer is runnable and demonstrates the subtle trailing-read rule. If an update arrives during a read, finishing that read is not enough because its snapshot may predate the second update.

import test from 'node:test';
import assert from 'node:assert/strict';

class RefreshCoalescer {
  private running = false;
  private dirty = false;

  constructor(private readonly refresh: () => Promise<void>) {}

  async invalidate(): Promise<void> {
    this.dirty = true;
    if (this.running) return;
    this.running = true;
    try {
      while (this.dirty) {
        this.dirty = false;
        await this.refresh();
      }
    } finally {
      this.running = false;
    }
  }
}

test('performs a trailing refresh after an update during a read', async () => {
  let releaseFirst: (() => void) | undefined;
  let calls = 0;
  const firstRead = new Promise<void>((resolve) => { releaseFirst = resolve; });
  const coalescer = new RefreshCoalescer(async () => {
    calls += 1;
    if (calls === 1) await firstRead;
  });

  const first = coalescer.invalidate();
  await new Promise<void>((resolve) => setImmediate(resolve));
  await coalescer.invalidate();
  assert.equal(calls, 1);
  releaseFirst?.();
  await first;

  assert.equal(calls, 2);
});

Run burst tests with deterministic barriers, not sleeps. A test that waits 100 milliseconds and hopes the first read is in flight will flicker under CI load. The unresolved promise above creates the exact race and releases it deliberately.

Keep URI Identity and Tenant Isolation Exact

Resource URIs are identifiers interpreted by the server. Do not normalize them in a generic test helper unless the server's URI rules explicitly require it. Case changes, percent decoding, query sorting, or dropping fragments can merge distinct resources. The strongest isolation suite uses identifiers that look deceptively similar.

Create subscriptions for tenant://acme/runbook, tenant://acme/runbook?locale=en, and tenant://acme-2/runbook. Mutate each in turn and assert an exact recipient matrix. Include two connections for one URI to prove fan-out, and two URIs on one connection to prove independent cancellation.

This self-contained recipient test catches both prefix matching and unsubscribe-all bugs:

import test from 'node:test';
import assert from 'node:assert/strict';

class Subscriptions {
  private byUri = new Map<string, Set<string>>();

  add(client: string, uri: string): void {
    const set = this.byUri.get(uri) ?? new Set<string>();
    set.add(client);
    this.byUri.set(uri, set);
  }

  remove(client: string, uri: string): void {
    this.byUri.get(uri)?.delete(client);
  }

  recipients(uri: string): string[] {
    return [...(this.byUri.get(uri) ?? [])].sort();
  }
}

test('uses exact URI identity and targeted unsubscribe', () => {
  const subscriptions = new Subscriptions();
  subscriptions.add('a', 'tenant://acme/runbook');
  subscriptions.add('b', 'tenant://acme/runbook');
  subscriptions.add('a', 'tenant://acme/runbook?locale=en');

  subscriptions.remove('a', 'tenant://acme/runbook');

  assert.deepEqual(subscriptions.recipients('tenant://acme/runbook'), ['b']);
  assert.deepEqual(
    subscriptions.recipients('tenant://acme/runbook?locale=en'),
    ['a'],
  );
  assert.deepEqual(subscriptions.recipients('tenant://acme-2/runbook'), []);
});

On the server, disconnect cleanup deserves a resource-leak assertion. Open and close many test connections, then inspect a supported diagnostic such as active subscription count, heap profile, or test-only adapter state. Do not demand a production-only introspection endpoint. The externally visible requirement is that closed clients stop receiving messages and new clients do not inherit their subscriptions.

Test Reconnect as a New Session

Subscriptions are associated with protocol connection state unless a particular product explicitly documents durable restoration. A network reconnect should therefore trigger a deliberate client decision: renegotiate capabilities, restore the desired subscription set, then refresh resources whose freshness is uncertain.

Use a controllable transport seam. Complete initialization and subscription, sever the channel without a graceful unsubscribe, mutate the resource, and connect again. Assert that no message is somehow delivered on the dead connection. After the new initialization completes, assert the client resubscribes exactly once and reads the current revision. If resubscription is a host feature, test it as host behavior rather than claiming the server preserved protocol state.

A reconnect ledger can be validated with a tiny executable reducer:

import test from 'node:test';
import assert from 'node:assert/strict';

type Event =
  | { kind: 'wanted'; uri: string }
  | { kind: 'connected' }
  | { kind: 'disconnected' };

function subscriptionCommands(events: Event[]): string[] {
  const wanted = new Set<string>();
  let connected = false;
  const sent: string[] = [];
  for (const event of events) {
    if (event.kind === 'wanted') {
      wanted.add(event.uri);
      if (connected) sent.push(event.uri);
    } else if (event.kind === 'connected') {
      connected = true;
      sent.push(...[...wanted].sort());
    } else {
      connected = false;
    }
  }
  return sent;
}

test('restores desired subscriptions after reconnect', () => {
  const sent = subscriptionCommands([
    { kind: 'wanted', uri: 'qa://a' },
    { kind: 'connected' },
    { kind: 'disconnected' },
    { kind: 'connected' },
  ]);
  assert.deepEqual(sent, ['qa://a', 'qa://a']);
});

Add a generation number in a real client implementation so callbacks from an old transport cannot mutate the new session's cache. The test should hold an old read open, reconnect, complete a new read, then release the old one. The cache must retain the new session's result. This is a classic stale completion bug: message ordering looks correct in each connection, but asynchronous completion crosses the boundary.

Diagnose the Failure Where the Chain Breaks

A realistic failure often looks like this: the server log says it emitted an update, the test recorder sees it, but the agent still answers from revision 12 after revision 13 was published. Counting notifications will produce a false green.

Diagnose it in order:

  1. Confirm initialization advertised resources.subscribe: true on the same connection.
  2. Compare the subscribed URI with the notified URI. Require byte-for-byte equality only when the server contract promises it, since a server may legitimately notify about a sub-resource of the URI you subscribed to; otherwise accept documented sub-resource URIs and re-read the URI that was actually notified.
  3. Confirm the notification has no request ID and uses the documented method.
  4. Confirm the client schedules a read after receiving it.
  5. Confirm the read response contains revision 13.
  6. Confirm the cache accepts revision 13 rather than losing a race to an older read.
  7. Confirm prompt assembly uses revision 13 for the next model request.

Suppose step 4 is missing. The likely defect is that the host treated the notification as informational UI state rather than cache invalidation. Suppose step 5 succeeds but step 6 fails after reconnect. The likely defect is an old asynchronous read overwriting new state. Those diagnoses point to different owners and regression tests.

Another common failure is an update storm after one file save. Inspect the server's domain watcher before blaming MCP delivery. The notification protocol does not promise storage-event deduplication. Decide whether the server coalesces domain events or the client coalesces refreshes, then assert the chosen boundary.

What Teams Get Wrong About Resource Updates

The most damaging misconception is “notification received equals current context.” It does not. The notification says a named resource changed; freshness requires another read and correct cache replacement. This distinction should appear in the test name and telemetry.

Teams also confuse resource-list changes with resource-content changes. notifications/resources/list_changed tells a client to discover the available set again. notifications/resources/updated identifies a subscribed resource whose contents changed. Testing one does not cover the other.

Avoid exact notification-count assertions unless the server contract promises them. Coalescing two rapid writes into one invalidation can be correct, and delivering two invalidations can also be correct, provided the client reaches the final state within the defined freshness target. Assert minimum necessary delivery, bounded amplification, and eventual final revision.

Finally, do not make the model response the only oracle. Keep a deterministic protocol transcript, resource revision, cache trace, and prompt-context record. The model layer can then have a small semantic test, while most defects receive precise and reproducible evidence.

Operate the Suite in CI and Agent Workflows

Split the suite by scope. A fast state-model suite runs on every change. A wire compatibility suite launches the real server transport and checks exact JSON-RPC exchanges. A host integration suite validates refresh and prompt assembly. A short soak test creates update bursts and reconnects while watching memory and read amplification.

SuiteMain evidenceSuggested triggerDefects caught
State modelTransitions and recipientsEvery commitBookkeeping and isolation
Protocol wireRecorded JSON-RPC messagesEvery server changeShape, direction, capability drift
Host integrationCache revision and context bytesEvery host changeStale reads and prompt assembly
Reconnect raceGeneration-aware final stateNightly or targetedCross-session stale completion
Burst soakFinal revision, read count, memoryScheduledAmplification and cleanup leaks

Use unique resource URIs per CI worker so parallel jobs cannot consume one another's events. Shell names must use braces around adjacent variables, for example RESOURCE_URI="qa://ci/${CI_PIPELINE_ID}_${CI_NODE_INDEX}". Record the resolved URI in failure output, but never include secrets or access tokens in it.

An AI coding agent can help generate transition cases from the state table, but review every generated method name against the pinned protocol schema. Give the agent a captured initialization response and transcript rather than asking it to recall the protocol from memory. Ready-made QA skills can also be installed from qaskills.sh with the qaskills CLI when a team wants a reusable workflow.

The release gate should answer one operational question: after any subscribed resource changes, can the next relevant agent turn be proven to use an authorized current representation without duplicate work growing unbounded? If the evidence covers every link in that sentence, the subscription feature is tested rather than merely exercised.

Frequently Asked Questions

Does an MCP resource update notification include the new content?

No. The resource update notification identifies the URI that changed. The client should treat it as invalidation and issue resources/read for that URI according to its refresh policy. Test the notification and the subsequent fresh read as separate observations. If a host immediately starts another agent turn without refreshing, it can still use stale context even though the protocol message arrived correctly. Keep a revision or content hash in test fixtures so the final cache and prompt context can be checked deterministically.

Should every resource update produce exactly one client read?

Not necessarily. A client may coalesce notifications while a read is pending or debounce a burst, provided its documented behavior reaches the latest state within the freshness objective. Exact one-to-one counting can reject efficient implementations. A better oracle proves at least one refresh after invalidation, no unbounded concurrent reads, and eventual storage of the final revision. If your product contract explicitly promises one read per notice, then count them, but recognize that this is a product policy rather than the core invalidation meaning.

How do I test subscriptions across a reconnect?

Treat the new transport connection as a new negotiated session. Disconnect after subscribing, mutate the resource, reconnect, initialize again, and observe whether the host restores its desired subscriptions. Then require a fresh read because notifications missed during disconnection may not be replayed. Hold an old read open during this flow to catch stale completion overwriting the new cache. The test should distinguish server protocol behavior from a host convenience feature that remembers and restores subscription intent.

What is the most useful assertion for an AI agent using subscribed resources?

Assert the exact resource revision or content bytes included in the next model request. That is stronger than checking a notification counter and more deterministic than judging the model's natural-language answer. Pair it with authorization evidence, the notification URI, and the read response revision. This gives a causal record from domain mutation to agent context. A small end-to-end semantic check can remain, but it should complement the deterministic context assertion rather than replace it.