prescient 0.7.0 → 0.8.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -268
  3. data/CHANGELOG.md +37 -0
  4. data/INTEGRATION_GUIDE.md +7 -1
  5. data/README.md +210 -1
  6. data/Steepfile +12 -12
  7. data/db/migrate/001_create_prescient_tables.rb +15 -16
  8. data/examples/README.md +2 -1
  9. data/examples/custom_contexts.rb +4 -4
  10. data/exe/prescient +2 -2
  11. data/exe/prescient-mcp +7 -0
  12. data/lib/prescient/agent/audit_log.rb +37 -0
  13. data/lib/prescient/agent/cli_adapter.rb +29 -0
  14. data/lib/prescient/agent/configuration.rb +57 -0
  15. data/lib/prescient/agent/context.rb +56 -0
  16. data/lib/prescient/agent/error_serializer.rb +47 -0
  17. data/lib/prescient/agent/errors.rb +25 -0
  18. data/lib/prescient/agent/parser.rb +49 -0
  19. data/lib/prescient/agent/prompt_builder.rb +31 -0
  20. data/lib/prescient/agent/result.rb +36 -0
  21. data/lib/prescient/agent/runtime.rb +175 -0
  22. data/lib/prescient/agent/schema_validator.rb +215 -0
  23. data/lib/prescient/agent/tool_registry.rb +89 -0
  24. data/lib/prescient/agent.rb +22 -0
  25. data/lib/prescient/api.rb +337 -274
  26. data/lib/prescient/base.rb +370 -372
  27. data/lib/prescient/cli.rb +586 -526
  28. data/lib/prescient/client.rb +7 -6
  29. data/lib/prescient/configuration_loader.rb +492 -488
  30. data/lib/prescient/document_source.rb +114 -0
  31. data/lib/prescient/errors.rb +1 -3
  32. data/lib/prescient/mcp/authentication.rb +39 -0
  33. data/lib/prescient/mcp/configuration.rb +38 -0
  34. data/lib/prescient/mcp/rack.rb +243 -0
  35. data/lib/prescient/mcp/server.rb +202 -0
  36. data/lib/prescient/mcp/stdio.rb +42 -0
  37. data/lib/prescient/mcp.rb +8 -0
  38. data/lib/prescient/pgvector.rb +193 -189
  39. data/lib/prescient/provider/anthropic.rb +129 -125
  40. data/lib/prescient/provider/deepseek.rb +122 -118
  41. data/lib/prescient/provider/gemini.rb +153 -149
  42. data/lib/prescient/provider/huggingface.rb +191 -187
  43. data/lib/prescient/provider/mistral.rb +151 -147
  44. data/lib/prescient/provider/ollama.rb +168 -165
  45. data/lib/prescient/provider/openai.rb +174 -169
  46. data/lib/prescient/provider/xai.rb +122 -118
  47. data/lib/prescient/tool/search_api.rb +125 -121
  48. data/lib/prescient/tool/searxng.rb +123 -119
  49. data/lib/prescient/tool.rb +100 -98
  50. data/lib/prescient/version.rb +1 -1
  51. data/lib/prescient.rb +68 -62
  52. data/sig/prescient.rbs +176 -1
  53. metadata +23 -1
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Prescient
6
+ # Bounded sources of JSON documents suitable for generation context.
7
+ module DocumentSource
8
+ # @return [Integer] Default maximum number of documents
9
+ DEFAULT_MAX_DOCUMENTS = 100
10
+ # @return [Integer] Default maximum serialized source size
11
+ DEFAULT_MAX_BYTES = 1_048_576
12
+
13
+ # Common validation and size-bound behavior for document sources.
14
+ class Base
15
+ def initialize(max_documents: DEFAULT_MAX_DOCUMENTS, max_bytes: DEFAULT_MAX_BYTES)
16
+ @max_documents = positive_integer(max_documents, "max_documents")
17
+ @max_bytes = positive_integer(max_bytes, "max_bytes")
18
+ end
19
+
20
+ # @return [Array<Hash>] JSON object documents
21
+ def fetch
22
+ raise NotImplementedError
23
+ end
24
+
25
+ private
26
+
27
+ def normalize(value)
28
+ documents = value.is_a?(Array) ? value : [value]
29
+ raise Prescient::Error, "document source must contain JSON objects" unless documents.all?(Hash)
30
+ if documents.length > @max_documents
31
+ raise Prescient::Error, "document source cannot contain more than #{@max_documents} documents"
32
+ end
33
+
34
+ serialized = JSON.generate(documents)
35
+ raise Prescient::Error, "document source exceeds #{@max_bytes} bytes" if serialized.bytesize > @max_bytes
36
+
37
+ documents
38
+ end
39
+
40
+ def parse_json(value)
41
+ normalize(JSON.parse(value))
42
+ rescue JSON::ParserError => e
43
+ raise Prescient::Error, "document source contains invalid JSON: #{e.message}"
44
+ end
45
+
46
+ def positive_integer(value, name)
47
+ return value if value.is_a?(Integer) && value.positive?
48
+
49
+ raise Prescient::Error, "#{name} must be a positive integer"
50
+ end
51
+ end
52
+
53
+ # Validate and bound application-provided JSON documents.
54
+ class Memory < Base
55
+ # @param documents [Array<Hash>, Hash] Documents to validate
56
+ def initialize(documents:, **options)
57
+ super(**options)
58
+ @documents = documents
59
+ end
60
+
61
+ # @return [Array<Hash>] Validated documents
62
+ def fetch
63
+ normalize(@documents)
64
+ end
65
+ end
66
+
67
+ # Read JSON documents from a local file.
68
+ class JsonFile < Base
69
+ # @param path [String] JSON file path
70
+ def initialize(path:, **options)
71
+ super(**options)
72
+ @path = path
73
+ return if @path.is_a?(String) && !@path.empty?
74
+
75
+ raise Prescient::Error, "document source path must be a non-empty string"
76
+ end
77
+
78
+ # @return [Array<Hash>] Documents loaded from the file
79
+ def fetch
80
+ raise Prescient::Error, "document source file not found: #{@path}" unless File.file?(@path)
81
+ raise Prescient::Error, "document source file exceeds #{@max_bytes} bytes" if File.size(@path) > @max_bytes
82
+
83
+ parse_json(File.read(@path))
84
+ rescue Errno::EACCES => e
85
+ raise Prescient::Error, "unable to read document source: #{e.message}"
86
+ end
87
+ end
88
+
89
+ # Read JSON documents from a Redis-compatible client.
90
+ # The client is injected to keep Redis optional for gem consumers.
91
+ class RedisJson < Base
92
+ # @param client [#get] Redis-compatible client
93
+ # @param key [String] Redis key containing a JSON document or array
94
+ def initialize(client:, key:, **options)
95
+ super(**options)
96
+ @client = client
97
+ @key = key
98
+ return if @key.is_a?(String) && !@key.empty?
99
+
100
+ raise Prescient::Error, "Redis document key must be a non-empty string"
101
+ end
102
+
103
+ # @return [Array<Hash>] Documents loaded from Redis
104
+ def fetch
105
+ value = @client.get(@key)
106
+ raise Prescient::Error, "Redis document source key not found: #{@key}" unless value
107
+
108
+ parse_json(value)
109
+ rescue NoMethodError
110
+ raise Prescient::Error, "Redis document source client must provide get"
111
+ end
112
+ end
113
+ end
114
+ end
@@ -3,9 +3,7 @@
3
3
  module Prescient
