textus 0.22.0 → 0.29.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +195 -48
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +79 -42
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/boot.rb +31 -29
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/group/mcp.rb +9 -0
  11. data/lib/textus/cli/group/zone.rb +9 -0
  12. data/lib/textus/cli/verb/accept.rb +1 -1
  13. data/lib/textus/cli/verb/audit.rb +2 -2
  14. data/lib/textus/cli/verb/blame.rb +1 -1
  15. data/lib/textus/cli/verb/boot.rb +1 -1
  16. data/lib/textus/cli/verb/build.rb +3 -3
  17. data/lib/textus/cli/verb/delete.rb +1 -1
  18. data/lib/textus/cli/verb/deps.rb +1 -1
  19. data/lib/textus/cli/verb/doctor.rb +1 -1
  20. data/lib/textus/cli/verb/freshness.rb +1 -1
  21. data/lib/textus/cli/verb/get.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -4
  23. data/lib/textus/cli/verb/hooks.rb +11 -14
  24. data/lib/textus/cli/verb/key_delete.rb +24 -0
  25. data/lib/textus/cli/verb/list.rb +1 -1
  26. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  27. data/lib/textus/cli/verb/migrate.rb +18 -0
  28. data/lib/textus/cli/verb/mv.rb +11 -3
  29. data/lib/textus/cli/verb/published.rb +1 -1
  30. data/lib/textus/cli/verb/pulse.rb +1 -1
  31. data/lib/textus/cli/verb/put.rb +8 -6
  32. data/lib/textus/cli/verb/rdeps.rb +1 -1
  33. data/lib/textus/cli/verb/refresh.rb +1 -1
  34. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  35. data/lib/textus/cli/verb/reject.rb +1 -1
  36. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  37. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  38. data/lib/textus/cli/verb/schema.rb +1 -1
  39. data/lib/textus/cli/verb/uid.rb +1 -1
  40. data/lib/textus/cli/verb/where.rb +1 -1
  41. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  42. data/lib/textus/cli/verb.rb +7 -7
  43. data/lib/textus/cli.rb +0 -7
  44. data/lib/textus/container.rb +23 -0
  45. data/lib/textus/dispatcher.rb +49 -0
  46. data/lib/textus/doctor/check/audit_log.rb +2 -2
  47. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  48. data/lib/textus/doctor/check/hooks.rb +4 -3
  49. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  50. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  51. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  52. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  53. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  54. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  55. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  56. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  57. data/lib/textus/doctor/check/schemas.rb +2 -2
  58. data/lib/textus/doctor/check/sentinels.rb +11 -9
  59. data/lib/textus/doctor/check/templates.rb +2 -2
  60. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  61. data/lib/textus/doctor/check.rb +12 -3
  62. data/lib/textus/doctor.rb +24 -27
  63. data/lib/textus/domain/authorizer.rb +6 -6
  64. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  65. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  66. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  67. data/lib/textus/domain/sentinel.rb +9 -65
  68. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  69. data/lib/textus/domain/staleness/intake_check.rb +20 -12
  70. data/lib/textus/domain/staleness.rb +4 -4
  71. data/lib/textus/envelope/io/reader.rb +44 -0
  72. data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
  73. data/lib/textus/hooks/builtin.rb +14 -14
  74. data/lib/textus/hooks/context.rb +30 -13
  75. data/lib/textus/hooks/error_log.rb +32 -0
  76. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  77. data/lib/textus/hooks/loader.rb +29 -3
  78. data/lib/textus/hooks/rpc_registry.rb +77 -0
  79. data/lib/textus/key/path.rb +7 -3
  80. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  81. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  82. data/lib/textus/maintenance/migrate.rb +51 -0
  83. data/lib/textus/maintenance/rule_lint.rb +56 -0
  84. data/lib/textus/maintenance/zone_mv.rb +51 -0
  85. data/lib/textus/maintenance.rb +15 -0
  86. data/lib/textus/manifest/data.rb +79 -0
  87. data/lib/textus/manifest/entry/base.rb +38 -18
  88. data/lib/textus/manifest/entry/derived.rb +8 -9
  89. data/lib/textus/manifest/entry/nested.rb +7 -9
  90. data/lib/textus/manifest/entry/parser.rb +2 -2
  91. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  94. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  96. data/lib/textus/manifest/entry/validators.rb +2 -2
  97. data/lib/textus/manifest/entry.rb +0 -5
  98. data/lib/textus/manifest/policy.rb +48 -0
  99. data/lib/textus/manifest/resolver.rb +14 -14
  100. data/lib/textus/manifest/rules.rb +1 -1
  101. data/lib/textus/manifest.rb +47 -110
  102. data/lib/textus/mcp/errors.rb +32 -0
  103. data/lib/textus/mcp/server.rb +126 -0
  104. data/lib/textus/mcp/session.rb +40 -0
  105. data/lib/textus/mcp/tool_schemas.rb +71 -0
  106. data/lib/textus/mcp/tools.rb +129 -0
  107. data/lib/textus/mcp.rb +6 -0
  108. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  109. data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
  110. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  111. data/lib/textus/{infra → ports}/clock.rb +1 -1
  112. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  113. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  114. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  115. data/lib/textus/ports/sentinel_store.rb +67 -0
  116. data/lib/textus/ports/storage/file_stat.rb +19 -0
  117. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  118. data/lib/textus/projection.rb +91 -0
  119. data/lib/textus/read/audit.rb +111 -0
  120. data/lib/textus/read/blame.rb +81 -0
  121. data/lib/textus/read/boot.rb +18 -0
  122. data/lib/textus/read/deps.rb +24 -0
  123. data/lib/textus/read/doctor.rb +19 -0
  124. data/lib/textus/read/freshness.rb +101 -0
  125. data/lib/textus/read/get.rb +66 -0
  126. data/lib/textus/read/get_or_refresh.rb +69 -0
  127. data/lib/textus/read/list.rb +15 -0
  128. data/lib/textus/read/policy_explain.rb +37 -0
  129. data/lib/textus/read/published.rb +15 -0
  130. data/lib/textus/read/pulse.rb +89 -0
  131. data/lib/textus/read/rdeps.rb +25 -0
  132. data/lib/textus/read/schema_envelope.rb +16 -0
  133. data/lib/textus/read/stale.rb +17 -0
  134. data/lib/textus/read/uid.rb +20 -0
  135. data/lib/textus/read/validate_all.rb +22 -0
  136. data/lib/textus/read/validator.rb +84 -0
  137. data/lib/textus/read/where.rb +16 -0
  138. data/lib/textus/role_scope.rb +49 -0
  139. data/lib/textus/schema/tools.rb +14 -10
  140. data/lib/textus/store.rb +25 -11
  141. data/lib/textus/version.rb +1 -1
  142. data/lib/textus/write/accept.rb +86 -0
  143. data/lib/textus/write/authority_gate.rb +24 -0
  144. data/lib/textus/write/delete.rb +54 -0
  145. data/lib/textus/write/materializer.rb +48 -0
  146. data/lib/textus/write/mv.rb +123 -0
  147. data/lib/textus/write/publish.rb +66 -0
  148. data/lib/textus/write/put.rb +59 -0
  149. data/lib/textus/write/refresh_all.rb +44 -0
  150. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  151. data/lib/textus/write/refresh_worker.rb +138 -0
  152. data/lib/textus/write/reject.rb +54 -0
  153. data/lib/textus.rb +7 -1
  154. metadata +75 -46
  155. data/lib/textus/application/context.rb +0 -34
  156. data/lib/textus/application/projection.rb +0 -91
  157. data/lib/textus/application/reads/audit.rb +0 -94
  158. data/lib/textus/application/reads/blame.rb +0 -82
  159. data/lib/textus/application/reads/deps.rb +0 -26
  160. data/lib/textus/application/reads/freshness.rb +0 -88
  161. data/lib/textus/application/reads/get.rb +0 -67
  162. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  163. data/lib/textus/application/reads/list.rb +0 -17
  164. data/lib/textus/application/reads/policy_explain.rb +0 -39
  165. data/lib/textus/application/reads/published.rb +0 -17
  166. data/lib/textus/application/reads/pulse.rb +0 -63
  167. data/lib/textus/application/reads/rdeps.rb +0 -27
  168. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  169. data/lib/textus/application/reads/stale.rb +0 -15
  170. data/lib/textus/application/reads/uid.rb +0 -23
  171. data/lib/textus/application/reads/validate_all.rb +0 -24
  172. data/lib/textus/application/reads/validator.rb +0 -86
  173. data/lib/textus/application/reads/where.rb +0 -18
  174. data/lib/textus/application/refresh/all.rb +0 -52
  175. data/lib/textus/application/refresh/orchestrator.rb +0 -78
  176. data/lib/textus/application/refresh/worker.rb +0 -116
  177. data/lib/textus/application/writes/accept.rb +0 -89
  178. data/lib/textus/application/writes/authority_gate.rb +0 -26
  179. data/lib/textus/application/writes/delete.rb +0 -33
  180. data/lib/textus/application/writes/materializer.rb +0 -50
  181. data/lib/textus/application/writes/mv.rb +0 -105
  182. data/lib/textus/application/writes/publish.rb +0 -81
  183. data/lib/textus/application/writes/put.rb +0 -37
  184. data/lib/textus/application/writes/reject.rb +0 -50
  185. data/lib/textus/infra/event_bus.rb +0 -27
  186. data/lib/textus/operations.rb +0 -176
