personality 0.1.4 → 0.1.5

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.
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Personality
9
+ module MCP
10
+ class OAuth
11
+ # Static client credentials (set via env or generate once)
12
+ CLIENT_ID = ENV.fetch("PSN_OAUTH_CLIENT_ID") { "psn-mcp-client" }
13
+ CLIENT_SECRET = ENV.fetch("PSN_OAUTH_CLIENT_SECRET") { SecureRandom.hex(32) }
14
+
15
+ # Token signing key
16
+ TOKEN_SECRET = ENV.fetch("PSN_TOKEN_SECRET") { SecureRandom.hex(32) }
17
+
18
+ attr_reader :base_url
19
+
20
+ def initialize(base_url:)
21
+ @base_url = base_url.chomp("/")
22
+ @auth_codes = {} # code => { client_id:, redirect_uri:, code_challenge:, expires_at: }
23
+ @tokens = {} # token => { client_id:, expires_at: }
24
+ end
25
+
26
+ # GET /.well-known/oauth-protected-resource
27
+ def protected_resource_metadata
28
+ {
29
+ resource: base_url,
30
+ authorization_servers: [base_url],
31
+ bearer_methods_supported: ["header"]
32
+ }
33
+ end
34
+
35
+ # GET /.well-known/oauth-authorization-server
36
+ def authorization_server_metadata
37
+ {
38
+ issuer: base_url,
39
+ authorization_endpoint: "#{base_url}/authorize",
40
+ token_endpoint: "#{base_url}/token",
41
+ registration_endpoint: "#{base_url}/register",
42
+ response_types_supported: ["code"],
43
+ grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"],
44
+ code_challenge_methods_supported: ["S256"],
45
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"]
46
+ }
47
+ end
48
+
49
+ # POST /register - Dynamic client registration (simple version)
50
+ def register(params)
51
+ # For simplicity, just return our static client
52
+ # A real implementation would create unique clients
53
+ {
54
+ client_id: CLIENT_ID,
55
+ client_secret: CLIENT_SECRET,
56
+ client_id_issued_at: Time.now.to_i,
57
+ client_secret_expires_at: 0 # never expires
58
+ }
59
+ end
60
+
61
+ # GET /authorize - Authorization endpoint
62
+ def authorize(params)
63
+ client_id = params["client_id"]
64
+ redirect_uri = params["redirect_uri"]
65
+ state = params["state"]
66
+ code_challenge = params["code_challenge"]
67
+ code_challenge_method = params["code_challenge_method"]
68
+ response_type = params["response_type"]
69
+
70
+ # Validate required params
71
+ unless client_id && redirect_uri && response_type == "code"
72
+ return {error: "invalid_request", error_description: "Missing required parameters"}
73
+ end
74
+
75
+ # Validate client (for now, accept our static client or any registered)
76
+ # In production, validate against registered clients
77
+
78
+ # Auto-approve (personal use) - generate authorization code
79
+ code = SecureRandom.urlsafe_base64(32)
80
+ @auth_codes[code] = {
81
+ client_id: client_id,
82
+ redirect_uri: redirect_uri,
83
+ code_challenge: code_challenge,
84
+ code_challenge_method: code_challenge_method,
85
+ expires_at: Time.now + 600 # 10 minutes
86
+ }
87
+
88
+ # Build redirect URL
89
+ redirect = URI.parse(redirect_uri)
90
+ query_params = URI.decode_www_form(redirect.query || "")
91
+ query_params << ["code", code]
92
+ query_params << ["state", state] if state
93
+ redirect.query = URI.encode_www_form(query_params)
94
+
95
+ {redirect_to: redirect.to_s}
96
+ end
97
+
98
+ # POST /token - Token endpoint
99
+ def token(params, auth_header: nil)
100
+ grant_type = params["grant_type"]
101
+
102
+ # Extract client credentials from Basic auth header if present
103
+ if auth_header&.start_with?("Basic ")
104
+ decoded = begin
105
+ auth_header[6..].unpack1("m0")
106
+ rescue
107
+ nil
108
+ end
109
+ if decoded
110
+ basic_client_id, basic_client_secret = decoded.split(":", 2)
111
+ params["client_id"] ||= basic_client_id
112
+ params["client_secret"] ||= basic_client_secret
113
+ end
114
+ end
115
+
116
+ case grant_type
117
+ when "authorization_code"
118
+ exchange_code(params)
119
+ when "refresh_token"
120
+ refresh_token(params)
121
+ when "client_credentials"
122
+ client_credentials(params)
123
+ else
124
+ {error: "unsupported_grant_type"}
125
+ end
126
+ end
127
+
128
+ # Validate Bearer token from Authorization header
129
+ def validate_token(auth_header)
130
+ return nil unless auth_header&.start_with?("Bearer ")
131
+
132
+ token = auth_header[7..]
133
+ token_data = @tokens[token]
134
+
135
+ return nil unless token_data
136
+ return nil if Time.now > token_data[:expires_at]
137
+
138
+ token_data
139
+ end
140
+
141
+ private
142
+
143
+ def exchange_code(params)
144
+ code = params["code"]
145
+ client_id = params["client_id"]
146
+ redirect_uri = params["redirect_uri"]
147
+ code_verifier = params["code_verifier"]
148
+
149
+ # Find and validate auth code
150
+ auth_code = @auth_codes.delete(code)
151
+ unless auth_code
152
+ return {error: "invalid_grant", error_description: "Invalid or expired code"}
153
+ end
154
+
155
+ if Time.now > auth_code[:expires_at]
156
+ return {error: "invalid_grant", error_description: "Code expired"}
157
+ end
158
+
159
+ if auth_code[:client_id] != client_id
160
+ return {error: "invalid_grant", error_description: "Client mismatch"}
161
+ end
162
+
163
+ if auth_code[:redirect_uri] != redirect_uri
164
+ return {error: "invalid_grant", error_description: "Redirect URI mismatch"}
165
+ end
166
+
167
+ # Validate PKCE
168
+ if auth_code[:code_challenge]
169
+ unless verify_pkce(code_verifier, auth_code[:code_challenge], auth_code[:code_challenge_method])
170
+ return {error: "invalid_grant", error_description: "PKCE verification failed"}
171
+ end
172
+ end
173
+
174
+ # Generate tokens
175
+ generate_tokens(client_id)
176
+ end
177
+
178
+ def refresh_token(params)
179
+ params["refresh_token"]
180
+ # Simple refresh - just generate new tokens
181
+ # In production, validate refresh token
182
+ generate_tokens(params["client_id"] || "unknown")
183
+ end
184
+
185
+ def client_credentials(params)
186
+ client_id = params["client_id"]
187
+ client_secret = params["client_secret"]
188
+
189
+ # Validate client credentials
190
+ unless client_id == CLIENT_ID && client_secret == CLIENT_SECRET
191
+ return {error: "invalid_client", error_description: "Invalid client credentials"}
192
+ end
193
+
194
+ generate_tokens(client_id)
195
+ end
196
+
197
+ def generate_tokens(client_id)
198
+ access_token = SecureRandom.urlsafe_base64(32)
199
+ refresh_token = SecureRandom.urlsafe_base64(32)
200
+ expires_in = 3600 # 1 hour
201
+
202
+ @tokens[access_token] = {
203
+ client_id: client_id,
204
+ expires_at: Time.now + expires_in
205
+ }
206
+
207
+ {
208
+ access_token: access_token,
209
+ token_type: "Bearer",
210
+ expires_in: expires_in,
211
+ refresh_token: refresh_token
212
+ }
213
+ end
214
+
215
+ def verify_pkce(verifier, challenge, method)
216
+ return false unless verifier && challenge
217
+
218
+ case method
219
+ when "S256"
220
+ digest = OpenSSL::Digest::SHA256.digest(verifier)
221
+ # URL-safe base64 without padding (RFC 7636)
222
+ expected = [digest].pack("m0").tr("+/", "-_").delete("=")
223
+ secure_compare(expected, challenge)
224
+ when "plain", nil
225
+ secure_compare(verifier, challenge)
226
+ else
227
+ false
228
+ end
229
+ end
230
+
231
+ def secure_compare(a, b)
232
+ return false if a.nil? || b.nil?
233
+ return false if a.bytesize != b.bytesize
234
+ a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "mcp"
5
+ require "mcp/server/transports/streamable_http_transport"
6
+ require_relative "server"
7
+ require_relative "oauth"
8
+
9
+ module Personality
10
+ module MCP
11
+ class RackApp
12
+ ALLOWED_ORIGINS = %w[
13
+ https://claude.ai
14
+ https://console.anthropic.com
15
+ ].freeze
16
+
17
+ def initialize(base_url: nil)
18
+ @base_url = base_url || ENV.fetch("PSN_BASE_URL", "https://psn.saiden.dev")
19
+ @oauth = OAuth.new(base_url: @base_url)
20
+ @server = build_server
21
+ @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server, stateless: true)
22
+ end
23
+
24
+ def call(env)
25
+ request = Rack::Request.new(env)
26
+ origin = env["HTTP_ORIGIN"]
27
+
28
+ # Debug logging
29
+ warn "[MCP] #{request.request_method} #{request.path_info} Origin: #{origin.inspect}"
30
+
31
+ # Handle CORS preflight
32
+ if request.options?
33
+ return cors_preflight_response(request)
34
+ end
35
+
36
+ # Route OAuth endpoints
37
+ case request.path_info
38
+ when "/.well-known/oauth-protected-resource"
39
+ return json_response(@oauth.protected_resource_metadata, origin)
40
+
41
+ when "/.well-known/oauth-authorization-server"
42
+ return json_response(@oauth.authorization_server_metadata, origin)
43
+
44
+ when "/register"
45
+ if request.post?
46
+ params = parse_body(request)
47
+ return json_response(@oauth.register(params), origin)
48
+ end
49
+
50
+ when "/authorize"
51
+ if request.get?
52
+ params = request.params
53
+ result = @oauth.authorize(params)
54
+ if result[:redirect_to]
55
+ return [302, {"Location" => result[:redirect_to]}, []]
56
+ else
57
+ return json_response(result, origin, status: 400)
58
+ end
59
+ end
60
+
61
+ when "/token"
62
+ if request.post?
63
+ params = parse_body(request)
64
+ auth_header = env["HTTP_AUTHORIZATION"]
65
+ result = @oauth.token(params, auth_header: auth_header)
66
+ status = result[:error] ? 400 : 200
67
+ return json_response(result, origin, status: status)
68
+ end
69
+ end
70
+
71
+ # Handle MCP endpoint at /mcp or root /
72
+ unless request.path_info == "/" || request.path_info == "/mcp"
73
+ return [404, add_cors_headers({"Content-Type" => "application/json"}, origin), ['{"error":"Not found"}']]
74
+ end
75
+
76
+ # For MCP endpoints, validate Bearer token
77
+ auth_header = env["HTTP_AUTHORIZATION"]
78
+ unless @oauth.validate_token(auth_header)
79
+ # Return 401 to trigger OAuth flow
80
+ return [401, add_cors_headers({"Content-Type" => "application/json", "WWW-Authenticate" => "Bearer"}, origin), ['{"error":"Unauthorized"}']]
81
+ end
82
+
83
+ # Validate Origin (DNS rebinding protection)
84
+ unless valid_origin?(origin)
85
+ return [403, {"Content-Type" => "application/json"}, ['{"error":"Invalid origin"}']]
86
+ end
87
+
88
+ # Delegate to MCP transport
89
+ status, headers, body = @transport.handle_request(request)
90
+
91
+ # Add CORS headers to response
92
+ headers = add_cors_headers(headers, origin)
93
+
94
+ [status, headers, body]
95
+ end
96
+
97
+ private
98
+
99
+ def build_server
100
+ DB.migrate!
101
+ # HTTP server defaults to :core mode (no indexer - that runs locally)
102
+ mode = ENV.fetch("PSN_MCP_MODE", "core").to_sym
103
+ server = Server.new(mode: mode)
104
+ server.instance_variable_get(:@server)
105
+ end
106
+
107
+ def parse_body(request)
108
+ body = request.body.read
109
+ request.body.rewind
110
+ return {} if body.empty?
111
+
112
+ content_type = request.content_type || ""
113
+ if content_type.include?("application/json")
114
+ JSON.parse(body)
115
+ else
116
+ # application/x-www-form-urlencoded
117
+ URI.decode_www_form(body).to_h
118
+ end
119
+ rescue JSON::ParserError
120
+ {}
121
+ end
122
+
123
+ def json_response(data, origin, status: 200)
124
+ headers = add_cors_headers({"Content-Type" => "application/json"}, origin)
125
+ [status, headers, [JSON.generate(data)]]
126
+ end
127
+
128
+ def cors_preflight_response(request)
129
+ origin = request.env["HTTP_ORIGIN"]
130
+ headers = {
131
+ "Access-Control-Allow-Origin" => valid_origin?(origin) ? origin : "",
132
+ "Access-Control-Allow-Methods" => "GET, POST, DELETE, OPTIONS",
133
+ "Access-Control-Allow-Headers" => "Content-Type, Accept, Authorization, X-API-Key, Mcp-Session-Id, MCP-Protocol-Version",
134
+ "Access-Control-Max-Age" => "86400"
135
+ }
136
+ [204, headers, []]
137
+ end
138
+
139
+ def add_cors_headers(headers, origin)
140
+ headers = headers.dup
141
+ if valid_origin?(origin)
142
+ headers["Access-Control-Allow-Origin"] = origin
143
+ headers["Access-Control-Expose-Headers"] = "Mcp-Session-Id"
144
+ end
145
+ headers
146
+ end
147
+
148
+ def valid_origin?(origin)
149
+ return true if origin.nil? # Non-browser clients
150
+ return true if origin.start_with?("http://localhost", "http://127.0.0.1")
151
+ ALLOWED_ORIGINS.include?(origin)
152
+ end
153
+ end
154
+ end
155
+ end