otto 1.5.0 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a50c097fe32fba3c0a6d84be84f95f68fffe385675f3fc353681b24f11aefa63
4
- data.tar.gz: 9ad3e5c757531c10b1fbd60d304a07aa5ce84915491acb6c36b61656b8b6f6b3
3
+ metadata.gz: 0acf8247a8132f9e836ba841732a45bf265efbc0cae190c744e323a97229d913
4
+ data.tar.gz: 05fe0d3c74d9385e470ea45856e0c8cb2b081ff6eea63a83951039bd426219b3
5
5
  SHA512:
6
- metadata.gz: c1be28acdcec40db91e12c39926a5197326b0c46b5d269187f8cc3114cfbcae6ae025f1fa69c6c8820faa076c2067c3200838a15818291c729df40271cca3b00
7
- data.tar.gz: bfa3af6f70fd650464b935b8b30c687b2393b9a687b68837ab21ffcb2ffb710acc1dea85942e043a8307a4b2d6360b4037846e710f2fc43e47a66100dddea807
6
+ metadata.gz: 1f86dd56ef78a8f892d4e75046f2ac4560e951999773b668dba6a15a270d4f811aa0df56f618c0a8b1ce75af7b76799ca083b0b30788cc5733cc570107233f64
7
+ data.tar.gz: 6fd7fcc1008ed682dbcc7a4e400a220c55a8f1cd9d3081e7d089e761c14f883d11028ad1b0e279d7ea96c33e2ff4fc6afb0aabf69356d0c42ba8527eb2e4627d
@@ -8,6 +8,12 @@ on:
8
8
  pull_request:
9
9
 
10
10
  workflow_dispatch:
11
+ inputs:
12
+ debug_enabled:
13
+ type: boolean
14
+ description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)"
15
+ required: false
16
+ default: false
11
17
 
12
18
  permissions:
13
19
  contents: read
@@ -17,10 +23,19 @@ jobs:
17
23
  timeout-minutes: 10
18
24
  runs-on: ubuntu-24.04
19
25
  name: "RSpec Tests (Ruby ${{ matrix.ruby }})"
26
+ continue-on-error: ${{ matrix.experimental }}
20
27
  strategy:
21
- fail-fast: true
28
+ fail-fast: false
22
29
  matrix:
23
- ruby: ["3.2", "3.3", "3.4", "3.5"]
30
+ include:
31
+ - ruby: "3.2"
32
+ experimental: false
33
+ - ruby: "3.3"
34
+ experimental: false
35
+ - ruby: "3.4"
36
+ experimental: false
37
+ - ruby: "3.5"
38
+ experimental: true
24
39
 
25
40
  steps:
26
41
  - uses: actions/checkout@v4
@@ -30,5 +45,29 @@ jobs:
30
45
  ruby-version: ${{ matrix.ruby }}
31
46
  bundler-cache: true
32
47
 
33
- - name: Run RSpec tests
34
- run: bundle exec rspec
48
+ - name: Setup tmate session
49
+ uses: mxschmitt/action-tmate@7b6a61a73bbb9793cb80ad69b8dd8ac19261834c # v3
50
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
51
+ with:
52
+ detached: true
53
+
54
+ - name: Install dependencies
55
+ continue-on-error: ${{ matrix.experimental }}
56
+ run: |
57
+ bundle config path vendor/bundle
58
+ bundle install --jobs 4 --retry 3 --with test
59
+
60
+ - name: Verify setup
61
+ run: |
62
+ bundle exec which rspec || echo "rspec not found"
63
+ bundle list | grep -E "(rspec|rake|test)"
64
+ ls -la bin/ || echo "No bin directory"
65
+
66
+ - name: Run test specs
67
+ env:
68
+ REDIS_URL: "redis://127.0.0.1:2121/0"
69
+ run: |
70
+ mkdir tmp && bundle exec rspec \
71
+ --format progress \
72
+ --format json \
73
+ --out tmp/rspec_results.json
data/.rubocop.yml CHANGED
@@ -135,7 +135,7 @@ Style/BlockDelimiters:
135
135
  Enabled: true