@@ -1,19 +1,19 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Resolver
4
- Resolution = Data.define(:entry, :path, :remaining)
4
+ Resolution = ::Data.define(:entry, :path, :remaining)
5
5
 
6
- def initialize(manifest)
7
- @manifest = manifest
6
+ def initialize(data)
7
+ @data = data
8
8
  end
9
9
 
10
10
  def resolve(key)
11
- @manifest.validate_key!(key)
11
+ @data.validate_key!(key)
12
12
  segments = key.split(".")
13
- candidates = @manifest.entries
14
- .map { |e| [e, e.key.split(".")] }
15
- .select { |(_, esegs)| esegs == segments[0, esegs.length] }
16
- .sort_by { |(_, esegs)| -esegs.length }
13
+ candidates = @data.entries
14
+ .map { |e| [e, e.key.split(".")] }
15
+ .select { |(_, esegs)| esegs == segments[0, esegs.length] }
16
+ .sort_by { |(_, esegs)| -esegs.length }
17
17
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
18
18
 
19
19
  entry, esegs = candidates.first
@@ -23,7 +23,7 @@ module Textus
23
23
 
24
24
  def suggestions_for(key)
25
25
  candidates = enumerate.map { |r| r[:key] }
26
- candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
26
+ candidates.concat(@data.entries.reject { |e| nested_entry?(e) }.map(&:key))
27
27
  candidates.uniq!
