Skip to content

Conversation

@chr-hertel
Copy link
Member

@chr-hertel chr-hertel commented Oct 8, 2025

Scope of this PR was reduced to introducing a ClientAwareInterface + Trait.

On top this contains:

  • reducing visibility of ClientGateway::request to private
  • removing additional ClientGateway::createMessage
  • changing return of ClientGateway::sample to throw new ClientException in case of error or just the result
  • minor polishing around the sampling VOs

@chr-hertel chr-hertel added the Server Issues & PRs related to the Server component label Oct 8, 2025
@chr-hertel chr-hertel force-pushed the feat-sampling branch 2 times, most recently from 96696ab to affcec0 Compare October 8, 2025 01:19
@CodeWithKyrian
Copy link
Contributor

Yeah, I’ve been thinking about this quite a bit after we talked about it, and I don’t think the ClientAwareInterface path is the best long-term direction. It ends up limiting which handlers can use sampling, since it only works for handlers that are classes, meaning closures passed directly to addTool, addPrompt, or addResource can’t benefit from it.

Instead, I’m proposing we move toward a Sampler service - a small injectable abstraction that handles creating and awaiting sampling responses. It’s more flexible and composable, and doesn’t impose structural constraints on handlers.

Proposed design

final class Sampler
{
    public function __construct(
        private readonly SessionInterface $session,
        private readonly MessageFactory $messageFactory,
        private readonly LoggerInterface $logger = new NullLogger(),
    ) {}

    /**
     * Request an LLM completion from the client.
     * 
     * @param SamplingMessage[] $messages Messages to send to the LLM
     */
    public function createMessage(array $messages, int $maxTokens, ...): CreateSamplingMessageResult;

    /**
     * Convenience method to create a simple text completion request.
     */
    public function complete(
        string $prompt,
        int $maxTokens = 1000,
        ?ModelPreferences $preferences = null,
        ?string $systemPrompt = null,
    ): string;
}

The idea is that this Sampler can be automatically injected into any handler that type hints it whether the handler is a class method or a closure. It won’t appear in the generated JSON Schema, and will simply be resolved in ReferenceHandler::prepareArguments.

Example Usages

class ContentTools
{
    /**
     * Summarize any given text using an LLM.
     *
     * @param string  $text
     * @param Sampler $sampler Automatically injected
     */
    #[McpTool(name: 'summarize_text')]
    public function summarize(string $text, Sampler $sampler): string
    {
        $preferences = new ModelPreferences(
            hints: [new ModelHint('claude-3-sonnet')],
            speedPriority: 0.8,
            intelligencePriority: 0.6,
        );

        return $sampler->complete(
            prompt: "Please summarize the following text concisely:\n\n{$text}",
            maxTokens: 500,
            preferences: $preferences,
            systemPrompt: "You are a helpful summarization assistant.",
        );
    }
}
class CodeReviewTool
{
    #[McpTool(name: 'review_code')]
    public function reviewCode(string $code, string $language, Sampler $sampler): array
    {
        $messages = [
            new SamplingMessage(
                role: Role::User,
                content: new TextContent("Review this {$language} code for best practices and potential issues:"),
            ),
            new SamplingMessage(
                role: Role::User,
                content: TextContent::code($code, $language),
            ),
        ];

        $result = $sampler->createMessage(
            messages: $messages,
            maxTokens: 1000,
            systemPrompt: "You are an expert code reviewer.",
        );

        return [
            'review' => $result->content instanceof TextContent ? $result->content->text : '',
            'model_used' => $result->model,
            'stop_reason' => $result->stopReason,
        ];
    }
}

In this setup, the handler signature itself declares intent... if you need sampling, just type-hint Sampler. It’s a clean, explicit contract. I also think it fits nicely into the context injection model we see in the Python and TypeScript SDKs, where things like session or context are automatically injected into the handler scope.

Speaking of context, check out the Context implementation in my PHP MCP Server package. It already allows a single Context parameter to be type-hinted, giving handlers access to things like the session and the ServerRequest (for HTTP). Extending that idea here, a Context object could also hold the Sampler (and anything else we might want to expose later). I even think it scales better long-term especially once we start dealing with authentication or other per-session utilities.

As for the back-channel part, I’ve been experimenting with ideas like using Fibers or a callback passed to the handler, but that’s still cooking. The current transport model assumes a strict request → response flow, so introducing a request → response → request → response chain will need some rethinking around session state or message routing. I’ve got a rough idea forming, and I’ll share it soon once it stabilizes. 😄

@chr-hertel
Copy link
Member Author

I'm currently more thinking about the transport/session request/response flow. Yes, fair enough we can just even support multiple styles for having easy DX for user land prompts/tools/etc.

i think all three are rather a flavor and depend on that people like:

  1. hard dependency via constructor injection: if there's a container in place, that is well integrated with the lib - super handy and quite intuitive. container (or manual wiring) manages the lifecycle of the service using that Sampler or ClientGateway service.
  2. optional dependency injection via setter (+interface+trait for DX): we don't have to rely on user land setup and can inject on-demand based on the implementation
  3. method argument injection: same here, we don't to think of how users manage their services lifecycle and can react to the parameter profile of the method

i don't think those strategies are exclusive to each other and we can support them all :)
i went with ClientGateway over Sampler to have a more central service for both cases, referring to elicitations. but having a Sampler works pretty well for me as well.

@chr-hertel chr-hertel force-pushed the feat-sampling branch 4 times, most recently from db20f7b to 30b71ca Compare October 30, 2025 15:18
@chr-hertel chr-hertel marked this pull request as ready for review October 30, 2025 15:20
@chr-hertel chr-hertel changed the title [Server] Enable servers to send sampling messages to clients [Server] Introduce ClientAwareTrait and Interface Oct 30, 2025
@chr-hertel chr-hertel changed the title [Server] Introduce ClientAwareTrait and Interface [Server] Introduce ClientAwareInterface and Trait Oct 30, 2025
@chr-hertel chr-hertel force-pushed the feat-sampling branch 4 times, most recently from a61ba29 to 323eee9 Compare October 30, 2025 20:51
@chr-hertel chr-hertel merged commit fb3c1e6 into main Nov 1, 2025
12 checks passed
@chr-hertel chr-hertel deleted the feat-sampling branch November 1, 2025 08:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants