Skip to content

feat(resources): Add resources/subscribe and resources/unsubscribe support #96

@getlarge

Description

@getlarge

Summary

The MCP specification includes resources/subscribe and resources/unsubscribe methods for clients to receive notifications when resources change. These are currently not implemented.

Additionally, resources/read uses exact URI matching only. When a resource is registered with a uriSchema for query parameters, reading resource://base?id=123 fails because the Map lookup is literal.

Current Behavior

// Client calls resources/subscribe
// Server returns: METHOD_NOT_FOUND (-32601)

For URI with query params:

app.mcpAddResource({
  uri: 'aip://findings',
  name: 'Findings',
  uriSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }
}, async (uri, params) => {
  // params.id should contain the query parameter value
  const finding = await db.getFinding(params.id);
  return { contents: [{ uri, text: JSON.stringify(finding), mimeType: 'application/json' }] };
});

// Reading 'aip://findings?id=abc123' fails - exact match only

Proposed Solution

1. Custom Subscription Handlers

Rather than building subscription tracking into the library, provide hooks for applications to implement their own storage:

// Application creates its own subscription store (memory, Redis, etc.)
const subscriptionStore = createSubscriptionStore();

app.mcpSetResourcesSubscribeHandler(async (params, context) => {
  await subscriptionStore.subscribe(context.sessionId, params.uri);
  return {};
});

app.mcpSetResourcesUnsubscribeHandler(async (params, context) => {
  await subscriptionStore.unsubscribe(context.sessionId, params.uri);
  return {};
});

// When resource changes, notify via existing mcpSendToSession
const subscribers = await subscriptionStore.getSubscribers(changedUri);
for (const sessionId of subscribers) {
  await app.mcpSendToSession(sessionId, {
    jsonrpc: '2.0',
    method: 'notifications/resources/updated',
    params: { uri: changedUri }
  });
}

2. Query Parameter URI Matching

When exact match fails in resources/read:

let resource = resources.get(uri);

// If not found and URI has query params, try base URI
if (!resource && uri.includes('?')) {
  const baseUri = uri.split('?')[0];
  const baseResource = resources.get(baseUri);
  // Only use if resource has uriSchema (expects query params)
  if (baseResource?.definition?.uriSchema) {
    resource = baseResource;
  }
}

3. Handler Types

interface ResourceHandlerContext {
  sessionId?: string;
  request: FastifyRequest;
  reply: FastifyReply;
  authContext?: AuthorizationContext;
}

type ResourceSubscribeHandler = (
  params: { uri: string },
  context: ResourceHandlerContext
) => Promise<Record<string, unknown>>;

type ResourceUnsubscribeHandler = (
  params: { uri: string },
  context: ResourceHandlerContext
) => Promise<Record<string, unknown>>;

Files to Modify

  • src/handlers.ts - Add subscription handlers, query param fallback
  • src/decorators/meta.ts - Add setter decorators
  • src/types.ts - Add handler types, Fastify declaration merging
  • src/index.ts - Create resourceHandlers object, pass through
  • src/routes/mcp.ts - Include resourceHandlers in dependencies

Design Decisions

  1. Custom handlers, not built-in tracking: Applications manage their own storage (memory, Redis, etc.). This follows the library's pattern of delegating application-specific logic.

  2. METHOD_NOT_FOUND when not configured: Clear signal that feature isn't enabled, rather than silent success.

  3. uriSchema as query param indicator: Only falls back to base URI if resource explicitly expects query params via uriSchema.

  4. Dependency injection: resourceHandlers object passed through plugin chain, avoiding module-level state.

Backwards Compatibility

  • Existing mcpAddResource registrations unchanged
  • Default resources/read behavior unchanged for exact matches
  • Subscribe/unsubscribe return errors (not silent success) when not configured
  • No changes to existing decorators or APIs

I'm happy to submit a PR with these changes. I have a working implementation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions