Description
Context
I've been working with the Python SDK and noticed that the type system has quite a bit of abstraction complexity compared to the underlying TypeScript schema. I'm curious about the design decisions here and wondering if there might be opportunities to simplify.
The current implementation is comprehensive and handles edge cases well, but I'm finding that simple operations require understanding multiple abstraction layers, which creates friction when reading and debugging code.
Example: Current complexity vs. original schema
Here's how a progress notification is currently constructed:
# server/session.py
async def send_progress_notification(
self,
progress_token: str | int,
progress: float,
total: float | None = None,
message: str | None = None,
related_request_id: str | None = None,
) -> None:
"""Send a progress notification."""
await self.send_notification(
types.ServerNotification(
types.ProgressNotification(
method="notifications/progress",
params=types.ProgressNotificationParams(
progressToken=progress_token,
progress=progress,
total=total,
message=message,
),
)
),
related_request_id,
)
This requires understanding several layers of abstractions in the type system. To send a progress notification, you're working through this chain:
# 1. Base parameter class
class NotificationParams(BaseModel):
class Meta(BaseModel):
model_config = ConfigDict(extra="allow")
meta: Meta | None = Field(alias="_meta", default=None)
# 2. Notification parameter type variables
NotificationParamsT = TypeVar(
"NotificationParamsT", bound=NotificationParams | dict[str, Any] | None
)
# 3. Generic notification base
class Notification(BaseModel, Generic[NotificationParamsT, MethodT]):
"""Base class for JSON-RPC notifications."""
method: MethodT
params: NotificationParamsT
model_config = ConfigDict(extra="allow")
# 4. Specific parameter class
class ProgressNotificationParams(NotificationParams):
"""Parameters for progress notifications."""
progressToken: ProgressToken
progress: float
total: float | None = None
message: str | None = None
model_config = ConfigDict(extra="allow")
# 5. Specific notification class
class ProgressNotification(
Notification[ProgressNotificationParams, Literal["notifications/progress"]]
):
"""An out-of-band notification used to inform the receiver of a progress update."""
method: Literal["notifications/progress"]
params: ProgressNotificationParams
# 6. Plus ServerNotification wrapper in actual usage
That's 5-6 levels of abstraction for what seems fairly simple. For example, the TypeScript schema give us:
export interface Notification {
method: string;
params?: {
_meta?: { [key: string]: unknown };
[key: string]: unknown;
};
}
export interface ProgressNotification extends Notification {
method: "notifications/progress";
params: {
progressToken: ProgressToken;
progress: number;
total?: number;
message?: string;
};
}
In general the Python version introduces several abstraction layers:
- Generic base classes with type variables (
Request[RequestParamsT, MethodT]
) - Nested parameter classes (
ProgressNotificationParams
) - Multiple wrapper types (
ServerNotification(ProgressNotification(...))
)
Questions
Am I missing some context about why these abstractions were chosen? Were there specific requirements that drove this design?
I'm wondering if we could explore simplifying the type system to more closely match the directness of the original TypeScript schema. This might:
- Reduce cognitive load for developers working with the SDK
- Make debugging easier (fewer abstraction layers to trace through)
- Reduce maintenance burden when the protocol evolves
- Make the codebase more approachable for new contributors
Potential approach
Happy to prototype a simplified version of a few message types (like PingRequest
and ProgressNotification
) to demonstrate what this might look like.
Thoughts?