syntropy 0.31.0 → 0.33.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +20 -0
  4. data/TODO.md +7 -1
  5. data/cmd/console.rb +77 -0
  6. data/cmd/serve.rb +1 -3
  7. data/cmd/test.rb +76 -20
  8. data/examples/blog/app/_layout/default.rb +11 -0
  9. data/examples/blog/app/_lib/post_store.rb +47 -0
  10. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  11. data/examples/blog/app/_setup.rb +4 -0
  12. data/examples/blog/app/index.rb +7 -0
  13. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  14. data/examples/blog/app/posts/[id]/index.rb +61 -0
  15. data/examples/blog/app/posts/index.rb +40 -0
  16. data/examples/blog/app/posts/new.rb +29 -0
  17. data/examples/mcp-oauth/README.md +3 -3
  18. data/examples/mcp-oauth/app/mcp.rb +55 -8
  19. data/examples/mcp-oauth/app/oauth/authorize.rb +0 -8
  20. data/examples/mcp-oauth/app/oauth/register.rb +0 -1
  21. data/examples/mcp-oauth/test/test_app.rb +2 -20
  22. data/examples/mcp-oauth/test/test_oauth.rb +93 -217
  23. data/lib/syntropy/app.rb +23 -9
  24. data/lib/syntropy/db/connection_pool.rb +71 -0
  25. data/lib/syntropy/db/schema.rb +92 -0
  26. data/lib/syntropy/db/store.rb +31 -0
  27. data/lib/syntropy/http/io_extensions.rb +33 -5
  28. data/lib/syntropy/http/server_connection.rb +21 -62
  29. data/lib/syntropy/{module.rb → module_loader.rb} +48 -8
  30. data/lib/syntropy/request/request_info.rb +3 -4
  31. data/lib/syntropy/request/response.rb +2 -2
  32. data/lib/syntropy/request/session.rb +113 -0
  33. data/lib/syntropy/request/validation.rb +1 -2
  34. data/lib/syntropy/request.rb +9 -0
  35. data/lib/syntropy/test.rb +84 -1
  36. data/lib/syntropy/version.rb +1 -1
  37. data/lib/syntropy.rb +4 -2
  38. data/syntropy.gemspec +3 -1
  39. data/test/app/_hook.rb +1 -1
  40. data/test/app/by_method.rb +9 -0
  41. data/test/app_setup/_setup.rb +7 -0
  42. data/test/app_setup/index.rb +1 -0
  43. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  44. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  45. data/test/schema/2026-01-02-foo.rb +12 -0
  46. data/test/schema/2026-05-30-bar.rb +7 -0
  47. data/test/test_app.rb +58 -3
  48. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  49. data/test/test_db_schema.rb +96 -0
  50. data/test/test_db_store.rb +24 -0
  51. data/test/test_http_protocol.rb +250 -0
  52. data/test/test_http_server_connection.rb +18 -24
  53. data/test/test_json_api.rb +1 -1
  54. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  55. data/test/test_request.rb +7 -4
  56. data/test/test_request_session.rb +254 -0
  57. data/test/test_server.rb +9 -13
  58. metadata +63 -12
  59. data/examples/mcp-oauth/test/helper.rb +0 -9
  60. data/lib/syntropy/connection_pool.rb +0 -61
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3908853da702fa27301792535415763adf546fe51bd9f3b91d530a65b55f8d66
4
- data.tar.gz: 34f6387d31fa46134ed8aa87d609b3400c506b832fe2f9baa64ee5225da1f929
3
+ metadata.gz: a39afb5d39d1a1c9b95e2e242b9ce55144ba6923ad4b0addee3c6c005d314ead
4
+ data.tar.gz: 0b067b50a4f27381ba495a32d8c8e471e4a64374d6eee3c62d25fb30aaa10a24
5
5
  SHA512:
6
- metadata.gz: f899a3f06335acc22181160e5820563fa21f1a1dd981fd33a7f0b1530ac61801455f430f82a1be130682a1c58d7d0b21dfb3d816efc742898c51133fa9223c24
7
- data.tar.gz: 4e7a6637bf8b4f280689d3053557e8a77585da711e626fab7e81f8bfa39fdc1065288a9bd9a5d940f2e04ad0b1a0b6a3f53ee3d65e6f53a14ebd7d31883e3696
6
+ metadata.gz: 9768274a2d09c8f4068625006660403e98655cfa7d7d742ed75f3ce0e31abfc0410f301d16d8d8910fcc94d7947008238a730b19b514342cbb67dac8669a4e76
7
+ data.tar.gz: 76a2d90674f7ebc34f8162e3435d81edb1ca56022ef7d490bcc6fcfa0041529f1ace321233451dd7df40867b05eb83327984634461f0986dd650db3e0913af6c
data/.gitignore CHANGED
@@ -54,3 +54,5 @@ Gemfile.lock
54
54
 
55
55
  # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
56
  # .rubocop-https?--*
57
+
58
+ examples/**/*.db*
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ # 0.33.0 2026-06-02
2
+
3
+ - Fix `ModuleLoader` to load a module only once
4
+ - Improve testing tools, add `Syntropy::Test` class
5
+ - Implement `Request#session`, `Request#flash`
6
+ - Improve `Request#set_cookie`
7
+
8
+ # 0.32.0 2026-06-01
9
+
10
+ - Ensure HTTP request body is consumed (skipped) before treating next request
11
+ - Fix `Request#auth_bearer_token`
12
+ - Add `syntropy console` CLI command
13
+ - Add `Module#http_methods` method for simpler REST controllers
14
+ - Add `App#setup_db` method
15
+ - Add `DB::Schema` for schema migrations with support for migration modules
16
+ - Add `DB::Store` class
17
+ - Rename `ConnectionPool` to `DB::ConnectionPool`
18
+ - Add support for setup file (`_setup.rb`)
19
+ - Remove escape_utils dependency
20
+
1
21
  # 0.31.0 2026-05-29
2
22
 
3
23
  - Add message kwarg to `Request#validate`
data/TODO.md CHANGED
@@ -39,9 +39,15 @@
39
39
  - [ ] Website
40
40
  - [v] Frontend part of JSON API
41
41
  - [v] Auto-refresh page when file changes
42
+ - [ ] SQLite database capabilities
43
+ - [ ] Model API + tools
44
+ - [ ] Do we need/want migrations?
45
+ - [ ] Stores
46
+ - [ ] KV store (with TTL)
42
47
  - [v] Examples
43
48
  - [v] Reactive app - counter or some other simple app showing interaction with
44
49
  server
50
+ - [ ] blog
45
51
 
46
52
  ## Testing facilities
47
53
 
@@ -49,7 +55,7 @@
49
55
  - Routes
50
56
  - Route responses
51
57
  - Changes to state / DB
52
- -
58
+ - Rendered HTML - presence of certain markup / elements / text
53
59
 
54
60
  ## Support for applets
55
61
 
data/cmd/console.rb ADDED
@@ -0,0 +1,77 @@
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
+ watch_files: true
11
+ }
12
+
13
+ parser = OptionParser.new do |o|
14
+ o.banner = 'Usage: syntropy serve [options] DIR'
15
+
16
+ o.on('-h', '--help', 'Show this help message') do
17
+ puts o
18
+ exit
19
+ end
20
+
21
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
22
+ p mount: path
23
+ env[:mount_path] = path
24
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
25
+ end
26
+
27
+ o.on('--no-builtin-applet', 'Do not mount builtin applet') do
28
+ env[:builtin_applet_path] = nil
29
+ end
30
+ end
31
+
32
+ RubyVM::YJIT.enable rescue nil
33
+
34
+ begin
35
+ parser.parse!
36
+ rescue OptionParser::InvalidOption
37
+ puts parser
38
+ exit
39
+ rescue StandardError => e
40
+ p e
41
+ puts e.message
42
+ puts e.backtrace.join("\n")
43
+ exit
44
+ end
45
+
46
+ $syntropy_dev_mode = env[:dev_mode]
47
+ env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
48
+
49
+ if !File.directory?(env[:root_dir])
50
+ puts "#{File.expand_path(env[:root_dir])} Not a directory"
51
+ exit
52
+ end
53
+
54
+ puts env[:banner] if env[:banner]
55
+ env[:banner] = false
56
+
57
+ # We set Syntropy.machine so we can reference it from anywhere
58
+ env[:machine] = Syntropy.machine = UM.new
59
+ env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
60
+
61
+ @app = Syntropy::App.load(env)
62
+ @env = env
63
+ @machine = env[:machine]
64
+ @connection_pool = @app.connection_pool if @app.respond_to?(:connection_pool)
65
+ @schema = @app.schema if @app.respond_to?(:schema)
66
+ @module_loader = @app.module_loader
67
+
68
+ require 'uringmachine/fiber_scheduler'
69
+ @scheduler = UM::FiberScheduler.new(@machine)
70
+ Fiber.set_scheduler(@scheduler)
71
+
72
+ def import(ref)
73
+ @module_loader.load(ref)
74
+ end
75
+
76
+ require 'irb'
77
+ IRB.start
data/cmd/serve.rb CHANGED
@@ -29,7 +29,7 @@ parser = OptionParser.new do |o|
29
29
 