136
136
 
137
137
  Naming/PredicateMethod:
138
- Enabled: true
138
+ Enabled: false
139
139
  Mode: "conservative"
140
140
  AllowedMethods:
141
141
  - validate!
data/Gemfile CHANGED
@@ -1,13 +1,22 @@
1
+ # Gemfile
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  gemspec
4
6
 
5
- group :development, :test do
7
+ group :test do
6
8
  gem 'rack-test'
7
9
  gem 'rspec', '~> 3.12'
8
10
  end
9
11
 
10
- group 'development' do
12
+ # bundle config set with 'optional'
13
+ group :development, :test, optional: true do
14
+ # Keep gems that need to be in both environments
15
+ gem 'json_schemer'
16
+ gem 'rack-attack'
17
+ end
18
+
19
+ group :development do
11
20
  gem 'pry-byebug', require: false
12
21
  gem 'rubocop', require: false
13
22
  gem 'rubocop-performance', require: false
@@ -16,5 +25,5 @@ group 'development' do
16
25
  gem 'ruby-lsp', require: false
17
26
  gem 'stackprof', require: false
18
27
  gem 'syntax_tree', require: false
19
- gem 'tryouts', '~> 3.3.1', require: false
28
+ gem 'tryouts', '~> 3.3.2', require: false
20
29
  end
data/Gemfile.lock CHANGED
@@ -1,7 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- otto (1.5.0)
4
+ otto (1.6.0)
5
+ facets (~> 3.1)
6
+ loofah (~> 2.20)
5
7
  ostruct
6
8
  rack (~> 3.1, < 4.0)
7
9
  rack-parser (~> 0.7)
@@ -11,22 +13,51 @@ GEM
11
13
  remote: https://rubygems.org/
12
14
  specs:
13
15
  ast (2.4.3)
16
+ bigdecimal (3.2.2)
14
17
  byebug (12.0.0)
15
18
  coderay (1.1.3)
19
+ concurrent-ruby (1.3.5)
20
+ crass (1.0.6)
16
21
  date (3.4.1)
17
22
  diff-lcs (1.6.2)
18
23
  erb (5.0.2)
24
+ facets (3.1.0)
25
+ hana (1.3.7)
19
26
  io-console (0.8.1)
20
27
  irb (1.15.2)
21
28
  pp (>= 0.6.0)
22
29
  rdoc (>= 4.0.0)
23
30
  reline (>= 0.4.2)
24
31
  json (2.13.2)
32
+ json_schemer (2.4.0)
33
+ bigdecimal
34
+ hana (~> 1.3)
35
+ regexp_parser (~> 2.0)
36
+ simpleidn (~> 0.2)
25
37
  language_server-protocol (3.17.0.5)
26
38
  lint_roller (1.1.0)
27
39
  logger (1.7.0)
40
+ loofah (2.24.1)
41
+ crass (~> 1.0.2)
42
+ nokogiri (>= 1.12.0)
28
43
  method_source (1.1.0)
29
44
  minitest (5.25.5)
45
+ nokogiri (1.18.9-aarch64-linux-gnu)
46
+ racc (~> 1.4)
47
+ nokogiri (1.18.9-aarch64-linux-musl)
48
+ racc (~> 1.4)
49
+ nokogiri (1.18.9-arm-linux-gnu)
50
+ racc (~> 1.4)
51
+ nokogiri (1.18.9-arm-linux-musl)
52
+ racc (~> 1.4)
53
+ nokogiri (1.18.9-arm64-darwin)
54
+ racc (~> 1.4)
55
+ nokogiri (1.18.9-x86_64-darwin)
56
+ racc (~> 1.4)
57
+ nokogiri (1.18.9-x86_64-linux-gnu)
58
+ racc (~> 1.4)
59
+ nokogiri (1.18.9-x86_64-linux-musl)
60
+ racc (~> 1.4)
30
61
  ostruct (0.6.3)
