Skip to content

MCP Events: Zod schemas and type exports#1

Open
elijahr wants to merge 22 commits into
mainfrom
mcp-events
Open

MCP Events: Zod schemas and type exports#1
elijahr wants to merge 22 commits into
mainfrom
mcp-events

Conversation

@elijahr

@elijahr elijahr commented Apr 8, 2026

Copy link
Copy Markdown

Summary

  • All event protocol types as Zod schemas (EventParams, EventEmitNotification, Subscribe/Unsubscribe/List)
  • Wired into ServerNotification, ClientRequest, ServerResult, ServerCapabilities unions
  • Pagination support for events/list (PaginatedRequest/Result)
  • EventsCapability uses looseObject for extensibility
  • 52 tests with rejection coverage for all schemas

Context

TypeScript protocol types for MCP events SEP reference implementation.
Mirrors the python-sdk event types as Zod schemas.

Test plan

  • pnpm -r test

Defines all event protocol types as Zod schemas: EventParams, EventEmitNotification,
EventSubscribe/Unsubscribe/List request and result schemas, EventEffect,
EventTopicDescriptor, EventsCapability. Wired into ServerNotification,
ClientRequest, ServerResult, and ServerCapabilities unions. Includes
pagination support for events/list and 52 tests with rejection coverage.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive event system to the core package, adding Zod schemas for event effects, topics, capabilities, and various request/response/notification types such as subscribe, unsubscribe, list, and emit. It also includes corresponding TypeScript type exports and a new test suite. Feedback suggests enhancing validation for date-time strings using Zod's datetime validator and ensuring that subscription and unsubscription topic arrays are non-empty to prevent unintentional empty requests.

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2118 to +2123
timestamp: z.string().optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.string().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved type safety and validation, consider using a more specific schema for timestamp strings. Zod's datetime validator can ensure the strings are valid ISO 8601 datetimes with timezone offsets.

I suggest changing z.string().optional() to z.string().datetime({ offset: true }).optional() for both timestamp and expires_at fields.

Suggested change
timestamp: z.string().optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.string().optional(),
timestamp: z.string().datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.string().datetime({ offset: true }).optional(),

Comment thread packages/core/src/types/schemas.ts Outdated
* Parameters for events/subscribe request.
*/
export const EventSubscribeParamsSchema = z.object({
topics: z.array(z.string()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

A request to subscribe to an empty list of topics is a valid operation but might not be intentional. To enforce that at least one topic is provided for subscription, consider adding .nonempty() to the topics array schema. This would make the intent clearer and fail early for empty requests.

If you apply this change, please also update the tests in packages/core/test/event-schemas.test.ts to assert that an empty array is rejected.

Suggested change
topics: z.array(z.string()),
topics: z.array(z.string()).nonempty(),

Comment thread packages/core/src/types/schemas.ts Outdated
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.string().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved type safety and to ensure a valid date-time format, I recommend using Zod's datetime validator for the timestamp field. This ensures the value is a valid ISO 8601 string with a timezone offset.

Suggested change
timestamp: z.string().optional(),
timestamp: z.string().datetime({ offset: true }).optional(),

Comment thread packages/core/src/types/schemas.ts Outdated
* Parameters for events/unsubscribe request.
*/
export const EventUnsubscribeParamsSchema = z.object({
topics: z.array(z.string()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the subscription parameters, a request to unsubscribe from an empty list of topics is likely not a meaningful operation. To ensure the request is intentional and contains topics to unsubscribe from, I suggest adding .nonempty() to the topics array schema.

Suggested change
topics: z.array(z.string()),
topics: z.array(z.string()).nonempty(),

elijahr added 2 commits April 8, 2026 15:33
Enforces strict ISO 8601 with timezone offset for temporal fields
per protocol design decision.
@elijahr

elijahr commented Apr 8, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive event system to the core package, adding Zod schemas and TypeScript types for event subscriptions, emissions, and topic management. It also includes a new test suite to validate these schemas. The review feedback identifies naming inconsistencies in the new schemas, specifically recommending the use of camelCase instead of snake_case for properties such as eventId, correlationId, and requestedEffects to align with the rest of the SDK.

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2110 to +2124
/**
* Parameters for events/emit notification.
* Extends NotificationsParamsSchema to inherit _meta for related_request_id tracking.
*/
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The property names in EventParamsSchema use snake_case (event_id, correlation_id, requested_effects, expires_at), which is inconsistent with the camelCase naming convention used throughout the rest of the MCP TypeScript SDK (e.g., requestId, taskId, lastModified, websiteUrl). To maintain consistency and follow TypeScript/JavaScript idioms, these should be renamed to camelCase. The documentation comment should also be updated to use relatedRequestId.

Suggested change
/**
* Parameters for events/emit notification.
* Extends NotificationsParamsSchema to inherit _meta for related_request_id tracking.
*/
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
});
/**
* Parameters for events/emit notification.
* Extends NotificationsParamsSchema to inherit _meta for relatedRequestId tracking.
*/
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
eventId: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlationId: z.string().optional(),
requestedEffects: z.array(EventEffectSchema).optional(),
expiresAt: z.iso.datetime({ offset: true }).optional(),
});

Comment on lines +2159 to +2164
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Rename event_id to eventId to match the camelCase naming convention used in the rest of the project.

Suggested change
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});
export const RetainedEventSchema = z.object({
topic: z.string(),
eventId: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});

