Class: Portage::Ucp::Mcp::Server
- Inherits:
-
Object
- Object
- Portage::Ucp::Mcp::Server
- 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):
Contextabove 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 W3Ctraceparentthe MCP spec passes through_metauntouched (SEP-414, seeMCP::TraceContext) so a caller that already traces its own calls gets one trace across both sides; generates a fallback only when absent.traceparentis unauthenticated input — reachable beforeauthorize/rate_limitrun, 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
-
.agent_profile_for(server_context) ⇒ Object
Same
_metapath astraceparentabove, but for the caller-supplieducp-agent.profilehint — no format validation, since (unlike traceparent) nothing here parses or trusts its shape, it's just threaded through for observability/policy consumers to interpret. - .authorize(authenticator, server_context, mutating:) ⇒ Object
- .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
- .build_tool(adapter:, capability:, action_name:, method_name:, context:) ⇒ Object
- .call_tool(context:, capability:, action_name:, mutating:, kwargs:) ⇒ Object
- .correlation_id_for(server_context) ⇒ Object
- .log_tool_event(logger, event, correlation_id, agent_profile, **fields) ⇒ Object
- .rate_limit(rate_limiter, key, capability_name, mutating:) ⇒ Object
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) = server_context[:_meta] if server_context.respond_to?(:[]) && (["ucp-agent.profile"] || [:"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.(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. }], 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
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 = (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) = server_context[:_meta] if server_context.respond_to?(:[]) traceparent = && ([:traceparent] || ["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
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. }], error: true) end |