30
30
  o.on('-d', '--dev', 'Development mode') do
31
31
  env[:dev_mode] = true
32
- env[:watch_files] = 0.1
32
+ env[:watch_files] = true
33
33
  end
34
34
 
35
35
  o.on('-h', '--help', 'Show this help message') do
@@ -38,7 +38,6 @@ parser = OptionParser.new do |o|
38
38
  end
39
39
 
40
40
  o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
41
- p mount: path
42
41
  env[:mount_path] = path
43
42
  env[:builtin_applet_path] = File.join(path, '.syntropy')
44
43
  end
@@ -83,7 +82,6 @@ end
83
82
  puts env[:banner] if env[:banner]
84
83
  env[:banner] = false
85
84
 
86
-
87
85
  # We set Syntropy.machine so we can reference it from anywhere
88
86
  env[:machine] = Syntropy.machine = UM.new
89
87
  env[:logger] = env[:logger] && Syntropy::Logger.new(env[:machine], **env)
data/cmd/test.rb CHANGED
@@ -1,17 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- env = {}
3
+ require 'fileutils'
4
+ require 'optparse'
5
+
6
+ pwd = FileUtils.pwd
7
+ env = {
8
+ root_dir: File.join(pwd, 'app'),
9
+ test_dir: File.join(pwd, 'test'),
10
+ mount_path: '/'
11
+ }
12
+ MINITEST_ARGV = []
13
+
14
+ parser = OptionParser.new do |o|
15
+ o.banner = 'Usage: syntropy test [options]'
16
+
17
+ o.on('-h', '--help', 'Show this help message') do
18
+ puts o
19
+ exit
20
+ end
21
+
22
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
23
+ env[:mount_path] = path
24
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
25
+ end
26
+
27
+ o.on('-w', '--watch', 'Watch for file changes') do
28
+ env[:watch_mode] = true
29
+ end
30
+
31
+ o.on('-a', '--app PATH', 'Set app root (default: ./app)') do |path|
32
+ env[:root_dir] = path
33
+ end
34
+
35
+ o.on('-t', '--test PATH', 'Set test root (default: ./test)') do |path|
36
+ env[:test_dir] = path
37
+ end
38
+
39
+ o.on('-n', '--name NAME', 'Specify test to run') do |name|
40
+ MINITEST_ARGV << '--name' << name
41
+ end
42
+
43
+ o.on('-V', '--verbose', 'Verbose test output') do
44
+ MINITEST_ARGV << '--verbose'
45
+ end
46
+
47
+ o.on('-s', '--seed SEED', 'Specify random seed') do |seed|
48
+ MINITEST_ARGV << '--seed' << seed
49
+ end
50
+
51
+ o.on('-v', '--version', 'Show version') do
52
+ require 'syntropy/version'
53
+ puts "Syntropy version #{Syntropy::VERSION}"
54
+ exit
55
+ end
56
+ end
57
+
4
58
  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
59
+ begin
60
+ parser.parse!
61
+ rescue OptionParser::InvalidOption
62
+ puts parser
63
+ exit
64
+ rescue StandardError => e
65
+ p e
66
+ puts e.message
67
+ puts e.backtrace.join("\n")
15
68
  exit
16
69
  end
17
70
 
@@ -21,20 +74,23 @@ require_relative '../lib/syntropy/test'
21
74
  $stdout.sync = true