@elijahr

elijahr commented Apr 8, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new "Events" capability to the MCP SDK, including Zod schemas, TypeScript types, and comprehensive tests for event subscription, unsubscription, listing, and emission. Feedback focuses on aligning field naming with the camelCase convention used in the project, ensuring protocol compliance by using JSONValueSchema for payloads, and extending base request schemas to support standard metadata. Additionally, it was noted that manual changes to spec.types.ts should be avoided as it is an auto-generated file.

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2114 to +2124
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The field names in EventParamsSchema should follow the camelCase convention used throughout the MCP specification and this repository (e.g., eventId instead of event_id, correlationId instead of correlation_id). Additionally, the payload field should use JSONValueSchema to ensure protocol compliance by only allowing JSON-serializable data, consistent with other generic payload fields in the SDK.

Suggested change
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
});
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
eventId: z.string(),
payload: JSONValueSchema,
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlationId: z.string().optional(),
requestedEffects: z.array(EventEffectSchema).optional(),
expiresAt: z.iso.datetime({ offset: true }).optional(),
});

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2137 to +2139
export const EventSubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Request parameter schemas should extend BaseRequestParamsSchema to inherit the _meta field. This is important for supporting protocol-level features like progress notifications and task association, which are standard across MCP requests.

Suggested change
export const EventSubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});
export const EventSubscribeParamsSchema = BaseRequestParamsSchema.extend({
topics: z.array(z.string()),
});

Comment on lines +2159 to +2164
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Field names should be camelCase (eventId) and payload should use JSONValueSchema for consistency and strict protocol validation.

Suggested change
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});
export const RetainedEventSchema = z.object({
topic: z.string(),
eventId: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: JSONValueSchema,
});

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2186 to +2188
export const EventUnsubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Request parameter schemas should extend BaseRequestParamsSchema to inherit the _meta field.

Suggested change
export const EventUnsubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});
export const EventUnsubscribeParamsSchema = BaseRequestParamsSchema.extend({
topics: z.array(z.string()),
});

Comment on lines +597 to +614
export interface EventTopicDescriptor {
/**
* A pattern identifying the topic.
*/
pattern: string;
/**
* A human-readable description of the topic.
*/
description?: string;
/**
* Whether the server retains the last published message for this topic.
*/
retained?: boolean;
/**
* An optional JSON Schema describing the shape of messages on this topic.
*/
schema?: JSONObject;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The file spec.types.ts is automatically generated and contains a strict warning against manual edits. Manual changes will be overwritten during the next update cycle. These types should instead be defined in schemas.ts and inferred in types.ts until they are officially incorporated into the upstream specification and the generator is updated.

@elijahr

elijahr commented Apr 8, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new event system to the core package, enabling servers to publish events to clients. It adds comprehensive Zod schemas and TypeScript interfaces for event topics, subscriptions, unsubscriptions, and notifications. These new structures are integrated into the existing server capabilities and message unions, and a new test suite is provided to ensure schema validation and integration work as expected. I have no feedback to provide.

@elijahr

elijahr commented Apr 9, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol (MCP), enabling servers to push asynchronous notifications to clients via named topics. The changes encompass new documentation in docs/server.md, Zod schemas for protocol validation in packages/core, corresponding TypeScript interfaces, and a comprehensive test suite. The review feedback highlights opportunities to improve documentation regarding multi-segment wildcards and suggests refining several Zod schemas to ensure better type safety, correct field optionality, and alignment with the protocol's extensibility patterns.

Comment thread docs/server.md Outdated
### Protocol flow

1. Server declares `events` in its capabilities during initialization, listing available topics.
2. Client sends `events/subscribe` with an array of topic patterns. Patterns use `+` as a single-segment wildcard (MQTT-style), so `builds/+/status` matches `builds/frontend/status`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The documentation mentions the + wildcard for single-segment matching but omits the # wildcard for multi-segment matching, which is used extensively in the test suite (e.g., build/#). For clarity and completeness, both MQTT-style wildcards should be documented.

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2114 to +2124
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are three improvements for EventParamsSchema:

  1. Optionality: The documentation states that the payload is optional, but the schema marks it as required. It should be made optional to match the spec.
  2. Type Safety: Using JSONValueSchema instead of z.unknown() ensures that the payload is a valid JSON value, which is appropriate for a JSON-RPC protocol.
  3. Extensibility: The base NotificationSchema makes its parameters loose by default. Overriding it with a strict schema here limits future protocol extensibility. Adding .loose() maintains consistency with the base schema.
Suggested change
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: z.unknown(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
});
export const EventParamsSchema = NotificationsParamsSchema.extend({
topic: z.string(),
event_id: z.string(),
payload: JSONValueSchema.optional(),
timestamp: z.iso.datetime({ offset: true }).optional(),
retained: z.boolean().optional(),
source: z.string().optional(),
correlation_id: z.string().optional(),
requested_effects: z.array(EventEffectSchema).optional(),
expires_at: z.iso.datetime({ offset: true }).optional(),
}).loose();

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2137 to +2139
export const EventSubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The base RequestSchema makes its parameters loose by default to allow for protocol extensions. Overriding it with a strict schema here prevents future additions to the events/subscribe parameters. It is recommended to make this schema loose for consistency.

