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.
- checksums.yaml +4 -4
- data/.claude/settings.local.json +9 -0
- data/README.md +92 -13
- data/exe/psn-http +28 -0
- data/exe/psn-mcp +15 -1
- data/exe/psn-voice +7 -0
- data/lib/personality/cart.rb +12 -1
- data/lib/personality/cart_manager.rb +1 -1
- data/lib/personality/cli/index.rb +50 -14
- data/lib/personality/cli/tts.rb +27 -2
- data/lib/personality/indexer.rb +27 -9
- data/lib/personality/mcp/oauth.rb +238 -0
- data/lib/personality/mcp/rack_app.rb +155 -0
- data/lib/personality/mcp/server.rb +183 -30
- data/lib/personality/mcp/tts_server.rb +11 -4
- data/lib/personality/mcp/voice_server.rb +412 -0
- data/lib/personality/tts.rb +168 -35
- data/lib/personality/version.rb +1 -1
- metadata +51 -1
|
@@ -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
|