Class: Portage::Ucp::Mcp::Server

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

Overview

Builds an MCP::Server (from the mcp gem) whose tools are generated from the CapabilityRegistry's advertised actions for a given adapter, so tools/list and tools/call stay in sync with the Dispatcher/registry instead of being hand-duplicated per capability.

Defined Under Namespace

Classes: Context

Constant Summary collapse

TRACEPARENT_FORMAT =

Per-request correlation only (§23): Context above is built once per process in .build, and mcp 0.25.0's Streamable HTTP transport is explicitly stateful/multi-session, so memoizing an id there would stamp every session in the process with the same value. Prefers the inbound W3C traceparent the MCP spec passes through _meta untouched (SEP-414, see MCP::TraceContext) so a caller that already traces its own calls gets one trace across both sides; generates a fallback only when absent.

traceparent is unauthenticated input — reachable before authorize/rate_limit run, same as the pre-auth event this correlation id feeds. Validated against W3C Trace Context's own format before use, which is spec-correct behavior (a malformed traceparent MUST be treated as absent, restarting the trace), and incidentally closes off unbounded-length log writes and non-String values reaching Dispatcher/CheckoutState as a "correlation_id".

/\A[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}\z/

Class Method Summary collapse

Class Method Details

.agent_profile_for(server_context) ⇒ Object

Same _meta path as traceparent above, but for the caller-supplied ucp-agent.profile hint — no format validation, since (unlike traceparent) nothing here parses or trusts its shape, it's just threaded through for observability/policy consumers to interpret.



110
111
112
113
# File 'lib/portage/ucp/mcp/server.rb', line 110

def self.agent_profile_for(server_context)
  meta = server_context[:_meta] if server_context.respond_to?(:[])
  meta && (meta["ucp-agent.profile"] || meta[:"ucp-agent.profile"])
end

.authorize(authenticator, server_context, mutating:) ⇒ Object



115
116
117
118
119
120
121
122
# File 'lib/portage/ucp/mcp/server.rb', line 115

def self.authorize(authenticator, server_context, mutating:)
  return unless mutating

  authenticator.call(server_context)
  nil
rescue Portage::Ucp::AuthenticationError => e
  ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
end

.build(adapter:, registry: Portage::Ucp.configuration.registry, authenticator: Portage::Ucp.configuration.authenticator, rate_limiter: Portage::Ucp.configuration.rate_limiter, logger: Portage::Ucp.configuration.logger, journal: nil, **server_opts) ⇒ Object

Parameters:

  • journal (#record_checkout, nil) (defaults to: nil) —

    forwarded straight to Dispatcher.new (see its own doc comment) — nil by default and never required from core (§2), same as Dispatcher itself. Named here explicitly, rather than left to fall through **server_opts, so a consumer wiring a real journal through Client.for_adapter/Loopback (both already forward **server_opts here) has a documented seam to do it at (§37).



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/portage/ucp/mcp/server.rb', line 23

def self.build(adapter:, registry: Portage::Ucp.configuration.registry,
               authenticator: Portage::Ucp.configuration.authenticator,
               rate_limiter: Portage::Ucp.configuration.rate_limiter,
               logger: Portage::Ucp.configuration.logger, journal: nil, **server_opts)
  context = Context.new(
    dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger,
                                             journal: journal),
    authenticator: authenticator, rate_limiter: rate_limiter, logger: logger
  )
  tools = registry.advertised(adapter).flat_map do |capability|
    capability.actions.map do |action_name, method_name|
      build_tool(adapter: adapter, capability: capability, action_name: action_name,
                 method_name: method_name, context: context)
    end
  end

  ::MCP::Server.new(name: "portage-ucp", tools: tools, **server_opts)
end

.build_tool(adapter:, capability:, action_name:, method_name:, context:) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/portage/ucp/mcp/server.rb', line 42

def self.build_tool(adapter:, capability:, action_name:, method_name:, context:)
  keyword_params = adapter.method(method_name).parameters.select { |type, _| i[key keyreq].include?(type) }
  required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
  properties = keyword_params.to_h { |_, name| [name.to_s, {}] }
  mutating = keyword_params.any? { |_, name| name == :idempotency_key }

  ::MCP::Tool.define(
    name: action_name,
    description: "#{capability.name}##{action_name}",
    input_schema: { type: "object", properties: properties, required: required }
  ) do |**kwargs|
    Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
                                        mutating: mutating, kwargs: kwargs)
  end
end

.call_tool(context:, capability:, action_name:, mutating:, kwargs:) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/portage/ucp/mcp/server.rb', line 58

def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
  server_context = kwargs.delete(:server_context)
  correlation_id = correlation_id_for(server_context)
  agent_profile = agent_profile_for(server_context)
  log_tool_event(context.logger, "tool_call_received", correlation_id, agent_profile,
                 capability: capability.name, action: action_name)

  rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
              rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
  return rejection if rejection

  log_tool_event(context.logger, "tool_called", correlation_id, agent_profile,
                 capability: capability.name, action: action_name, arguments: kwargs)

  result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs,
                                   correlation_id: correlation_id, agent_profile: agent_profile)
  ::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
end

.correlation_id_for(server_context) ⇒ Object



100
101
102
103
104
# File 'lib/portage/ucp/mcp/server.rb', line 100

def self.correlation_id_for(server_context)
  meta = server_context[:_meta] if server_context.respond_to?(:[])
  traceparent = meta && (meta[:traceparent] || meta["traceparent"])
  traceparent.is_a?(String) && TRACEPARENT_FORMAT.match?(traceparent) ? traceparent : SecureRandom.uuid
end

.log_tool_event(logger, event, correlation_id, agent_profile, **fields) ⇒ Object



77
78
79
80
# File 'lib/portage/ucp/mcp/server.rb', line 77

def self.log_tool_event(logger, event, correlation_id, agent_profile, **fields)
  Portage::Ucp::Observability.log(logger, event, correlation_id: correlation_id, agent_profile: agent_profile,
                                                 **fields)
end

.rate_limit(rate_limiter, key, capability_name, mutating:) ⇒ Object

Parameters:

  • key (Object) —

    whatever the consumer's RateLimiter derives an identity from — the gem hands over the raw MCP server_context rather than inventing its own per-session/per-key extraction (§9).



127
128
129
130
131
132
133
134
# File 'lib/portage/ucp/mcp/server.rb', line 127

def self.rate_limit(rate_limiter, key, capability_name, mutating:)
  return unless mutating

  rate_limiter.check!(key, capability_name)
  nil
rescue Portage::Ucp::RateLimitExceededError => e
  ::MCP::Tool::Response.new([{ type: "text", text: e.message }], error: true)
end