31
62
  parallel (1.27.0)
32
63
  parser (3.3.9.0)
@@ -50,6 +81,8 @@ GEM
50
81
  stringio
51
82
  racc (1.8.1)
52
83
  rack (3.2.0)
84
+ rack-attack (6.7.0)
85
+ rack (>= 1.0, < 4)
53
86
  rack-parser (0.7.0)
54
87
  rack
55
88
  rack-test (2.2.0)
@@ -60,7 +93,7 @@ GEM
60
93
  rdoc (6.14.2)
61
94
  erb
62
95
  psych (>= 4.0.0)
63
- regexp_parser (2.11.0)
96
+ regexp_parser (2.11.2)
64
97
  reline (0.6.2)
65
98
  io-console (~> 0.5)
66
99
  rexml (3.4.1)
@@ -77,7 +110,7 @@ GEM
77
110
  diff-lcs (>= 1.2.0, < 2.0)
78
111
  rspec-support (~> 3.13.0)
79
112
  rspec-support (3.13.4)
80
- rubocop (1.79.1)
113
+ rubocop (1.79.2)
81
114
  json (~> 2.3)
82
115
  language_server-protocol (~> 3.17.0.2)
83
116
  lint_roller (~> 1.1.0)
@@ -107,11 +140,13 @@ GEM
107
140
  prism (>= 1.2, < 2.0)
108
141
  rbs (>= 3, < 5)
109
142
  ruby-progressbar (1.13.0)
143
+ simpleidn (0.2.3)
110
144
  stackprof (0.2.27)
111
145
  stringio (3.1.7)
112
146
  syntax_tree (6.3.0)
113
147
  prettier_print (>= 1.2.0)
114
- tryouts (3.3.1)
148
+ tryouts (3.3.2)
149
+ concurrent-ruby (~> 1.0)
115
150
  irb
116
151
  minitest (~> 5.0)
117
152
  pastel (~> 0.8)
@@ -127,12 +162,20 @@ GEM
127
162
  unicode-emoji (4.0.4)
128
163
 
129
164
  PLATFORMS
130
- arm64-darwin-24
131
- ruby
165
+ aarch64-linux-gnu
166
+ aarch64-linux-musl
167
+ arm-linux-gnu
168
+ arm-linux-musl
169
+ arm64-darwin
170
+ x86_64-darwin
171
+ x86_64-linux-gnu
172
+ x86_64-linux-musl
132
173
 
133
174
  DEPENDENCIES
175
+ json_schemer
134
176
  otto!
135
177
  pry-byebug
178
+ rack-attack
136
179
  rack-test
137
180
  rspec (~> 3.12)
138
181
  rubocop
@@ -142,7 +185,7 @@ DEPENDENCIES
142
185
  ruby-lsp
143
186
  stackprof
144
187
  syntax_tree
145
- tryouts (~> 3.3.1)
188
+ tryouts (~> 3.3.2)
146
189
 
147
190
  BUNDLED WITH
