omni_agent 0.1.10 → 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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +100 -2
  4. data/Rakefile +4 -0
  5. data/app/jobs/omni_agent/embed_job.rb +10 -0
  6. data/app/jobs/omni_agent/ingest_job.rb +12 -0
  7. data/docs/agent/callbacks.md +60 -0
  8. data/docs/agent/delegation.md +56 -0
  9. data/docs/agent/provider.md +93 -0
  10. data/docs/agent/run-entrypoints.md +81 -0
  11. data/docs/agent/streaming.md +62 -0
  12. data/docs/agent/structured-output.md +80 -0
  13. data/docs/configuration.md +68 -0
  14. data/docs/evals/caching.md +21 -0
  15. data/docs/evals/generating-and-running.md +87 -0
  16. data/docs/evals/golden-sets.md +32 -0
  17. data/docs/evals/judge-provider.md +20 -0
  18. data/docs/generators.md +143 -0
  19. data/docs/index.md +98 -0
  20. data/docs/intro.md +93 -0
  21. data/docs/mcp/authentication.md +226 -0
  22. data/docs/mcp/defining-servers.md +196 -0
  23. data/docs/mcp/transports.md +166 -0
  24. data/docs/rag.md +372 -0
  25. data/docs/stock-agents/ocr.md +52 -0
  26. data/docs/stock-agents/overview.md +91 -0
  27. data/docs/supported-providers.md +47 -0
  28. data/docs/tools.md +171 -0
  29. data/exe/omni_agent +48 -10
  30. data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
  31. data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
  32. data/lib/generators/omni_agent/install/install_generator.rb +78 -1
  33. data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +4 -0
  34. data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
  35. data/lib/generators/omni_agent/vector_store.rb +113 -0
  36. data/lib/omni_agent/agent.rb +168 -39
  37. data/lib/omni_agent/chunkable.rb +181 -0
  38. data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
  39. data/lib/omni_agent/chunkers/markdown.rb +41 -0
  40. data/lib/omni_agent/chunkers/recursive.rb +53 -0
  41. data/lib/omni_agent/chunkers.rb +31 -0
  42. data/lib/omni_agent/configuration.rb +14 -1
  43. data/lib/omni_agent/docs.rb +32 -0
  44. data/lib/omni_agent/embeddable.rb +131 -0
  45. data/lib/omni_agent/engine.rb +11 -0
  46. data/lib/omni_agent/errors.rb +15 -0
  47. data/lib/omni_agent/loaders/html.rb +21 -0
  48. data/lib/omni_agent/loaders/markdown.rb +18 -0
  49. data/lib/omni_agent/loaders/pdf.rb +25 -0
  50. data/lib/omni_agent/loaders/text.rb +15 -0
  51. data/lib/omni_agent/loaders.rb +68 -0
  52. data/lib/omni_agent/mcp/authenticator.rb +5 -2
  53. data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
  54. data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
  55. data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
  56. data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
  57. data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
  58. data/lib/omni_agent/mcp/oauth.rb +18 -0
  59. data/lib/omni_agent/mcp/rack_app.rb +31 -7
  60. data/lib/omni_agent/mcp/server.rb +22 -5
  61. data/lib/omni_agent/providers/base.rb +14 -0
  62. data/lib/omni_agent/providers/mock.rb +89 -4
  63. data/lib/omni_agent/providers/ollama.rb +4 -0
  64. data/lib/omni_agent/providers/openai.rb +33 -2
  65. data/lib/omni_agent/providers/response.rb +6 -1
  66. data/lib/omni_agent/providers.rb +7 -0
  67. data/lib/omni_agent/schema.rb +200 -0
  68. data/lib/omni_agent/semantic_search.rb +45 -0
  69. data/lib/omni_agent/stock_agent.rb +59 -0
  70. data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
  71. data/lib/omni_agent/stock_agents/ocr.rb +70 -0
  72. data/lib/omni_agent/stock_agents.rb +26 -0
  73. data/lib/omni_agent/tool.rb +7 -107
  74. data/lib/omni_agent/version.rb +1 -1
  75. data/lib/omni_agent.rb +15 -1
  76. data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
  77. data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
  78. metadata +55 -8
  79. data/app/assets/stylesheets/omni_agent/application.css +0 -15
  80. data/app/controllers/omni_agent/application_controller.rb +0 -4
  81. data/app/helpers/omni_agent/application_helper.rb +0 -4
  82. data/app/jobs/omni_agent/application_job.rb +0 -4
  83. data/app/mailers/omni_agent/application_mailer.rb +0 -6
  84. data/app/models/omni_agent/application_record.rb +0 -5
  85. data/app/views/layouts/omni_agent/application.html.erb +0 -17
