riffer 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/architecture.md +36 -2
  3. data/.release-please-manifest.json +1 -1
  4. data/CHANGELOG.md +19 -0
  5. data/README.md +1 -0
  6. data/docs/03_AGENTS.md +4 -0
  7. data/docs/10_CONFIGURATION.md +24 -5
  8. data/docs/13_SKILLS.md +1 -1
  9. data/docs/14_MCP.md +144 -0
  10. data/lib/riffer/agent.rb +81 -2
  11. data/lib/riffer/config.rb +13 -0
  12. data/lib/riffer/mcp/authenticated_tool.rb +65 -0
  13. data/lib/riffer/mcp/client.rb +67 -0
  14. data/lib/riffer/mcp/manifest.rb +45 -0
  15. data/lib/riffer/mcp/registration.rb +76 -0
  16. data/lib/riffer/mcp/registry.rb +62 -0
  17. data/lib/riffer/mcp/tool_factory.rb +54 -0
  18. data/lib/riffer/mcp.rb +57 -0
  19. data/lib/riffer/providers/amazon_bedrock.rb +18 -0
  20. data/lib/riffer/providers/anthropic.rb +43 -38
  21. data/lib/riffer/providers/base.rb +7 -3
  22. data/lib/riffer/providers/mock.rb +15 -0
  23. data/lib/riffer/providers/open_ai.rb +33 -26
  24. data/lib/riffer/version.rb +1 -1
  25. data/sig/generated/riffer/agent.rbs +33 -0
  26. data/sig/generated/riffer/config.rbs +20 -0
  27. data/sig/generated/riffer/mcp/authenticated_tool.rbs +17 -0
  28. data/sig/generated/riffer/mcp/client.rbs +33 -0
  29. data/sig/generated/riffer/mcp/manifest.rbs +30 -0
  30. data/sig/generated/riffer/mcp/registration.rbs +49 -0
  31. data/sig/generated/riffer/mcp/registry.rbs +35 -0
  32. data/sig/generated/riffer/mcp/tool_factory.rbs +25 -0
  33. data/sig/generated/riffer/mcp.rbs +51 -0
  34. data/sig/generated/riffer/providers/amazon_bedrock.rbs +15 -0
  35. data/sig/generated/riffer/providers/anthropic.rbs +4 -4
  36. data/sig/generated/riffer/providers/base.rbs +7 -3
  37. data/sig/generated/riffer/providers/mock.rbs +12 -0
  38. metadata +44 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55bd24093ac1af4a33163b95afc320f2f3326bf6105b8e55253e78bd0763551d
4
- data.tar.gz: dc70e95bb46d2a7b51840299a2da6a4f79a27421791a06d18255c655afcd4cfe
3
+ metadata.gz: 7701c51a8dff2c1da03718cb852c86296ab738839163b86ddf46b5e4e78600da
4
+ data.tar.gz: f99baf1c378b7a8a8ce2ec8dad231cd01e70d25cee4a277c2d724c0377dfc56d
5
5
  SHA512:
6
- metadata.gz: 992ab0a333cedb5107bc90fcf00f0d48151ea4cb09ceadb455dd80c95d06fd00ba6e005405b844f2857619f3a549dd0c8ff82939ea764db0b280fa22643f8511
7
- data.tar.gz: 2de5d6c8add2a20442cf38d6d216384379fa270f115bc92cf81fa0ded554573de5a513694845615365fdb041308a8c2d830ab47044fbddf30e741c42107681c1
6
+ metadata.gz: 423051f85e8fb037526b5aa6500b57b86fa4106a8a701e673df868c216661245a31702d9fb1fb3602a249d0f57dbe074a439e4f5f7a121d6d0c1c945d771f42f
7
+ data.tar.gz: a52e8baf66cebd4dc522dc1d836325f35442272d3fb02146c153156cd1232f08ada5cc3dc968033badd65df599fd453fa882d479afecddac8512e3c7d5e12bad
@@ -40,7 +40,7 @@ Adapters for LLM APIs. The base class uses a template-method pattern — `genera
40
40
 
41
41
  Providers are registered in `Riffer::Providers::Repository::REPO` with identifiers (e.g., `openai`, `amazon_bedrock`).
42
42
 
43
- Each provider declares a preferred skill adapter via `self.skills_adapter` (Markdown for most, XML for Anthropic).
43
+ Each provider declares a preferred skill adapter via `self.skills_adapter(model = nil)`. Default is Markdown; Anthropic returns XML; Amazon Bedrock returns XML when the model identifier matches an Anthropic model (e.g. `anthropic.claude-…` or `us.anthropic.claude-…`); Mock returns XML when the model name contains `claude`. The agent passes the resolved model identifier so proxy providers (Bedrock, Mock) can pick the right adapter without per-agent overrides.
44
44
 
45
45
  ### Skills (`lib/riffer/skills/`)
46
46
 
@@ -132,9 +132,35 @@ Built-in runtimes:
132
132
 
133
133
  Context flow: `Agent#execute_tool_calls` → `ToolRuntime#execute(tool_calls, tools:, context:)` → `Runner#map(tool_calls, context:) { dispatch }` → `Tool#call(context:, **args)`