28
28
  Key::Distance.suggest(key, candidates, limit: 5)
29
29
  rescue StandardError
@@ -31,7 +31,7 @@ module Textus
31
31
  end
32
32
 
33
33
  def enumerate(prefix: nil)
34
- out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
34
+ out = @data.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
35
35
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
36
36
  out.sort_by { |row| row[:key] }
37
37
  end
@@ -53,10 +53,10 @@ module Textus
53
53
 
54
54
  index_fn = entry.index_filename
55
55
  path = if index_fn
56
- File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
56
+ File.join(@data.root, "zones", entry.path, *remaining, index_fn)
57
57
  else
58
58
  primary_ext = Textus::Entry.for_format(entry.format).extensions.first
59
- File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
59
+ File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
60
60
  end
61
61
  Resolution.new(entry: entry, path: path, remaining: remaining)
62
62
  end
@@ -68,7 +68,7 @@ module Textus
68
68
  end
69
69
 
70
70
  def enumerate_nested(entry)
71
- base = File.join(@manifest.root, "zones", entry.path)
71
+ base = File.join(@data.root, "zones", entry.path)
72
72
  return [] unless File.directory?(base)
73
73
 
74
74
  entry_index_filename = entry.index_filename
@@ -101,7 +101,7 @@ module Textus
101
101
  end
102
102
 
103
103
  def resolve_leaf_path(entry)
104
- Textus::Key::Path.resolve(@manifest, entry)
104
+ Textus::Key::Path.resolve(@data, entry)
105
105
  end
106
106
 
107
107
  def nested_glob(format)
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Rules
4
- RuleSet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
4
+ RuleSet = ::Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
5
  EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
6
6
 
7
7
  def self.parse(raw)