4
4
  # Base error class for all Prescient-specific errors
5
5
  class Error < StandardError
6
- attr_reader :provider
7
- attr_reader :operation
8
- attr_reader :status
6
+ attr_reader :provider, :operation, :status
9
7
 
10
8
  def initialize(message = nil, provider: nil, operation: nil, status: nil)
11
9
  super(message)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ # rubocop:disable Style/ClassAndModuleChildren
6
+ module Prescient::MCP
7
+ # Standard bearer-token authentication policy for HTTP MCP transports.
8
+ module Authentication
9
+ # Authenticates requests with a configured bearer token.
10
+ class BearerToken
11
+ def initialize(token:, principal: nil)
12
+ raise ArgumentError, "token must be a non-empty string" unless token.is_a?(String) && !token.empty?
13
+
14
+ @token = token
15
+ @principal = principal
16
+ end
17
+
18
+ # Authenticate a Rack environment without exposing the token.
19
+ # @param env [Hash] Rack environment
20
+ # @return [Object, false] Principal on success or false on failure
21
+ def call(env)
22
+ value = env["HTTP_AUTHORIZATION"].to_s
23
+ scheme, candidate = value.split(" ", 2)
24
+ return false unless scheme&.casecmp("Bearer")&.zero? && secure_equal?(candidate.to_s, @token)
25
+
26
+ @principal || { type: "bearer" }
27
+ end
28
+
29
+ private
30
+
31
+ def secure_equal?(candidate, expected)
32
+ return false unless candidate.bytesize == expected.bytesize
33
+
34
+ OpenSSL.fixed_length_secure_compare(candidate, expected)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren, Layout/IndentationWidth
4
+ module Prescient::MCP
5
+ # Policy and capability configuration for the optional MCP adapter.
6
+ class Configuration
7
+ # @return [Integer] Maximum default MCP input size in bytes
8
+ DEFAULT_MAX_INPUT_BYTES = 64_000
9
+ # @return [Array<String>] Default MCP tools
10
+ DEFAULT_TOOLS = %w[
11
+ prescient_generate prescient_embed prescient_providers prescient_health prescient_agent
12
+ ].freeze
13
+ # @return [Array<String>] Resources supported by the adapter
14
+ SUPPORTED_RESOURCES = ["prescient://providers", "prescient://health"].freeze
15
+
16
+ attr_reader :name, :version, :max_input_bytes, :tools, :resources
17
+
18
+ def initialize(name: "prescient", version: Prescient::VERSION,
19
+ max_input_bytes: DEFAULT_MAX_INPUT_BYTES,
20
+ tools: DEFAULT_TOOLS,
21
+ resources: ["prescient://providers", "prescient://health"])
22
+ @name = name
23
+ @version = version
24
+ @max_input_bytes = validate_limit(max_input_bytes)
25
+ @tools = Array(tools).map(&:to_s).freeze
26
+ @resources = Array(resources).map(&:to_s).freeze
27
+ end
28
+
29
+ private
30
+
31
+ def validate_limit(value)
32
+ return value if value.is_a?(Integer) && value.positive?
33
+
34
+ raise ArgumentError, "max_input_bytes must be a positive integer"
35
+ end
36
+ end
37
+ end
38
+ # rubocop:enable Style/ClassAndModuleChildren, Layout/IndentationWidth
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "securerandom"
6
+ require "stringio"
7
+
8
+ # rubocop:disable Style/ClassAndModuleChildren
9
+ module Prescient::MCP
10
+ # Optional Rack-compatible MCP HTTP handler with explicit authentication.
11
+ # rubocop:disable Metrics/ClassLength
12
+ class Rack
13
+ # MCP protocol version supported by the HTTP transport.
14
+ # @return [String] Protocol version identifier
15
+ PROTOCOL_VERSION = "2025-06-18"
16
+ # Rack environment key containing the MCP session identifier.
17
+ # @return [String] Rack header key
18
+ SESSION_HEADER = "HTTP_MCP_SESSION_ID"
19
+ # Rack environment key containing the MCP protocol version.
20
+ # @return [String] Rack header key
21
+ PROTOCOL_HEADER = "HTTP_MCP_PROTOCOL_VERSION"
22
+ # Default policy allowing requests without an Origin header restriction.
23
+ # @return [Array<String>] Empty Origin allowlist
24
+ ALLOWED_ORIGINS_DEFAULT = [].freeze
25
+
26
+ def initialize(authentication:, server: Server.new, request_context: nil,
27
+ max_body_bytes: Configuration::DEFAULT_MAX_INPUT_BYTES,
28
+ allowed_origins: ALLOWED_ORIGINS_DEFAULT, session_ttl: 3600)
29
+ @server = server
30
+ @authentication = authentication
31
+ @request_context = request_context
32
+ @max_body_bytes = validate_limit(max_body_bytes)
33
+ @allowed_origins = Array(allowed_origins).map(&:to_s).freeze
34
+ @session_ttl = validate_session_ttl(session_ttl)
35
+ @sessions = {}
36
+ @sessions_lock = Monitor.new
37
+ end
38
+
39
+ # Handle one MCP Streamable HTTP request.
40
+ # @param env [Hash] Rack environment
41
+ # @return [Array] Rack response
42
+ def call(env)
43
+ method = env.fetch("REQUEST_METHOD", "GET").upcase
44
+ unless %w[POST GET DELETE].include?(method)
45
+ return response(
46
+ 405, { error: "method_not_allowed" }, { "allow" => "POST, GET, DELETE" }
47
+ )
48
+ end
49
+ return response(403, { error: "origin_not_allowed" }) unless origin_allowed?(env)
50
+
51
+ authentication = authenticate(env)
52
+ unless authentication
53
+ return response(401, { error: "authentication_required" }, { "www-authenticate" => "Bearer" })
54
+ end
55
+
56
+ return post(env, authentication) if method == "POST"
57
+ return get(env, authentication) if method == "GET"
58
+
59
+ delete(env, authentication)
60
+ rescue JSON::ParserError
61
+ response(400, { error: "invalid_json" })
62
+ rescue SessionNotFoundError
63
+ response(404, { error: "session_not_found" })
64
+ rescue AuthenticationError
65
+ response(401, { error: "authentication_failed" })
66
+ rescue ArgumentError
67
+ response(400, { error: "invalid_request" })
68
+ rescue StandardError
69
+ response(500, { error: "internal_error" })
70
+ end
71
+
72
+ private
73
+
74
+ def post(env, authentication)
75
+ request = parse_request(env)
76
+ initialize_request = request["method"] == "initialize"
77
+ session_id = env[SESSION_HEADER]
78
+ if initialize_request
79
+ raise ArgumentError, "initialize must not include a session" if session_id
80
+
81
+ validate_protocol!(request)
82
+ result = @server.dispatch(request, context: request_context(env, authentication))
83
+ session_id = new_session(authentication)
84
+ return response_for(request, result, 200, headers: { "mcp-session-id" => session_id })
85
+ end
86
+
87
+ session = session_for(session_id)
88
+ validate_session!(session, authentication)
89
+ validate_protocol_header!(env)
90
+ result = @server.dispatch(request, context: request_context(env, authentication))
91
+ return response(202, nil) if notification?(request)
92
+
93
+ response_for(request, result, 200, sse: accepts_sse?(env))
94
+ end
95
+
96
+ def parse_request(env)
97
+ body = env.fetch("rack.input", StringIO.new).read(@max_body_bytes + 1)
98
+ raise ArgumentError, "request body exceeds configured limit" if body.bytesize > @max_body_bytes
99
+
100
+ request = JSON.parse(body)
101
+ raise ArgumentError, "request must be an object" unless request.is_a?(Hash)
102
+ raise ArgumentError, "request must be JSON-RPC 2.0" unless request["jsonrpc"] == "2.0"
103
+ raise ArgumentError, "request method must be a string" unless request["method"].is_a?(String)
104
+
105
+ request
106
+ end
107
+
108
+ def get(env, authentication)
109
+ session = session_for(env[SESSION_HEADER])
110
+ validate_session!(session, authentication)
111
+ validate_protocol_header!(env)
112
+ raise ArgumentError, "GET requires text/event-stream" unless accepts_sse?(env)
113
+
114
+ response(200, nil, { "content-type" => "text/event-stream", "cache-control" => "no-cache",
115
+ "x-accel-buffering" => "no" }, body: ": keep-alive\n\n")
116
+ end
117
+
118
+ def delete(env, authentication)
119
+ session_id = env[SESSION_HEADER]
120
+ session = session_for(session_id)
121
+ validate_session!(session, authentication)
122
+ validate_protocol_header!(env)
123
+ @sessions_lock.synchronize { @sessions.delete(session_id) }
124
+ [204, {}, []]
125
+ end
126
+
127
+ def authenticate(env)
128
+ result = @authentication.call(env)
129
+ result == false || result.nil? ? nil : result
130
+ end
131
+
132
+ def origin_allowed?(env)
133
+ origin = env["HTTP_ORIGIN"]
134
+ origin.nil? || @allowed_origins.include?(origin)
135
+ end
136
+
137
+ def new_session(authentication)
138
+ session_id = SecureRandom.uuid
139
+ expires_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @session_ttl
140
+ @sessions_lock.synchronize do
141
+ prune_expired_sessions
142
+ @sessions[session_id] = { authentication: authentication, expires_at: expires_at }
143
+ end
144
+ session_id
145
+ end
146
+
147
+ def session_for(session_id)
148
+ raise ArgumentError, "MCP session is required" if session_id.to_s.empty?
149
+
150
+ session = @sessions_lock.synchronize do
151
+ prune_expired_sessions
152
+ @sessions[session_id]
153
+ end
154
+ raise SessionNotFoundError unless session
155
+
156
+ session
157
+ end
158
+
159
+ def validate_session!(session, authentication)
160
+ return if session[:authentication] == authentication
161
+
162
+ raise AuthenticationError
163
+ end
164
+
165
+ def validate_protocol!(request)
166
+ version = request.dig("params", "protocolVersion")
167
+ return if version.nil? || version == PROTOCOL_VERSION
168
+
169
+ raise ArgumentError, "unsupported MCP protocol version"
170
+ end
171
+
172
+ def validate_protocol_header!(env)
173
+ return if env[PROTOCOL_HEADER].to_s == PROTOCOL_VERSION
174
+
175
+ raise ArgumentError, "MCP protocol version header is required"
176
+ end
177
+
178
+ def notification?(request)
179
+ !request.key?("id")
180
+ end
181
+
182
+ def accepts_sse?(env)
183
+ env.fetch("HTTP_ACCEPT", "").split(",").map(&:strip).include?("text/event-stream")
184
+ end
185
+
186
+ def response_for(request, result, status, sse: false, headers: {})
187
+ payload = { jsonrpc: "2.0", id: request["id"], result: }
188
+ if sse
189
+ body = "data: #{JSON.generate(payload)}\n\n"
190
+ sse_headers = headers.merge("content-type" => "text/event-stream", "cache-control" => "no-cache")
191
+ return response(status, nil, sse_headers, body:)
192
+ end
193
+
194
+ response(status, payload, headers)
195
+ end
196
+
197
+ def request_context(env, authentication)
198
+ base = {
199
+ request_id: SecureRandom.uuid,
200
+ tenant_id: env["HTTP_X_TENANT_ID"],
201
+ principal: authentication == true ? env["REMOTE_USER"] : authentication
202
+ }.compact
203
+ return base unless @request_context
204
+
205
+ context = @request_context.call(env)
206
+ raise ArgumentError, "request context hook must return a mapping" unless context.is_a?(Hash)
207
+
208
+ base.merge(context)
209
+ end
210
+
211
+ def response(status, payload, headers = {}, body: nil)
212
+ body ||= payload.nil? ? "" : JSON.generate(payload)
213
+ response_headers = { "content-type" => "application/json", "content-length" => body.bytesize.to_s }
214
+ [status, response_headers.merge(headers), body.empty? ? [] : [body]]
215
+ end
216
+
217
+ def validate_limit(value)
218
+ return value if value.is_a?(Integer) && value.positive?
219
+
220
+ raise ArgumentError, "max_body_bytes must be a positive integer"
221
+ end
222
+
223
+ def validate_session_ttl(value)
224
+ return value if value.is_a?(Numeric) && value.positive?
225
+
226
+ raise ArgumentError, "session_ttl must be a positive number"
227
+ end
228
+
229
+ def prune_expired_sessions
230
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
231
+ @sessions.delete_if { |_id, session| session[:expires_at] <= now }
232
+ end
233
+ end
234
+ # rubocop:enable Metrics/ClassLength
235
+
236
+ class SessionNotFoundError < StandardError
237
+ end
238
+
239
+ # Raised when an authenticated request does not own the MCP session.
240
+ class AuthenticationError < StandardError
241
+ end
242
+ end
243
+ # rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren, Layout/IndentationWidth
4
+ module Prescient::MCP
5
+ # Dependency-free MCP capability adapter over Prescient's public API.
6
+ class Server
7
+ # rubocop:disable Layout/HashAlignment
8
+ TOOL_DEFINITIONS = {
9
+ "prescient_generate" => {
10
+ description: "Generate a text response.",
11
+ input_schema: { type: "object", required: ["prompt"] }
12
+ },
13
+ "prescient_embed" => {
14
+ description: "Generate an embedding.",
15
+ input_schema: { type: "object", required: ["input"] }
16
+ },
17
+ "prescient_providers" => {
18
+ description: "List configured providers.",
19
+ input_schema: { type: "object" }
20
+ },
21
+ "prescient_health" => {
22
+ description: "Check configured provider health.",
23
+ input_schema: { type: "object" }
24
+ },
25
+ "prescient_agent" => {
26
+ description: "Run a bounded agent task with explicitly supplied tools.",
27
+ input_schema: { type: "object", required: ["prompt"] }
28
+ }
29
+ }.freeze
30
+ # rubocop:enable Layout/HashAlignment
31
+
32
+ def initialize(configuration: Configuration.new, client_factory: nil, authorization: nil)
33
+ @configuration = configuration
34
+ @client_factory = client_factory || ->(provider) { Prescient.client(provider) }
35
+ @authorization = authorization
36
+ end
37
+
38
+ # Return the MCP initialization response.
39
+ # @return [Hash] Server capabilities and metadata
40
+ def initialize_result
41
+ # rubocop:disable Layout/HashAlignment
42
+ {
43
+ protocolVersion: "2025-06-18",
44
+ serverInfo: { name: @configuration.name, version: @configuration.version },
45
+ capabilities: { tools: {}, resources: {} }
46
+ }
47
+ # rubocop:enable Layout/HashAlignment
48
+ end
49
+
50
+ # Dispatch one MCP JSON-RPC method.
51
+ # @param request [Hash] JSON-RPC request
52
+ # @param context [Hash] Request-scoped context
53
+ # @return [Hash] Method result
54
+ def dispatch(request, context: {})
55
+ case request["method"]
56
+ when "initialize" then initialize_result
57
+ when "notifications/initialized", "notifications/cancelled", "notifications/progress" then nil
58
+ when "tools/list" then { tools: }
59
+ when "tools/call"
60
+ call_tool(request.dig("params", "name"), request.dig("params", "arguments") || {}, context:)
61
+ when "resources/list" then { resources: }
62
+ when "resources/read" then read_resource(request.dig("params", "uri"))
63
+ else raise ArgumentError, "MCP method not found: #{request["method"]}"
64
+ end
65
+ end
66
+
67
+ # Return enabled MCP tool definitions.
68
+ # @return [Array<Hash>] Discoverable tools
69
+ def tools
70
+ @configuration.tools.filter_map do |name|
71
+ definition = TOOL_DEFINITIONS[name]
72
+ definition && { name:, **definition }
73
+ end
74
+ end
75
+
76
+ # Return enabled MCP resources.
77
+ # @return [Array<Hash>] Discoverable resources
78
+ def resources
79
+ @configuration.resources.filter_map do |uri|
80
+ next unless Configuration::SUPPORTED_RESOURCES.include?(uri)
81
+
82
+ { uri:, name: uri.delete_prefix("prescient://"), mimeType: "application/json" }
83
+ end
84
+ end
85
+
86
+ # Execute one enabled MCP tool.
87
+ # @param name [String, Symbol] Tool name
88
+ # @param arguments [Hash] Tool arguments
89
+ # @param context [Hash] Request-scoped context
90
+ # @return [Hash] MCP tool result
91
+ def call_tool(name, arguments = {}, context: {})
92
+ ensure_enabled!(name)
93
+ validate_input(arguments)
94
+ authorize!(name, arguments, context)
95
+ result = case name.to_s
96
+ when "prescient_generate" then generate(arguments)
97
+ when "prescient_embed" then embed(arguments)
98
+ when "prescient_providers" then providers
99
+ when "prescient_health" then health(arguments)
100
+ when "prescient_agent" then agent(arguments, context)
101
+ else raise ArgumentError, "MCP tool not enabled: #{name}"
102
+ end
103
+ { content: [{ type: "text", text: JSON.generate(result) }], isError: false }
104
+ rescue StandardError => e
105
+ { content: [{ type: "text", text: JSON.generate(error: safe_error(e)) }], isError: true }
106
+ end
107
+
108
+ # Read one enabled MCP resource.
109
+ # @param uri [String, Symbol] Resource URI
110
+ # @return [Hash] MCP resource response
111
+ def read_resource(uri)
112
+ payload = case uri.to_s
113
+ when "prescient://providers" then providers
114
+ when "prescient://health" then health({})
115
+ else raise ArgumentError, "MCP resource not enabled: #{uri}"
116
+ end
117
+ { contents: [{ uri:, mimeType: "application/json", text: JSON.generate(payload) }] }
118
+ end
119
+
120
+ private
121
+
122
+ def generate(arguments)
123
+ prompt = required_string(arguments, "prompt")
124
+ client_for(arguments).generate_response(prompt, arguments.fetch("context", []), **model_options(arguments))
125
+ end
126
+
127
+ def embed(arguments)
128
+ input = required_string(arguments, "input")
129
+ embedding = client_for(arguments).generate_embedding(input, **model_options(arguments))
130
+ { embedding:, dimensions: embedding.length }
131
+ end
132
+
133
+ def providers
134
+ { providers: Prescient.configuration.providers.map do |name, registration|
135
+ { name: name.to_s, class: registration[:class].name }
136
+ end }
137
+ end
138
+
139
+ def health(arguments)
140
+ provider = arguments["provider"]
141
+ return Prescient.health_check(provider: provider.to_sym) if provider
142
+
143
+ Prescient.configuration.providers.keys.to_h { |name| [name.to_s, Prescient.health_check(provider: name)] }
144
+ end
145
+
146
+ def agent(arguments, context)
147
+ require "prescient/agent"
148
+ configuration = Prescient::Agent::Configuration.new(max_loops: arguments.fetch("max_loops", 5))
149
+ result = Prescient::Agent::Runtime.new(
150
+ client: client_for(arguments),
151
+ tool_names: arguments.fetch("tools", []),
152
+ configuration: configuration,
153
+ authorization: @authorization,
154
+ request_context: context,
155
+ generation_options: model_options(arguments)
156
+ ).run(required_string(arguments, "prompt"))
157
+ result.to_h
158
+ end
159
+
160
+ def client_for(arguments)
161
+ @client_factory.call(arguments["provider"]&.to_sym)
162
+ end
163
+
164
+ def model_options(arguments)
165
+ arguments["model"] ? { model: arguments["model"] } : {}
166
+ end
167
+
168
+ def required_string(arguments, key)
169
+ value = arguments[key]
170
+ raise ArgumentError, "#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
171
+
172
+ value
173
+ end
174
+
175
+ def validate_input(arguments)
176
+ raise ArgumentError, "MCP arguments must be an object" unless arguments.is_a?(Hash)
177
+ return unless JSON.generate(arguments).bytesize > @configuration.max_input_bytes
178
+
179
+ raise ArgumentError, "MCP arguments exceed configured limit"
180
+ end
181
+
182
+ def ensure_enabled!(name)
183
+ return if @configuration.tools.include?(name.to_s)
184
+
185
+ raise ArgumentError, "MCP tool not enabled: #{name}"
186
+ end
187
+
188
+ def authorize!(name, arguments, context)
189
+ return unless @authorization
190
+ return if @authorization.call(tool: name.to_sym, arguments: arguments.dup, context: context.dup) == true
191
+
192
+ raise Prescient::AuthenticationError, "MCP authorization denied"
193
+ end
194
+
195
+ def safe_error(error)
196
+ return { type: "invalid_request", message: error.message } if error.is_a?(ArgumentError)
197
+
198
+ { type: "internal_error", message: "MCP operation failed" }
199
+ end
200
+ end
201
+ end
202
+ # rubocop:enable Style/ClassAndModuleChildren, Layout/IndentationWidth
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Style/ClassAndModuleChildren, Layout/IndentationWidth
4
+ module Prescient::MCP
5
+ # Minimal newline-delimited JSON-RPC transport for local MCP clients.
6
+ class Stdio
7
+ def initialize(server: Server.new, input: $stdin, output: $stdout)
8
+ @server = server
9
+ @input = input
10
+ @output = output
11
+ end
12
+
13
+ # Process newline-delimited JSON-RPC requests until input closes.
14
+ # @return [void]
15
+ def run
16
+ @input.each_line do |line|
17
+ request = JSON.parse(line)
18
+ @output.puts(JSON.generate(handle(request)))
19
+ @output.flush
20
+ rescue JSON::ParserError
21
+ @output.puts(JSON.generate(error_response(nil, -32_700, "invalid JSON")))
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def handle(request)
28
+ id = request["id"]
29
+ result = @server.dispatch(request)
30
+ { jsonrpc: "2.0", id:, result: }
31
+ rescue ArgumentError
32
+ error_response(id, -32_601, "MCP method not found")
33
+ rescue StandardError
34
+ error_response(id, -32_600, "MCP operation failed")
35
+ end
36
+
37
+ def error_response(id, code, message)
38
+ { jsonrpc: "2.0", id:, error: { code:, message: } }
39
+ end
40
+ end
41
+ end
42
+ # rubocop:enable Style/ClassAndModuleChildren, Layout/IndentationWidth