[MCP] Changes

   n a web application, the buttons, links, and data that you see change based on the context. Whether the user is currently logged in, whether there is any data available, what role the user has, etc. MCP servers are no different. The information and tools they provide can change at any time.
MCP provides a robust system for notifying clients about changes so that UIs and agents can stay in sync with the server's current capabilities and data.
This exercise will teach you how to implement and respond to change notifications in MCP, including:
  • list_changed notifications for tools, prompts, and resources
  • Special handling for resource template list changes
  • Resource update notifications and subscriptions
  • Recommended practices for keeping clients and users up-to-date
Change notifications are essential for building responsive, real-time AI apps. They let clients update menus, toolbars, and resource lists automatically.
 

1. List Change (Tools, Prompts, and Resources)

Whenever the set of available tools, prompts, or resources changes, the server should send a list_changed notification. This tells the client to refresh its list and fetch the latest definitions.
  • Tools: If a tool is added, removed, or updated, send a notifications/tools/list_changed request.
  • Prompts: If a prompt is enabled, disabled, or updated, send a notifications/prompts/list_changed request.
  • Resources: If a static resource or resource template is registered, unregistered, or its metadata changes, send a notifications/resources/list_changed request.
Clients that support list_changed can provide a seamless, always-up-to-date experience for users, no manual refresh required!
 

Example: Enabling/Disabling a Tool in Mission Control

Suppose your space station operations console has a "Dock Module" tool that should only be available when a docking port is free. When a new port becomes available, enable the tool and send a tools/list_changed notification. If all ports are occupied, disable the tool and notify again.
// Pseudocode
if (freeDockingPorts.length > 0 && !dockModuleTool.enabled) {
	dockModuleTool.enable()
} else if (freeDockingPorts.length === 0 && dockModuleTool.enabled) {
	dockModuleTool.disable()
}
The TypeScript SDK automatically sends list_changed notifications when tools are enabled or disabled. To avoid over-sending notifications, you should check whether the tool is already enabled or disabled before enabling or disabling it.

Example: Resource List Change Notification

When new experiment logs become available in the space station's research database:
 
{
	"jsonrpc": "2.0",
	"method": "notifications/resources/list_changed"
}
This tells the client that the available resources have changed, prompting it to refresh its resource list and discover new experiment logs.
 
Code: 
查看代码
export async function initializePrompts(agent: EpicMeMCP) {
	const suggestTagsPrompt = agent.server.registerPrompt(
		'suggest_tags',
		{
			title: 'Suggest Tags',
			description: 'Suggest tags for a journal entry',
			argsSchema: {
				entryId: completable(
					z
						.string()
						.describe('The ID of the journal entry to suggest tags for'),
					async (value) => {
						const entries = await agent.db.getEntries()
						return entries
							.map((entry) => entry.id.toString())
							.filter((id) => id.includes(value))
					},
				),
			},
		},
		async ({ entryId }) => {
			invariant(entryId, 'entryId is required')
			const entryIdNum = Number(entryId)
			invariant(!Number.isNaN(entryIdNum), 'entryId must be a valid number')

			const entry = await agent.db.getEntry(entryIdNum)
			invariant(entry, `entry with the ID "${entryId}" not found`)

			const tags = await agent.db.listTags()
			return {
				messages: [
					{
						role: 'user',
						content: {
							type: 'text',
							text: `
Below is my EpicMe journal entry with ID "${entryId}" and the tags I have available.

Please suggest some tags to add to it. Feel free to suggest new tags I don't have yet.

For each tag I approve, if it does not yet exist, create it with the EpicMe "create_tag" tool. Then add approved tags to the entry with the EpicMe "add_tag_to_entry" tool.
								`.trim(),
						},
					},
					{
						role: 'user',
						content: {
							type: 'resource',
							resource: {
								uri: 'epicme://tags',
								mimeType: 'application/json',
								text: JSON.stringify(tags),
							},
						},
					},
					{
						role: 'user',
						content: {
							type: 'resource',
							resource: {
								uri: `epicme://entries/${entryId}`,
								mimeType: 'application/json',
								text: JSON.stringify(entry),
							},
						},
					},
				],
			}
		},
	)

	async function updatePrompts() {
		const entries = await agent.db.getEntries()
		if (entries.length > 0) {
			if (!suggestTagsPrompt.enabled) suggestTagsPrompt.enable()
		} else {
			if (suggestTagsPrompt.enabled) suggestTagsPrompt.disable()
		}
	}
	agent.db.subscribe(updatePrompts)
	await updatePrompts()
}
 
 

2. Resource List Change (Templates and Expansions)