22
75
  $stderr.sync = true
23
76
 
24
- Dir.glob("./test/test_*.rb").each { require(it) }
77
+ Dir.glob("#{env[:test_dir]}/test_*.rb").each { require(it) }
25
78
 
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"
79
+ def watch_for_file_changes(machine)
80
+ machine.write(UM::STDOUT_FILENO, "Waiting for file changes in #{FileUtils.pwd}\n")
81
+ machine.file_watch(FileUtils.pwd, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) {
82
+ machine.write(UM::STDOUT_FILENO, "File changed: #{it[:fn]}\n")
31
83
  break
32
84
  }
33
85
  end
34
86
 
35
- Minitest.run ARGV
87
+ Syntropy::Test.env=(env)
88
+ Minitest.run MINITEST_ARGV
89
+
36
90
  if env[:watch_mode]
37
- puts
38
- watch_for_file_changes
91
+ m = UM.new(size: 4)
92
+ m.write(UM::STDOUT_FILENO, "\n")
93
+ trap('SIGINT') { m.write(UM::STDOUT_FILENO, "\n"); exit! }
94
+ watch_for_file_changes(m)
39
95
  exec("ruby", __FILE__, *argv_copy)
40
96
  end
@@ -0,0 +1,11 @@
1
+ export template { |**props|
2
+ html {
3
+ head {
4
+ title "My awesome blog"
5
+ }
6
+ body {
7
+ render_children(**props)
8
+ auto_refresh!
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,47 @@
1
+ class PostStore < Syntropy::DB::Store
2
+ # @return [Integer] post id
3
+ def create(title, body)
4
+ query_single_value <<~SQL, title:, body:
5
+ insert into posts (title, body)
6
+ values (:title, :body)
7
+ returning id;
8
+ SQL
9
+ end
10
+
11
+ # @return [void]
12
+ def update(id, title, body)
13
+ execute <<~SQL, id:, title:, body:
14
+ update posts
15
+ set title = :title, body = :body
16
+ where id = :id
17
+ SQL
18
+ end
19
+
20
+ # @return [void]
21
+ def delete(id)
22
+ execute <<~SQL, id:
23
+ delete from posts
24
+ where id = :id
25
+ SQL
26
+ end
27
+
28
+ # return [Hash]
29
+ def get(id)
30
+ query_single_row <<~SQL, id:
31
+ select id, title, body
32
+ from posts
33
+ where id = :id
34
+ SQL
35
+ end
36
+
37
+ # return [Array<Hash>]
38
+ def get_all
39
+ query <<~SQL
40
+ select id, title, body
41
+ from posts
42
+ order by id desc
43
+ SQL
44
+ end
45
+ end
46
+
47
+ export PostStore.new(@app.connection_pool)
@@ -0,0 +1,9 @@
1
+ export ->(db) {
2
+ db.execute <<~SQL
3
+ create table posts (
4
+ id integer primary key autoincrement,
5
+ title text,
6
+ body text
7
+ )
8
+ SQL
9
+ }
@@ -0,0 +1,4 @@
1
+ @app.setup_db(
2
+ db_path: File.join(@app.root_dir, '../blog.db'),
3
+ schema_root: '_schema'
4
+ )
@@ -0,0 +1,7 @@
1
+ layout = import '_layout/default'
2
+
3
+ export layout.apply {
4
+ p {
5
+ a 'Blog posts', href: '/posts'
6
+ }
7
+ }
@@ -0,0 +1,33 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ id = req.route_params['id'].to_i
8
+ post = @post_store.get(id)
9
+ raise Syntropy::Error.not_found if !post
10
+
11
+ req.respond_html(
12
+ @template.render(post:)
13
+ )
14
+ end
15
+
16
+ @template = @layout.apply { |post:, **props|
17
+ h1 "Edit blog post"
18
+ div {
19
+ form(action: "/posts/#{post[:id]}", method: 'post') {
20
+ div {
21
+ label 'Title', for: 'title'
22
+ input name: 'title', type: 'text', value: post[:title]
23
+ }
24
+ div {
25
+ label 'Body', for: 'body'
26
+ textarea post[:body], name: 'body', rows: 5
27
+ }
28
+ div {
29
+ button 'Submit', type: 'submit'
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,61 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ id = req.route_params['id'].to_i
8
+ post = @post_store.get(id)
9
+ raise Syntropy::Error.not_found if !post
10
+
11
+ req.respond_html(
12
+ @template.render(post:, req:)
13
+ )
14
+ end
15
+
16
+ def post(req)
17
+ data = req.get_form_data
18
+ return delete(req) if data['method'] == 'delete'
19
+
20
+ id = req.route_params['id'].to_i
21
+ title = req.validate(data['title'], String, /.+/)
22
+ body = req.validate(data['body'], String, /.+/)
23
+
24
+ updated = @post_store.update(id, title, body)
25
+ raise BadRequestError, "Failed to update post" if updated != 1
26
+
27
+ req.flash[:notice] = 'Post was successfully updated.'
28
+ req.redirect "/posts/#{id}", Syntropy::HTTP::SEE_OTHER
29
+ end
30
+
31
+ def delete(req)
32
+ id = req.route_params['id'].to_i
33
+
34
+ deleted = @post_store.delete(id)
35
+ raise BadRequestError, "Failed to delete post" if deleted != 1
36
+
37
+ req.flash[:notice] = 'Post was successfully destroyed.'
38
+ req.redirect "/posts", Syntropy::HTTP::SEE_OTHER
39
+ end
40
+
41
+ @template = @layout.apply { |post:, **props|
42
+ h1 "My blog"
43
+ p props[:req]&.flash[:notice], style: 'color: green'
44
+ div {
45
+ h2 {
46
+ a post[:title]
47
+ }
48
+ p post[:body]
49
+ }
50
+ p {
51
+ a "Edit", href: "/posts/#{post[:id]}/edit"
52
+ span '|'
53
+ a "Back to posts", href: '/posts'
54
+ }
55
+ div {
56
+ form(method: 'post') {
57
+ input type: 'hidden', name: 'method', value: 'delete'
58
+ button 'Delete this post', name: 'delete', type: 'submit'
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,40 @@
1
+ @post_store = import '_lib/post_store'
2
+ @layout = import '_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ posts = @post_store.get_all
8
+ req.respond_html(
9
+ @template.render(posts:, req:)
10
+ )
11
+ end
12
+
13
+ def post(req)
14
+ data = req.get_form_data
15
+ title = req.validate(data['title'], String, /.+/)
16
+ body = req.validate(data['body'], String, /.+/)
17
+ id = @post_store.create(title, body)
18
+
19
+ req.flash[:notice] = 'Post was successfully created.'
20
+ req.redirect("posts/#{id}")
21
+ end
22
+
23
+ @template = @layout.apply { |**props|
24
+ h1 "My blog"
25
+ p props[:req]&.flash[:notice], style: 'color: green'
26
+ props[:posts].each { |post|
27
+ div {
28
+ h2 {
29
+ a post[:title], href: "/posts/#{post[:id]}"
30
+ }
31
+ p post[:body]
32
+ }
33
+ }
34
+
35
+ div {
36
+ p {
37
+ a "New post", href: '/posts/new'
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,29 @@
1
+ @post_store = import '/_lib/post_store'
2
+ @layout = import '/_layout/default'
3
+
4
+ export http_methods
5
+
6
+ def get(req)
7
+ req.respond_html(
8
+ @template.render
9
+ )
10
+ end
11
+
12
+ @template = @layout.apply { |**props|
13
+ h1 "Create blog post"
14
+ div {
15
+ form(action: "/posts", method: 'post') {
16
+ div {
17
+ label 'Title', for: 'title'
18
+ input name: 'title', type: 'text'
19
+ }
20
+ div {
21
+ label 'Body', for: 'body'
22
+ textarea '', name: 'body', rows: 5
23
+ }
24
+ div {
25
+ button 'Submit', type: 'submit'
26
+ }
27
+ }
28
+ }
29
+ }
@@ -69,7 +69,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
69
69
  ```
70
70
  HTTP/1.1 201 Created
71
71
  Content-Type: application/json
72
-
72
+
73
73
  {
74
74
  "client_id": "mcp_client_xyz789",
75
75
  "client_name": "Cursor AI Agent",
@@ -102,7 +102,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
102
102
  POST /oauth/token HTTP/1.1
103
103
  Host: auth.example.com
104
104
  Content-Type: application/x-www-form-urlencoded
105
-
105
+
106
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
107
  ```
108
108
 
@@ -112,7 +112,7 @@ This app implements a site that includes an MCP server with OAuth 2.1 authorizat
112
112
  ```
113
113
  HTTP/1.1 200 OK
114
114
  Content-Type: application/json
115
-
115
+
116
116
  {
117
117
  "access_token": "mcp_access_token_abc123",
118
118
  "token_type": "Bearer",
@@ -1,16 +1,26 @@
1
+ AuthStore = import './_lib/auth_store'
2
+
1
3
  export ->(req) {
2
4
  req.validate_http_method('post')
3
5
  req.validate_content_type('application/json')
4
6
 
5
- if valid_token?(req)
6
- respond_authorized(req)
7
- else
7
+ if !(token_info = valid_token?(req))
8
8
  respond_unauthorized(req)
9
+ return
9
10
  end
11
+
12
+ handle(req, token_info)
10
13
  }
11
14
 
15
+ # @param req [Syntropy::Request]
12
16
  def valid_token?(req)
13
- false
17
+ token = req.auth_bearer_token
18
+ return false if !token
19
+
20
+ token_info = AuthStore.fetch(token)
21
+ return false if !token_info
22
+
23
+ true
14
24
  end
15
25
 
16
26
  def respond_unauthorized(req)
@@ -27,12 +37,49 @@ def respond_unauthorized(req)
27
37
  )
28
38
  end
29
39
 
30
- def respond_authorized(req)
40
+ def handle(req, token_info)
41
+ req.validate_http_method('post')
42
+ req.validate_content_type('application/json')
43
+ json = JSON.parse(req.read)
44
+
45
+ req.validate(json['jsonrpc'], '2.0')
46
+ req.validate(json['id'], [Integer, String])
47
+
48
+ method = req.validate(json['method'], String)
49
+ sym = :"handle_#{method}"
50
+ raise Syntropy::ValidationError, 'METHOD_NOT_FOUND: method not found' if !respond_to?(sym)
51
+
52
+ send(sym, req, json, token_info)
53
+ rescue Syntropy::ValidationError => e
54
+ if (m = e.message.match(/(.+)\: (.+)/))
55
+ type, message = m[1], m[2]
56
+ else
57
+ type, message = 'INVALID_REQUEST', e.message
58
+ end
59
+ respond_error(req, json, type, message)
60
+ end
61
+
62
+ ERROR_CODES = {
63
+ 'INVALID_REQUEST' => -32600,
64
+ 'METHOD_NOT_FOUND' => -32601,
65
+ 'INVALID_PARAMS' => -32602,
66
+ 'INTERNAL_ERROR' => -32603,
67
+ 'PARSE_ERROR' => -32700
68
+ }
69
+
70
+ def respond_error(req, json, error_type, error_message)
71
+ error_code = ERROR_CODES[type] || ERROR_CODES['INTERNAL_ERROR']
31
72
  req.respond_json(
32
73
  {
33
- resource: "http://localhost:1234/mcp",
34
- authorization_servers: ["http://localhost:1234/"],
35
- scopes_supported: ["mcp:tools", "mcp:resources"]
74
+ jsonrpc: '2.0',
75
+ id: json['id'],
76
+ error: {
77
+ code: error_code,
78
+ message: error_message
79
+ }
36
80
  }
37
81
  )
38
82
  end
83
+
84
+ def handle_initialize()
85
+ end
@@ -9,14 +9,6 @@ export ->(req) {
9
9
  client_info = AuthStore.fetch(params['client_id'])
10
10
  req.validate(client_info, Hash)
11
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
12
  key = AuthStore.store(req.query)
21
13
  req.respond(nil, {
22
14
  ':status' => Syntropy::HTTP::FOUND,
@@ -4,7 +4,6 @@ AuthStore = import '../_lib/auth_store'
4
4
  export ->(req) {
5
5
  req.validate_http_method('post')
6
6
  req.validate_content_type('application/json')
7
-
8
7
  client_info = JSON.parse(req.read)
9
8
  client_id = AuthStore.store(client_info)
10
9