@@ -1,127 +1,64 @@
1
1
  require "yaml"
2
+
3
+ module Textus
4
+ # Manifest is the composition record for a parsed manifest. It bundles
5
+ # four collaborators:
6
+ #
7
+ # * data — frozen value: raw, root, zones, entries, audit_config, role_mapping
8
+ # * resolver — resolves keys → entry + path
9
+ # * policy — zone/role authority (zone_writers, zone_kinds, permission_for, …)
10
+ # * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
11
+ #
12
+ # Use `manifest.data.entries`, `manifest.policy.zone_kinds(z)`, etc.
13
+ Manifest = Data.define(:data, :resolver, :policy, :rules)
14
+ end
15
+
2
16
  require_relative "manifest/schema"
17
+ require_relative "manifest/data"
18
+ require_relative "manifest/policy"
3
19
  require_relative "manifest/resolver"
4
20
  require_relative "manifest/role_kinds"
5
21
 
6
- module Textus
22
+ # Reopen Textus::Manifest (defined above as a Data.define) to attach
23
+ # class-level loaders and helpers.
24
+ module Textus # rubocop:disable Style/OneClassPerFile
7
25
  class Manifest
8
- attr_reader :root, :entries, :raw
9
-
10
- def zones
11
- @zones ||= Array(@raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
12
- end
13
-
14
- def zone_readers
15
- @zone_readers ||= Array(@raw["zones"]).to_h do |z|
16
- rp = z["read_policy"]
17
- [z["name"], rp.nil? ? :all : Array(rp)]
26
+ class << self
27
+ def parse(yaml_text, root: ".")
28
+ raw = YAML.safe_load(yaml_text, aliases: false)
29
+ check_version!(raw, "<string>")
30
+ build(raw, root)
18
31
  end
19
- end
20
-
21
- def zone_writers(zone_name)
22
- zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
23
- end
24
32
 
25
- def permission_for(zone_name)
26
- Textus::Domain::Permission.new(
27
- zone: zone_name,
28
- write_policy: zone_writers(zone_name),
29
- read_policy: zone_readers[zone_name] || :all,
30
- )
31
- end
32
-
33
- AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
34
-
35
- def audit_config
36
- raw = @raw["audit"] || {}
37
- {
38
- max_size: raw["max_size"] || AUDIT_DEFAULTS[:max_size],
39
- keep: raw["keep"] || AUDIT_DEFAULTS[:keep],
40
- }
41
- end
42
-
43
- def role_mapping
44
- @role_mapping ||= RoleKinds.resolve(@raw["roles"])
45
- end
33
+ def load(root)
34
+ manifest_path = File.join(root, "manifest.yaml")
35
+ raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
46
36
 
47
- def role_kind(name)
48
- role_mapping[name]
49
- end
50
-
51
- def roles_with_kind(kind)
52
- role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
53
- end
54
-
55
- def zone_kinds(zone_name)
56
- @zone_kinds_cache ||= {}
57
- @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
58
- k = role_kind(w)
59
- acc << k if k
60
- end.freeze
61
- end
62
-
63
- def self.parse(yaml_text, root: ".")
64
- raw = YAML.safe_load(yaml_text, aliases: false)
65
- check_version!(raw, "<string>")
66
- new(root, raw)
67
- end
68
-
69
- def self.load(root)
70
- manifest_path = File.join(root, "manifest.yaml")
71
- raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
72
-
73
- raw = YAML.safe_load_file(manifest_path, aliases: false)
74
- check_version!(raw, manifest_path)
75
- new(root, raw)
76
- end
77
-
78
- def self.check_version!(raw, source)
79
- return if raw["version"] == PROTOCOL
80
-
81
- raise BadFrontmatter.new(
82
- source,
83
- "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
84
- )
85
- end
86
- private_class_method :check_version!
87
-
88
- def initialize(root, raw)
89
- @root = root
90
- @raw = raw
91
- raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
92
-
93
- Schema.validate!(raw)
94
-
95
- @entries = Array(raw["entries"]).map do |e|
96
- entry = Manifest::Entry::Parser.call(self, e)
97
- Manifest::Entry::Validators.run_all(entry)
98
- entry
37
+ raw = YAML.safe_load_file(manifest_path, aliases: false)
38
+ check_version!(raw, manifest_path)
39
+ build(raw, root)
99
40
  end
100
- validate_declared_keys!
101
- end
102
-
103
- def rules
104
- @rules ||= Textus::Manifest::Rules.parse(@raw["rules"] || [])
105
- end
106
-
107
- def rules_for(key)
108
- rules.for(key)
109
- end
110
41
 
111
- def resolver
112
- @resolver ||= Resolver.new(self)
113
- end
114
-
115
- def validate_key!(key)
116
- raise UsageError.new("empty key") if key.nil? || key.empty?
42
+ private
117
43
 
118
- Key::Grammar.validate!(key)
119
- end
44
+ def build(raw, root)
45
+ data = Manifest::Data.parse(raw, root: root)
46
+ new(
47
+ data: data,
48
+ resolver: Manifest::Resolver.new(data),
49
+ policy: data.policy,
50
+ rules: Manifest::Rules.parse(raw["rules"] || []),
51
+ )
52
+ end
120
53
 
121
- private
54
+ def check_version!(raw, source)
55
+ return if raw["version"] == PROTOCOL
122
56
 
123
- def validate_declared_keys!
124
- @entries.each { |e| validate_key!(e.key) }
57
+ raise BadFrontmatter.new(
58
+ source,
59
+ "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
60
+ )
61
+ end
125
62
  end
126
63
  end
127
64
  end
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module MCP
3
+ # Manifest fingerprint changed mid-session. Client should re-boot.
4
+ class ContractDrift < Textus::Error
5
+ JSONRPC_CODE = -32_001
6
+
7
+ def initialize(message, details: {})
8
+ super("contract_drift", message, details: details)
9
+ end
10
+ end
11
+
12
+ # Audit cursor fell off the keep window. Client should re-boot and
13
+ # resume from the new latest_seq.
14
+ class CursorExpired < Textus::Error
15
+ JSONRPC_CODE = -32_002
16
+
17
+ def initialize(message, details: {})
18
+ super("cursor_expired", message, details: details)
19
+ end
20
+ end
21
+
22
+ # Tool execution failed (validation, authorization, IO). Wraps an
23
+ # underlying Textus::Error or generic StandardError.
24
+ class ToolError < Textus::Error
25
+ JSONRPC_CODE = -32_000
26
+
27
+ def initialize(message, details: {})
28
+ super("tool_error", message, details: details)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,126 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module MCP
5
+ # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
6
+ # message (NDJSON). Holds a single Session for the lifetime of stdin.
7
+ class Server
8
+ PROTOCOL_VERSION = "2024-11-05"
9
+ SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
10
+
11
+ def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
12
+ @store = store
13
+ @stdin = stdin
14
+ @stdout = stdout
15
+ @role = role
16
+ @session = nil
17
+ end
18
+
19
+ def run
20
+ @stdin.each_line do |line|
21
+ line = line.strip
22
+ next if line.empty?
23
+
24
+ handle_line(line)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def handle_line(line)
31
+ msg = JSON.parse(line)
32
+ rescue JSON::ParserError => e
33
+ emit_error(nil, -32_700, "parse error: #{e.message}")
34
+ else
35
+ dispatch(msg)
36
+ end
37
+
38
+ def dispatch(msg)
39
+ rid = msg["id"]
40
+ case msg["method"]
41
+ when "initialize" then handle_initialize(rid, msg["params"] || {})
42
+ when "tools/list" then handle_tools_list(rid)
43
+ when "tools/call" then handle_tools_call(rid, msg["params"] || {})
44
+ when "ping" then emit_result(rid, {})
45
+ when "shutdown" then emit_result(rid, nil)
46
+ when "notifications/initialized" then nil
47
+ else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
48
+ end
49
+ end
50
+
51
+ def handle_initialize(rid, _params)
52
+ proposer = @store.manifest.policy.roles_with_kind(:proposer).first
53
+ propose_zone = nil
54
+ if proposer
55
+ @store.manifest.data.zones.each do |zname, writers|
56
+ if writers.include?(proposer) && zname.include?("review")
57
+ propose_zone = zname
58
+ break
59
+ end
60
+ end
61
+ end
62
+
63
+ @session = Session.new(
64
+ role: @role,
65
+ cursor: @store.audit_log.latest_seq,
66
+ propose_zone: propose_zone,
67
+ manifest_etag: manifest_etag,
68
+ )
69
+
70
+ emit_result(rid, {
71
+ "protocolVersion" => PROTOCOL_VERSION,
72
+ "serverInfo" => SERVER_INFO,
73
+ "capabilities" => { "tools" => {} },
74
+ })
75
+ end
76
+
77
+ def handle_tools_list(rid)
78
+ emit_result(rid, { "tools" => ToolSchemas.all })
79
+ end
80
+
81
+ def handle_tools_call(rid, params)
82
+ unless @session
83
+ emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
84
+ return
85
+ end
86
+
87
+ @session.check_etag!(manifest_etag)
88
+
89
+ name = params["name"]
90
+ args = params["arguments"] || {}
91
+ result = Tools.call(name, session: @session, store: @store, args: args)
92
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
93
+
94
+ emit_result(rid, {
95
+ "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
96
+ "isError" => false,
97
+ })
98
+ rescue ContractDrift => e
99
+ emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
100
+ rescue CursorExpired => e
101
+ emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
102
+ rescue ToolError => e
103
+ emit_error(rid, ToolError::JSONRPC_CODE, e.message)
104
+ rescue StandardError => e
105
+ emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
106
+ end
107
+
108
+ def manifest_etag
109
+ @store.file_store.etag(File.join(@store.root, "manifest.yaml"))
110
+ end
111
+
112
+ def emit_result(rid, result)
113
+ write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
114
+ end
115
+
116
+ def emit_error(rid, code, message)
117
+ write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
118
+ end
119
+
120
+ def write(obj)
121
+ @stdout.puts(JSON.dump(obj))
122
+ @stdout.flush
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,40 @@
1
+ module Textus
2
+ module MCP
3
+ # Per-connection state held by the server. Immutable; advance_cursor
4
+ # returns a new instance.
5
+ class Session
6
+ attr_reader :role, :cursor, :propose_zone, :manifest_etag
7
+
8
+ def initialize(role:, cursor:, propose_zone:, manifest_etag:)
9
+ @role = role
10
+ @cursor = cursor
11
+ @propose_zone = propose_zone
12
+ @manifest_etag = manifest_etag
13
+ end
14
+
15
+ def advance_cursor(new_cursor)
16
+ self.class.new(
17
+ role: @role, cursor: new_cursor,
18
+ propose_zone: @propose_zone, manifest_etag: @manifest_etag
19
+ )
20
+ end
21
+
22
+ def check_etag!(observed_etag)
23
+ return if observed_etag == @manifest_etag
24
+
25
+ raise ContractDrift.new(
26
+ "manifest changed (was #{short_etag(@manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
33
+ # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
34
+ # a no-op when the prefix is absent).
35
+ def short_etag(etag)
36
+ etag.to_s.delete_prefix("sha256:")[0, 8]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,71 @@
1
+ module Textus
2
+ module MCP
3
+ # JSON-Schema definitions for every MCP tool's inputSchema. Returned by
4
+ # the server in tools/list. Static today — a follow-up will enrich with
5
+ # manifest-derived enums for `zone`, `key`, etc.
6
+ module ToolSchemas
7
+ module_function
8
+
9
+ def all # rubocop:disable Metrics/MethodLength
10
+ [
11
+ tool("boot", "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart.", {}, []),
12
+ tool("tick", "Delta since cursor. Returns {cursor, changed, stale, pending_review, doctor}.",
13
+ { "since" => { "type" => "integer", "minimum" => 0 } }, []),
14
+ tool("find", "List keys filtered by zone and/or prefix.",
15
+ { "zone" => { "type" => "string" }, "prefix" => { "type" => "string" } }, []),
16
+ tool("read", "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness).",
17
+ { "key" => { "type" => "string" } }, ["key"]),
18
+ tool("write", "Create or update an entry. Schema-validated. Returns {uid, etag}.",
19
+ {
20
+ "key" => { "type" => "string" },
21
+ "meta" => { "type" => "object" },
22
+ "body" => { "type" => "string" },
23
+ "content" => { "type" => "object" },
24
+ "if_etag" => { "type" => "string" },
25
+ }, %w[key meta]),
26
+ tool("propose", "Write a proposal to the session's propose_zone. Auto-prefixes the key.",
27
+ {
28
+ "key" => { "type" => "string", "description" => "Key relative to propose_zone, e.g. 'proposal.feature-x'" },
29
+ "meta" => { "type" => "object" },
30
+ "body" => { "type" => "string" },
31
+ }, %w[key meta]),
32
+ tool("refresh", "Run an intake refresh for one key. Returns the refresh Outcome.",
33
+ { "key" => { "type" => "string" } }, ["key"]),
34
+ tool("refresh_stale", "Refresh all stale intake entries, optionally scoped by zone/prefix.",
35
+ {
36
+ "zone" => { "type" => "string" },
37
+ "prefix" => { "type" => "string" },
38
+ }, []),
39
+ tool("schema", "Return the schema (field shape) for an entry family.",
40
+ { "family" => { "type" => "string" } }, ["family"]),
41
+ tool("rules", "Return effective rules for a key (refresh, promote, ...).",
42
+ { "key" => { "type" => "string" } }, ["key"]),
43
+ tool("key_mv_prefix",
44
+ "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
45
+ { "from_prefix" => { "type" => "string" }, "to_prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
46
+ %w[from_prefix to_prefix]),
47
+ tool("key_delete_prefix", "Bulk-delete every leaf key under prefix.",
48
+ { "prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
49
+ ["prefix"]),
50
+ tool("zone_mv", "Rename a zone — manifest + files. Refuses if destination exists.",
51
+ { "from" => { "type" => "string" }, "to" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
52
+ %w[from to]),
53
+ tool("rule_lint", "Diff candidate manifest YAML's rules against the live manifest. No writes.",
54
+ { "candidate_yaml" => { "type" => "string" } },
55
+ ["candidate_yaml"]),
56
+ tool("migrate", "Run a YAML migration plan (multi-op).",
57
+ { "plan_yaml" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
58
+ ["plan_yaml"]),
59
+ ].freeze
60
+ end
61
+
62
+ def tool(name, description, properties, required)
63
+ {
64
+ name: name,
65
+ description: description,
66
+ inputSchema: { type: "object", properties: properties, required: required },
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,129 @@
1
+ module Textus
2
+ module MCP
3
+ # Dispatch table for MCP tool names → implementations. Each implementation
4
+ # receives (session:, store:, args:) and returns a JSON-encodable value.
5
+ # Tool errors are wrapped in ToolError; ContractDrift / CursorExpired
6
+ # propagate verbatim so the server can map them to JSON-RPC codes.
7
+ module Tools
8
+ module_function
9
+
10
+ def call(name, session:, store:, args:)
11
+ impl = REGISTRY[name] or raise ToolError.new("unknown tool: #{name}")
12
+ impl.call(session, store, args || {})
13
+ rescue ContractDrift, CursorExpired
14
+ raise
15
+ rescue Textus::Error => e
16
+ raise ToolError.new("#{name}: #{e.message}")
17
+ end
18
+
19
+ def ops_for(session, store)
20
+ store.as(session.role)
21
+ end
22
+
23
+ REGISTRY = {
24
+ "boot" => ->(_s, store, _a) { store.boot },
25
+
26
+ "find" => lambda do |s, store, args|
27
+ ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
28
+ end,
29
+
30
+ "read" => lambda do |s, store, args|
31
+ key = args.fetch("key") { raise ToolError.new("read: missing key") }
32
+ env = ops_for(s, store).get(key)
33
+ env.to_h_for_wire
34
+ end,
35
+
36
+ "tick" => lambda do |s, store, args|
37
+ since = (args["since"] || s.cursor).to_i
38
+ ops_for(s, store).pulse(since: since)
39
+ end,
40
+
41
+ "write" => lambda do |s, store, args|
42
+ key = args.fetch("key") { raise ToolError.new("write: missing key") }
43
+ env = ops_for(s, store).put(
44
+ key,
45
+ meta: args["meta"] || {},
46
+ body: args["body"],
47
+ content: args["content"],
48
+ if_etag: args["if_etag"],
49
+ )
50
+ { "uid" => env.uid, "etag" => env.etag }
51
+ end,
52
+
53
+ "propose" => lambda do |s, store, args|
54
+ raise ToolError.new("propose: session has no propose_zone") unless s.propose_zone
55
+
56
+ rel = args.fetch("key") { raise ToolError.new("propose: missing key") }
57
+ target = "#{s.propose_zone}.#{rel}"
58
+ env = ops_for(s, store).put(
59
+ target,
60
+ meta: args["meta"] || {},
61
+ body: args["body"],
62
+ content: args["content"],
63
+ )
64
+ { "uid" => env.uid, "etag" => env.etag, "key" => target }
65
+ end,
66
+
67
+ "refresh" => lambda do |s, store, args|
68
+ key = args.fetch("key") { raise ToolError.new("refresh: missing key") }
69
+ outcome = ops_for(s, store).refresh(key)
70
+ { "outcome" => outcome.class.name.split("::").last.downcase }
71
+ end,
72
+
73
+ "refresh_stale" => lambda do |s, store, args|
74
+ ops_for(s, store).refresh_all(zone: args["zone"], prefix: args["prefix"])
75
+ end,
76
+
77
+ "schema" => lambda do |_s, store, args|
78
+ family = args.fetch("family") { raise ToolError.new("schema: missing family") }
79
+ store.schemas.fetch(family)
80
+ end,
81
+
82
+ "rules" => lambda do |_s, store, args|
83
+ key = args.fetch("key") { raise ToolError.new("rules: missing key") }
84
+ set = store.manifest.rules.for(key)
85
+ {
86
+ "refresh" => set.refresh&.to_h,
87
+ "promote" => set.respond_to?(:promote) ? set.promote&.to_h : nil,
88
+ }.compact
89
+ end,
90
+
91
+ "key_mv_prefix" => lambda do |s, store, args|
92
+ ops_for(s, store).key_mv_prefix(
93
+ from_prefix: args.fetch("from_prefix") { raise ToolError.new("key_mv_prefix: missing from_prefix") },
94
+ to_prefix: args.fetch("to_prefix") { raise ToolError.new("key_mv_prefix: missing to_prefix") },
95
+ dry_run: args["dry_run"] || false,
96
+ ).to_h
97
+ end,
98
+
99
+ "key_delete_prefix" => lambda do |s, store, args|
100
+ ops_for(s, store).key_delete_prefix(
101
+ prefix: args.fetch("prefix") { raise ToolError.new("key_delete_prefix: missing prefix") },
102
+ dry_run: args["dry_run"] || false,
103
+ ).to_h
104
+ end,
105
+
106
+ "zone_mv" => lambda do |s, store, args|
107
+ ops_for(s, store).zone_mv(
108
+ from: args.fetch("from") { raise ToolError.new("zone_mv: missing from") },
109
+ to: args.fetch("to") { raise ToolError.new("zone_mv: missing to") },
110
+ dry_run: args["dry_run"] || false,
111
+ ).to_h
112
+ end,
113
+
114
+ "rule_lint" => lambda do |s, store, args|
115
+ ops_for(s, store).rule_lint(
116
+ candidate_yaml: args.fetch("candidate_yaml") { raise ToolError.new("rule_lint: missing candidate_yaml") },
117
+ ).to_h
118
+ end,
119
+
120
+ "migrate" => lambda do |s, store, args|
121
+ ops_for(s, store).migrate(
122
+ plan_yaml: args.fetch("plan_yaml") { raise ToolError.new("migrate: missing plan_yaml") },
123
+ dry_run: args["dry_run"] || false,
124
+ ).to_h
125
+ end,
126
+ }.freeze
127
+ end
128
+ end
129
+ end
data/lib/textus/mcp.rb ADDED
@@ -0,0 +1,6 @@
1
+ module Textus
2
+ # The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
3
+ # Wraps Textus::RoleScope as auto-derived tools. See ADR 0015.
4
+ module MCP
5
+ end
6
+ end
@@ -3,7 +3,7 @@ require "json"
3
3
  require "time"
4
4
 
5
5
  module Textus
6
- module Infra
6
+ module Ports
7
7
  class AuditLog
8
8
  DEFAULT_MAX_SIZE = 10_485_760
9
9
  DEFAULT_KEEP = 5