textus 0.54.0 → 0.54.2

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.
@@ -1,161 +1,108 @@
1
- require "json"
2
- require_relative "routing"
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
3
4
 
4
5
  module Textus
5
6
  module Surfaces
6
7
  module MCP
7
- # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
8
- # message (NDJSON). Holds a single Session for the lifetime of stdin.
8
+ # MCP stdio server backed by the official mcp gem. The SDK owns protocol
9
+ # negotiation, tool dispatch, and JSON-RPC framing. This class owns the
10
+ # textus Session lifecycle (built lazily on first tool call) and delegates
11
+ # execution to Catalog.
9
12
  class Server
10
- include Routing
11
-
12
- PROTOCOL_VERSION = "2024-11-05"
13
- SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
14
- MAX_LINE_BYTES = 1_048_576 # 1 MB — protects against OOM from oversized tool calls
15
-
16
- def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
17
- @store = store
18
- @stdin = stdin
19
- @stdout = stdout
20
- @role = role
21
- @session = nil
22
- end
23
-
24
- def run
25
- @stdin.each_line do |line|
26
- line = line.strip
27
- next if line.empty?
28
-
29
- handle_line(line)
30
- end
31
- end
32
-
33
- private
34
-
35
- def handle_line(line)
36
- return reject_oversized(line) if line.bytesize > MAX_LINE_BYTES
37
-
38
- parse_and_dispatch(line)
39
- end
40
-
41
- def reject_oversized(line)
42
- emit_error(nil, -32_700, "message too large (#{line.bytesize} bytes, limit #{MAX_LINE_BYTES})")
43
- end
44
-
45
- def parse_and_dispatch(line)
46
- dispatch(JSON.parse(line))
47
- rescue JSON::ParserError => e
48
- emit_error(nil, -32_700, "parse error: #{e.message}")
49
- end
50
-
51
- def handle_initialize(rid, _params)
52
- @session = build_session
53
- emit_result(rid, {
54
- "protocolVersion" => PROTOCOL_VERSION,
55
- "serverInfo" => SERVER_INFO,
56
- "capabilities" => { "tools" => {}, "resources" => {} },
57
- })
58
- end
59
-
60
- def build_session
61
- # The acting role IS the resolved connection role (ADR 0040): the MCP
62
- # transport defaults to `agent`, which can write the queue, so its
63
- # propose_lane resolves directly. If a connection's role cannot propose,
64
- # propose_lane is nil and the `propose` tool reports that honestly.
65
- Session.new(
13
+ def initialize(store:, role: Textus::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
14
+ @store = store
15
+ @role = role
16
+ @stdin = stdin
17
+ @stdout = stdout
18
+ # Session built eagerly so the contract_etag is captured at server start.
19
+ # Changes to manifest/hooks/schemas after this point are detected as drift.
20
+ @session = Textus::Session.new(
66
21
  role: @role,
67
22
  cursor: @store.audit_log.latest_seq,
68
23
  propose_lane: @store.manifest.policy.propose_lane_for(@role),
69
- contract_etag: contract_etag,
24
+ contract_etag: contract_etag_now,
70
25
  )
71
- end
72
-
73
- def handle_tools_list(rid)
74
- emit_result(rid, { "tools" => Catalog.tool_schemas })
75
- end
76
-
77
- def handle_tools_call(rid, params)
78
- return unless session_ready?(rid)
79
26
 
80
- invoke_tool(rid, params["name"], params["arguments"] || {})
81
- rescue Textus::ContractDrift, CursorExpired, ToolError => e
82
- emit_error(rid, e.class::JSONRPC_CODE, e.message)
83
- rescue StandardError => e
84
- emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
27
+ @sdk = ::MCP::Server.new(
28
+ name: "textus",
29
+ version: Textus::VERSION,
30
+ tools: Catalog.build_tools(self),
31
+ resources: build_resources,
32
+ server_context: { mcp_server: self },
33
+ )
34
+ @sdk.resources_read_handler { |params, server_context:| handle_resource_read(params[:uri].to_s, server_context) }
85
35
  end
86
36
 
87
- def session_ready?(rid)
88
- return true if @session
37
+ # Runs the stdio line loop; delegates each JSON line to the SDK.
38
+ def run
39
+ @stdin.each_line do |line|
40
+ line = line.strip
41
+ next if line.empty?
89
42
 
90
- emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
91
- false
92
- end
43
+ response = @sdk.handle_json(line)
44
+ next unless response
93
45
 
94
- def invoke_tool(rid, name, args)
95
- # ADR 0083: contract-drift guard gates mutating verbs only
96
- @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
97
- result = Catalog.call(name, session: @session, store: @store, args: args)
98
- update_session_for(name)
99
- emit_tool_result(rid, result)
46
+ @stdout.puts(response)
47
+ @stdout.flush
48
+ end
100
49
  end
101
50
 
102
- def update_session_for(name)
103
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
104
- @session = @session.with(contract_etag: contract_etag) if name == "boot"
51
+ # Called from every MCP::Tool handler block in Catalog.
52
+ # The SDK parses JSON with symbolize_names: true — all nested keys are symbols.
53
+ # Deep-stringify so Catalog.call receives the string-key format it expects.
54
+ def dispatch(verb_name, args, _server_context)
55
+ str_args = deep_stringify_keys(args)
56
+ @session.check_etag!(contract_etag_now) unless Catalog.read_verbs.include?(verb_name.to_s)
57
+ result = Catalog.call(verb_name.to_s, session: @session, store: @store, args: str_args)
58
+ update_session_for(verb_name.to_s)
59
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.dump(result) }])
60
+ rescue Textus::ContractDrift => e
61
+ raise_handler_error(e.message, Textus::ContractDrift::JSONRPC_CODE)
62
+ rescue CursorExpired => e
63
+ raise_handler_error(e.message, CursorExpired::JSONRPC_CODE)
64
+ rescue Textus::Surfaces::MCP::ToolError => e
65
+ raise_handler_error(e.message, ToolError::JSONRPC_CODE)
66
+ rescue StandardError => e
67
+ raise_handler_error("internal: #{e.class}: #{e.message}", -32_603)
105
68
  end
106
69
 
107
- def emit_tool_result(rid, result)
108
- emit_result(rid, {
109
- "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
110
- "isError" => false,
111
- })
112
- end
70
+ private
113
71
 
114
- def handle_resources_list(rid)
115
- emit_result(rid, { "resources" => machine_resources })
72
+ def update_session_for(verb_name)
73
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if verb_name == "pulse"
74
+ @session = @session.with(contract_etag: contract_etag_now) if verb_name == "boot"
116
75
  end
117
76
 
118
- def machine_resources
77
+ def build_resources
119
78
  machine_lane = @store.manifest.policy.machine_lane
120
79
  return [] unless machine_lane
121
80
 
122
- produced_entries(machine_lane).map { |e| resource_descriptor(e) }
123
- end
124
-
125
- def produced_entries(machine_lane)
126
81
  @store.manifest.data.entries
127
82
  .select { |e| e.lane == machine_lane && e.is_a?(Textus::Manifest::Entry::Produced) }
83
+ .map { |e| ::MCP::Resource.new(uri: "textus://#{e.key.tr(".", "/")}", name: e.key, mime_type: mime_for_format(e.format)) }
128
84
  end
129
85
 
130
- def resource_descriptor(entry)
131
- {
132
- "uri" => "textus://#{entry.key.tr(".", "/")}",
133
- "name" => entry.key,
134
- "mimeType" => mime_for_format(entry.format),
135
- }
136
- end
137
-
138
- def handle_resources_read(rid, params)
139
- uri = params["uri"].to_s
86
+ def handle_resource_read(uri, _server_context)
140
87
  key = uri.delete_prefix("textus://").tr("/", ".")
141
- emit_result(rid, resource_contents(uri, key))
142
- rescue Textus::Error => e
143
- emit_error(rid, ToolError::JSONRPC_CODE, "resource read failed: #{e.message}")
144
- end
145
-
146
- def resource_contents(uri, key)
147
88
  env = @store.as(@role).get(key)
148
- text = resource_text(env.content || env.body || "")
89
+ text = env.content.is_a?(Hash) ? JSON.dump(env.content) : (env.body || "").to_s
149
90
  mime = mime_for_format(@store.manifest.resolver.resolve(key).entry.format)
