syntropy 0.30.0 → 0.31.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/bin/syntropy +8 -86
  4. data/cmd/_banner.rb +16 -0
  5. data/cmd/help.rb +12 -0
  6. data/cmd/serve.rb +95 -0
  7. data/cmd/test.rb +40 -0
  8. data/examples/{counter.rb → basic/counter.rb} +1 -1
  9. data/examples/{templates.rb → basic/templates.rb} +1 -1
  10. data/examples/mcp-oauth/.ruby-version +1 -0
  11. data/examples/mcp-oauth/Gemfile +8 -0
  12. data/examples/mcp-oauth/README.md +128 -0
  13. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  14. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  15. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  16. data/examples/mcp-oauth/app/index.md +1 -0
  17. data/examples/mcp-oauth/app/mcp.rb +38 -0
  18. data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
  19. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  20. data/examples/mcp-oauth/app/oauth/register.rb +15 -0
  21. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  22. data/examples/mcp-oauth/app/signin.rb +85 -0
  23. data/examples/mcp-oauth/test/helper.rb +9 -0
  24. data/examples/mcp-oauth/test/test_app.rb +27 -0
  25. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  26. data/lib/syntropy/app.rb +15 -4
  27. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  28. data/lib/syntropy/applets/builtin/req.rb +1 -1
  29. data/lib/syntropy/dev_mode.rb +1 -1
  30. data/lib/syntropy/errors.rb +6 -0
  31. data/lib/syntropy/http/client.rb +43 -0
  32. data/lib/syntropy/http/client_connection.rb +36 -0
  33. data/lib/syntropy/http/io_extensions.rb +148 -0
  34. data/lib/syntropy/http/server.rb +5 -5
  35. data/lib/syntropy/http/{connection.rb → server_connection.rb} +9 -38
  36. data/lib/syntropy/http.rb +3 -1
  37. data/lib/syntropy/logger.rb +5 -1
  38. data/lib/syntropy/papercraft_extensions.rb +1 -1
  39. data/lib/syntropy/request/mock_adapter.rb +2 -0
  40. data/lib/syntropy/request/request_info.rb +20 -1
  41. data/lib/syntropy/request/response.rb +2 -2
  42. data/lib/syntropy/request/validation.rb +10 -3
  43. data/lib/syntropy/routing_tree.rb +2 -1
  44. data/lib/syntropy/test.rb +65 -0
  45. data/lib/syntropy/version.rb +1 -1
  46. data/lib/syntropy.rb +1 -21
  47. data/syntropy.gemspec +1 -2
  48. data/test/app/.well-known/foo.rb +3 -0
  49. data/test/helper.rb +1 -25
  50. data/test/test_app.rb +53 -68
  51. data/test/test_caching.rb +1 -1
  52. data/test/test_http_client.rb +52 -0
  53. data/test/test_http_client_connection.rb +43 -0
  54. data/test/{test_connection.rb → test_http_server_connection.rb} +29 -29
  55. data/test/test_json_api.rb +4 -4
  56. data/test/{test_request_extensions.rb → test_request.rb} +150 -18
  57. data/test/test_routing_tree.rb +15 -3
  58. metadata +41 -29
  59. data/test/test_request_info.rb +0 -90
  60. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  61. /data/examples/{card.rb → basic/card.rb} +0 -0
  62. /data/examples/{counter.js → basic/counter.js} +0 -0
  63. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  64. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  65. /data/examples/{index.md → basic/index.md} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70dae64fe246aa0851a582ff914d647bcfc650755a237ef476a37de8fd0cc052
4
- data.tar.gz: 9fde1a73c1136b316f4a5a42d7d0e359df76f4a7015146a5e4c0583eb22c9f76
3
+ metadata.gz: 3908853da702fa27301792535415763adf546fe51bd9f3b91d530a65b55f8d66
4
+ data.tar.gz: 34f6387d31fa46134ed8aa87d609b3400c506b832fe2f9baa64ee5225da1f929
5
5
  SHA512:
