Class: Portage::Ucp::CapabilityNegotiator

Inherits:
Object
  • Object
show all
Defined in:
lib/portage/ucp/capability_negotiator.rb

Overview

Reconciles UCP's capability-version negotiation with MCP's own transport-level capability negotiation (§10) — they answer different questions and both apply regardless of transport:

  • MCP initialize negotiates protocol features (tools/resources/prompts).
  • This negotiator picks, per capability, which version both sides speak: the platform's advertised versions intersected with the versions this server (its registry + adapter) actually advertises.

Over HTTP the platform's advertised versions come from the UCP-Agent header; over stdio there's no header, so they arrive in initialize params instead. Either way this class only deals in the parsed version list — transports are responsible for extracting it from their own request shape.

§23 step 3: §12 promises a capability_negotiated event here, but #negotiate has no call site anywhere in this gem outside its own spec — nothing in the request path (transport initialize handling, or otherwise) invokes it yet. Wiring the event would mean building that call site first, which is a bigger change than threading a logger through an existing collaborator; left undone, and cut from §12 rather than promised.

§25: the reason there's no call site is structural, not just unbuilt. mcp 0.25.0's MCP::Server#init (lib/mcp/server.rb:608) never reads params[:_meta] and never calls add_instrumentation_data, unlike call_tool — so nothing at initialize time reaches around_request/instrumentation_callback or the _meta mechanism Mcp::Server.correlation_id_for already relies on (§23/§24). And over HTTP, StreamableHTTPTransport#handle_initialization (lib/mcp/server/transports/streamable_http_transport.rb:812) builds a Rack::Request with full headers but only pulls HTTP_ORIGIN out of it before handing the raw JSON body string to ServerSession#handle_json — a UCP-Agent header never crosses into the server at all. Wiring #negotiate for real means monkeypatching both (subclass/prepend MCP::Server#init to capture clientInfo/_meta, and StreamableHTTPTransport#handle_initialization to capture the UCP-Agent header), coupling this gem to mcp internals that could silently break on a mcp gem upgrade. Revisiting this needs either an upstream mcp gem hook at initialize time, or a deliberate decision to accept the monkeypatch coupling — not something to build silently as a side effect of another task.

Instance Method Summary collapse

Constructor Details

#initialize(registry: CapabilityRegistry.default) ⇒ CapabilityNegotiator

Returns a new instance of CapabilityNegotiator.



47
48
49
# File 'lib/portage/ucp/capability_negotiator.rb', line 47

def initialize(registry: CapabilityRegistry.default)
  @registry = registry
end

Instance Method Details

#negotiate(adapter:, platform_versions: {}) ⇒ Hash<String, String>

Returns capability name => negotiated version. A capability with no version in common is omitted, not raised on — it simply isn't usable for this session.

Parameters:

  • adapter (Portage::Ucp::Adapter)
  • platform_versions (Hash<String, Array<String>>) (defaults to: {}) —

    capability name => versions the platform/agent advertises support for. A capability absent from this hash falls back to "offer all business-advertised versions, newest-first" (§10's stdio fallback, applied uniformly).

Returns:

  • (Hash<String, String>) —

    capability name => negotiated version. A capability with no version in common is omitted, not raised on — it simply isn't usable for this session.



59
60
61
62
63
64
65
66
67
# File 'lib/portage/ucp/capability_negotiator.rb', line 59

def negotiate(adapter:, platform_versions: {})
  @registry.advertised(adapter).each_with_object({}) do |capability, negotiated|
    business_versions = [capability.version]
    offered = platform_versions[capability.name]

    version = offered ? (offered & business_versions).first : business_versions.max
    negotiated[capability.name] = version if version
  end
end