Resources are special because there's a difference between what the ResourceTemplate "expands" into and whether that resource template is available in the first place:
const ingredientsResource = agent.server.registerResource(
	'ingredient',
	new ResourceTemplate('sandwich://ingredients/{id}', {
		list: async () => {
			// what this returns can change as a result of database changes etc.
		},
	}),
	{
		title: 'Ingredient',
		description: 'A single sandwich ingredient by ID',
	},
	async (uri, { id }) => {
		// ...
	},
)

// this can also change as a result of user's access
ingredientsResource.enable() // or disable()
Resources in MCP can be either static (a single file, a database record) or dynamic via resource templates (e.g., a directory of files, a database table, or even a set of modules on a space station 🚀). Resource templates allow the server to expose a pattern or collection of resources that can change over time.
notifications/resources/list_changed notification (like the one in the example above) is sent when:
  • A new resource template is enabled/disabled
  • The set of expansions for a template changes (e.g., a new module docks, a new experiment log appears, or a file is added to a directory)
  • The metadata for a resource or template changes (e.g., the title of a module changes)

Let's look at an example of how this is done:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'

const server = new McpServer(
	{ name: 'DuckCollector', version: '1.0.0' },
	{
		capabilities: {
			resources: { listChanged: true },
			// ...other capabilities
		},
	},
)

// Whenever the ducky or tag list changes, notify the client
function updateResourceTemplates() {
	// ...logic to check for changes...
	server.sendResourceListChanged()
}

The MCP SDK makes it easy to keep resource lists in sync. Just call sendResourceListChanged() whenever your templates expand (items are added), contract (items are removed), or change metadata.

To make this work in our app, you'll want to subscribe to changes in your database and any other dynamic sources (like video uploads). When something changes, call sendResourceListChanged() so the client can refresh its resource lists and show users the latest and greatest.

 

Code:

查看代码
export async function initializeResources(agent: EpicMeMCP) {
	agent.db.subscribe(() => agent.server.sendResourceListChanged())
	subscribeToVideoChanges(() => agent.server.sendResourceListChanged())

	const tagListResource = agent.server.registerResource(
		'tags',
		'epicme://tags',
		{
			title: 'Tags',
			description: 'All tags currently in the database',
		},
		async (uri) => {
			const tags = await agent.db.getTags()
			return {
				contents: [
					{
						mimeType: 'application/json',
						text: JSON.stringify(tags),
						uri: uri.toString(),
					},
				],
			}
		},
	)

	const tagsResource = agent.server.registerResource(
		'tag',
		new ResourceTemplate('epicme://tags/{id}', {
			complete: {
				async id(value) {
					const tags = await agent.db.getTags()
					return tags
						.map((tag) => tag.id.toString())
						.filter((id) => id.includes(value))
				},
			},
			list: async () => {
				const tags = await agent.db.getTags()
				return {
					resources: tags.map((tag) => ({
						name: tag.name,
						uri: `epicme://tags/${tag.id}`,
						mimeType: 'application/json',
					})),
				}
			},
		}),
		{
			title: 'Tag',
			description: 'A single tag with the given ID',
		},
		async (uri, { id }) => {
			const tag = await agent.db.getTag(Number(id))
			invariant(tag, `Tag with ID "${id}" not found`)
			return {
				contents: [
					{
						mimeType: 'application/json',
						text: JSON.stringify(tag),
						uri: uri.toString(),
					},
				],
			}
		},
	)

	const entryResource = agent.server.registerResource(
		'entry',
		new ResourceTemplate('epicme://entries/{id}', {
			list: undefined,
			complete: {
				async id(value) {
					const entries = await agent.db.getEntries()
					return entries
						.map((entry) => entry.id.toString())
						.filter((id) => id.includes(value))
				},
			},
		}),
		{
			title: 'Journal Entry',
			description: 'A single journal entry with the given ID',
		},
		async (uri, { id }) => {
			const entry = await agent.db.getEntry(Number(id))
			invariant(entry, `Entry with ID "${id}" not found`)
			return {
				contents: [
					{
						mimeType: 'application/json',
						text: JSON.stringify(entry),
						uri: uri.toString(),
					},
				],
			}
		},
	)

	const videoResource = agent.server.registerResource(
		'video',
		new ResourceTemplate('epicme://videos/{videoId}', {
			complete: {
				async videoId(value) {
					const videos = await listVideos()
					return videos.filter((video) => video.includes(value))
				},
			},
			list: async () => {
				const videos = await listVideos()
				return {
					resources: videos.map((video) => ({
						name: video,
						uri: `epicme://videos/${video}`,
						mimeType: 'application/json',
					})),
				}
			},
		}),
		{
			title: 'EpicMe Videos',
			description: 'A single video with the given ID',
		},
		async (uri, { videoId }) => {
			invariant(typeof videoId === 'string', 'Video ID is required')

			const videoBase64 = await getVideoBase64(videoId)
			invariant(videoBase64, `Video with ID "${videoId}" not found`)
			return {
				contents: [
					{
						mimeType: 'video/mp4',
						text: videoBase64,
						uri: uri.toString(),
					},
				],
			}
		},
	)

	async function updateResources() {
		const entries = await agent.db.getEntries()
		const tags = await agent.db.getTags()
		const videos = await listVideos()

		if (tags.length > 0) {
			if (!tagListResource.enabled) tagListResource.enable()
			if (!tagsResource.enabled) tagsResource.enable()
		} else {
			if (tagListResource.enabled) tagListResource.disable()
			if (tagsResource.enabled) tagsResource.disable()
		}

		if (entries.length > 0) {
			if (!entryResource.enabled) entryResource.enable()
		} else {
			if (entryResource.enabled) entryResource.disable()
		}

		if (videos.length > 0) {
			if (!videoResource.enabled) videoResource.enable()
		} else {
			if (videoResource.enabled) videoResource.disable()
		}
	}

	agent.db.subscribe(updateResources)
	await updateResources()
}

 

3. Resource Subscriptions (Updates to Specific URIs)

While list_changed tells clients about changes in what resources are available, resource subscriptions are about changes to the content of a specific resource URI. Clients can subscribe to updates for a particular resource (e.g., a specific module or experiment log) and receive a notifications/resources/updated notification when its content changes.
  • Clients subscribe to resource URIs using the subscribe capability
  • The server tracks subscriptions and notifies only interested clients

Example: Subscribing to Module Status Updates

A client wants to stay updated on a specific space station module:
{
	"jsonrpc": "2.0",
	"id": 1,
	"method": "resources/subscribe",
	"params": { "uri": "spacestation://modules/habitat-alpha" }
}

When the module's status changes (e.g., a new experiment starts, or the module is undocked), the server sends:

{
	"jsonrpc": "2.0",
	"method": "notifications/resources/updated",
	"params": {
		"uri": "spacestation://modules/habitat-alpha",
		"title": "Habitat Alpha Module"
	}
}

Subscriptions are for updates to the content of a specific resource URI, not for changes in the set of available resources. Use list_changed for the latter.

 

Here's an example:

import {
	SubscribeRequestSchema,
	UnsubscribeRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'

const petSubscriptions = new Set<string>()

// NOTE: the server.server is how the MCP SDK exposes the underlying server
// instance for more advanced APIs like this one.

// Allow clients to subscribe to updates for a specific pet
server.server.setRequestHandler(SubscribeRequestSchema, async ({ params }) => {
	petSubscriptions.add(params.uri)
	return {} // no specific response data is needed
})

// Allow clients to unsubscribe from updates
server.server.setRequestHandler(
	UnsubscribeRequestSchema,
	async ({ params }) => {
		petSubscriptions.delete(params.uri)
		return {} // no specific response data is needed
	},
)

// When a pet's status changes, notify subscribed clients
function onPetStatusChange(petId: string, newStatus: string) {
	const uri = `pethotel://pets/${petId}`
	if (petSubscriptions.has(uri)) {
		server.server.notification({
			method: 'notifications/resources/updated',
			params: { uri, title: `Pet ${petId} status updated to ${newStatus}` },
		})
	}
}

 

Code:

查看代码
export async function initializeSubscriptions(agent: EpicMeMCP) {
	agent.server.server.setRequestHandler(
		SubscribeRequestSchema,
		async ({ params }) => {
			uriSubscriptions.add(params.uri)
			return {}
		},
	)

	agent.server.server.setRequestHandler(
		UnsubscribeRequestSchema,
		async ({ params }) => {
			uriSubscriptions.delete(params.uri)
			return {}
		},
	)

	agent.db.subscribe(async (changes) => {
		for (const entryId of changes.entries ?? []) {
			const uri = `epicme://entries/${entryId}`
			if (uriSubscriptions.has(uri)) {
				await agent.server.server.notification({
					method: 'notifications/resources/updated',
					params: { uri, title: `Entry ${entryId}` },
				})
			}
		}

		for (const tagId of changes.tags ?? []) {
			const uri = `epicme://tags/${tagId}`
			if (uriSubscriptions.has(uri)) {
				await agent.server.server.notification({
					method: 'notifications/resources/updated',
					params: { uri, title: `Tag ${tagId}` },
				})
			}
		}
	})

	subscribeToVideoChanges(async () => {
		const videos = await listVideos()
		for (const video of videos) {
			const uri = `epicme://videos/${video}`
			if (uriSubscriptions.has(uri)) {
				await agent.server.server.notification({
					method: 'notifications/resources/updated',
					params: { uri, title: `Video ${video}` },
				})
			}
		}
	})
}

posted @ 2026-01-27 14:48  Zhentiw  阅读(2)  评论(0)    收藏  举报