6
- metadata.gz: 2a5561fd30d062766fbb5e5df5ab80b1a6840b5a0bb29c2b1bc0e9a3a7872462face8a9e908d72f1a5426f5ad781b91adc079ca2412efbe95071b68f76c030d6
7
- data.tar.gz: 22708dd2c98a250068613cbc1d54157ec5673100d2d4df2df216fa353085cbc13c696ae3903a527926c03c17ba746a145c1ff70f0aefddf7fd8996d60527d263
6
+ metadata.gz: f899a3f06335acc22181160e5820563fa21f1a1dd981fd33a7f0b1530ac61801455f430f82a1be130682a1c58d7d0b21dfb3d816efc742898c51133fa9223c24
7
+ data.tar.gz: 4e7a6637bf8b4f280689d3053557e8a77585da711e626fab7e81f8bfa39fdc1065288a9bd9a5d940f2e04ad0b1a0b6a3f53ee3d65e6f53a14ebd7d31883e3696
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ # 0.31.0 2026-05-29
2
+
3
+ - Add message kwarg to `Request#validate`
4
+ - Reraise internal server error in test mode
5
+ - Use strings rather than symbols for query keys
6
+ - Add content type validation
7
+ - Add syntropy CLI commands: serve, test, help
8
+ - Rename `#auto_refresh_watch!` to `#auto_refresh!`
9
+ - Use `zed` instead of `vscode` scheme for editor links (in HTML-rendered error
10
+ backtraces)
11
+ - Implement basic `HTTP::Client` API
12
+ - Rename `#json_response` to `#respond_json`, `#html_response` to
13
+ `#respond_html`
14
+ - Rename `HTTP::Connection` to `HTTP::ServerConnection`
15
+ - Refactor HTTP protocol into UM::IO extensions
16
+ - Fix routing for files/dirs starting with . (e.g. /.well-known/*)
17
+
1
18
  # 0.30.0 2026-05-10
2
19
 
3
20
  - Refactor HTTP modules
data/bin/syntropy CHANGED
@@ -1,93 +1,15 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require 'syntropy'
5
- require 'optparse'
4
+ require_relative '../cmd/_banner'
6
5
 
7
- env = {
8
- mount_path: '/',
9
- banner: Syntropy::BANNER,
10
- logger: true,
11
- builtin_applet_path: '/.syntropy',
12
- server_extensions: {
13
- date: true,
14
- name: 'Syntropy'
15
- }
16
- }
6
+ def cmd_fn(cmd)= File.join(__dir__, "../cmd/#{cmd}.rb")
17
7
 
18
- parser = OptionParser.new do |o|
19
- o.banner = 'Usage: syntropy [options] DIR'
8
+ cmd = ARGV.shift || 'help'
9
+ cmd = 'help' if cmd !~ /^[a-z]+$/
20
10
 
21
- o.on('-b', '--bind BIND', String,
22
- 'Bind address (default: http://0.0.0.0:1234). You can specify this flag multiple times to bind to multiple addresses.') do
23
- env[:bind] ||= []
24
- env[:bind] << it
25
- end
11
+ fn = cmd_fn(cmd)
12
+ fn = cmd_fn('help') if !File.file?(fn)
26
13
 
27
- o.on('-s', '--silent', 'Silent mode') do
28
- env[:banner] = nil
29
- env[:logger] = nil
30
- end
31
-
32
- o.on('-d', '--dev', 'Development mode') do
33
- env[:dev_mode] = true
34
- env[:watch_files] = 0.1
35
- end
36
-
37
- o.on('-h', '--help', 'Show this help message') do
38
- puts o
39
- exit
40
- end
41
-
42
- o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
43
- p mount: path
44
- env[:mount_path] = path
45
- env[:builtin_applet_path] = File.join(path, '.syntropy')
46
- end
47
-
48
- o.on('--no-builtin-applet', 'Do not mount builtin applet') do
49
- env[:builtin_applet_path] = nil
50
- end
51
-
52
- o.on('--no-server-headers', 'Don\'t include Server and Date headers') do
53
- env[:server_extensions] = nil
54
- end
55
-
56
- o.on('-v', '--version', 'Show version') do
57
- require 'syntropy/version'
58
- puts "Syntropy version #{Syntropy::VERSION}"
59
- exit
60
- end
61
- end
62
-
63
- RubyVM::YJIT.enable rescue nil
64
-
65
- begin
66
- parser.parse!
67
- rescue StandardError => e
68
- puts e.message
69
- puts e.backtrace.join("\n")
70
- exit
71
- end
72
-
73
- $syntropy_dev_mode = env[:dev_mode]
74
- env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
75
-
76
- if !File.directory?(env[:root_dir])
77
- puts "#{File.expand_path(env[:root_dir])} Not a directory"
78
- exit
79
- end
80
-
81
- puts env[:banner] if env[:banner]
82
- env[:banner] = false
83
-
84
-
85
- # We set Syntropy.machine so we can reference it from anywhere
86
- env[:machine] = Syntropy.machine = UM.new
87
- env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
88
-
89
- require 'syntropy/version'
90
- require 'syntropy/dev_mode' if env[:dev_mode]
91
-
92
- app = Syntropy::App.load(env)
93
- Syntropy.run(env) { app.call(it) }
14
+ puts SYNTROPY_BANNER
15
+ require(fn)
data/cmd/_banner.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ green = "\e[32m"
4
+ clear = "\e[0m"
5
+ yellow = "\e[33m"
6
+
7
+ SYNTROPY_BANNER =
8
+ "\n"\
9
+ " #{green}\n"\
10
+ " #{green} ooo\n"\
11
+ " #{green}ooooo\n"\
12
+ " #{green} ooo vvv #{clear}Syntropy - a web framework for Ruby\n"\
13
+ " #{green} o vvvvv #{clear}--------------------------------------\n"\
14
+ " #{green} #{yellow}|#{green} vvv o #{clear}https://github.com/digital-fabric/syntropy\n"\
15
+ " #{green} :#{yellow}|#{green}:::#{yellow}|#{green}::#{yellow}|#{green}:\n"\
16
+ "#{yellow}++++++++++++++++++++++++++++++++++++++++++++++++++++++++++#{clear}\n\n"
data/cmd/help.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ HELP = <<~MSG
4
+ Usage: syntropy COMMAND [options]
5
+
6
+ Available commands:
7
+
8
+ serve Start a Syntropy server
9
+ test Run tests
10
+ MSG
11
+
12
+ $stdout << HELP
data/cmd/serve.rb ADDED
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/syntropy'
4
+ require 'optparse'
5
+
6
+ env = {
7
+ mount_path: '/',
8
+ logger: true,
9
+ builtin_applet_path: '/.syntropy',
10
+ server_extensions: {
11
+ date: true,
12
+ name: 'Syntropy'
13
+ }
14
+ }
15
+
16
+ parser = OptionParser.new do |o|
17
+ o.banner = 'Usage: syntropy serve [options] DIR'
18
+
19
+ o.on('-b', '--bind BIND', String,
20
+ 'Bind address (default: http://0.0.0.0:1234). You can specify this flag multiple times to bind to multiple addresses.') do
21
+ env[:bind] ||= []
22
+ env[:bind] << it
23
+ end
24
+
25
+ o.on('-s', '--silent', 'Silent mode') do
26
+ env[:banner] = nil
27
+ env[:logger] = nil
28
+ end
29
+
30
+ o.on('-d', '--dev', 'Development mode') do
31
+ env[:dev_mode] = true
32
+ env[:watch_files] = 0.1
33
+ end
34
+
35
+ o.on('-h', '--help', 'Show this help message') do
36
+ puts o
37
+ exit
38
+ end
39
+
40
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
41
+ p mount: path
42
+ env[:mount_path] = path
43
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
44
+ end
45
+
46
+ o.on('--no-builtin-applet', 'Do not mount builtin applet') do
47
+ env[:builtin_applet_path] = nil
48
+ end
49
+
50
+ o.on('--no-server-headers', 'Don\'t include Server and Date headers') do
51
+ env[:server_extensions] = nil
52
+ end
53
+
54
+ o.on('-v', '--version', 'Show version') do
55
+ require 'syntropy/version'
56
+ puts "Syntropy version #{Syntropy::VERSION}"
57
+ exit
58
+ end
59
+ end
60
+
61
+ RubyVM::YJIT.enable rescue nil
62
+
63
+ begin
64
+ parser.parse!
65
+ rescue OptionParser::InvalidOption
66
+ puts parser
67
+ exit
68
+ rescue StandardError => e
69
+ p e
70
+ puts e.message
71
+ puts e.backtrace.join("\n")
72
+ exit
73
+ end
74
+
75
+ $syntropy_dev_mode = env[:dev_mode]
76
+ env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
77
+
78
+ if !File.directory?(env[:root_dir])
79
+ puts "#{File.expand_path(env[:root_dir])} Not a directory"
80
+ exit
81
+ end
82
+
83
+ puts env[:banner] if env[:banner]
84
+ env[:banner] = false
85
+
86
+
87
+ # We set Syntropy.machine so we can reference it from anywhere
88
+ env[:machine] = Syntropy.machine = UM.new
89
+ env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
90
+
91
+ require 'syntropy/version'
92
+ require 'syntropy/dev_mode' if env[:dev_mode]
93
+
94
+ app = Syntropy::App.load(env)
95
+ Syntropy.run(env) { app.call(it) }
data/cmd/test.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ env = {}
4
+ argv_copy = ARGV.dup
5
+ case ARGV[0]
6
+ when '-w', '--watch'
7
+ env[:watch_mode] = true
8
+ ARGV.shift
9
+ when '-h', '--help'
10
+ puts <<~MSG
11
+ Usage: syntropy test [options] [minitest options]
12
+ -w, --watch Rerun tests on file system changes
13
+ -h, --help Show this help message
14
+ MSG
15
+ exit
16
+ end
17
+
18
+ require_relative '../lib/syntropy'
19
+ require_relative '../lib/syntropy/test'
20
+
21
+ $stdout.sync = true
22
+ $stderr.sync = true
23
+
24
+ Dir.glob("./test/test_*.rb").each { require(it) }
25
+
26
+ def watch_for_file_changes
27
+ m = UM.new
28
+ puts "Waiting for file changes in #{FileUtils.pwd}"
29
+ m.file_watch(FileUtils.pwd, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) {
30
+ puts "Detected changes to #{it[:fn]}, restarting"
31
+ break
32
+ }
33
+ end
34
+
35
+ Minitest.run ARGV
36
+ if env[:watch_mode]
37
+ puts
38
+ watch_for_file_changes
39
+ exec("ruby", __FILE__, *argv_copy)
40
+ end
@@ -18,6 +18,6 @@ export template {
18
18
  div { font-weight: bold; font-size: 1.3em }
19
19
  value { display: inline-block; padding: 0 1em; color: blue; width: 1em }
20
20
  CSS
21
- auto_refresh_watch!
21
+ auto_refresh!
22
22
  }
23
23
  }
@@ -32,7 +32,7 @@ export template {
32
32
 
33
33
  Card()
34
34
  }
35
- auto_refresh_watch!
35
+ auto_refresh!
36
36
  debug_template!
37
37
  }
38
38
  }
@@ -0,0 +1 @@
1
+ 4.0.3
@@ -0,0 +1,8 @@
1
+ source 'https://gem.coop'
2
+
3
+ gem 'syntropy', path: '../..'
4
+ gem 'jwt'
5
+
6
+ group :development do
7
+ gem 'minitest'
8
+ end
@@ -0,0 +1,128 @@
1
+ # Syntropy OAuth 2.1 Example App
2
+
3
+ This app implements a site that includes an MCP server with OAuth 2.1 authorization.
4
+
5
+ ## Authorization workflow:
6
+
7
+ ### Phase 1: Discovery
8
+
9
+ - MCP client accesses the mcp endpoint:
10
+
11
+ `GET /mcp`
12
+
13
+ Response headers:
14
+
15
+ ```
16
+ HTTP/1.1 401 Unauthorized
17
+ WWW-Authenticate: Bearer realm="mcp", resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"
18
+ ```
19
+
20
+ - MCP client makes a request to the protected resource endpoint in order to the
21
+ protected resource metadata:
22
+
23
+ `GET /.well-known/oauth-protected-resource`
24
+
25
+ Response JSON:
26
+
27
+ ```
28
+ {
29
+ resource: "http://localhost:1234/",
30
+ authorization_servers: ["http://localhost:1234/"],
31
+ scopes_supported: ["mcp:read", "mcp:write"]
32
+ }
33
+ ```
34
+
35
+ - MCP client makes a request to the authorization server:
36
+
37
+ `GET /.well-known/oauth-authorization-server`
38
+
39
+ Response JSON:
40
+
41
+ ```
42
+ {
43
+ issuer: "http://localhost:1234/",
44
+ registration_endpoint: "http://localhost:1234/oauth/register",
45
+ authorization_endpoint: "http://localhost:1234/oauth/authorize",
46
+ token_endpoint: "http://localhost:1234/oauth/token",
47
+ scopes_supported: ["mcp:read", "mcp:write"],
48
+ response_types_supported: ["code"]
49
+ }
50
+ ```
51
+
52
+ ### Phase 2: Dynamic Client Registration (DCR)
53
+
54
+ - MCP client makes a request to the register endpoint
55
+
56
+ ```
57
+ POST /oauth/register
58
+ Content-Type: application/json
59
+
60
+ {
61
+ "client_name": "Cursor AI Agent",
62
+ "redirect_uris": ["http://localhost:8400/callback"],
63
+ "grant_types": ["authorization_code", "refresh_token"]
64
+ }
65
+ ```
66
+
67
+ Response:
68
+
69
+ ```
70
+ HTTP/1.1 201 Created
71
+ Content-Type: application/json
72
+
73
+ {
74
+ "client_id": "mcp_client_xyz789",
75
+ "client_name": "Cursor AI Agent",
76
+ "redirect_uris": ["http://localhost:8400/callback"],
77
+ "grant_types": ["authorization_code", "refresh_token"]
78
+ }
79
+ ```
80
+
81
+ ### Phase 3: Authorization Request with PKCE
82
+
83
+ - MCP client opens a browser with a URL pointing to the authorization endpoint:
84
+
85
+ `GET /oauth/authorize?response_type=code&client_id=mcp_client_xyz789&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=random_state_string HTTP/1.1`
86
+
87
+ the server leads the user through signin and consent workflow. Finally it
88
+ generates a temporary auth code and redirects to the client's callback URL:
89
+
90
+ Response:
91
+
92
+ ```
93
+ HTTP/1.1 302 Found
94
+ Location: http://localhost:8400/callback?code=splat-auth-code-123&state=random_state_string
95
+ ```
96
+
97
+ ### Phase 4: Token Exchange
98
+
99
+ - MCP client grabs the code and exchanges it with the token endpoint:
100
+
101
+ ```
102
+ POST /oauth/token HTTP/1.1
103
+ Host: auth.example.com
104
+ Content-Type: application/x-www-form-urlencoded
105
+
106
+ grant_type=authorization_code&code=splat-auth-code-123&redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback&client_id=mcp_client_xyz789&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
107
+ ```
108
+
109
+ The authorization server hashes the code_verifier and verifies it matches the
110
+ challenge submitted in Phase 3. If it does, it returns an access token:
111
+
112
+ ```
113
+ HTTP/1.1 200 OK
114
+ Content-Type: application/json
115
+
116
+ {
117
+ "access_token": "mcp_access_token_abc123",
118
+ "token_type": "Bearer",
119
+ "expires_in": 3600,
120
+ "refresh_token": "mcp_refresh_token_def456"
121
+ }
122
+ ```
123
+
124
+ ### And we're done
125
+
126
+ The client can now seamlessly access the MCP resources by attaching the header:
127
+
128
+ `Authorization: Bearer mcp_access_token_abc123`
@@ -0,0 +1,18 @@
1
+ # https://datatracker.ietf.org/doc/html/rfc8414#section-2
2
+ export ->(req) {
3
+ req.respond_json(
4
+ {
5
+ issuer: "http://localhost:1234/",
6
+ registration_endpoint: "http://localhost:1234/oauth/register",
7
+ authorization_endpoint: "http://localhost:1234/oauth/authorize",
8
+ token_endpoint: "http://localhost:1234/oauth/token",
9
+ scopes_supported: ["mcp:read", "mcp:write"],
10
+ response_types_supported: ["code"]
11
+ # jwks_uri: "http://localhost:1234/.well-known/jwks.json",
12
+ # grant_types_supported: ["authorization_code", "refresh_token"],
13
+ # code_challenge_methods_supported: ["S256"],
14
+ # claims_supported: ["aud", "iss", "exp", "scope", "sub"],
15
+ # client_id_metadata_document_supported: true
16
+ }
17
+ )
18
+ }
@@ -0,0 +1,10 @@
1
+ # https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata
2
+ export ->(req) {
3
+ req.respond_json(
4
+ {
5
+ resource: "http://localhost:1234/",
6
+ authorization_servers: ["http://localhost:1234/"],
7
+ scopes_supported: ["mcp:read", "mcp:write"]
8
+ }
9
+ )
10
+ }
@@ -0,0 +1,23 @@
1
+ export self
2
+
3
+ require 'securerandom'
4
+
5
+ @store = {}
6
+
7
+ def store(params)
8
+ key = SecureRandom.hex(16)
9
+ @store[key] = params
10
+ key
11
+ end
12
+
13
+ def fetch(key)
14
+ @store[key]
15
+ end
16
+
17
+ def update(key, value)
18
+ @store[key] = value
19
+ end
20
+
21
+ def fetch_and_remove(key)
22
+ @store.delete(key)
23
+ end
@@ -0,0 +1 @@
1
+ # Syntropy OAuth 2.1 Example
@@ -0,0 +1,38 @@
1
+ export ->(req) {
2
+ req.validate_http_method('post')
3
+ req.validate_content_type('application/json')
4
+
5
+ if valid_token?(req)
6
+ respond_authorized(req)
7
+ else
8
+ respond_unauthorized(req)
9
+ end
10
+ }
11
+
12
+ def valid_token?(req)
13
+ false
14
+ end
15
+
16
+ def respond_unauthorized(req)
17
+ req.respond_json(
18
+ {
19
+ error: 'unauthorized',
20
+ message: 'Authentication required'
21
+ },
22
+ ':status' => HTTP::UNAUTHORIZED,
23
+ 'WWW-Authenticate' => <<~EOF.tr("\n", ' ')
24
+ Bearer realm="mcp",
25
+ resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"
26
+ EOF
27
+ )
28
+ end
29
+
30
+ def respond_authorized(req)
31
+ req.respond_json(
32
+ {
33
+ resource: "http://localhost:1234/mcp",
34
+ authorization_servers: ["http://localhost:1234/"],
35
+ scopes_supported: ["mcp:tools", "mcp:resources"]
36
+ }
37
+ )
38
+ end
@@ -0,0 +1,26 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ # https://datatracker.ietf.org/doc/html/rfc6749#section-3.1
4
+ export ->(req) {
5
+ req.validate_http_method('get')
6
+ params = req.query
7
+ req.validate(params['response_type'], 'code')
8
+
9
+ client_info = AuthStore.fetch(params['client_id'])
10
+ req.validate(client_info, Hash)
11
+
12
+ # GET /oauth/authorize?
13
+ # response_type=code
14
+ # client_id=mcp_client_xyz789
15
+ # redirect_uri=http%3A%2F%2Flocalhost%3A8400%2Fcallback
16
+ # code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
17
+ # code_challenge_method=S256
18
+ # state=random_state_string
19
+
20
+ key = AuthStore.store(req.query)
21
+ req.respond(nil, {
22
+ ':status' => Syntropy::HTTP::FOUND,
23
+ 'Location' => '/signin',
24
+ 'Set-Cookie' => "oauth_signin_id=#{key}"
25
+ })
26
+ }
@@ -0,0 +1,86 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ export ->(req) {
4
+ case req.method
5
+ when 'get'
6
+ render_consent_form(req)
7
+ when 'post'
8
+ validate_consent(req)
9
+ else
10
+ raise Syntropy::Error.method_not_allowed
11
+ end
12
+ }
13
+
14
+ def render_consent_form(req)
15
+ oauth_signin_id = req.cookies['oauth_signin_id']
16
+ auth_info = AuthStore.fetch(oauth_signin_id)
17
+ req.validate(auth_info, Hash)
18
+
19
+ client_id = auth_info['client_id']
20
+ req.validate(client_id, String)
21
+ client_info = AuthStore.fetch(client_id)
22
+
23
+ sid = auth_info['sid']
24
+ req.validate(sid, String)
25
+ session_info = AuthStore.fetch(sid)
26
+
27
+ req.respond_html(@consent_form.render(client_info, session_info))
28
+ end
29
+
30
+ def validate_consent(req)
31
+ data = req.get_form_data
32
+ decision = data['decision']
33
+ req.validate(decision, ['deny', 'allow'])
34
+
35
+ oauth_signin_id = req.cookies['oauth_signin_id']
36
+ auth_info = AuthStore.fetch(oauth_signin_id)
37
+ req.validate(auth_info, Hash)
38
+
39
+ callback_query = case decision
40
+ when 'deny'
41
+ {
42
+ error: 'access_denied',
43
+ state: auth_info['state']
44
+ }
45
+ when 'allow'
46
+ auth_code = AuthStore.store(auth_info)
47
+ {
48
+ code: auth_code,
49
+ state: auth_info['state']
50
+ }
51
+ end
52
+
53
+ uri = format(
54
+ '%s?%s', auth_info['redirect_uri'],
55
+ URI.encode_www_form(callback_query)
56
+ )
57
+ req.respond(
58
+ nil,
59
+ ':status' => Syntropy::HTTP::FOUND,
60
+ 'Location' => uri
61
+ )
62
+ end
63
+
64
+ @consent_form = template { |client_info, session_info|
65
+ html {
66
+ head {
67
+ title 'My awesome site'
68
+ }
69
+ body {
70
+ h2 client_info['client_name']
71
+ p {
72
+ span 'wants to access my awesome site on behalf of '
73
+ em session_info[:username]
74
+ span '.'
75
+ }
76
+
77
+ form(action: '') {
78
+ div {
79
+ button 'Deny', type: 'submit', name: 'decision', value: 'deny'
80
+ button 'Allow', type: 'submit', name: 'decision', value: 'allow'
81
+ }
82
+ }
83
+ }
84
+ auto_refresh!
85
+ }
86
+ }
@@ -0,0 +1,15 @@
1
+ AuthStore = import '../_lib/auth_store'
2
+
3
+ # https://datatracker.ietf.org/doc/html/rfc7591
4
+ export ->(req) {
5
+ req.validate_http_method('post')
6
+ req.validate_content_type('application/json')
7
+
8
+ client_info = JSON.parse(req.read)
9
+ client_id = AuthStore.store(client_info)
10
+
11
+ req.respond_json(
12
+ { client_id: }.merge(client_info),
13
+ ':status' => HTTP::CREATED
14
+ )
15
+ }