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.
- checksums.yaml +4 -4
- data/.rubocop.yml +21 -268
- data/CHANGELOG.md +37 -0
- data/INTEGRATION_GUIDE.md +7 -1
- data/README.md +210 -1
- data/Steepfile +12 -12
- data/db/migrate/001_create_prescient_tables.rb +15 -16
- data/examples/README.md +2 -1
- data/examples/custom_contexts.rb +4 -4
- data/exe/prescient +2 -2
- data/exe/prescient-mcp +7 -0
- data/lib/prescient/agent/audit_log.rb +37 -0
- data/lib/prescient/agent/cli_adapter.rb +29 -0
- data/lib/prescient/agent/configuration.rb +57 -0
- data/lib/prescient/agent/context.rb +56 -0
- data/lib/prescient/agent/error_serializer.rb +47 -0
- data/lib/prescient/agent/errors.rb +25 -0
- data/lib/prescient/agent/parser.rb +49 -0
- data/lib/prescient/agent/prompt_builder.rb +31 -0
- data/lib/prescient/agent/result.rb +36 -0
- data/lib/prescient/agent/runtime.rb +175 -0
- data/lib/prescient/agent/schema_validator.rb +215 -0
- data/lib/prescient/agent/tool_registry.rb +89 -0
- data/lib/prescient/agent.rb +22 -0
- data/lib/prescient/api.rb +337 -274
- data/lib/prescient/base.rb +370 -372
- data/lib/prescient/cli.rb +586 -526
- data/lib/prescient/client.rb +7 -6
- data/lib/prescient/configuration_loader.rb +492 -488
- data/lib/prescient/document_source.rb +114 -0
- data/lib/prescient/errors.rb +1 -3
- data/lib/prescient/mcp/authentication.rb +39 -0
- data/lib/prescient/mcp/configuration.rb +38 -0
- data/lib/prescient/mcp/rack.rb +243 -0
- data/lib/prescient/mcp/server.rb +202 -0
- data/lib/prescient/mcp/stdio.rb +42 -0
- data/lib/prescient/mcp.rb +8 -0
- data/lib/prescient/pgvector.rb +193 -189
- data/lib/prescient/provider/anthropic.rb +129 -125
- data/lib/prescient/provider/deepseek.rb +122 -118
- data/lib/prescient/provider/gemini.rb +153 -149
- data/lib/prescient/provider/huggingface.rb +191 -187
- data/lib/prescient/provider/mistral.rb +151 -147
- data/lib/prescient/provider/ollama.rb +168 -165
- data/lib/prescient/provider/openai.rb +174 -169
- data/lib/prescient/provider/xai.rb +122 -118
- data/lib/prescient/tool/search_api.rb +125 -121
- data/lib/prescient/tool/searxng.rb +123 -119
- data/lib/prescient/tool.rb +100 -98
- data/lib/prescient/version.rb +1 -1
- data/lib/prescient.rb +68 -62
- data/sig/prescient.rbs +176 -1
- 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
|
data/lib/prescient/errors.rb
CHANGED
|
@@ -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
|