134
134
 
135
+ ### MCP Integration (`lib/riffer/mcp/`)
136
+
137
+ Register third-party MCP servers globally; agents opt-in by tag via `use_mcp`. Tags are application-defined (manifests may list several; any overlap with `use_mcp` opts in—see `docs/14_MCP.md`).
138
+
139
+ ```ruby
140
+ Riffer::Mcp.register(
141
+ name: "github",
142
+ tags: [:github],
143
+ endpoint: "https://mcp.github.com",
144
+ discovery_headers: -> { {"Authorization" => "Bearer #{ENV['GITHUB_TOKEN']}"} }
145
+ )
146
+
147
+ # Optional: per-run tools/call headers (see docs/14_MCP.md)
148
+ Riffer.configure { |c| c.mcp.credentials = ->(manifest:, matched_tags:, context:) { ... } }
149
+
150
+ class ResearchAgent < Riffer::Agent
151
+ model "openai/gpt-5-mini"
152
+ use_mcp :github # picks up any :github-tagged registration
153
+ use_mcp :search, on_pending: :wait # per-call override
154
+ end
155
+ ```
156
+
157
+ Key types: `Manifest` (`discovery_headers`, optional `credentials_scope` hint), `Registry` (thread-safe store), `Registration` (spawns discovery thread → `ToolFactory`), `Client` (wraps `mcp` gem), `AuthenticatedTool` (wraps MCP tools when `credentials` proc is set).
158
+
159
+ **on_pending strategies** (global default `:ignore`): `:ignore` skips the server; `:wait` blocks until ready, re-raises failed discovery immediately, or times out; `:raise` re-raises failed discovery or `NotReadyError` while still pending.
160
+
135
161
  ## Key Patterns
136
162
 
137
- - Model config accepts a `provider/model` string (e.g., `openai/gpt-4`) or a Proc/lambda that returns one
163
+ - Model config accepts a `provider/model` string (e.g., `openai/gpt-5-mini`) or a Proc/lambda that returns one
138
164
  - Configuration via `Riffer.configure { |c| c.openai.api_key = "..." }`
139
165
  - Providers use `depends_on` helper for runtime dependency checking
140
166
  - Zeitwerk for autoloading - file structure must match module/class names
@@ -199,6 +225,14 @@ lib/
199
225
  reasoning_done.rb # Reasoning done event
200
226
  web_search_status.rb # Web search status event
201
227
  web_search_done.rb # Web search done event
228
+ mcp.rb # MCP public API + error classes
229
+ mcp/
230
+ manifest.rb # Server config value object
231
+ registry.rb # Thread-safe global store
232
+ registration.rb # Per-server state + discovery thread
233
+ client.rb # Thin mcp gem wrapper
234
+ authenticated_tool.rb # Wraps MCP tools when credentials proc is configured
235
+ tool_factory.rb # Generates Riffer::Tool subclasses from MCP tools
202
236
  test/
203
237
  test_helper.rb # Minitest configuration with VCR
204
238
  riffer_test.rb # Main module tests
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.25.0"
2
+ ".": "0.27.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.27.0](https://github.com/janeapp/riffer/compare/riffer/v0.26.0...riffer/v0.27.0) (2026-05-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * integrate MCP server tools into agent tool resolution ([6c7a09a](https://github.com/janeapp/riffer/commit/6c7a09a1797dcc8fbafd198665fb665ede93f009))
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * close streaming HTTP connections when consumer raises ([#235](https://github.com/janeapp/riffer/issues/235)) ([2a51a10](https://github.com/janeapp/riffer/commit/2a51a106d0f9d1410040c6b3a887722378a0dc14))
19
+
20
+ ## [0.26.0](https://github.com/janeapp/riffer/compare/riffer/v0.25.0...riffer/v0.26.0) (2026-04-29)
21
+
22
+
23
+ ### Features
24
+
25
+ * model-aware skills adapter selection ([#232](https://github.com/janeapp/riffer/issues/232)) ([74a5323](https://github.com/janeapp/riffer/commit/74a5323945f3f6f30538d472400cc5fe27b588da))
26
+
8
27
  ## [0.25.0](https://github.com/janeapp/riffer/compare/riffer/v0.24.2...riffer/v0.25.0) (2026-04-29)
9
28
 
10
29
 
data/README.md CHANGED
@@ -60,6 +60,7 @@ For comprehensive documentation, see the [docs](docs/) directory:
60
60
  - [Evals](docs/11_EVALS.md) - Evaluating agent quality
61
61
  - [Guardrails](docs/12_GUARDRAILS.md) - Input/output validation
62
62
  - [Skills](docs/13_SKILLS.md) - Packaged agent capabilities
63
+ - [MCP](docs/14_MCP.md) - Integrating third-party MCP servers
63
64
  - [Providers](docs/providers/01_PROVIDERS.md) - LLM provider adapters
64
65
 
65
66
  ### API Reference
data/docs/03_AGENTS.md CHANGED
@@ -124,6 +124,10 @@ class MyAgent < Riffer::Agent
124
124
  end
125
125
  ```
126
126
 
127
+ ### use_mcp
128
+
129
+ Loads tools from registered [MCP](14_MCP.md) servers by tag. Like `uses_tools`, **`use_mcp` is not inherited**—add it on each subclass that should include MCP tools.
130
+
127
131
  ### provider_options
128
132
 
129
133
  Passes options to the provider client:
@@ -34,6 +34,25 @@ Riffer.config.anthropic.api_key
34
34
 
35
35
  For provider credentials and setup, see the individual [Provider guides](providers/).
36
36
 
37
+ ### MCP (Model Context Protocol)
38
+
39
+ Optional settings for [MCP server integrations](14_MCP.md):
40
+
41
+ | Option | Description |
42
+ | ------------------ | --------------------------------------------------------------------------------------------------------------- |
43
+ | `credentials` | Optional `Proc` for per-run `tools/call` HTTP headers: `->(manifest:, matched_tags:, context:) { Hash or nil }` |
44
+ | `discovery_runner` | `Riffer::Runner` instance for tool discovery (default `Runner::Sequential.new`) |
45
+
46
+ ```ruby
47
+ Riffer.configure do |config|
48
+ config.mcp.credentials = lambda do |manifest:, matched_tags:, context:|
49
+ {"Authorization" => "Bearer #{token_for(context)}"}
50
+ end
51
+ end
52
+ ```
53
+
54
+ See [MCP](14_MCP.md) for registration, tags, and agent `use_mcp`.
55
+
37
56
  ### Tool Runtime (Experimental)
38
57
 
39
58
  > **Warning:** This feature is experimental and may be removed or changed without warning in a future release.
@@ -46,11 +65,11 @@ Riffer.configure do |config|
46
65
  end
47
66
  ```
48
67
 
49
- | Value | Description |
50
- | ------------------------------ | ------------------------------------------------------------------------------------------------- |
51
- | `Riffer::ToolRuntime` subclass | Instantiated automatically (e.g., `Riffer::ToolRuntime::Inline`, `Riffer::ToolRuntime::Threaded`) |
52
- | `Riffer::ToolRuntime` instance | Custom runtime with specific options |
53
- | `Proc` | Dynamic resolution |
68
+ | Value | Description |
69
+ | ------------------------------- | -------------------------------------------------------------------------------------------------- |
70
+ | `Riffer::ToolRuntime` subclass | Instantiated automatically (e.g., `Riffer::ToolRuntime::Inline`, `Riffer::ToolRuntime::Threaded`) |
71
+ | `Riffer::ToolRuntime` instance | Custom runtime with specific options |
72
+ | `Proc` | Dynamic resolution |
54
73
 
55
74
  Per-agent configuration overrides this global default. See [Advanced Tool Configuration — Tool Runtime](07_TOOL_ADVANCED.md#tool-runtime-experimental) for details.
56
75
 
data/docs/13_SKILLS.md CHANGED
@@ -70,7 +70,7 @@ end
70
70
 
71
71
  ### Custom Adapter
72
72
 
73
- The adapter controls how the skill catalog is rendered in the system prompt and which tool the LLM calls to activate a skill. The adapter is auto-selected by provider (Markdown for most, XML for Anthropic). Override with:
73
+ The adapter controls how the skill catalog is rendered in the system prompt and which tool the LLM calls to activate a skill. The adapter is auto-selected by provider, with model-aware fallback for proxy providers — Markdown for most providers, XML for Anthropic, and XML for Anthropic models routed through Amazon Bedrock (e.g. `us.anthropic.claude-sonnet-4-6`). Override with:
74
74
 
75
75
  ```ruby
76
76
  skills do
data/docs/14_MCP.md ADDED
@@ -0,0 +1,144 @@
1
+ # MCP
2
+
3
+ Riffer can consume third-party [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers as tool sources. Tools are discovered automatically at registration time — no handwritten Ruby per tool required.
4
+
5
+ ## Overview
6
+
7
+ 1. Register an MCP server globally with `Riffer::Mcp.register`.
8
+ 2. Opt an agent into that server's tools using the `use_mcp` DSL.
9
+ 3. The agent picks up the tools and calls them like any other Riffer tool.
10
+
11
+ ## Registering a Server
12
+
13
+ ```ruby
14
+ Riffer::Mcp.register(
15
+ name: "github", # unique identifier
16
+ tags: [:github], # agents opt-in by tag
17
+ endpoint: "https://mcp.github.com",
18
+ discovery_headers: -> { {"Authorization" => "Bearer #{ENV['GITHUB_TOKEN']}"} }
19
+ )
20
+ ```
21
+
22
+ `register` blocks until tool discovery completes (or fails). Discovery uses the configured `Riffer::Runner` (default `Runner::Sequential` — inline). To run discovery on a pool thread (e.g. for Rails connection-pool isolation), set `config.mcp.discovery_runner = Riffer::Runner::Threaded.new`; `register` still blocks but the work runs off the calling thread.
23
+
24
+ ### Discovery headers
25
+
26
+ `discovery_headers` is a Hash or Proc used **only** for MCP `tools/list` when the discovery client is built (the Proc runs once at that time). Use it for bootstrap identity: service account, env-based token, or `{}` if listing is unauthenticated.
27
+
28
+ Optional **`credentials_scope`** on the manifest documents whether you expect invocation headers to depend on tenant and/or user keys in the agent `context` (e.g. `:global`, `:tenant`, `:user`). It does **not** store tenant or user ids — only a hint for your app and docs. For multi-tenant apps, `:user` often means “user in tenant” and your `context` may include both tenant and user identifiers.
29
+
30
+ ## Session credentials callback
31
+
32
+ When **`Riffer.config.mcp.credentials`** is set to a Proc, each MCP `tools/call` resolves HTTP headers through that callback instead of reusing `discovery_headers`.
33
+
34
+ **Signature:**
35
+
36
+ ```ruby
37
+ Riffer.configure do |config|
38
+ config.mcp.credentials = lambda do |manifest:, matched_tags:, context:|
39
+ # return nil to omit this server's tools for this agent run (at resolve time)
40
+ # return Hash<String,String> headers for tools/call (e.g. Authorization)
41
+ end
42
+ end
43
+ ```
44
+
45
+ - **`manifest`** — the server's `Riffer::Mcp::Manifest`.
46
+ - **`matched_tags`** — intersection of the agent's `use_mcp` tags and `manifest.tags` for this registration (unioned across multiple `use_mcp` lines).
47
+ - **`context`** — the same hash passed to `generate` / `stream` for this run.
48
+
49
+ **Resolve time:** Before tools are exposed to the model, the proc is invoked once per matching registration. If it returns **`nil`**, that server's tools are omitted for this run (e.g. tenant has no integration).
50
+
51
+ **Call time:** Authenticated tool wrappers invoke the proc again for each execution. If it returns **`nil`**, `Riffer::Mcp::CredentialsDeniedError` is raised.
52
+
53
+ If **`credentials` is unset**, discovery and `tools/call` share one client built from `discovery_headers` (same behaviour as a single static token for both list and call).
54
+
55
+ ## Tags
56
+
57
+ Tags are entirely up to your application; Riffer does not define a canonical vocabulary. Each server may declare multiple tags; an agent includes every registration that shares **any** tag passed to `use_mcp`.
58
+
59
+ When MCPs are registered, assign **stable bucket tags** so agent classes can opt in without listing every server name—for example `tags: [:connectors, :github]` with `use_mcp :connectors` for all enabled connectors, or `use_mcp :github` for that integration only.
60
+
61
+ Registrations are **global** (endpoint + tags); **tenant and user** access are enforced in your **`credentials`** proc and whatever you put in **`context`**, not by putting ids on the manifest.
62
+
63
+ ## Opting an Agent In
64
+
65
+ ```ruby
66
+ class ResearchAgent < Riffer::Agent
67
+ model "openai/gpt-5-mini"
68
+ instructions "You are a research assistant."
69
+
70
+ use_mcp :github
71
+ end
72
+ ```
73
+
74
+ `use_mcp` accepts any tag registered via `Riffer::Mcp.register`. Multiple calls accumulate — the agent receives tools from all matching servers:
75
+
76
+ ```ruby
77
+ class MultiAgent < Riffer::Agent
78
+ model "openai/gpt-5-mini"
79
+
80
+ use_mcp :github
81
+ use_mcp :jira
82
+ end
83
+ ```
84
+
85
+ MCP tools are appended after any tools declared with `uses_tools`.
86
+
87
+ Tool names must be unique across `uses_tools` and all included MCP servers; duplicate names raise `Riffer::ArgumentError` when tools are resolved.
88
+
89
+ ### Subclassing
90
+
91
+ Like [`uses_tools`](03_AGENTS.md#uses_tools), **`use_mcp` is not inherited** from the superclass. Declare `use_mcp` on each agent class that should load MCP tools.
92
+
93
+ ## Unregistering a Server
94
+
95
+ ```ruby
96
+ Riffer::Mcp.unregister("github")
97
+ ```
98
+
99
+ Subsequent agent runs will not include tools from that server. Call `register` again (with fresh `discovery_headers` if needed) to re-register.
100
+
101
+ ## Introspection
102
+
103
+ ```ruby
104
+ Riffer::Mcp.registrations
105
+ # => {"github" => #<Riffer::Mcp::Registration ...>, ...}
106
+
107
+ reg = Riffer::Mcp.registrations["github"]
108
+ reg.tools # => [<Class:...>, ...] (Riffer::Tool subclasses)
109
+ ```
110
+
111
+ Discovery failures raise from `register` directly, typically `Faraday::Error` for network issues or `Riffer::DependencyError` if the `mcp`/`faraday` gems are missing. Rescue `StandardError` for graceful degradation:
112
+
113
+ ```ruby
114
+ begin
115
+ Riffer::Mcp.register(name: "github", ...)
116
+ rescue StandardError => e
117
+ Rails.logger.warn("MCP registration failed: #{e.message}")
118
+ end
119
+ ```
120
+
121
+ ## Error Classes
122
+
123
+ | Class | Raised when |
124
+ | ------------------------------------- | ---------------------------------------------------- |
125
+ | `Riffer::Mcp::CredentialsDeniedError` | `credentials` proc returns `nil` during `tools/call` |
126
+
127
+ All inherit from `Riffer::Mcp::Error < Riffer::Error`.
128
+
129
+ ## Limitations
130
+
131
+ - **Tool results:** `tools/call` responses are reduced to joined **text** content from MCP `content` items. Non-text parts (e.g. images, embedded resources) are not surfaced in this release.
132
+ - **Session credentials:** When `Riffer.config.mcp.credentials` is set, authenticated tool wrappers may build a **new HTTP client per tool invocation** so headers stay fresh; there is no connection pooling in this release.
133
+ - **Context window / progressive disclosure:** Discovery registers **all** tools from each server; matching agents see the full set in one shot. There is no built-in lazy listing or prompt-size budgeting yet. Tighter control may require application-level filtering, MCP server design, or future Riffer APIs.
134
+
135
+ ## Requirements
136
+
137
+ The `mcp` and `faraday` gems are **optional** dependencies — they are not included in Riffer's runtime gemspec. To use MCP integration, add both to your own Gemfile:
138
+
139
+ ```ruby
140
+ gem "mcp"
141
+ gem "faraday"
142
+ ```
143
+
144
+ Faraday is required for the MCP HTTP transport. If either gem is missing when `Riffer::Mcp.register` is called, a `Riffer::DependencyError` is raised.
data/lib/riffer/agent.rb CHANGED
@@ -172,6 +172,24 @@ class Riffer::Agent
172
172
  end
173
173
  private_class_method :resolve_uses_tools_config
174
174
 
175
+ # Opts this agent into tools from all MCP registrations that share any of
176
+ # the given tag(s).
177
+ #
178
+ # +tag+ - a String or Symbol; matched against registration manifest tags.
179
+ #
180
+ #: (String | Symbol) -> void
181
+ def self.use_mcp(tag)
182
+ @mcp_configs ||= []
183
+ @mcp_configs << {tags: [tag.to_sym]}
184
+ end
185
+
186
+ # Returns the accumulated +use_mcp+ configurations for this agent class.
187
+ #
188
+ #: () -> Array[Hash[Symbol, untyped]]
189
+ def self.mcp_configs
190
+ @mcp_configs || []
191
+ end
192
+
175
193
  # Gets or sets the tool runtime for this agent.
176
194
  #
177
195
  # Accepts a Riffer::ToolRuntime subclass, a Riffer::ToolRuntime instance,
@@ -755,9 +773,70 @@ class Riffer::Agent
755
773
  end
756
774
 
757
775
  #--
776
+ #: () -> Array[singleton(Riffer::Tool)]
777
+ def resolve_uses_tools_config
778
+ config = self.class.uses_tools
779
+
780
+ if config.nil?
781
+ []
782
+ elsif config.is_a?(Proc)
783
+ (config.arity == 0) ? config.call : config.call(@context)
784
+ else
785
+ config
786
+ end
787
+ end
788
+
789
+ #--
790
+ #: () -> Array[singleton(Riffer::Tool)]
791
+ def resolve_mcp_tool_classes
792
+ configs = self.class.mcp_configs
793
+ return [] if configs.empty?
794
+
795
+ cred = Riffer.config.mcp.credentials
796
+ ctx = @context
797
+ gather_mcp_registrations_with_tags(configs).flat_map do |reg, tag_accum|
798
+ matched_tags = tag_accum.uniq
799
+ mcp_tools_for_registration(reg, matched_tags, cred, ctx)
800
+ end
801
+ end
802
+
803
+ # Each matching MCP registration once, with tag symbols unioned across +use_mcp+ rows.
804
+ #
805
+ #: (Array[Hash[Symbol, untyped]]) -> Hash[Riffer::Mcp::Registration, Array[Symbol]]
806
+ def gather_mcp_registrations_with_tags(configs)
807
+ by_reg = {}
808
+ configs.each do |cfg|
809
+ Riffer::Mcp::Registry.find_by_tags(cfg[:tags]).each do |reg|
810
+ (by_reg[reg] ||= []).concat(cfg[:tags] & reg.manifest.tags)
811
+ end
812
+ end
813
+ by_reg
814
+ end
815
+
816
+ #: (Riffer::Mcp::Registration, Array[Symbol], Proc?, Hash[Symbol, untyped]) -> Array[singleton(Riffer::Tool)]
817
+ def mcp_tools_for_registration(reg, matched_tags, cred, ctx)
818
+ return reg.tools unless cred
819
+ return [] if cred.call(manifest: reg.manifest, matched_tags: matched_tags, context: ctx).nil?
820
+ Riffer::Mcp::AuthenticatedTool.wrap_all(reg.tools, reg.manifest, matched_tags)
821
+ end
822
+
823
+ # Raises if two or more tool classes share the same +.name+ (ambiguous dispatch).
824
+ #
825
+ #: (Array[singleton(Riffer::Tool)]) -> void
826
+ def assert_distinct_tool_names!(tool_classes)
827
+ tally = Hash.new(0) #: Hash[String, Integer]
828
+ tool_classes.each { |tc| tally[tc.name] += 1 }
829
+ dupes = tally.filter_map { |name, n| name if n > 1 }
830
+ return if dupes.empty?
831
+
832
+ raise Riffer::ArgumentError, "Duplicate tool names: #{dupes.sort.join(", ")}"
833
+ end
834
+
758
835
  #: () -> Array[singleton(Riffer::Tool)]
759
836
  def resolved_tools
760
- @resolved_tools ||= self.class.resolved_tool_classes(context: @context)
837
+ @resolved_tools ||= self.class.resolved_tool_classes(context: @context) + resolve_mcp_tool_classes
838
+ assert_distinct_tool_names!(@resolved_tools)
839
+ @resolved_tools
761
840
  end
762
841
 
763
842
  #--
@@ -799,7 +878,7 @@ class Riffer::Agent
799
878
  return nil if skills_list.empty?
800
879
 
801
880
  skills = skills_list.to_h { |s| [s.name, s] }
802
- adapter_class = self.class.skills.adapter || provider_class.skills_adapter
881
+ adapter_class = self.class.skills.adapter || provider_class.skills_adapter(@model_name)
803
882
  skill_activate_tool_class = self.class.skills.activate_tool || Riffer.config.skills.default_activate_tool
804
883
 
805
884
  skills_context = Riffer::Skills::Context.new(
data/lib/riffer/config.rb CHANGED
@@ -21,6 +21,7 @@ class Riffer::Config
21
21
  Gemini = Struct.new(:api_key, :open_timeout, :read_timeout, keyword_init: true)
22
22
  OpenAI = Struct.new(:api_key, keyword_init: true)
23
23
  Evals = Struct.new(:judge_model, keyword_init: true)
24
+ Mcp = Struct.new(:credentials, :discovery_runner, keyword_init: true)
24
25
 
25
26
  # Skills-related global configuration.
26
27
  #
@@ -93,6 +94,17 @@ class Riffer::Config
93
94
  # Evals configuration (Struct with +judge_model+).
94
95
  attr_reader :evals #: Riffer::Config::Evals
95
96
 
97
+ # MCP configuration (Struct with +credentials+ and +discovery_runner+).
98
+ #
99
+ # +credentials+ is an optional Proc for per-run MCP +tools/call+ HTTP headers.
100
+ # Signature: +->(manifest:, matched_tags:, context:) { Hash or nil }+.
101
+ # +nil+ from the proc at tool-resolution time omits that server's tools; +nil+
102
+ # at tool-call time raises Riffer::Mcp::CredentialsDeniedError.
103
+ #
104
+ # +discovery_runner+ is the Riffer::Runner used to execute tool discovery
105
+ # (default +Runner::Sequential+).
106
+ attr_reader :mcp #: Riffer::Config::Mcp
107
+
96
108
  # Global tool runtime configuration (experimental).
97
109
  #
98
110
  # Accepts a Riffer::ToolRuntime subclass, a Riffer::ToolRuntime instance,
@@ -148,6 +160,7 @@ class Riffer::Config
148
160
  @gemini = Gemini.new
149
161
  @openai = OpenAI.new
150
162
  @evals = Evals.new
163
+ @mcp = Mcp.new(credentials: nil, discovery_runner: Riffer::Runner::Sequential.new)
151
164
  @tool_runtime = Riffer::ToolRuntime::Inline.new
152
165
  @skills = Skills.new
153
166
  @message_id_strategy = :none
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Wraps MCP-generated tool classes so +tools/call+ uses +Riffer.config.mcp.credentials+
5
+ # per invocation while delegating metadata to the inner class.
6
+ #
7
+ module Riffer::Mcp::AuthenticatedTool
8
+ # Returns one wrapper class per inner tool, sharing +manifest+ and +matched_tags+.
9
+ #
10
+ #--
11
+ #: (Array[singleton(Riffer::Tool)], Riffer::Mcp::Manifest, Array[Symbol]) -> Array[singleton(Riffer::Tool)]
12
+ def self.wrap_all(tool_classes, manifest, matched_tags)
13
+ tool_classes.map { |tc| wrap_one(tc, manifest, matched_tags) }
14
+ end
15
+
16
+ #--
17
+ #: (singleton(Riffer::Tool), Riffer::Mcp::Manifest, Array[Symbol]) -> singleton(Riffer::Tool)
18
+ # Class.new(Riffer::Tool) is typed as ::Class by steep — it cannot verify the subtype
19
+ # relationship for dynamically created anonymous classes, so the ignore is required.
20
+ def self.wrap_one(inner_class, manifest, matched_tags) # steep:ignore MethodBodyTypeMismatch
21
+ inner = inner_class
22
+ man = manifest
23
+ tags = matched_tags
24
+
25
+ Class.new(Riffer::Tool) do
26
+ @identifier = inner.identifier
27
+
28
+ define_singleton_method(:name) { inner.name }
29
+ define_singleton_method(:mcp_server_tool_name) { inner.mcp_server_tool_name }
30
+ define_singleton_method(:description) { inner.description }
31
+ define_singleton_method(:parameters_schema) { |strict: false| inner.parameters_schema(strict: strict) }
32
+
33
+ # Builds a client for a single +tools/call+ invocation.
34
+ #
35
+ # Creates a fresh client per call so headers from the credentials proc stay
36
+ # current.
37
+ # TODO: A per-headers cache would reduce connection churn under load, and
38
+ # requires a follow-up investigation to determine how to invalidate failing
39
+ # clients.
40
+ define_method(:build_call_client) do |endpoint, headers|
41
+ Riffer::Mcp::Client.new(endpoint: endpoint, headers: headers)
42
+ end
43
+ private :build_call_client
44
+
45
+ define_method(:call) do |context:, **kwargs|
46
+ cred = Riffer.config.mcp.credentials
47
+ unless cred
48
+ # `next` rather than `return`: inside define_method the block IS the method
49
+ # body, so both exit :call identically at runtime. `next` avoids a false
50
+ # steep ReturnTypeMismatch that would otherwise need a steep:ignore.
51
+ next inner.new.call(context: context, **kwargs)
52
+ end
53
+
54
+ headers = cred.call(manifest: man, matched_tags: tags, context: context)
55
+ if headers.nil?
56
+ raise Riffer::Mcp::CredentialsDeniedError,
57
+ "MCP credentials returned nil for server '#{man.name}' during tools/call"
58
+ end
59
+
60
+ client = build_call_client(man.endpoint, headers)
61
+ text(client.tools_call(inner.mcp_server_tool_name, kwargs))
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Thin wrapper around the MCP Ruby SDK client (mcp gem v0.8+).
5
+ #
6
+ # Resolves headers (if a Proc) once at initialization, then provides
7
+ # +tools_list+ and +tools_call+. Used for discovery (+Manifest#discovery_headers+)
8
+ # and for +tools/call+ when no +credentials+ proc is configured.
9
+ #
10
+ # MCP gem API used:
11
+ # MCP::Client::HTTP.new(url:, headers:) — HTTP transport (requires faraday)
12
+ # MCP::Client.new(transport:) — client
13
+ # client.tools — Array<MCP::Client::Tool>
14
+ # client.call_tool(tool:, arguments:) — raw JSON-RPC response Hash
15
+ #
16
+ class Riffer::Mcp::Client
17
+ include Riffer::Helpers::Dependencies
18
+
19
+ #--
20
+ #: (endpoint: String, ?headers: (Hash[String, String] | Proc), ?client: untyped?) -> void
21
+ def initialize(endpoint:, headers: {}, client: nil)
22
+ depends_on "mcp"
23
+ depends_on "faraday"
24
+
25
+ @client = client || begin
26
+ resolved_headers = headers.is_a?(Proc) ? headers.call : headers
27
+ transport = MCP::Client::HTTP.new(url: endpoint, headers: resolved_headers)
28
+ MCP::Client.new(transport: transport)
29
+ end
30
+ end
31
+
32
+ # Returns an array of tool definition hashes, each with +:name+, +:description+,
33
+ # and +:input_schema+ keys.
34
+ #
35
+ #--
36
+ #: () -> Array[Hash[Symbol, untyped]]
37
+ def tools_list
38
+ @client.tools.map do |tool|
39
+ {
40
+ name: tool.name,
41
+ description: tool.description,
42
+ input_schema: tool.input_schema
43
+ }
44
+ end
45
+ end
46
+
47
+ # Calls a tool on the MCP server and returns joined text content from the response.
48
+ #
49
+ #--
50
+ #: (String, ?Hash[untyped, untyped]) -> String
51
+ def tools_call(name, arguments = {})
52
+ tool = MCP::Client::Tool.new(name: name, description: nil, input_schema: nil)
53
+ response = @client.call_tool(tool: tool, arguments: arguments)
54
+
55
+ if response["error"]
56
+ raise Riffer::Error, response.dig("error", "message") || "MCP tool call failed"
57
+ end
58
+
59
+ if response.dig("result", "isError")
60
+ message = (response.dig("result", "content") || []).filter_map { |item| item["text"] }.join
61
+ raise Riffer::Error, message.empty? ? "MCP tool '#{name}' failed" : message
62
+ end
63
+
64
+ content = response.dig("result", "content") || []
65
+ content.filter_map { |item| item["text"] }.join
66
+ end
67
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "uri"
5
+
6
+ # Riffer::Mcp::Manifest holds the configuration for a single MCP server.
7
+ #
8
+ # +name+ - String identifier used as the registration key and generated-agent identifier.
9
+ # +tags+ - Array[Symbol]; normalized to symbols at construction time.
10
+ # +endpoint+ - String HTTPS URL passed to the MCP transport.
11
+ # +discovery_headers+ - Hash or Proc; resolved once when building the discovery client for +tools/list+.
12
+ # +credentials_scope+ - Optional symbol hint: +:global+, +:tenant+, +:user+ — documents whether
13
+ # invocation credentials are expected to depend on tenant and/or user keys in +context+ (no ids stored).
14
+ # Apps may treat +:user+ as "user in tenant" and pass both keys in +context+.
15
+ #
16
+ class Riffer::Mcp::Manifest
17
+ attr_reader :name #: String
18
+ attr_reader :tags #: Array[Symbol]
19
+ attr_reader :endpoint #: String
20
+ attr_reader :discovery_headers #: (Hash[String, untyped] | ::Proc)?
21
+ attr_reader :credentials_scope #: Symbol?
22
+
23
+ #--
24
+ #: (name: String, endpoint: String, ?tags: Array[untyped]?, ?discovery_headers: (Hash[String, untyped] | ::Proc)?, ?credentials_scope: (String | Symbol)?) -> void
25
+ def initialize(name:, endpoint:, tags: nil, discovery_headers: nil, credentials_scope: nil)
26
+ @name = name.to_s.strip
27
+ raise Riffer::ArgumentError, "MCP manifest name is required" if @name.empty?
28
+
29
+ @endpoint = endpoint.to_s.strip
30
+ raise Riffer::ArgumentError, "MCP manifest endpoint must be a valid HTTPS URL" unless valid_endpoint?
31
+
32
+ @tags = Array(tags).map(&:to_sym)
33
+ @discovery_headers = discovery_headers
34
+ @credentials_scope = credentials_scope&.to_sym
35
+ end
36
+
37
+ private
38
+
39
+ def valid_endpoint?
40
+ uri = URI.parse(@endpoint)
41
+ uri.is_a?(URI::HTTPS) && uri.host
42
+ rescue URI::InvalidURIError
43
+ false
44
+ end
45
+ end