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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +38 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +15 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +15 -4
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +6 -0
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +148 -0
- data/lib/syntropy/http/server.rb +5 -5
- data/lib/syntropy/http/{connection.rb → server_connection.rb} +9 -38
- data/lib/syntropy/http.rb +3 -1
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +2 -0
- data/lib/syntropy/request/request_info.rb +20 -1
- data/lib/syntropy/request/response.rb +2 -2
- data/lib/syntropy/request/validation.rb +10 -3
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +65 -0
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +1 -21
- data/syntropy.gemspec +1 -2
- data/test/app/.well-known/foo.rb +3 -0
- data/test/helper.rb +1 -25
- data/test/test_app.rb +53 -68
- data/test/test_caching.rb +1 -1
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +29 -29
- data/test/test_json_api.rb +4 -4
- data/test/{test_request_extensions.rb → test_request.rb} +150 -18
- data/test/test_routing_tree.rb +15 -3
- metadata +41 -29
- data/test/test_request_info.rb +0 -90
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /data/examples/{index.md → basic/index.md} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3908853da702fa27301792535415763adf546fe51bd9f3b91d530a65b55f8d66
|
|
4
|
+
data.tar.gz: 34f6387d31fa46134ed8aa87d609b3400c506b832fe2f9baa64ee5225da1f929
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
5
|
-
require 'optparse'
|
|
4
|
+
require_relative '../cmd/_banner'
|
|
6
5
|
|
|
7
|
-
|
|
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
|
-
|
|
19
|
-
|
|
8
|
+
cmd = ARGV.shift || 'help'
|
|
9
|
+
cmd = 'help' if cmd !~ /^[a-z]+$/
|
|
20
10
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.3
|
|
@@ -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
|
+
}
|