Suggested change
export const EventSubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});
export const EventSubscribeParamsSchema = z.object({
topics: z.array(z.string()),
}).loose();

Comment on lines +2159 to +2164
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to EventParamsSchema, the payload in RetainedEventSchema should be optional and use JSONValueSchema for better type safety and consistency with the protocol documentation.

Suggested change
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: z.unknown(),
});
export const RetainedEventSchema = z.object({
topic: z.string(),
event_id: z.string(),
timestamp: z.iso.datetime({ offset: true }).optional(),
payload: JSONValueSchema.optional(),
});

Comment thread packages/core/src/types/schemas.ts Outdated
Comment on lines +2186 to +2188
export const EventUnsubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with other request parameter schemas and to allow for future protocol extensions, EventUnsubscribeParamsSchema should be made loose.

Suggested change
export const EventUnsubscribeParamsSchema = z.object({
topics: z.array(z.string()),
});
export const EventUnsubscribeParamsSchema = z.object({
topics: z.array(z.string()),
}).loose();

@elijahr

elijahr commented Apr 9, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements an experimental Events API, allowing servers to push asynchronous notifications to clients through a topic-based subscription model. The changes encompass detailed documentation, Zod schemas for protocol messages, and updated TypeScript definitions. Reviewer feedback highlights opportunities to strengthen validation by enforcing non-empty topic lists in subscription requests, improving the clarity of test descriptions, and ensuring consistent test coverage across subscription and unsubscription schemas.

*/
export const EventSubscribeParamsSchema = z
.object({
topics: z.array(z.string())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The topics array can currently be empty. While technically valid, allowing subscription to an empty list of topics can lead to ambiguous behavior and might be better treated as a client error. Consider making it a non-empty array to be more explicit about the expected input.

topics: z.array(z.string()).nonempty()

This change should also be applied to EventUnsubscribeParamsSchema for consistency.

If you apply this change, please also update the tests in packages/core/test/event-schemas.test.ts to assert that an empty array is rejected.

Suggested change
topics: z.array(z.string())
topics: z.array(z.string()).nonempty()

expect(() => EventSubscribeParamsSchema.parse({})).toThrow();
});