150
- { "contents" => [{ "uri" => uri, "mimeType" => mime, "text" => text }] }
91
+ [{ uri: uri, mimeType: mime, text: text }]
92
+ rescue Textus::Error => e
93
+ raise_handler_error("resource read failed: #{e.message}", -32_603)
151
94
  end
152
95
 
153
- def resource_text(content)
154
- content.is_a?(Hash) ? JSON.dump(content) : content.to_s
155
- end
96
+ def contract_etag_now = Textus::Etag.for_contract(@store.root)
156
97
 
157
- def contract_etag
158
- Textus::Etag.for_contract(@store.root)
98
+ # The SDK parses JSON with symbolize_names:true, making all nested hash keys symbols.
99
+ # Recursively stringify so Catalog.call receives string-keyed hashes throughout.
100
+ def deep_stringify_keys(obj)
101
+ case obj
102
+ when Hash then obj.transform_keys(&:to_s).transform_values { |v| deep_stringify_keys(v) }
103
+ when Array then obj.map { |v| deep_stringify_keys(v) }
104
+ else obj
105
+ end
159
106
  end
160
107
 
161
108
  def mime_for_format(format)
@@ -166,17 +113,8 @@ module Textus
166
113
  end
167
114
  end
168
115
 
169
- def emit_result(rid, result)
170
- write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
171
- end
172
-
173
- def emit_error(rid, code, message)
174
- write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
175
- end
176
-
177
- def write(obj)
178
- @stdout.puts(JSON.dump(obj))
179
- @stdout.flush
116
+ def raise_handler_error(message, code)
117
+ raise ::MCP::Server::RequestHandlerError.new(message, nil, error_code: code)
180
118
  end
181
119
  end
182
120
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/types"
4
+
5
+ module Textus
6
+ module Types
7
+ include Dry.Types()
8
+
9
+ RoleName = Types::String.constrained(included_in: Textus::Role::NAMES)
10
+ Cursor = Types::Integer.constrained(gteq: 0)
11
+ FormatName = Types::String.constrained(
12
+ included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
13
+ )
14
+ end
15
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.54.0"
3
- PROTOCOL = "textus/3"
2
+ VERSION = "0.54.2"
3
+ PROTOCOL = "textus/4"
4
4
  end
@@ -64,6 +64,21 @@ module Textus
64
64
  mentry: ctx.entry,
65
65
  payload: Envelope::Writer::Payload.new(**normalized),
66
66
  )
67
+ publish_external(key, ctx)
68
+ end
69
+
70
+ def publish_external(key, ctx)
71
+ entry = ctx.entry
72
+ return unless entry.publish_tree || !Array(entry.publish_to).empty?
73
+
74
+ entry_path = @container.manifest.resolver.resolve(key).path
75
+ return unless entry.publish_tree || File.exist?(entry_path)
76
+
77
+ reader = Textus::Envelope::Reader.from(container: @container)
78
+ pctx = Textus::Manifest::Entry::Base::PublishContext.new(
79
+ container: @container, call: @call, reader: reader.method(:read),
80
+ )
81
+ entry.publish_via(pctx)
67
82
  end
68
83
 
69
84
  def normalize(data, format)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.54.0
4
+ version: 0.54.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -24,19 +24,47 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '3.0'
26
26
  - !ruby/object:Gem::Dependency
27
- name: mustache
27
+ name: dry-schema
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '1.1'
32
+ version: '1.13'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '1.1'
39
+ version: '1.13'
40
+ - !ruby/object:Gem::Dependency
41
+ name: dry-struct
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.6'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.6'
54
+ - !ruby/object:Gem::Dependency
55
+ name: mcp
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.20'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.20'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: psych
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -191,6 +219,7 @@ files:
191
219
  - lib/textus/doctor/check/schema_violations.rb
192
220
  - lib/textus/doctor/check/schemas.rb
193
221
  - lib/textus/doctor/check/sentinels.rb
222
+ - lib/textus/doctor/check/stale_reviewed_stamp.rb
194
223
  - lib/textus/doctor/check/templates.rb
195
224
  - lib/textus/doctor/check/unowned_schema_fields.rb
196
225
  - lib/textus/doctor/validator.rb
@@ -251,7 +280,9 @@ files:
251
280
  - lib/textus/manifest/resolver.rb
252
281
  - lib/textus/manifest/rules.rb
253
282
  - lib/textus/manifest/schema.rb
283
+ - lib/textus/manifest/schema/contract.rb
254
284
  - lib/textus/manifest/schema/keys.rb
285
+ - lib/textus/manifest/schema/semantics.rb
255
286
  - lib/textus/manifest/schema/validator.rb
256
287
  - lib/textus/manifest/schema/vocabulary.rb
257
288
  - lib/textus/ports/audit_log.rb
@@ -260,6 +291,7 @@ files:
260
291
  - lib/textus/ports/job_store.rb
261
292
  - lib/textus/ports/job_store/job.rb
262
293
  - lib/textus/ports/publisher.rb
294
+ - lib/textus/ports/raw_index.rb
263
295
  - lib/textus/ports/sentinel_store.rb
264
296
  - lib/textus/ports/storage/file_stat.rb
265
297
  - lib/textus/ports/storage/file_store.rb
@@ -293,12 +325,10 @@ files:
293
325
  - lib/textus/surfaces/mcp.rb
294
326
  - lib/textus/surfaces/mcp/catalog.rb
295
327
  - lib/textus/surfaces/mcp/errors.rb
296
- - lib/textus/surfaces/mcp/routing.rb
297
328
  - lib/textus/surfaces/mcp/server.rb
298
- - lib/textus/surfaces/mcp/session.rb
299
- - lib/textus/surfaces/mcp/tool_schemas.rb
300
329
  - lib/textus/surfaces/role_scope.rb
301
330
  - lib/textus/surfaces/watcher.rb
331
+ - lib/textus/types.rb
302
332
  - lib/textus/uid.rb
303
333
  - lib/textus/version.rb
304
334
  - lib/textus/workflow.rb
@@ -1,51 +0,0 @@
1
- module Textus
2
- module Surfaces
3
- module MCP
4
- # Protocol routing for the MCP JSON-RPC server. Mixed into Server so all
5
- # handle_* and emit_* methods are in scope without exposing them publicly.
6
- module Routing
7
- TOOL_METHODS = %w[initialize tools/list tools/call].freeze
8
- RESOURCE_METHODS = %w[resources/list resources/read].freeze
9
-
10
- def dispatch(msg)
11
- rid = msg["id"]
12
- params = msg["params"] || {}
13
- route(msg["method"], rid, params)
14
- end
15
-
16
- private
17
-
18
- def route(method, rid, params)
19
- return route_tool(method, rid, params) if TOOL_METHODS.include?(method)
20
- return route_resource(method, rid, params) if RESOURCE_METHODS.include?(method)
21
-
22
- route_protocol(method, rid)
23
- end
24
-
25
- def route_tool(method, rid, params)
26
- case method
27
- when "initialize" then handle_initialize(rid, params)
28
- when "tools/list" then handle_tools_list(rid)
29
- when "tools/call" then handle_tools_call(rid, params)
30
- end
31
- end
32
-
33
- def route_resource(method, rid, params)
34
- case method
35
- when "resources/list" then handle_resources_list(rid)
36
- when "resources/read" then handle_resources_read(rid, params)
37
- end
38
- end
39
-
40
- def route_protocol(method, rid)
41
- case method
42
- when "ping" then emit_result(rid, {})
43
- when "shutdown" then emit_result(rid, nil)
44
- when "notifications/initialized" then nil
45
- else emit_error(rid, -32_601, "method not found: #{method}")
46
- end
47
- end
48
- end
49
- end
50
- end
51
- end
@@ -1,9 +0,0 @@
1
- module Textus
2
- module Surfaces
3
- module MCP
4
- # The session value now lives in core (ADR 0036); retained here as an
5
- # alias so existing MCP references keep resolving.
6
- Session = Textus::Session
7
- end
8
- end
9
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Surfaces
3
- module MCP
4
- # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
5
- # per-verb contracts; this delegates to MCP::Catalog. The hand-written
6
- # array is gone — a kwarg rename now updates the schema automatically (and
7
- # the signature guard fails if the contract lags the use-case).
8
- module ToolSchemas
9
- module_function
10
-
11
- def all
12
- Catalog.tool_schemas
13
- end
14
- end
15
- end
16
- end
17
- end