omni_agent 0.1.11 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +96 -2
- data/Rakefile +4 -0
- data/app/jobs/omni_agent/embed_job.rb +10 -0
- data/app/jobs/omni_agent/ingest_job.rb +12 -0
- data/docs/agent/callbacks.md +60 -0
- data/docs/agent/delegation.md +56 -0
- data/docs/agent/provider.md +93 -0
- data/docs/agent/run-entrypoints.md +81 -0
- data/docs/agent/streaming.md +62 -0
- data/docs/agent/structured-output.md +80 -0
- data/docs/configuration.md +68 -0
- data/docs/evals/caching.md +21 -0
- data/docs/evals/generating-and-running.md +87 -0
- data/docs/evals/golden-sets.md +32 -0
- data/docs/evals/judge-provider.md +20 -0
- data/docs/generators.md +143 -0
- data/docs/index.md +98 -0
- data/docs/intro.md +93 -0
- data/docs/mcp/authentication.md +226 -0
- data/docs/mcp/defining-servers.md +196 -0
- data/docs/mcp/transports.md +166 -0
- data/docs/rag.md +372 -0
- data/docs/stock-agents/ocr.md +52 -0
- data/docs/stock-agents/overview.md +91 -0
- data/docs/supported-providers.md +47 -0
- data/docs/tools.md +171 -0
- data/exe/omni_agent +48 -10
- data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
- data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
- data/lib/generators/omni_agent/install/install_generator.rb +78 -1
- data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
- data/lib/generators/omni_agent/vector_store.rb +113 -0
- data/lib/omni_agent/agent.rb +168 -39
- data/lib/omni_agent/chunkable.rb +181 -0
- data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
- data/lib/omni_agent/chunkers/markdown.rb +41 -0
- data/lib/omni_agent/chunkers/recursive.rb +53 -0
- data/lib/omni_agent/chunkers.rb +31 -0
- data/lib/omni_agent/configuration.rb +14 -1
- data/lib/omni_agent/docs.rb +32 -0
- data/lib/omni_agent/embeddable.rb +131 -0
- data/lib/omni_agent/engine.rb +11 -0
- data/lib/omni_agent/errors.rb +15 -0
- data/lib/omni_agent/loaders/html.rb +21 -0
- data/lib/omni_agent/loaders/markdown.rb +18 -0
- data/lib/omni_agent/loaders/pdf.rb +25 -0
- data/lib/omni_agent/loaders/text.rb +15 -0
- data/lib/omni_agent/loaders.rb +68 -0
- data/lib/omni_agent/providers/base.rb +14 -0
- data/lib/omni_agent/providers/mock.rb +89 -4
- data/lib/omni_agent/providers/ollama.rb +4 -0
- data/lib/omni_agent/providers/openai.rb +33 -2
- data/lib/omni_agent/providers/response.rb +6 -1
- data/lib/omni_agent/providers.rb +7 -0
- data/lib/omni_agent/schema.rb +200 -0
- data/lib/omni_agent/semantic_search.rb +45 -0
- data/lib/omni_agent/stock_agent.rb +59 -0
- data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
- data/lib/omni_agent/stock_agents/ocr.rb +70 -0
- data/lib/omni_agent/stock_agents.rb +26 -0
- data/lib/omni_agent/tool.rb +7 -107
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +12 -1
- data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
- data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
- metadata +49 -8
- data/app/assets/stylesheets/omni_agent/application.css +0 -15
- data/app/controllers/omni_agent/application_controller.rb +0 -4
- data/app/helpers/omni_agent/application_helper.rb +0 -4
- data/app/jobs/omni_agent/application_job.rb +0 -4
- data/app/mailers/omni_agent/application_mailer.rb +0 -6
- data/app/models/omni_agent/application_record.rb +0 -5
- data/app/views/layouts/omni_agent/application.html.erb +0 -17
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/mcp/authentication.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/mcp/authentication -->
|
|
3
|
+
|
|
4
|
+
# Authentication
|
|
5
|
+
|
|
6
|
+
An MCP server mounted over HTTP lets anyone who can reach the URL run your tools. For that reason, **every server must declare `authenticate`**. A server without it raises `OmniAgent::MCPError` on the first HTTP request. To make a server public on purpose, write `authenticate :none`.
|
|
7
|
+
|
|
8
|
+
Authentication returns a **principal**: the identity of the caller. The principal is passed to [`authorize_tool`](defining-servers.md#per-principal-tool-access), to the [`context`](defining-servers.md#tool-context) block, and to tools as `context[:mcp_principal]`.
|
|
9
|
+
|
|
10
|
+
Requests that fail authentication get `401 Unauthorized` with `WWW-Authenticate: Bearer realm="<server_name>"`. [OAuth 2.1](#oauth-21) servers add more detail to that header.
|
|
11
|
+
|
|
12
|
+
## Static Bearer Tokens
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
authenticate :bearer, tokens: -> { ENV.fetch("SUPPORT_MCP_TOKENS", "").split(",") }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Clients send `Authorization: Bearer <token>`. `tokens:` accepts:
|
|
19
|
+
|
|
20
|
+
* **An Array**: Any listed token is accepted. The principal is `:bearer_token`.
|
|
21
|
+
* **A Hash of `principal => token`**: The principal is the matching key. Use it to tell callers apart:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
authenticate :bearer, tokens: -> { { "ci" => ENV["CI_MCP_TOKEN"], "ops" => ENV["OPS_MCP_TOKEN"] } }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
* **A Proc that returns either of the above**: The proc runs on every request, so you can rotate tokens in the environment or in credentials without restarting the server. Use a proc whenever you read tokens from `ENV`.
|
|
28
|
+
|
|
29
|
+
Tokens are compared with `ActiveSupport::SecurityUtils.secure_compare`. Every configured token is checked, with no early exit. Blank tokens are ignored, so an unset environment variable never matches an empty `Authorization` header.
|
|
30
|
+
|
|
31
|
+
## Custom Authentication
|
|
32
|
+
|
|
33
|
+
Pass a block to authenticate against your own data. The block receives an `OmniAgent::MCP::Request` (a `Rack::Request` that also has `#bearer_token`). Return the principal, or `nil`/`false` to reject the request:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
authenticate do |request|
|
|
37
|
+
User.find_by(api_token: request.bearer_token)
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The returned user is then the principal everywhere:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
authorize_tool { |tool_class, user| user.admin? || !tool_class.tags.include?(:admin) }
|
|
45
|
+
context { |user, _request| { current_user: user } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## OAuth 2.1
|
|
49
|
+
|
|
50
|
+
`authenticate :oauth` implements the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). Your MCP server is an OAuth 2.1 **resource server**: an external **authorization server** (Auth0, Keycloak, Okta, Entra ID, WorkOS, Doorkeeper, ...) signs users in and issues access tokens, and OmniAgent validates those tokens on every request. MCP clients such as Claude Code, Claude Desktop and the MCP Inspector discover the authorization server and run the OAuth flow by themselves, so users never copy tokens by hand.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class SupportServer < OmniAgent::MCP::Server
|
|
54
|
+
authenticate :oauth,
|
|
55
|
+
resource: "https://app.example.com/mcp/support",
|
|
56
|
+
authorization_servers: "https://auth.example.com",
|
|
57
|
+
scopes: %w[mcp:tools],
|
|
58
|
+
jwks_uri: "https://auth.example.com/.well-known/jwks.json" do |access_token, _request|
|
|
59
|
+
User.find_by(oauth_subject: access_token.subject)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
OmniAgent does not include an authorization server. Use your identity provider, or a gem such as Doorkeeper in your app. The MCP specification expects it to support PKCE and to accept the `resource` parameter ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)), so that tokens name your server as their audience.
|
|
65
|
+
|
|
66
|
+
### Options
|
|
67
|
+
|
|
68
|
+
* **`resource:`** (required): The canonical URL of this MCP endpoint, as clients connect to it. Tokens must list it in `aud`. It is configured rather than read from the `Host` header, so a token for another server cannot be replayed by changing that header.
|
|
69
|
+
* **`authorization_servers:`** (required): One or more issuer URLs. They are published in the metadata, and a token's `iss` must be one of them.
|
|
70
|
+
* **`scopes:`**: Scopes every token must have. Defaults to none. A valid token that lacks one gets `403` with `error="insufficient_scope"`.
|
|
71
|
+
* **`scopes_supported:`**: Scopes advertised in the metadata. Defaults to `scopes:`.
|
|
72
|
+
* **`audience:`**: Accept this `aud` value instead of `resource:`. Use it when your authorization server issues a fixed API identifier (for example `api://support`).
|
|
73
|
+
* **Exactly one way to validate tokens**: `jwks_uri:`, `introspection:` or `verify_token:`. See below.
|
|
74
|
+
|
|
75
|
+
`resource:`, `authorization_servers:`, `audience:` and the `introspection:` values also accept a Proc, which runs on every request:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
authenticate :oauth, resource: -> { ENV.fetch("SUPPORT_MCP_URL") }, ...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Validating Tokens
|
|
82
|
+
|
|
83
|
+
**JWT access tokens** ([RFC 9068](https://www.rfc-editor.org/rfc/rfc9068)), checked against the authorization server's public keys. Add `gem "jwt"` to your Gemfile:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
authenticate :oauth, ..., jwks_uri: "https://auth.example.com/.well-known/jwks.json"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The signature, `exp`, `nbf` and `iss` are checked. Tokens must contain `exp` and `iss`. Only asymmetric algorithms (`RS*`, `PS*`, `ES*`) are accepted; change them with `algorithms:`. The key set is cached for 5 minutes. A token with an unknown `kid` refetches it (key rotation), at most every 30 seconds. One request refreshes the keys while the others keep using the cached ones. If the authorization server cannot be reached, OmniAgent keeps using the cached keys, or, if it has none yet, rejects requests with `401` right away and tries again after 30 seconds.
|
|
90
|
+
|
|
91
|
+
**Token introspection** ([RFC 7662](https://www.rfc-editor.org/rfc/rfc7662)), for opaque tokens. Every request asks the authorization server whether the token is active:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
authenticate :oauth, ...,
|
|
95
|
+
introspection: {
|
|
96
|
+
url: "https://auth.example.com/oauth/introspect",
|
|
97
|
+
client_id: -> { Rails.application.credentials.mcp_client_id },
|
|
98
|
+
client_secret: -> { Rails.application.credentials.mcp_client_secret }
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> **Warning: Introspection responses must include `aud`**
|
|
103
|
+
>
|
|
104
|
+
> Every token must name your server in `aud` (see below), but many introspection endpoints leave it out. Doorkeeper, for example, does not return it by default, so every request gets `401` and nothing explains why. Add it to the response. With Doorkeeper, use `custom_introspection_response`:
|
|
105
|
+
>
|
|
106
|
+
> ```ruby
|
|
107
|
+
> # config/initializers/doorkeeper.rb
|
|
108
|
+
> Doorkeeper.configure do
|
|
109
|
+
> custom_introspection_response do |_token, _context|
|
|
110
|
+
> { aud: "https://app.example.com/mcp/support" }
|
|
111
|
+
> end
|
|
112
|
+
> end
|
|
113
|
+
> ```
|
|
114
|
+
|
|
115
|
+
An MCP session sends many requests, and each one calls the introspection endpoint. If that becomes a problem, cache the result yourself with `verify_token:`. A revoked token keeps working until its cache entry expires, so keep the expiry short:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
introspector = OmniAgent::MCP::OAuth::IntrospectionVerifier.new(
|
|
119
|
+
url: "https://auth.example.com/oauth/introspect",
|
|
120
|
+
client_id: -> { Rails.application.credentials.mcp_client_id },
|
|
121
|
+
client_secret: -> { Rails.application.credentials.mcp_client_secret }
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
authenticate :oauth, ...,
|
|
125
|
+
verify_token: ->(token) {
|
|
126
|
+
Rails.cache.fetch("mcp_token/#{Digest::SHA256.hexdigest(token)}", expires_in: 1.minute) do
|
|
127
|
+
introspector.call(token)
|
|
128
|
+
end
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Custom verification**, when you already have the logic. The block receives the raw token (and, optionally, the request) and returns the token's claims as a Hash, or `nil` to reject it:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
authenticate :oauth, ...,
|
|
136
|
+
verify_token: ->(token) {
|
|
137
|
+
record = OAuthToken.active.find_by(digest: Digest::SHA256.hexdigest(token))
|
|
138
|
+
record && { "sub" => record.user_id.to_s, "aud" => record.resource,
|
|
139
|
+
"scope" => record.scopes, "exp" => record.expires_at.to_i }
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Whatever the method, OmniAgent then checks the claims: `aud` must contain `resource:` (or `audience:`), `exp` and `nbf` must be current (30 seconds of clock skew allowed), `iss` (if present) must be one of `authorization_servers:`, and `scope` (or `scp`) must contain every scope in `scopes:`. If the authorization server cannot be reached, the request gets `401` and the error is logged.
|
|
144
|
+
|
|
145
|
+
Exceptions raised by your own code, in `verify_token:` or in the block that returns the principal (for example a database error in `User.find_by`), are not caught. The request gets `500`, not `401`.
|
|
146
|
+
|
|
147
|
+
### The Principal and the Access Token
|
|
148
|
+
|
|
149
|
+
The validated token is an `OmniAgent::MCP::OAuth::AccessToken`, with `#subject`, `#client_id`, `#issuer`, `#audience`, `#scopes`, `#scope?(*names)`, `#expires_at` and `#claims`.
|
|
150
|
+
|
|
151
|
+
Without a block, the access token is the principal. With a block, the block's return value is the principal; return `nil` to reject the request. Either way, the token is available as `access_token` inside `authorize_tool` and `context`, and to tools as `context[:mcp_access_token]`. Use it to require extra scopes for some tools:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
authorize_tool { |tool_class, _user| !tool_class.tags.include?(:admin) || access_token.scope?("mcp:admin") }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Discovery
|
|
158
|
+
|
|
159
|
+
Clients find the authorization server through Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)). OmniAgent serves it under the endpoint, with no authentication and with CORS enabled for browser-based clients:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
curl https://app.example.com/mcp/support/.well-known/oauth-protected-resource
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"resource": "https://app.example.com/mcp/support",
|
|
168
|
+
"authorization_servers": ["https://auth.example.com"],
|
|
169
|
+
"scopes_supported": ["mcp:tools"],
|
|
170
|
+
"bearer_methods_supported": ["header"],
|
|
171
|
+
"resource_name": "support"
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Responses that reject a token point clients to it:
|
|
176
|
+
|
|
177
|
+
| Case | Status | `WWW-Authenticate` |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| No token | `401` | `Bearer realm="support", resource_metadata="https://app.example.com/mcp/support/.well-known/oauth-protected-resource", scope="mcp:tools"` |
|
|
180
|
+
| Invalid, expired or wrong-audience token | `401` | Same, plus `error="invalid_token"` |
|
|
181
|
+
| Missing scope | `403` | Same, plus `error="insufficient_scope"` |
|
|
182
|
+
|
|
183
|
+
Tokens are only read from the `Authorization` header, never from the query string.
|
|
184
|
+
|
|
185
|
+
## No Authentication
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
authenticate :none
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Every request is accepted and the principal is `nil`. Use this only for servers that are not reachable from outside your machine or network, or for tools that are safe for anyone to run.
|
|
192
|
+
|
|
193
|
+
## stdio
|
|
194
|
+
|
|
195
|
+
The stdio transport runs as a local subprocess that the MCP client starts. It already has your shell's access, so it does **not** run `authenticate`. Use `stdio_principal` to choose who the local caller is:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
stdio_principal { User.find_by(email: ENV["MCP_USER_EMAIL"]) }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The block runs once when the process starts. Without it, the principal is `nil`. `authorize_tool` and `context` still apply over stdio.
|
|
202
|
+
|
|
203
|
+
## Origin Checks
|
|
204
|
+
|
|
205
|
+
Browsers send an `Origin` header. A malicious web page can use DNS rebinding to make a browser send requests to a server on `localhost`. For this reason, the MCP specification requires servers to check `Origin`. OmniAgent rejects requests that have an `Origin` header that is not in the allowlist with `403 Forbidden`. Requests without an `Origin` header are allowed. CLI and desktop clients do not send one.
|
|
206
|
+
|
|
207
|
+
If a browser-based client must call your server, allow its origin on the server or globally:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class SupportServer < OmniAgent::MCP::Server
|
|
211
|
+
allowed_origins "https://inspector.example.com", %r{\Ahttps://.*\.mycompany\.com\z}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
OmniAgent.configure do |config|
|
|
215
|
+
config.mcp_allowed_origins = [ "https://inspector.example.com" ]
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Security Checklist
|
|
220
|
+
|
|
221
|
+
* Serve the endpoint over **HTTPS** in production. Bearer tokens in plain HTTP can be read by anyone on the network.
|
|
222
|
+
* Give each client its own token (Hash form) so you can revoke one without affecting the others.
|
|
223
|
+
* Hide destructive or expensive tools (such as [exposed agents](defining-servers.md#exposing-agents)) with `authorize_tool`.
|
|
224
|
+
* Tool error messages are returned to the client. Do not put secrets in exception messages.
|
|
225
|
+
* Request bodies larger than `mcp_max_request_bytes` (default 1 MiB) are rejected with `413`. See [Configuration](../configuration.md).
|
|
226
|
+
* With [OAuth 2.1](#oauth-21), set `resource:` to the exact public URL of the endpoint, and keep `scopes:` minimal.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/mcp/defining-servers.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/mcp/defining-servers -->
|
|
3
|
+
|
|
4
|
+
# Defining MCP Servers
|
|
5
|
+
|
|
6
|
+
The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) lets AI clients such as Claude Code, Claude Desktop and Cursor call tools that live in your app. `OmniAgent::MCP::Server` turns the `OmniAgent::Tool` classes you already wrote for your agents — and the agents themselves — into an MCP server, with no extra gem.
|
|
7
|
+
|
|
8
|
+
v1 supports MCP **tools** only (no resources or prompts), over [Streamable HTTP and stdio](transports.md).
|
|
9
|
+
|
|
10
|
+
A server gets its tools in two ways, and you can use both together:
|
|
11
|
+
|
|
12
|
+
* **Its own `tools/` directory**: tools that only this server uses. They are added automatically, like agent tools.
|
|
13
|
+
* **The DSL**: `tools`, `tool`, `tools_from` and `expose_agent` reuse tools and agents that you already have.
|
|
14
|
+
|
|
15
|
+
## Generate a Server
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
rails generate omni_agent:mcp_server Support --agents SupportAgent
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This creates `app/mcp_servers/support_server.rb` and mounts it at `/mcp/support`. Add `--with-tools LookupOrder RefundOrder` to also scaffold server tools. See [Generators](../generators.md#mcp-server-generator) for every option.
|
|
22
|
+
|
|
23
|
+
## The Server Tools Directory
|
|
24
|
+
|
|
25
|
+
Put tools that belong only to the server in `app/mcp_servers/<server_name>/tools/`, namespaced under `<ServerClass>::Tools`:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
app/mcp_servers/
|
|
29
|
+
├── support_server.rb
|
|
30
|
+
└── support_server/
|
|
31
|
+
└── tools/
|
|
32
|
+
├── lookup_order.rb
|
|
33
|
+
└── refund_order.rb
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# app/mcp_servers/support_server/tools/lookup_order.rb
|
|
38
|
+
module SupportServer::Tools
|
|
39
|
+
class LookupOrder < OmniAgent::Tool
|
|
40
|
+
description "Find an order by number."
|
|
41
|
+
annotations read_only: true
|
|
42
|
+
|
|
43
|
+
input do
|
|
44
|
+
string :number, description: "Order number"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def execute(number:)
|
|
48
|
+
Order.find_by!(number: number).as_json(only: %i[number status total])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every `OmniAgent::Tool` subclass in `SupportServer::Tools` is exposed under its class name (`LookupOrder`). There is nothing to register. Rails autoloads these files and reloads them in development, so new or changed tools appear on the next request.
|
|
55
|
+
|
|
56
|
+
Directory tools are listed first, sorted by name, followed by the tools you declare with the DSL. A server that inherits from another server also gets the parent's directory tools.
|
|
57
|
+
|
|
58
|
+
## The Server DSL
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# app/mcp_servers/support_server.rb
|
|
62
|
+
class SupportServer < OmniAgent::MCP::Server
|
|
63
|
+
server_name "support" # default: underscored class name minus _server
|
|
64
|
+
server_version "1.0.0" # default: "0.1.0"
|
|
65
|
+
instructions "Tools for the support team."
|
|
66
|
+
|
|
67
|
+
tools ResearchAgent::Tools::GetWeather, TaskAgent::Tools::SetPriority
|
|
68
|
+
tool TaskAgent::Tools::AssignTicket, as: "assign_ticket"
|
|
69
|
+
tools_from TaskAgent
|
|
70
|
+
expose_agent ResearchAgent, as: :research, description: "Ask the research agent", forward: [ :current_user ]
|
|
71
|
+
|
|
72
|
+
authenticate :bearer, tokens: -> { ENV.fetch("SUPPORT_MCP_TOKENS", "").split(",") }
|
|
73
|
+
authorize_tool { |tool_class, principal| principal.admin? || !tool_class.tags.include?(:admin) }
|
|
74
|
+
context { |principal, request| { current_user: principal } }
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
* **`server_name`** / **`server_version`**: Reported to the client in `serverInfo` during the `initialize` handshake.
|
|
79
|
+
* **`instructions`**: Optional text the client can show to its model, describing how to use your tools.
|
|
80
|
+
* **`<ServerClass>::Tools`**: Added automatically. See [The Server Tools Directory](#the-server-tools-directory).
|
|
81
|
+
* **`tools`**: One or more `OmniAgent::Tool` classes. Each is exposed under its demodulized class name (`ResearchAgent::Tools::GetWeather` → `GetWeather`), the same name agents use.
|
|
82
|
+
* **`tool klass, as:`**: Expose a single tool under a different name.
|
|
83
|
+
* **`tools_from AgentClass`**: Expose every tool the agent has — its `<AgentClass>::Tools` namespace plus any [`delegate_to`](../agent/delegation.md) tools. Resolved on each request, so new tools show up without editing the server.
|
|
84
|
+
* **`expose_agent`**: Expose a whole agent as a single tool. See [Exposing Agents](#exposing-agents).
|
|
85
|
+
* **`authenticate`**: Required for HTTP. See [Authentication](authentication.md).
|
|
86
|
+
* **`authorize_tool`** / **`context`**: See [Per-Principal Tool Access](#per-principal-tool-access) and [Tool Context](#tool-context).
|
|
87
|
+
|
|
88
|
+
Tool classes can also be passed as strings (`tools "ResearchAgent::Tools::GetWeather"`). They are resolved when the tool list is built, which helps if the server file loads before your agents.
|
|
89
|
+
|
|
90
|
+
### Tool Names
|
|
91
|
+
|
|
92
|
+
MCP tool names must be 1–128 characters from `A-Z a-z 0-9 _ - .`. Two tools with the same name raise `OmniAgent::MCPError`, including a directory tool and a declared tool. If two agents both define a `Search` tool, rename one:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
tools_from DocsAgent
|
|
96
|
+
tool TicketsAgent::Tools::Search, as: "search_tickets"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Sharing Configuration
|
|
100
|
+
|
|
101
|
+
Servers inherit from parent servers. Settings (`authenticate`, `authorize_tool`, `context`, `stdio_principal`, `instructions`, `server_version`) use the nearest declaration. Tools and allowed origins add up:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class ApplicationMcpServer < OmniAgent::MCP::Server
|
|
105
|
+
authenticate { |request| User.find_by(api_token: request.bearer_token) }
|
|
106
|
+
context { |principal, _request| { current_user: principal } }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class SupportServer < ApplicationMcpServer
|
|
110
|
+
tools_from SupportAgent
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Tool Annotations
|
|
115
|
+
|
|
116
|
+
MCP clients use optional hints to decide, for example, whether to ask the user before a call. Declare them on the tool class:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class GetWeather < OmniAgent::Tool
|
|
120
|
+
title "Get weather"
|
|
121
|
+
annotations read_only: true, destructive: false, idempotent: true, open_world: true
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| DSL key | MCP annotation | Meaning |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `read_only:` | `readOnlyHint` | The tool does not change anything |
|
|
128
|
+
| `destructive:` | `destructiveHint` | The tool can delete or overwrite data |
|
|
129
|
+
| `idempotent:` | `idempotentHint` | Calling it again with the same arguments has no extra effect |
|
|
130
|
+
| `open_world:` | `openWorldHint` | The tool talks to external systems |
|
|
131
|
+
|
|
132
|
+
`title` is a human-friendly display name. Neither `title` nor `annotations` is sent to LLM providers when the tool is used by an agent. Clients treat annotations as hints, not as guarantees, so do not rely on them for security.
|
|
133
|
+
|
|
134
|
+
## How Calls Work
|
|
135
|
+
|
|
136
|
+
* `tools/list` returns each tool's name, `title`, `description`, `inputSchema` (the same JSON schema built by `input do ... end`) and annotations.
|
|
137
|
+
* `tools/call` creates a new tool instance, sets its `context`, and calls `invoke` with the client's arguments. Arguments are validated exactly as they are for agents.
|
|
138
|
+
* **Return values**: a `String` becomes text content. A `Hash` becomes JSON text plus `structuredContent`. An `Array` becomes JSON text. `nil` becomes empty text. Any other value is converted with `to_s`.
|
|
139
|
+
* **Errors**: Validation failures (`ArgumentError`) and exceptions raised by `#execute` return a tool result with `isError: true` and the text `Error executing tool: <message>`, the same text agents receive. The model can then correct its arguments. Errors are logged to `Rails.logger`.
|
|
140
|
+
* An unknown tool name, or a tool the principal may not use, returns JSON-RPC error `-32602`.
|
|
141
|
+
|
|
142
|
+
## Exposing Agents
|
|
143
|
+
|
|
144
|
+
`expose_agent` wraps an agent as one tool with a single `input` string argument. It works like [`delegate_to`](../agent/delegation.md): the agent runs its full loop, including its own tool calls, and returns its final answer as the tool result.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
expose_agent ResearchAgent, as: :research,
|
|
148
|
+
description: "Ask the research agent",
|
|
149
|
+
run_alias: :summarize,
|
|
150
|
+
forward: [ :current_user ]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
* **`as:`**: The MCP tool name, used as given (`:research` → `research`).
|
|
154
|
+
* **`description:`**: Defaults to `"Delegate to <AgentClass>."`.
|
|
155
|
+
* **`run_alias:`**: Call a [run entrypoint](../agent/run-entrypoints.md) instead of `run`.
|
|
156
|
+
* **`forward:`**: Tool context keys passed to the agent's `context:`. Use `true` for all keys. The default is `[]` (no context). Combine with the [`context`](#tool-context) block to give the agent the current user.
|
|
157
|
+
|
|
158
|
+
Each call to an exposed agent is a call to your LLM provider, so it costs money. Protect these tools with authentication and, if needed, `authorize_tool`.
|
|
159
|
+
|
|
160
|
+
## Per-Principal Tool Access
|
|
161
|
+
|
|
162
|
+
`authorize_tool` decides which tools each principal can see and call. It receives the tool class, the principal returned by [authentication](authentication.md) and the registered tool name. It runs for both `tools/list` and `tools/call`. A hidden tool is reported as unknown.
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
authorize_tool do |tool_class, principal, name|
|
|
166
|
+
next true if principal.admin?
|
|
167
|
+
|
|
168
|
+
!tool_class.tags.include?(:admin) && name != "research"
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The block runs on the server instance, so `transport` (`:http` or `:stdio`) and `request` are also available.
|
|
173
|
+
|
|
174
|
+
## Tool Context
|
|
175
|
+
|
|
176
|
+
Every MCP tool call sets `tool.context` to:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
{ mcp_principal: principal, mcp_request: request } # request is nil over stdio
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
With [`authenticate :oauth`](authentication.md#oauth-21), it also includes `mcp_access_token`, the validated `OmniAgent::MCP::OAuth::AccessToken`.
|
|
183
|
+
|
|
184
|
+
The `context` block merges extra keys. Use it to give tools that you share with agents the same keys that the agents give them:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
context { |principal, request| { current_user: principal, ip: request&.ip } }
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class SetPriority < OmniAgent::Tool
|
|
192
|
+
def execute(ticket:, priority:)
|
|
193
|
+
Ticket.find(ticket).update!(priority: priority, updated_by: context[:current_user])
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
```
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/mcp/transports.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/mcp/transports -->
|
|
3
|
+
|
|
4
|
+
# Transports and Clients
|
|
5
|
+
|
|
6
|
+
OmniAgent supports both MCP transports:
|
|
7
|
+
|
|
8
|
+
* **Streamable HTTP**: Mount the server in your routes with `mcp_server`. Remote clients connect to the URL.
|
|
9
|
+
* **stdio**: The client starts your app as a subprocess and talks to it over stdin and stdout. Use it for local development.
|
|
10
|
+
|
|
11
|
+
Both transports run the same server class.
|
|
12
|
+
|
|
13
|
+
## Streamable HTTP
|
|
14
|
+
|
|
15
|
+
Mount servers in `config/routes.rb` with the `mcp_server` and `mcp_servers` route helpers:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Rails.application.routes.draw do
|
|
19
|
+
mcp_server :support # SupportServer at /mcp/support
|
|
20
|
+
mcp_server :billing, path: "/internal/mcp" # BillingServer at a custom path
|
|
21
|
+
mcp_server "admin/reports" # Admin::ReportsServer at /mcp/admin/reports
|
|
22
|
+
mcp_servers :inventory, :shipping # several servers at once
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The name is converted to the class name `<Name>Server`, and the default path is `/mcp/<name>`. Underscores in the path become dashes (`:customer_support` → `/mcp/customer-support`). Each route is named `<name>_mcp_server`, so you can use `support_mcp_server_url` to build the URL.
|
|
27
|
+
|
|
28
|
+
| Option | Default | Meaning |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `path:` | `/mcp/<name>` | Mount path |
|
|
31
|
+
| `server:` | `"<Name>Server"` | Server class or class name, for servers that do not follow the naming convention |
|
|
32
|
+
| `as:` | `<name>_mcp_server` | Route name |
|
|
33
|
+
|
|
34
|
+
### Mount Every Server
|
|
35
|
+
|
|
36
|
+
Call `mcp_servers` with no names to mount every server in `app/mcp_servers/` at its default path:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
Rails.application.routes.draw do
|
|
40
|
+
mcp_servers
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
* Every `*_server.rb` file is mounted, including files in subdirectories (`admin/reports_server.rb` → `/mcp/admin/reports`).
|
|
45
|
+
* Files inside a server's own directory, such as `support_server/tools/`, are skipped.
|
|
46
|
+
* Abstract base servers whose file name starts with `application_` (for example, `application_mcp_server.rb`) are skipped.
|
|
47
|
+
* Use `except:` to skip other servers. To give one server a custom path, skip it and mount it separately:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
mcp_servers except: [ :billing ]
|
|
51
|
+
mcp_server :billing, path: "/internal/mcp"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
With this form, adding a server file adds an endpoint without a change to `routes.rb`. Every endpoint still requires [authentication](authentication.md), but review new server files with that in mind.
|
|
55
|
+
|
|
56
|
+
### Lazy Loading
|
|
57
|
+
|
|
58
|
+
The helpers do not load your server classes. Each request looks up the class by name, so code reloading works in development and the routes file does not load your tools before your agents. `mcp_server` is shorthand for this lower-level form, which you can still use:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
mount OmniAgent::MCP::RackApp.new("SupportServer") => "/mcp/support"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The endpoint is **stateless**:
|
|
65
|
+
|
|
66
|
+
* Each `POST` carries one JSON-RPC message and gets one JSON response (`200`). Notifications get `202 Accepted` with no body.
|
|
67
|
+
* There are no sessions (`Mcp-Session-Id`) and no server-sent event stream. `GET` and `DELETE` return `405 Method Not Allowed`. The MCP specification allows both choices.
|
|
68
|
+
* Because there is no session state, the server works behind a load balancer with several Puma workers or hosts.
|
|
69
|
+
* Servers that use [`authenticate :oauth`](authentication.md#oauth-21) also answer `GET <endpoint>/.well-known/oauth-protected-resource` with their Protected Resource Metadata.
|
|
70
|
+
|
|
71
|
+
| Status | When |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `200` | JSON-RPC response (including tool errors with `isError: true`) |
|
|
74
|
+
| `202` | Notification or client response accepted |
|
|
75
|
+
| `400` | Invalid JSON (`-32700`), invalid JSON-RPC message, or unsupported `MCP-Protocol-Version` header |
|
|
76
|
+
| `401` | Authentication failed |
|
|
77
|
+
| `403` | `Origin` header not allowed, or the OAuth token lacks a required scope (`error="insufficient_scope"`) |
|
|
78
|
+
| `405` | Method other than `POST` |
|
|
79
|
+
| `413` | Body larger than `mcp_max_request_bytes` |
|
|
80
|
+
|
|
81
|
+
Supported protocol versions are `2025-11-25`, `2025-06-18` and `2025-03-26`.
|
|
82
|
+
|
|
83
|
+
### Try It With curl
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
SUPPORT_MCP_TOKENS=secret bin/rails server
|
|
87
|
+
|
|
88
|
+
curl -s localhost:3000/mcp/support \
|
|
89
|
+
-H 'Content-Type: application/json' \
|
|
90
|
+
-H 'Authorization: Bearer secret' \
|
|
91
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## stdio
|
|
95
|
+
|
|
96
|
+
Run the server from your Rails app root:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bundle exec omni_agent mcp SupportServer
|
|
100
|
+
# or
|
|
101
|
+
bin/rails "omni_agent:mcp[SupportServer]"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The process reads one JSON-RPC message per line from stdin and writes one response per line to stdout. Anything else written to stdout would corrupt the protocol, so OmniAgent redirects stdout to stderr after it boots. `puts` calls and loggers that write to stdout then go to stderr. The process exits when the client closes stdin.
|
|
105
|
+
|
|
106
|
+
stdio does not use `authenticate`. See [stdio authentication](authentication.md#stdio).
|
|
107
|
+
|
|
108
|
+
## Connecting Clients
|
|
109
|
+
|
|
110
|
+
### Claude Code
|
|
111
|
+
|
|
112
|
+
Over HTTP:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
claude mcp add --transport http support https://myapp.example.com/mcp/support \
|
|
116
|
+
--header "Authorization: Bearer $SUPPORT_MCP_TOKEN"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Over stdio (run from your app root):
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
claude mcp add support -- bundle exec omni_agent mcp SupportServer
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Claude Desktop
|
|
126
|
+
|
|
127
|
+
Add the server to `claude_desktop_config.json`. Desktop does not start the process in your app root, so change to it first:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"mcpServers": {
|
|
132
|
+
"support": {
|
|
133
|
+
"command": "bash",
|
|
134
|
+
"args": ["-lc", "cd /path/to/your/app && bundle exec omni_agent mcp SupportServer"]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Cursor
|
|
141
|
+
|
|
142
|
+
Add the server to `.cursor/mcp.json`:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"mcpServers": {
|
|
147
|
+
"support": {
|
|
148
|
+
"url": "http://localhost:3000/mcp/support",
|
|
149
|
+
"headers": { "Authorization": "Bearer secret" }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### MCP Inspector
|
|
156
|
+
|
|
157
|
+
[MCP Inspector](https://github.com/modelcontextprotocol/inspector) is useful for debugging. It can list tools and call them by hand:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
npx @modelcontextprotocol/inspector --cli http://localhost:3000/mcp/support \
|
|
161
|
+
--transport http --header "Authorization: Bearer secret" --method tools/list
|
|
162
|
+
|
|
163
|
+
npx @modelcontextprotocol/inspector --cli bundle exec omni_agent mcp SupportServer --method tools/list
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The browser UI of the Inspector sends an `Origin` header. Add its origin to [`allowed_origins`](authentication.md#origin-checks) to use it over HTTP.
|