@@ -0,0 +1,53 @@
1
+ module OmniAgent
2
+ module Chunkers
3
+ # Splits on the coarsest boundary that fits: headings, then paragraphs,
4
+ # lines, sentences and words, cutting mid-word only as a last resort.
5
+ # Pieces are merged back up to `size` characters, and each chunk starts
6
+ # with up to `overlap` characters of whole pieces from the one before.
7
+ module Recursive
8
+ # Zero-width, so every separator stays attached to its text.
9
+ SEPARATORS = [
10
+ /(?=^\#{1,6}\s)/, # Markdown headings
11
+ /(?<=\n\n)/, # paragraphs
12
+ /(?<=\n)/, # lines
13
+ /(?<=[.!?]\s)/, # sentences
14
+ /(?<=\s)/ # words
15
+ ].freeze
16
+
17
+ def self.call(text, size: 1000, overlap: 200)
18
+ merge(split(text.to_s, size, SEPARATORS), size, overlap)
19
+ end
20
+
21
+ def self.split(text, size, separators)
22
+ return [ text ] if text.length <= size
23
+
24
+ separator, *rest = separators
25
+ return text.scan(/.{1,#{size}}/m) if separator.nil?
26
+
27
+ text.split(separator).flat_map { |piece| split(piece, size, rest) }
28
+ end
29
+
30
+ def self.merge(pieces, size, overlap)
31
+ chunks = []
32
+ current = []
33
+
34
+ pieces.each do |piece|
35
+ if current.any? && length(current) + piece.length > size
36
+ chunks << current.join
37
+ current.shift while current.any? && (length(current) > overlap || length(current) + piece.length > size)
38
+ end
39
+ current << piece
40
+ end
41
+ chunks << current.join if current.any?
42
+
43
+ chunks.map(&:strip).reject(&:empty?)
44
+ end
45
+
46
+ def self.length(pieces)
47
+ pieces.sum(&:length)
48
+ end
49
+
50
+ private_class_method :split, :merge, :length
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,31 @@
1
+ module OmniAgent
2
+ # Strategies that split text into chunks for `has_chunks`. A chunker is
3
+ # anything that responds to `call(text, size:, overlap:)` and returns
4
+ # Strings, or Hashes with :content and optional :context and :metadata.
5
+ module Chunkers
6
+ def self.registry
7
+ {
8
+ recursive: Recursive,
9
+ markdown: Markdown,
10
+ hierarchical: Hierarchical
11
+ }
12
+ end
13
+
14
+ def self.fetch(chunker)
15
+ return chunker if chunker.respond_to?(:call)
16
+
17
+ registry.fetch(chunker.to_sym) do
18
+ raise ArgumentError, "Unknown chunker #{chunker.inspect}. Use #{registry.keys.join(', ')}, or an object that responds to call."
19
+ end
20
+ end
21
+
22
+ # Runs a chunker and normalizes its output to Hashes, dropping blank chunks.
23
+ def self.chunk(text, chunker:, size:, overlap:)
24
+ fetch(chunker).call(text, size: size, overlap: overlap).filter_map do |chunk|
25
+ chunk = chunk.is_a?(Hash) ? chunk.to_h.transform_keys(&:to_sym) : { content: chunk.to_s }
26
+ content = chunk[:content].to_s.strip
27
+ { content: content, context: chunk[:context].presence, metadata: chunk[:metadata] || {} } unless content.empty?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -2,7 +2,8 @@ module OmniAgent
2
2
  class Configuration
3
3
  attr_accessor :default_provider, :default_model, :max_retries, :retry_base_delay, :max_tool_iterations,
4
4
  :max_delegation_depth, :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path,
5
- :mcp_allowed_origins, :mcp_max_request_bytes
5
+ :mcp_allowed_origins, :mcp_max_request_bytes,
6
+ :embedding_provider, :embedding_model, :document_loaders
6
7
 
7
8
  def initialize
8
9
  @default_provider = :openai
@@ -17,6 +18,18 @@ module OmniAgent
17
18
  @eval_cache_path = "tmp/omni_agent_eval_cache.json"
18
19
  @mcp_allowed_origins = []
19
20
  @mcp_max_request_bytes = 1_048_576
21
+ @embedding_provider = nil
22
+ @embedding_model = nil
23
+ @embedding_job_options = {}
24
+ @document_loaders = {}
25
+ end
26
+
27
+ attr_reader :embedding_job_options
28
+
29
+ # ActiveJob `set` options for OmniAgent::EmbedJob (has_embedding later: true),
30
+ # e.g. { queue: :embeddings, priority: 10 }.
31
+ def embedding_job_options=(options)
32
+ @embedding_job_options = OmniAgent::Embeddable.validate_job_options!(options, "embedding_job_options")
20
33
  end
21
34
  end
22
35
  end
@@ -0,0 +1,32 @@
1
+ module OmniAgent
2
+ # The plain-Markdown documentation bundled in the gem's docs/ directory, so
3
+ # coding agents working in a host app can read it offline. Generated from
4
+ # omniagent-docs/ with `bundle exec rake docs:sync`.
5
+ module Docs
6
+ ROOT = File.expand_path("../../docs", __dir__)
7
+
8
+ class << self
9
+ def root
10
+ ROOT
11
+ end
12
+
13
+ # Page names relative to the docs root, without the .md extension
14
+ # (e.g. "tools", "mcp/authentication").
15
+ def pages
16
+ Dir.glob("**/*.md", base: root).map { |path| path.delete_suffix(".md") }.sort
17
+ end
18
+
19
+ def path(page = "index")
20
+ name = page.to_s.strip.delete_prefix("/").delete_suffix(".md")
21
+ return unless pages.include?(name)
22
+
23
+ File.join(root, "#{name}.md")
24
+ end
25
+
26
+ def read(page = "index")
27
+ file = path(page)
28
+ File.read(file) if file
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,131 @@
1
+ module OmniAgent
2
+ # ActiveRecord macro that keeps an embedding of some attributes up to date
3
+ # and adds `semantic_search`. Extended into every model by the engine.
4
+ #
5
+ # class Document < ApplicationRecord
6
+ # has_embedding :title, :content
7
+ # end
8
+ #
9
+ # Document.where(account_id: 1).semantic_search("refund policy", limit: 5)
10
+ #
11
+ # The default backend is the neighbor gem (pgvector, sqlite-vec, MariaDB).
12
+ # `later: true` embeds in OmniAgent::EmbedJob after commit instead; pass a
13
+ # Hash (`later: { queue: :embeddings }`) to override the global
14
+ # `embedding_job_options` for this model.
15
+ #
16
+ # `using: :ruby` stores vectors in a JSON column and ranks them in Ruby;
17
+ # it loads every row per search, so it only suits small tables.
18
+ module Embeddable
19
+ BACKENDS = %i[neighbor ruby].freeze
20
+ # The options ActiveJob's `set` takes, for `later:` and embedding_job_options.
21
+ JOB_OPTIONS = %i[queue priority wait wait_until].freeze
22
+
23
+ def self.validate_job_options!(options, setting)
24
+ unknown = options.keys.map(&:to_sym) - JOB_OPTIONS
25
+ return options.to_h.transform_keys(&:to_sym) if unknown.empty?
26
+
27
+ raise ArgumentError, "Unknown #{setting} option #{unknown.first.inspect}. Use #{JOB_OPTIONS.join(', ')}."
28
+ end
29
+
30
+ def has_embedding(*sources, column: :embedding, dimensions: nil, using: :neighbor, later: false)
31
+ raise ArgumentError, "has_embedding requires at least one attribute" if sources.empty?
32
+ raise ArgumentError, "has_embedding using: must be :neighbor or :ruby" unless BACKENDS.include?(using)
33
+ raise ArgumentError, "has_embedding later: must be true, false or a Hash of job options" unless [ true, false ].include?(later) || later.is_a?(Hash)
34
+ raise ArgumentError, "#{name} already uses has_chunks. A model uses has_embedding or has_chunks, not both." if respond_to?(:chunk_options)
35
+
36
+ class_attribute :embedding_options, instance_writer: false
37
+ self.embedding_options = {
38
+ sources: sources.map(&:to_sym),
39
+ column: column.to_sym,
40
+ using: using,
41
+ job_options: later.is_a?(Hash) ? Embeddable.validate_job_options!(later, "later:") : {}
42
+ }
43
+
44
+ if using == :neighbor
45
+ unless respond_to?(:has_neighbors)
46
+ raise OmniAgent::MissingDependencyError,
47
+ "has_embedding needs the neighbor gem: add `gem \"neighbor\"` to your Gemfile. " \
48
+ "Without a vector database, `using: :ruby` works for small tables only."
49
+ end
50
+
51
+ has_neighbors column, dimensions: dimensions
52
+ end
53
+
54
+ extend ClassMethods
55
+ include InstanceMethods
56
+
57
+ if later
58
+ after_commit :embed_later, on: %i[create update], if: -> { embedding_sources.any? { |source| saved_change_to_attribute?(source) } }
59
+ else
60
+ before_save :assign_embedding, if: -> { embedding_sources.any? { |source| will_save_change_to_attribute?(source) } }
61
+ end
62
+ end
63
+
64
+ module ClassMethods
65
+ def semantic_search(query, limit: 5)
66
+ vector = OmniAgent.embed(query)
67
+ column = embedding_options[:column]
68
+
69
+ if embedding_options[:using] == :ruby
70
+ # ponytail: loads every embedded row and ranks in Ruby, O(n) per search; use neighbor past ~10k rows.
71
+ all.where.not(column => nil).to_a.max_by(limit) { |record| cosine_similarity(record[column], vector) }
72
+ else
73
+ all.nearest_neighbors(column, vector, distance: "cosine").first(limit)
74
+ end
75
+ end
76
+
77
+ # Embeds records that have no vector yet, or every record with
78
+ # force: true (e.g. after switching embedding models).
79
+ def embed_all(force: false, batch_size: 100)
80
+ column = embedding_options[:column]
81
+ records = force ? all : all.where(column => nil)
82
+
83
+ records.find_in_batches(batch_size: batch_size) do |batch|
84
+ batch = batch.reject { |record| record.embedding_text.blank? }
85
+ next if batch.empty?
86
+
87
+ OmniAgent.embed(batch.map(&:embedding_text)).zip(batch) do |vector, record|
88
+ record.update_column(column, vector)
89
+ end
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def cosine_similarity(a, b)
96
+ norms = Math.sqrt(a.sum { |x| x * x }) * Math.sqrt(b.sum { |x| x * x })
97
+ norms.zero? ? 0.0 : a.zip(b).sum { |x, y| x * y } / norms
98
+ end
99
+ end
100
+
101
+ module InstanceMethods
102
+ # The text that gets embedded, and what a `searches` tool shows the model.
103
+ def embedding_text
104
+ embedding_sources.filter_map { |source| public_send(source).presence&.to_s }.join("\n\n")
105
+ end
106
+
107
+ def embed!
108
+ update_column(embedding_options[:column], embedding_for(embedding_text))
109
+ end
110
+
111
+ private
112
+
113
+ def embedding_sources
114
+ embedding_options[:sources]
115
+ end
116
+
117
+ def embedding_for(text)
118
+ text.blank? ? nil : OmniAgent.embed(text)
119
+ end
120
+
121
+ def assign_embedding
122
+ self[embedding_options[:column]] = embedding_for(embedding_text)
123
+ end
124
+
125
+ def embed_later
126
+ job_options = OmniAgent.configuration.embedding_job_options.merge(embedding_options[:job_options])
127
+ OmniAgent::EmbedJob.set(**job_options).perform_later(self)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,3 +1,7 @@
1
+ # EmbedJob (has_embedding later:) needs ActiveJob. Loading its railtie here,
2
+ # during Bundler.require, also works in apps that don't require it themselves.
3
+ require "active_job/railtie"
4
+
1
5
  module OmniAgent
2
6
  class Engine < ::Rails::Engine
3
7
  isolate_namespace OmniAgent
@@ -5,5 +9,12 @@ module OmniAgent
5
9
  initializer "omni_agent.mcp_routing" do
6
10
  ActionDispatch::Routing::Mapper.include(OmniAgent::MCP::Routing)
7
11
  end
12
+
13
+ initializer "omni_agent.active_record" do
14
+ ActiveSupport.on_load(:active_record) do
15
+ extend OmniAgent::Embeddable
16
+ extend OmniAgent::Chunkable
17
+ end
18
+ end
8
19
  end
9
20
  end
@@ -7,6 +7,18 @@ module OmniAgent
7
7
  class MaxDelegationDepthError < Error; end
8
8
  class EvalAssertionError < Error; end
9
9
  class MCPError < Error; end
10
+ # Carries the raw model output so callers can log, retry or repair it.
11
+ class StructuredOutputError < Error
12
+ attr_reader :content, :response
13
+
14
+ def initialize(message = nil, content: nil, response: nil)
15
+ super(message)
16
+ @content = content
17
+ @response = response
18
+ end
19
+ end
20
+ class UnknownStockAgentError < Error; end
21
+ class UnsupportedDocumentError < Error; end
10
22
  end
11
23
 
12
24
  Error = Errors::Error
@@ -16,4 +28,7 @@ module OmniAgent
16
28
  MaxDelegationDepthError = Errors::MaxDelegationDepthError
17
29
  EvalAssertionError = Errors::EvalAssertionError
18
30
  MCPError = Errors::MCPError
31
+ StructuredOutputError = Errors::StructuredOutputError
32
+ UnknownStockAgentError = Errors::UnknownStockAgentError
33
+ UnsupportedDocumentError = Errors::UnsupportedDocumentError
19
34
  end
@@ -0,0 +1,21 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ # Visible text only, with block elements on their own paragraphs. Uses
4
+ # Loofah, which Rails already depends on.
5
+ module HTML
6
+ EXTENSIONS = %w[.html .htm .xhtml].freeze
7
+ CONTENT_TYPES = %w[text/html application/xhtml+xml].freeze
8
+
9
+ def self.handles?(filename:, content_type:)
10
+ Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
11
+ end
12
+
13
+ def self.call(io, **)
14
+ require "loofah"
15
+ document = Loofah.document(io.read)
16
+ document.css("script, style, noscript, template").remove
17
+ document.to_text
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ # Reads the file as-is, so the Markdown and hierarchical chunkers can
4
+ # split on its headings.
5
+ module Markdown
6
+ EXTENSIONS = %w[.md .markdown .mdx].freeze
7
+ CONTENT_TYPES = %w[text/markdown text/x-markdown text/x-web-markdown].freeze
8
+
9
+ def self.handles?(filename:, content_type:)
10
+ Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
11
+ end
12
+
13
+ def self.call(io, **)
14
+ io.read
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ # Text-based PDFs, via the optional pdf-reader gem. Scanned PDFs have no
4
+ # text layer and come out empty; they need OCR, in a custom loader.
5
+ module PDF
6
+ EXTENSIONS = %w[.pdf].freeze
7
+ CONTENT_TYPES = %w[application/pdf].freeze
8
+
9
+ def self.handles?(filename:, content_type:)
10
+ Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
11
+ end
12
+
13
+ def self.call(io, **)
14
+ begin
15
+ require "pdf-reader"
16
+ rescue LoadError
17
+ raise OmniAgent::MissingDependencyError, "Reading PDFs needs the pdf-reader gem: add `gem \"pdf-reader\"` to your Gemfile."
18
+ end
19
+
20
+ # pdf-reader pads text with spaces to keep the page layout.
21
+ ::PDF::Reader.new(io).pages.map { |page| page.text.gsub(/[ \t]{2,}/, " ") }.join("\n\n")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ module Text
4
+ EXTENSIONS = %w[.txt .text .csv .log].freeze
5
+
6
+ def self.handles?(filename:, content_type:)
7
+ content_type.to_s.start_with?("text/") || Loaders.matches?(filename, content_type, EXTENSIONS, [])
8
+ end
9
+
10
+ def self.call(io, **)
11
+ io.read
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ module OmniAgent
2
+ # Strategies that turn a document into text for `has_chunks`. A loader is
3
+ # anything that responds to `call(io, filename:, content_type:)` and returns
4
+ # a String. Loaders that also answer `handles?(filename:, content_type:)`
5
+ # take part in auto-detection; register your own in
6
+ # `config.document_loaders` (they're tried before the built-in ones).
7
+ module Loaders
8
+ # Content types that say nothing about the format, so detection skips them.
9
+ GENERIC_CONTENT_TYPES = [ "application/octet-stream", "binary/octet-stream" ].freeze
10
+ SNIFF_BYTES = 8192
11
+
12
+ def self.registry
13
+ built_in = { pdf: PDF, html: HTML, markdown: Markdown, text: Text }
14
+ OmniAgent.configuration.document_loaders.transform_keys(&:to_sym).merge(built_in) { |_name, custom, _built_in| custom }
15
+ end
16
+
17
+ def self.fetch(loader)
18
+ return loader if loader.respond_to?(:call)
19
+
20
+ registry.fetch(loader.to_sym) do
21
+ raise ArgumentError, "Unknown loader #{loader.inspect}. Use :auto, #{registry.keys.join(', ')}, or an object that responds to call."
22
+ end
23
+ end
24
+
25
+ # Reads a document into clean text, with the given loader or, for
26
+ # as: :auto, the first one that handles its filename or content type.
27
+ # Without either, it looks at the first bytes: PDF and HTML are
28
+ # recognized, anything else without NUL bytes is read as text.
29
+ def self.load(io, as: :auto, filename: nil, content_type: nil)
30
+ content_type = nil if GENERIC_CONTENT_TYPES.include?(content_type)
31
+ loader = as.to_s == "auto" ? detect(io, filename: filename, content_type: content_type) : fetch(as)
32
+
33
+ clean(loader.call(io, filename: filename, content_type: content_type))
34
+ end
35
+
36
+ def self.detect(io, filename:, content_type:)
37
+ loader = registry.values.find { |candidate| candidate.respond_to?(:handles?) && candidate.handles?(filename: filename, content_type: content_type) }
38
+ loader || sniff(io) || raise(
39
+ OmniAgent::UnsupportedDocumentError,
40
+ "Can't read #{filename || 'this document'} (#{content_type || 'unknown type'}). " \
41
+ "Pass `as:` a loader, or register one in config.document_loaders."
42
+ )
43
+ end
44
+
45
+ def self.sniff(io)
46
+ sample = io.read(SNIFF_BYTES).to_s
47
+ io.rewind
48
+
49
+ if sample.start_with?("%PDF-") then PDF
50
+ elsif sample.match?(/\A\s*<(!doctype html|html)/i) then HTML
51
+ elsif !sample.include?("\0") then Text
52
+ end
53
+ end
54
+
55
+ # Fixes encodings and strips what databases and chunkers trip on:
56
+ # invalid bytes, NULs (Postgres rejects them), CRLFs and runs of blank lines.
57
+ def self.clean(text)
58
+ text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\u0000")
59
+ .gsub(/\r\n?/, "\n").gsub(/[ \t]+$/, "").gsub(/\n{3,}/, "\n\n").strip
60
+ end
61
+
62
+ def self.matches?(filename, content_type, extensions, content_types)
63
+ content_types.include?(content_type.to_s.split(";").first) || extensions.include?(File.extname(filename.to_s).downcase)
64
+ end
65
+
66
+ private_class_method :detect, :sniff, :clean
67
+ end
68
+ end
@@ -3,7 +3,8 @@ require "active_support/security_utils"
3
3
  module OmniAgent
4
4
  module MCP
5
5
  class Authenticator
6
- Result = Struct.new(:authenticated, :principal) do
6
+ # error and access_token are only set by `authenticate :oauth`.
7
+ Result = Struct.new(:authenticated, :principal, :error, :access_token) do
7
8
  def authenticated?
8
9
  authenticated == true
9
10
  end
@@ -21,7 +22,7 @@ module OmniAgent
21
22
  if authentication.nil?
22
23
  raise OmniAgent::MCPError,
23
24
  "#{@server_class.name} does not declare authentication. " \
24
- "Add `authenticate :bearer, tokens: ...`, `authenticate { |request| ... }`, " \
25
+ "Add `authenticate :bearer, tokens: ...`, `authenticate :oauth, ...`, `authenticate { |request| ... }`, " \
25
26
  "or explicitly opt out with `authenticate :none`."
26
27
  end
27
28
 
@@ -30,6 +31,8 @@ module OmniAgent
30
31
  success(nil)
31
32
  when :bearer
32
33
  authenticate_bearer(request, authentication[:tokens])
34
+ when :oauth
35
+ authentication[:resource_server].authenticate(request)
33
36
  when :custom
34
37
  principal = authentication[:block].call(request)
35
38
  principal ? success(principal) : failure
@@ -0,0 +1,60 @@
1
+ module OmniAgent
2
+ module MCP
3
+ module OAuth
4
+ # A validated access token and its claims (JWT payload or introspection
5
+ # response). It is the principal unless `authenticate :oauth` has a block.
6
+ class AccessToken
7
+ attr_reader :token, :claims
8
+
9
+ def initialize(token, claims)
10
+ @token = token
11
+ @claims = claims.transform_keys(&:to_s).freeze
12
+ end
13
+
14
+ def [](key)
15
+ claims[key.to_s]
16
+ end
17
+
18
+ def subject
19
+ claims["sub"]
20
+ end
21
+
22
+ def issuer
23
+ claims["iss"]
24
+ end
25
+
26
+ def client_id
27
+ claims["client_id"] || claims["azp"]
28
+ end
29
+
30
+ def audience
31
+ Array(claims["aud"]).map(&:to_s)
32
+ end
33
+
34
+ # RFC 9068 / RFC 7662 use a space-separated "scope"; some providers
35
+ # (Okta, Entra ID) use "scp", as a string or an array.
36
+ def scopes
37
+ raw = claims.key?("scope") ? claims["scope"] : claims["scp"]
38
+ raw.is_a?(Array) ? raw.map(&:to_s) : raw.to_s.split
39
+ end
40
+
41
+ def scope?(*names)
42
+ (names.flatten.map(&:to_s) - scopes).empty?
43
+ end
44
+
45
+ def expires_at
46
+ Time.at(claims["exp"]) if claims["exp"].is_a?(Numeric)
47
+ end
48
+
49
+ def to_s
50
+ subject.to_s
51
+ end
52
+
53
+ # Never print the raw token.
54
+ def inspect
55
+ "#<#{self.class.name} sub=#{subject.inspect} client_id=#{client_id.inspect} scopes=#{scopes.inspect}>"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,52 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "openssl"
4
+ require "uri"
5
+
6
+ module OmniAgent
7
+ module MCP
8
+ module OAuth
9
+ # Minimal JSON client for the authorization server (JWKS, introspection).
10
+ module HTTPClient
11
+ OPEN_TIMEOUT = 5
12
+ READ_TIMEOUT = 5
13
+
14
+ NETWORK_ERRORS = [
15
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError
16
+ ].freeze
17
+
18
+ module_function
19
+
20
+ def get_json(url)
21
+ uri = URI.parse(url.to_s)
22
+ request(uri, Net::HTTP::Get.new(uri))
23
+ end
24
+
25
+ def post_form_json(url, form, basic_auth: nil)
26
+ uri = URI.parse(url.to_s)
27
+ post = Net::HTTP::Post.new(uri)
28
+ post.set_form_data(form)
29
+ post.basic_auth(*basic_auth) if basic_auth
30
+ request(uri, post)
31
+ end
32
+
33
+ def request(uri, http_request)
34
+ http_request["accept"] = "application/json"
35
+
36
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
37
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
38
+ http.request(http_request)
39
+ end
40
+
41
+ unless response.is_a?(Net::HTTPSuccess)
42
+ raise VerificationError, "#{http_request.method} #{uri} returned HTTP #{response.code}"
43
+ end
44
+
45
+ JSON.parse(response.body.to_s)
46
+ rescue JSON::ParserError, *NETWORK_ERRORS => e
47
+ raise VerificationError, "#{http_request.method} #{uri} failed: #{e.class}: #{e.message}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ require "uri"
2
+
3
+ module OmniAgent
4
+ module MCP
5
+ module OAuth
6
+ # Asks the authorization server whether a token is active (RFC 7662).
7
+ # Works with opaque tokens. Returns the introspection response, or nil
8
+ # when the token is not active.
9
+ class IntrospectionVerifier
10
+ def initialize(url:, client_id: nil, client_secret: nil)
11
+ raise ArgumentError, "introspection: requires url:" if url.nil?
12
+
13
+ @url = url
14
+ @client_id = client_id
15
+ @client_secret = client_secret
16
+ end
17
+
18
+ def call(token, _request = nil)
19
+ response = HTTPClient.post_form_json(
20
+ OAuth.resolve(@url),
21
+ { "token" => token, "token_type_hint" => "access_token" },
22
+ basic_auth: credentials
23
+ )
24
+ response.is_a?(Hash) && response["active"] == true ? response : nil
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class.name} url=#{@url.inspect}>"
29
+ end
30
+
31
+ private
32
+
33
+ # client_secret_basic: RFC 6749 section 2.3.1 form-encodes both values.
34
+ def credentials
35
+ client_id = OAuth.resolve(@client_id)
36
+ return if client_id.nil?
37
+
38
+ [ client_id, OAuth.resolve(@client_secret) ].map { |value| URI.encode_www_form_component(value.to_s) }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end