148
- 2.6.9
191
+ 2.7.1
data/bin/rspec ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12
+
13
+ require "rubygems"
14
+ require "bundler/setup"
15
+
16
+ load Gem.bin_path("rspec-core", "rspec")
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Example Otto application with MCP support
4
+ # This demonstrates Phase 1 & 2 implementation
5
+
6
+ require_relative '../../lib/otto'
7
+
8
+ class UserAPI
9
+ def self.mcp_list_users
10
+ {
11
+ users: [
12
+ { id: 1, name: 'Alice', email: 'alice@example.com' },
13
+ { id: 2, name: 'Bob', email: 'bob@example.com' }
14
+ ]
15
+ }.to_json
16
+ end
17
+
18
+ def self.mcp_create_user(arguments, env)
19
+ # Tool handler that creates a user
20
+ name = arguments['name'] || 'Anonymous'
21
+ email = arguments['email'] || "#{name.downcase}@example.com"
22
+
23
+ new_user = {
24
+ id: rand(1000..9999),
25
+ name: name,
26
+ email: email,
27
+ created_at: Time.now.iso8601
28
+ }
29
+
30
+ "Created user: #{new_user.to_json}"
31
+ end
32
+ end
33
+
34
+ # Initialize Otto with MCP support
35
+ otto = Otto.new('routes', {
36
+ mcp_enabled: true,
37
+ auth_tokens: ['demo-token-123'], # Simple token auth
38
+ requests_per_minute: 10, # Lower for demo
39
+ tools_per_minute: 5
40
+ })
41
+
42
+ # Enable MCP with authentication tokens
43
+ otto.enable_mcp!({
44
+ auth_tokens: ['demo-token-123', 'another-token-456'],
45
+ enable_validation: true,
46
+ enable_rate_limiting: true
47
+ })
48
+
49
+ puts "Otto MCP Demo Server starting..."
50
+ puts "MCP endpoint: POST /_mcp"
51
+ puts "Auth tokens: demo-token-123, another-token-456"
52
+ puts "Usage: curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\"
53
+ puts " -d '{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"id\":1,\"params\":{}}' \\"
54
+ puts " http://localhost:9292/_mcp"
55
+
56
+ otto
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../../lib/otto'
4
+
5
+ class DemoApp
6
+ def self.index(req, res)
7
+ res.body = <<-HTML
8
+ <h1>Otto MCP Demo</h1>
9
+ <p>MCP endpoint available at: <code>POST /_mcp</code></p>
10
+ <p>Auth tokens: <code>demo-token-123</code>, <code>another-token-456</code></p>
11
+
12
+ <h2>Test MCP Initialize</h2>
13
+ <pre>curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\
14
+ -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}' \\
15
+ http://localhost:9292/_mcp</pre>
16
+
17
+ <h2>List Resources</h2>
18
+ <pre>curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\
19
+ -d '{"jsonrpc":"2.0","method":"resources/list","id":2}' \\
20
+ http://localhost:9292/_mcp</pre>
21
+
22
+ <h2>List Tools</h2>
23
+ <pre>curl -H 'Authorization: Bearer demo-token-123' -H 'Content-Type: application/json' \\
24
+ -d '{"jsonrpc":"2.0","method":"tools/list","id":3}' \\
25
+ http://localhost:9292/_mcp</pre>
26
+ HTML
27
+ end
28
+
29
+ def self.health(req, res)
30
+ res.body = 'OK'
31
+ end
32
+ end
33
+
34
+ class UserAPI
35
+ def self.mcp_list_users
36
+ {
37
+ users: [
38
+ { id: 1, name: 'Alice', email: 'alice@example.com' },
39
+ { id: 2, name: 'Bob', email: 'bob@example.com' }
40
+ ]
41
+ }.to_json
42
+ end
43
+
44
+ def self.mcp_create_user(arguments, env)
45
+ # Tool handler that creates a user
46
+ name = arguments['name'] || 'Anonymous'
47
+ email = arguments['email'] || "#{name.downcase}@example.com"
48
+
49
+ new_user = {
50
+ id: rand(1000..9999),
51
+ name: name,
52
+ email: email,
53
+ created_at: Time.now.iso8601
54
+ }
55
+
56
+ "Created user: #{new_user.to_json}"
57
+ end
58
+ end
59
+
60
+ # Initialize Otto with MCP support
61
+ otto = Otto.new('routes', {
62
+ mcp_enabled: true,
63
+ auth_tokens: ['demo-token-123', 'another-token-456'],
64
+ requests_per_minute: 60,
65
+ tools_per_minute: 20
66
+ })
67
+
68
+ run otto
@@ -0,0 +1,9 @@
1
+ # Basic HTTP routes
2
+ GET / DemoApp.index
3
+ GET /health DemoApp.health
4
+
5
+ # MCP Resources - provide read-only data
6
+ MCP /users UserAPI.mcp_list_users
7
+
8
+ # MCP Tools - provide actions/operations
9
+ TOOL /create_user UserAPI.mcp_create_user
@@ -0,0 +1,68 @@
1
+ # lib/concurrent_cache_store.rb
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ # Thread-safe cache store with TTL support for Rack::Attack
6
+ # Provides ActiveSupport::Cache::MemoryStore-compatible interface
7
+ #
8
+ # Usage:
9
+ #
10
+ # Rack::Attack.cache.store = ConcurrentCacheStore.new(default_ttl: 300)
11
+ #
12
+ class ConcurrentCacheStore
13
+ # @param default_ttl [Integer] Default time-to-live in seconds for cache entries
14
+ def initialize(default_ttl: 300)
15
+ @store = Concurrent::Map.new
16
+ @default_ttl = default_ttl
17
+ end
18
+
19
+ # Retrieves a value from the cache
20
+ # @param key [String] The cache key
21
+ # @return [Object, nil] The cached value or nil if expired/missing
22
+ def read(key)
23
+ entry = @store[key]
24
+ return nil unless entry
25
+
26
+ if Time.now > entry[:expires_at]
27
+ @store.delete(key)
28
+ nil
29
+ else
30
+ entry[:value]
31
+ end
32
+ end
33
+
34
+ # Stores a value in the cache with expiration
35
+ # @param key [String] The cache key
36
+ # @param value [Object] The value to store
37
+ # @param expires_in [Integer] TTL in seconds (optional)
38
+ # @return [Object] The stored value
39
+ def write(key, value, expires_in: @default_ttl)
40
+ @store[key] = {
41
+ value: value,
42
+ expires_at: Time.now + expires_in,
43
+ }
44
+ value
45
+ end
46
+
47
+ # Atomically increments a numeric value, creating if missing
48
+ # @param key [String] The cache key
49
+ # @param amount [Integer] Amount to increment by
50
+ # @param expires_in [Integer] TTL in seconds for new entries
51
+ # @return [Integer] The new value after increment
52
+ def increment(key, amount = 1, expires_in: @default_ttl)
53
+ @store.compute(key) do |_, entry|
54
+ if entry && Time.now <= entry[:expires_at]
55
+ entry[:value] += amount
56
+ entry
57
+ else
58
+ { value: amount, expires_at: Time.now + expires_in }
59
+ end
60
+ end[:value]
61
+ end
62
+
63
+ # Removes all entries from the cache
64
+ # @return [void]
65
+ def clear
66
+ @store.clear
67
+ end
68
+ end
@@ -0,0 +1,83 @@
1
+ # lib/otto/helpers/validation.rb
2
+
3
+ class Otto
4
+ module Security
5
+ module ValidationHelpers
6
+ def validate_input(input, max_length: 1000, allow_html: false)
7
+ return input if input.nil?
8
+
9
+ input_str = input.to_s
10
+ return input_str if input_str.empty?
11
+
12
+ # Check length
13
+ if input_str.length > max_length
14
+ raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
15
+ end
16
+
17
+ # Use Loofah for HTML sanitization and validation
18
+ unless allow_html
19
+ # Check for script injection first (these should always be rejected)
20
+ if looks_like_script_injection?(input_str)
21
+ raise Otto::Security::ValidationError, 'Dangerous content detected'
22
+ end
23
+
24
+ # Use Loofah to sanitize less dangerous HTML content
25
+ sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
26
+ input_str = sanitized_input
27
+ end
28
+
29
+ # Always check for SQL injection
30
+ ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
31
+ if input_str.match?(pattern)
32
+ raise Otto::Security::ValidationError, 'Potential SQL injection detected'
33
+ end
34
+ end
35
+
36
+ input_str
37
+ end
38
+
39
+ def sanitize_filename(filename)
40
+ return nil if filename.nil?
41
+ return 'file' if filename.empty?
42
+
43
+ # Use Facets File.sanitize for basic filesystem-safe filename
44
+ clean_name = File.sanitize(filename.to_s)
45
+
46
+ # Handle edge cases and improve on Facets behavior to match test expectations
47
+ if clean_name.nil? || clean_name.empty?
48
+ clean_name = 'file'
49
+ else
50
+ # Additional cleanup that Facets doesn't do but our tests expect
51
+ clean_name = clean_name.gsub(/_{2,}/, '_') # Collapse multiple underscores
52
+ clean_name = clean_name.gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
53
+ clean_name = 'file' if clean_name.empty? # Handle case where only underscores remain
54
+ end
55
+
56
+ # Ensure reasonable length (255 is filesystem limit, leave some padding)
57
+ clean_name = clean_name[0..99] if clean_name.length > 100
58
+
59
+ clean_name
60
+ end
61
+
62
+ private
63
+
64
+ # Check if content looks like it contains HTML tags or entities
65
+ def contains_html_like_content?(content)
66
+ content.match?(/[<>&]/) || content.match?(/&\w+;/)
67
+ end
68
+
69
+ # Detect likely script injection attempts that should be rejected
70
+ def looks_like_script_injection?(content)
71
+ dangerous_patterns = [
72
+ /javascript:/i,
73
+ /<script[^>]*>/i,
74
+ /on\w+\s*=/i, # event handlers like onclick=
75
+ /expression\s*\(/i,
76
+ /data:.*base64/i,
77
+ ]
78
+
79
+ dangerous_patterns.any? { |pattern| content.match?(pattern) }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,76 @@
1
+ require 'json'
2
+
3
+ class Otto
4
+ module MCP
5
+ module Auth
6
+ class TokenAuth
7
+ def initialize(tokens)
8
+ @tokens = Array(tokens).to_set
9
+ end
10
+
11
+ def authenticate(env)
12
+ token = extract_token(env)
13
+ return false unless token
14
+
15
+ @tokens.include?(token)
16
+ end
17
+
18
+ private
19
+
20
+ def extract_token(env)
21
+ # Try Authorization header first (Bearer token)
22
+ auth_header = env['HTTP_AUTHORIZATION']
23
+ if auth_header&.start_with?('Bearer ')
24
+ return auth_header[7..]
25
+ end
26
+
27
+ # Try X-MCP-Token header
28
+ env['HTTP_X_MCP_TOKEN']
29
+ end
30
+ end
31
+
32
+ class TokenMiddleware
33
+ def initialize(app, security_config = nil)
34
+ @app = app
35
+ @security_config = security_config
36
+ end
37
+
38
+ def call(env)
39
+ # Only apply to MCP endpoints
40
+ return @app.call(env) unless mcp_endpoint?(env)
41
+
42
+ # Get auth instance from security config
43
+ auth = @security_config&.mcp_auth
44
+ if auth && !auth.authenticate(env)
45
+ return unauthorized_response
46
+ end
47
+
48
+ @app.call(env)
49
+ end
50
+
51
+ private
52
+
53
+ def mcp_endpoint?(env)
54
+ endpoint = env['otto.mcp_http_endpoint'] || '/_mcp'
55
+ path = env['PATH_INFO'].to_s
56
+ path.start_with?(endpoint)
57
+ end
58
+
59
+ def unauthorized_response
60
+ body = JSON.generate({
61
+ jsonrpc: '2.0',
62
+ id: nil,
63
+ error: {
64
+ code: -32_000,
65
+ message: 'Unauthorized',
66
+ data: 'Valid token required',
67
+ },
68
+ },
69
+ )
70
+
71
+ [401, { 'content-type' => 'application/json' }, [body]]
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end