it('should reject empty topics array', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The title of this test case is misleading. It says it should reject an empty topics array, but the test actually checks for the wrong type ('not-an-array'). An empty array [] would currently pass validation.

Please consider renaming this test to reflect what it's actually testing.

Suggested change
it('should reject empty topics array', () => {
it('should reject wrong type for topics array', () => {

Comment on lines +320 to +324
describe('EventUnsubscribeParamsSchema', () => {
it('should reject missing topics', () => {
expect(() => EventUnsubscribeParamsSchema.parse({})).toThrow();
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The tests for EventUnsubscribeParamsSchema are less comprehensive than for EventSubscribeParamsSchema. For consistency and better coverage, consider adding tests for invalid types, similar to what's done for EventSubscribeParamsSchema.

For example:

it('should reject wrong type for topics', () => {
    expect(() => EventUnsubscribeParamsSchema.parse({ topics: 'not-an-array' })).toThrow();
});

@elijahr

elijahr commented Apr 9, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

- ProvenanceEnvelope: client-side event provenance with XML rendering
- McpEventQueue: priority-aware event buffering with overflow eviction
- Client methods: subscribeEvents, unsubscribeEvents, listEvents, onEvent
- Capability check for events/* methods
- Tests for all new code
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, allowing servers to push asynchronous notifications to clients via named topics. The changes include new protocol schemas, client-side methods for subscription management, a priority-aware event queue, and provenance metadata for tracking event sources. Feedback focuses on improving the robustness of the McpEventQueue by handling edge cases where maxSize is zero and replacing flaky setTimeout calls in integration tests with promise-based synchronization.

Comment on lines +31 to +44
enqueue(envelope: ProvenanceEnvelope): boolean {
if (this.queue.length >= this.maxSize) {
const minIdx = this._findMinPriorityIndex();
const minPriority = this.priorityFn(this.queue[minIdx]);
const newPriority = this.priorityFn(envelope);
if (newPriority > minPriority) {
this.queue.splice(minIdx, 1);
} else {
return false;
}
}
this.queue.push(envelope);
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There's a potential crash in enqueue if maxSize is set to 0. In that scenario, _findMinPriorityIndex would be called on an empty queue, leading to an error when this.priorityFn(this.queue[0]) is executed because this.queue[0] would be undefined.

To prevent this, I suggest adding a guard to handle cases where maxSize is not a positive number.

On a related note, the current implementation of enqueue has a time complexity of O(N) when the queue is full, and drain is O(N log N). This is likely acceptable for the default queue size, but if this becomes a performance bottleneck, consider using a more efficient data structure like a binary heap.

Suggested change
enqueue(envelope: ProvenanceEnvelope): boolean {
if (this.queue.length >= this.maxSize) {
const minIdx = this._findMinPriorityIndex();
const minPriority = this.priorityFn(this.queue[minIdx]);
const newPriority = this.priorityFn(envelope);
if (newPriority > minPriority) {
this.queue.splice(minIdx, 1);
} else {
return false;
}
}
this.queue.push(envelope);
return true;
}
enqueue(envelope: ProvenanceEnvelope): boolean {
if (this.maxSize <= 0) {
return false;
}
if (this.queue.length >= this.maxSize) {
const minIdx = this._findMinPriorityIndex();
const minPriority = this.priorityFn(this.queue[minIdx]);
const newPriority = this.priorityFn(envelope);
if (newPriority > minPriority) {
this.queue.splice(minIdx, 1);
} else {
return false;
}
}
this.queue.push(envelope);
return true;
}

Comment on lines +71 to +93
it('onEvent() should receive events/emit notifications', async () => {
const received: EventParams[] = [];
client.onEvent((event) => {
received.push(event);
});

// Server emits an event notification
await server.notification({
method: 'events/emit',
params: {
topic: 'myapp/status',
payload: { status: 'ok' },
event_id: 'evt-1',
},
});

// Allow async propagation
await new Promise((r) => setTimeout(r, 50));

expect(received).toHaveLength(1);
expect(received[0].topic).toBe('myapp/status');
expect(received[0].payload).toEqual({ status: 'ok' });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a fixed setTimeout to wait for asynchronous operations can lead to flaky tests. If the event propagation takes longer than the specified 50ms for any reason, the test will fail. A more robust approach is to use a Promise that is resolved by the event handler, which ensures the test waits exactly as long as needed.

    it('onEvent() should receive events/emit notifications', async () => {
        const receivedPromise = new Promise<EventParams>(resolve => {
            client.onEvent(event => {
                resolve(event);
            });
        });

        // Server emits an event notification
        await server.notification({
            method: 'events/emit',
            params: {
                topic: 'myapp/status',
                payload: { status: 'ok' },
                event_id: 'evt-1',
            },
        });

        const receivedEvent = await receivedPromise;

        expect(receivedEvent.topic).toBe('myapp/status');
        expect(receivedEvent.payload).toEqual({ status: 'ok' });
    });

Add non-null assertions for array indexing under noUncheckedIndexedAccess.
All assertions are provably safe: queue bounds are checked before access.
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental "Events" API, allowing servers to push asynchronous notifications to clients on named topics. This includes new protocol schemas, client-side methods for subscribing, unsubscribing, listing events, and handling incoming event notifications. Additionally, a ProvenanceEnvelope for client-side event metadata and a McpEventQueue for priority-aware event handling have been added, along with comprehensive documentation and tests. Review feedback suggests refactoring the _findMinPriorityIndex method in McpEventQueue for improved readability and type safety, and enhancing the escapeXml function in ProvenanceEnvelope to also escape single quotes for better robustness.

Comment on lines +66 to +80
private _findMinPriorityIndex(): number {
let minIdx = 0;
// queue is non-empty when this is called (only called after length >= maxSize check)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let minPri = this.priorityFn(this.queue[0]!);
for (let i = 1; i < this.queue.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const pri = this.priorityFn(this.queue[i]!);
if (pri < minPri) {
minPri = pri;
minIdx = i;
}
}
return minIdx;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method can be refactored for better readability and type safety by removing the need for non-null assertions (!) and the temporary minPri variable. This makes the code cleaner and less prone to errors if the calling logic changes.

Suggested change
private _findMinPriorityIndex(): number {
let minIdx = 0;
// queue is non-empty when this is called (only called after length >= maxSize check)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let minPri = this.priorityFn(this.queue[0]!);
for (let i = 1; i < this.queue.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const pri = this.priorityFn(this.queue[i]!);
if (pri < minPri) {
minPri = pri;
minIdx = i;
}
}
return minIdx;
}
private _findMinPriorityIndex(): number {
if (this.isEmpty) {
// This path should be unreachable given the current call site in `enqueue`.
// Throwing an error makes it fail-fast if logic changes.
throw new Error('Cannot find minimum priority in an empty queue.');
}
let minIndex = 0;
for (let i = 1; i < this.queue.length; i++) {
if (this.priorityFn(this.queue[i]) < this.priorityFn(this.queue[minIndex])) {
minIndex = i;
}
}
return minIndex;
}

Comment on lines +43 to +49
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The escapeXml function doesn't handle single quotes ('). While the current XML generation uses double quotes for attributes, it's more robust to escape single quotes as &apos;. This prevents potential rendering issues or injection vulnerabilities if the XML is processed by other tools or if single quotes are used in attributes in the future.

Suggested change
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

…n provenance

- Rename src/shared/event-queue.ts -> eventQueue.ts (unicorn/filename-case)
- Rename test/shared/event-queue.test.ts -> eventQueue.test.ts
- Update all imports and barrel exports to reference new filename
- Replace String#replace() with String#replaceAll() in provenance.ts escapeXml (unicorn/prefer-string-replace-all)
- Fix simple-import-sort warnings in barrel exports via autofix
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, enabling servers to push asynchronous notifications to clients via named topics. The changes include protocol-level schema definitions, client-side methods for event subscription and management, and a priority-aware event queue implementation. My review identified a potential runtime error in the event queue constructor due to lack of input validation for maxSize, and a recommendation to improve the robustness of the XML escaping utility by including single-quote handling.

Comment thread packages/core/src/shared/eventQueue.ts Outdated
Comment on lines +22 to +23
this.maxSize = options?.maxSize ?? 1000;
this.priorityFn = options?.priorityFn ?? (() => 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The maxSize option is not validated. If a user provides maxSize: 0 or a negative number, the enqueue method can cause a runtime error. Specifically, if maxSize is 0, this.queue.length >= this.maxSize will be true for an empty queue, causing _findMinPriorityIndex to be called on an empty queue, which will then attempt to access this.queue[0], resulting in an error.

It's recommended to validate that maxSize is a positive number in the constructor. Consider also adding a test case to packages/core/test/shared/eventQueue.test.ts to verify that an invalid maxSize throws an error.

        this.maxSize = options?.maxSize ?? 1000;
        if (this.maxSize <= 0) {
            throw new Error('McpEventQueue maxSize must be a positive number.');
        }
        this.priorityFn = options?.priorityFn ?? (() => 0);

Comment thread packages/core/src/shared/provenance.ts Outdated
Comment on lines +44 to +48
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The escapeXml function is incomplete. It should also escape single quotes (') to be fully robust, especially if the generated XML is ever consumed by processors that might have issues with unescaped apostrophes in attribute values, or if this function is reused in a context where attributes might be single-quoted. The standard XML entity for a single quote is &apos;.

Consider adding a test case to packages/core/test/shared/provenance.test.ts to cover escaping of single quotes.

    return s
        .replaceAll('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll('\'', '&apos;');

@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol (MCP), enabling servers to push asynchronous notifications to clients. The changes include comprehensive documentation, new Zod schemas, and TypeScript types for event-related messages. Core additions include a priority-aware McpEventQueue and a ProvenanceEnvelope for tracking event metadata and rendering events as XML. The Client class has been updated with methods to subscribe, unsubscribe, list events, and register event handlers. A potential runtime error was identified in the XML rendering logic when processing undefined event payloads.

Comment thread packages/core/src/shared/provenance.ts Outdated
}

const payloadXml =
typeof this.event.payload === 'string' ? escapeXml(this.event.payload) : escapeXml(JSON.stringify(this.event.payload));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The JSON.stringify(this.event.payload) call can return undefined if this.event.payload is undefined. Since escapeXml expects a string, this will cause a runtime error (e.g., TypeError: Cannot read properties of undefined (reading 'replaceAll')). Given that payload is optional in the protocol schema, this is a likely scenario.

        const payloadXml =
            typeof this.event.payload === 'string' ? escapeXml(this.event.payload) : escapeXml(JSON.stringify(this.event.payload) ?? '');

Sort exports in cfWorker.ts (client and server packages) to satisfy
simple-import-sort/exports rule. Fix import order and formatting in
client.ts to satisfy simple-import-sort/imports and prettier checks.
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, enabling servers to push asynchronous notifications to clients via named topics. The changes include new protocol schemas for event subscription, unsubscription, and emission, as well as client-side utilities like a priority-aware event queue and provenance metadata for tracking event origins. Feedback focuses on improving the robustness of the XML rendering for events with missing payloads, preventing potential crashes in the event queue when initialized with a zero size, and addressing performance inefficiencies in the queue's draining logic.

Comment thread packages/core/src/shared/provenance.ts Outdated
Comment on lines +35 to +36
const payloadXml =
typeof this.event.payload === 'string' ? escapeXml(this.event.payload) : escapeXml(JSON.stringify(this.event.payload));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The toXml method will throw a TypeError if this.event.payload is undefined. This occurs because JSON.stringify(undefined) returns undefined, and escapeXml expects a string input. Since the EventParams schema defines payload as optional, events without a payload are valid and will cause this method to crash when rendered. You should handle the undefined case to ensure the method is robust.

Suggested change
const payloadXml =
typeof this.event.payload === 'string' ? escapeXml(this.event.payload) : escapeXml(JSON.stringify(this.event.payload));
const payload = this.event.payload;
const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload);
const payloadXml = payloadString !== undefined ? escapeXml(payloadString) : '';

Comment on lines +21 to +24
constructor(options?: McpEventQueueOptions) {
this.maxSize = options?.maxSize ?? 1000;
this.priorityFn = options?.priorityFn ?? (() => 0);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The McpEventQueue constructor does not validate the maxSize option. If a user provides maxSize: 0, the enqueue method will crash on the first call because the condition this.queue.length >= this.maxSize (0 >= 0) will be true, leading to _findMinPriorityIndex being called on an empty array. This results in this.queue[0] being undefined, which causes a crash when passed to priorityFn. Consider enforcing a minimum maxSize of 1 or handling the zero-size case explicitly in enqueue.

Suggested change
constructor(options?: McpEventQueueOptions) {
this.maxSize = options?.maxSize ?? 1000;
this.priorityFn = options?.priorityFn ?? (() => 0);
}
constructor(options?: McpEventQueueOptions) {
this.maxSize = Math.max(1, options?.maxSize ?? 1000);
this.priorityFn = options?.priorityFn ?? (() => 0);
}

Comment on lines +52 to +56
drain(count?: number): ProvenanceEnvelope[] {
this.queue.sort((a, b) => this.priorityFn(b) - this.priorityFn(a));
const n = count ?? this.queue.length;
return this.queue.splice(0, n);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The drain implementation sorts the entire queue on every call, which is $O(N \log N)$. If drain is called frequently with a small count (e.g., to process events one by one), this becomes inefficient ($O(N^2 \log N)$ total for draining the whole queue). Since enqueue is already $O(N)$ when the queue is full (due to finding the minimum priority index), you could maintain the array in a sorted state during enqueue to make drain an $O(1)$ or $O(count)$ operation, or use a binary heap for $O(\log N)$ performance on both operations.

Sort imports per simple-import-sort rule and reformat with Prettier.
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, enabling servers to push asynchronous notifications to clients via named topics. The changes include new protocol schemas for event subscription, unsubscription, and listing, as well as client-side support for handling these events. Additionally, a priority-aware McpEventQueue and a ProvenanceEnvelope for tracking event metadata and rendering XML for LLM context injection have been added. Feedback focuses on improving the robustness of the McpEventQueue against zero-sized configurations, enhancing XML escaping in the provenance utility, and replacing flaky timeouts in integration tests with promise-based synchronization.

Comment on lines +31 to +46
enqueue(envelope: ProvenanceEnvelope): boolean {
if (this.queue.length >= this.maxSize) {
const minIdx = this._findMinPriorityIndex();
// queue[minIdx] is always defined: queue is non-empty (length >= maxSize >= 1)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minPriority = this.priorityFn(this.queue[minIdx]!);
const newPriority = this.priorityFn(envelope);
if (newPriority > minPriority) {
this.queue.splice(minIdx, 1);
} else {
return false;
}
}
this.queue.push(envelope);
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There's a potential crash in the enqueue method if maxSize is initialized to 0. When maxSize is 0, the condition this.queue.length >= this.maxSize becomes 0 >= 0, which is true. This leads to _findMinPriorityIndex being called on an empty queue, causing a crash when it tries to access this.queue[0].

You can fix this by adding a guard at the beginning of the enqueue method to handle the maxSize: 0 case.

    enqueue(envelope: ProvenanceEnvelope): boolean {
        if (this.maxSize === 0) {
            return false;
        }
        if (this.queue.length >= this.maxSize) {
            const minIdx = this._findMinPriorityIndex();
            // queue[minIdx] is always defined: queue is non-empty (length >= maxSize >= 1)
            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
            const minPriority = this.priorityFn(this.queue[minIdx]!);
            const newPriority = this.priorityFn(envelope);
            if (newPriority > minPriority) {
                this.queue.splice(minIdx, 1);
            } else {
                return false;
            }
        }
        this.queue.push(envelope);
        return true;
    }

Comment on lines +42 to +44
function escapeXml(s: string): string {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The escapeXml function doesn't escape single quotes ('). While not strictly necessary when attribute values are double-quoted, it's a good practice to escape them to &apos; for more robust XML generation, especially if the generated XML might be consumed by systems that are stricter about this.

Suggested change
function escapeXml(s: string): string {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
function escapeXml(s: string): string {
return s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", "&apos;");
}

Comment on lines +64 to +86
it('onEvent() should receive events/emit notifications', async () => {
const received: EventParams[] = [];
client.onEvent(event => {
received.push(event);
});

// Server emits an event notification
await server.notification({
method: 'events/emit',
params: {
topic: 'myapp/status',
payload: { status: 'ok' },
event_id: 'evt-1'
}
});

// Allow async propagation
await new Promise(r => setTimeout(r, 50));

expect(received).toHaveLength(1);
expect(received[0].topic).toBe('myapp/status');
expect(received[0].payload).toEqual({ status: 'ok' });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test uses setTimeout to wait for the event notification to be processed. This can lead to flaky tests if the propagation takes longer than the timeout. A more robust approach is to use a promise that resolves when the event handler is called.

    it('onEvent() should receive events/emit notifications', async () => {
        const receivedPromise = new Promise<EventParams>(resolve => {
            client.onEvent(event => {
                resolve(event);
            });
        });

        // Server emits an event notification
        await server.notification({
            method: 'events/emit',
            params: {
                topic: 'myapp/status',
                payload: { status: 'ok' },
                event_id: 'evt-1'
            }
        });

        const received = await receivedPromise;

        expect(received.topic).toBe('myapp/status');
        expect(received.payload).toEqual({ status: 'ok' });
    });

@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, enabling servers to push asynchronous notifications to clients via named topics. The implementation includes new client methods for event subscription and management, Zod schemas for protocol validation, and utility classes for priority-aware event queuing and provenance tracking. Feedback was provided regarding the performance of the McpEventQueue.drain method, which currently sorts the entire queue on every call regardless of the requested count.

Comment on lines +55 to +59
*/
drain(count?: number): ProvenanceEnvelope[] {
this.queue.sort((a, b) => this.priorityFn(b) - this.priorityFn(a));
const n = count ?? this.queue.length;
return this.queue.splice(0, n);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The drain method sorts the entire queue on every call, which is inefficient if count is provided and much smaller than the queue size. For a queue of up to 1000 items, this results in $O(N \log N)$ complexity per drain. If drain(count) is called repeatedly in a loop to process events, it could lead to $O(N^2 \log N)$ total time. Consider using a more efficient data structure like a binary heap for the priority queue, or at least skip sorting if the priorityFn is the default no-op function.

Per the MCP Events spec, clients that support events MUST include
"events": { "supported": true } in their initialize capabilities.

- Add events field to ClientCapabilitiesSchema in schemas.ts
- Add events field to ClientCapabilities interface in spec.types.ts
- Set events: { supported: true } as a default in the Client constructor,
  since event methods (subscribeEvents, unsubscribeEvents, listEvents,
  onEvent) are unconditionally available on every Client instance
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, enabling servers to push asynchronous notifications to clients via named topics. The changes include comprehensive documentation, new Zod schemas for protocol messages, and client-side methods for managing subscriptions and event handlers. Additionally, the PR adds utility classes for tracking event provenance and a priority-aware event queue. Review feedback suggests enhancing the robustness of the client-side event listener with runtime validation and optimizing the event queue's performance by using a more efficient data structure like a min-heap.

Comment on lines +1107 to +1109
this.setNotificationHandler('events/emit', notification => {
handler(notification.params as EventParams);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onEvent handler uses a type cast as EventParams for incoming notification parameters. This is unsafe as it doesn't perform runtime validation on data received from the server. A malformed events/emit notification could lead to runtime errors in the handler.

To improve robustness, I recommend validating the notification parameters against the EventParamsSchema before passing them to the user-provided handler. You can use EventParamsSchema.safeParse() for this.

Suggested change
this.setNotificationHandler('events/emit', notification => {
handler(notification.params as EventParams);
});
this.setNotificationHandler('events/emit', notification => {
const parsed = EventParamsSchema.safeParse(notification.params);
if (parsed.success) {
handler(parsed.data);
} else {
this.log('error', 'Received invalid events/emit notification from server', { error: parsed.error });
}
});

* Named McpEventQueue (not EventQueue) to avoid collision with opencode's
* internal EventQueue namespace.
*/
export class McpEventQueue {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of McpEventQueue uses a plain array, which leads to suboptimal performance for a priority queue. Specifically:

  • enqueue() is O(N) when the queue is full due to _findMinPriorityIndex() iterating the entire array.
  • drain() is O(N log N) due to this.queue.sort().

For use cases with a high volume of events or large queue sizes, this could become a performance bottleneck. Consider using a more efficient data structure like a min-heap, which would offer O(log N) for enqueue and O(k log N) for draining k items. There are several well-tested third-party libraries available for this (e.g., tinyqueue or js-priority-queue).

Update 4 getClientCapabilities() assertions in server.test.ts to include
`events: { supported: true }` now that clients declare event support by default.
@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, allowing servers to broadcast asynchronous notifications to clients. Key changes include new Zod schemas, documentation, and core utilities like a priority-aware event queue and provenance tracking. Feedback suggests adding schema validation to the client's event handler to prevent runtime errors and optimizing the event queue's sorting and eviction logic to improve performance under high throughput.

Comment on lines +1107 to +1109
this.setNotificationHandler('events/emit', notification => {
handler(notification.params as EventParams);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onEvent convenience method registers a handler for events/emit notifications but does not perform any schema validation on the incoming parameters. Since notification.params is typed as optional and loose in the base protocol, it could be undefined or malformed at runtime, potentially causing crashes in user-provided handlers that expect a valid EventParams object. It is recommended to validate the parameters against EventParamsSchema before invoking the handler.

    onEvent(handler: (event: EventParams) => void): void {
        this.setNotificationHandler('events/emit', notification => {
            const result = parseSchema(EventParamsSchema, notification.params);
            if (result.success) {
                handler(result.data);
            }
        });
    }

* Drain up to `count` events, highest priority first.
* Equal-priority events drain in FIFO order (ES2019+ stable sort).
*/
drain(count?: number): ProvenanceEnvelope[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The drain method sorts the entire queue every time it is called. With a default maxSize of 1000, this results in $O(N \log N)$ complexity per call. If drain is called frequently with a small count (e.g., to process events one by one), this becomes a significant performance bottleneck ($O(N^2 \log N)$ to empty the queue). Additionally, if the default priorityFn is used (which returns 0 for all items), the sort is entirely unnecessary as the queue already maintains FIFO order via push. Consider optimizing for the default case or using a more efficient data structure like a heap.

Comment on lines +36 to +47
if (this.queue.length >= this.maxSize) {
const minIdx = this._findMinPriorityIndex();
// queue[minIdx] is always defined: queue is non-empty (length >= maxSize >= 1)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const minPriority = this.priorityFn(this.queue[minIdx]!);
const newPriority = this.priorityFn(envelope);
if (newPriority > minPriority) {
this.queue.splice(minIdx, 1);
} else {
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The enqueue method performs an $O(N)$ search via _findMinPriorityIndex whenever the queue is full. While acceptable for small buffers, this makes enqueuing into a full queue inefficient. If this class is intended for high-throughput event processing, consider maintaining the queue in a sorted state or using a min-heap to allow $O(\log N)$ eviction and insertion.

@elijahr

elijahr commented Apr 10, 2026

Copy link
Copy Markdown
Author

@gemini-code-assist please re-review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental Events API to the Model Context Protocol, enabling servers to push asynchronous notifications to clients. Key changes include the addition of protocol schemas for event subscription and emission, a priority-aware McpEventQueue for managing event flow, and a ProvenanceEnvelope class for tracking event metadata and rendering events as XML. The Client class has been updated with methods to subscribe to topics and register event handlers. Feedback focuses on enhancing runtime validation for incoming event notifications, optimizing the performance of the event queue, and ensuring that event identifiers are correctly captured in the XML representation.

ElicitRequestSchema,
ElicitResultSchema,
EmptyResultSchema,
EventListResultSchema,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The EventParamsSchema is missing from the imports, but it is required if you intend to perform runtime validation of incoming event notifications in the onEvent method.

Suggested change
EventListResultSchema,
EventListResultSchema,
EventParamsSchema,

Comment on lines +1106 to +1110
onEvent(handler: (event: EventParams) => void): void {
this.setNotificationHandler('events/emit', notification => {
handler(notification.params as EventParams);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onEvent method performs an unsafe type cast of notification.params to EventParams. Since notification.params is optional in the base JSON-RPC notification structure and could be malformed or missing required fields (like topic or event_id), this cast may lead to runtime errors in the user-provided handler. Consistent with the patterns used in setRequestHandler for elicitation and sampling, you should validate the parameters against EventParamsSchema before invoking the handler.

    onEvent(handler: (event: EventParams) => void): void {
        this.setNotificationHandler('events/emit', notification => {
            const result = parseSchema(EventParamsSchema, notification.params);
            if (result.success) {
                handler(result.data);
            }
        });
    }

Comment on lines +56 to +60
drain(count?: number): ProvenanceEnvelope[] {
this.queue.sort((a, b) => this.priorityFn(b) - this.priorityFn(a));
const n = count ?? this.queue.length;
return this.queue.splice(0, n);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The drain method performs a full sort of the queue on every invocation, resulting in $O(N \log N)$ complexity. For a class explicitly named McpEventQueue and intended for priority management, this is inefficient, particularly if drain is called frequently with a small count. Additionally, the enqueue method performs a linear search ($O(N)$) to find the minimum priority item when the queue is full. Consider using a binary heap or maintaining a sorted array to achieve $O(\log N)$ complexity for insertions and extractions.

Comment thread packages/core/src/shared/provenance.ts Outdated
Comment on lines +31 to +33
if (this.provenance.original_event_id) {
attrs.push(`event_id="${escapeXml(this.provenance.original_event_id)}"`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The toXml method only includes an event_id attribute if provenance.original_event_id is explicitly set, ignoring the event_id property that is part of the MCP event itself. Since the protocol schemas (like EventParamsSchema) require an event_id, this implementation may result in the loss of the event's unique identifier in the XML representation used for LLM context. It is recommended to use this.event.event_id as the primary source or a fallback for the XML attribute.

Suggested change
if (this.provenance.original_event_id) {
attrs.push(`event_id="${escapeXml(this.provenance.original_event_id)}"`);
}
const eventId = this.provenance.original_event_id ?? this.event.event_id;
if (eventId) {
attrs.push(`event_id="${escapeXml(eventId)}"`);
}

elijahr added 4 commits April 11, 2026 10:07
Update ProvenanceData interface: server_id->server, server_trust->trust,
add source/correlation_id. Add priority, source, correlation_id to
toXml() output. Update tests to match new attribute structure.
Update the event schemas, type exports, and provenance envelope to match
the MCP Events Spec v2:

- EventParamsSchema: rename wire fields to camelCase (`eventId`,
  `expiresAt`), add top-level `priority`, and drop `correlationId`,
  `requestedEffects`, and `timestamp`.
- EventTopicDescriptorSchema: add required `kind` (content/signal) and
  optional `suggestedHandle`. Reuse `JSONObjectSchema` for the JSON
  Schema payload shape.
- Introduce EventPriority, EventKind, and EventHandle enums. Remove
  EventEffect entirely (no longer part of the wire format).
- RetainedEventSchema: rename `event_id` to camelCase `eventId`, drop
  the unused `timestamp` field, and add optional `retained`.
- ProvenanceEnvelope: remove `correlationId` from both the client-side
  metadata and XML output. Fix attribute emission order to the spec-
  mandated sequence (server, topic, priority, event_id, trust, source).
  Always emit `trust` from the client-supplied provenance.
- Update event-schemas, provenance, eventQueue, and integration tests
  to cover the new shapes and strict ordering. Fix a pre-existing test
  typing issue in eventQueue.test.ts (`server_id`/`server_trust` ->
  `server`/`trust`).
- Extend the vendored spec.types.ts with the v2 EventTopicDescriptor
  additions so the SDK↔spec mutual-assignability invariant still holds
  until the upstream schema is updated.
- Refresh docs/server.md to describe priority, kind, suggestedHandle,
  and the `{agent_id}` topic pattern placeholder.
Rewrite workspace-name imports to relative paths inside packages/*/src/**
and add root exports field so consumers can pin github:axiomantic/typescript-sdk
without needing pnpm workspace resolution. Align Zod version with opencode.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant