textus 0.22.0 → 0.26.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +102 -0
  4. data/README.md +1 -1
  5. data/SPEC.md +12 -12
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/boot.rb +27 -29
  48. data/lib/textus/builder/pipeline.rb +3 -3
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +2 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +1 -1
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +1 -1
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/doctor/check/audit_log.rb +2 -2
  83. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  84. data/lib/textus/doctor/check/hooks.rb +4 -3
  85. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  86. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  87. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  88. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  89. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  90. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  91. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  92. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  93. data/lib/textus/doctor/check/schemas.rb +2 -2
  94. data/lib/textus/doctor/check/sentinels.rb +2 -2
  95. data/lib/textus/doctor/check/templates.rb +2 -2
  96. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  97. data/lib/textus/doctor/check.rb +5 -3
  98. data/lib/textus/doctor.rb +24 -27
  99. data/lib/textus/domain/authorizer.rb +4 -4
  100. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  101. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  102. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  103. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  104. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  105. data/lib/textus/domain/staleness.rb +1 -1
  106. data/lib/textus/hooks/builtin.rb +14 -14
  107. data/lib/textus/hooks/context.rb +13 -13
  108. data/lib/textus/hooks/error_log.rb +32 -0
  109. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  110. data/lib/textus/hooks/loader.rb +29 -3
  111. data/lib/textus/hooks/rpc_registry.rb +77 -0
  112. data/lib/textus/infra/audit_subscriber.rb +6 -7
  113. data/lib/textus/infra/refresh/detached.rb +1 -1
  114. data/lib/textus/key/path.rb +7 -3
  115. data/lib/textus/manifest/data.rb +78 -0
  116. data/lib/textus/manifest/entry/base.rb +4 -4
  117. data/lib/textus/manifest/entry/derived.rb +4 -5
  118. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  119. data/lib/textus/manifest/policy.rb +48 -0
  120. data/lib/textus/manifest/resolver.rb +14 -14
  121. data/lib/textus/manifest/rules.rb +1 -1
  122. data/lib/textus/manifest.rb +53 -111
  123. data/lib/textus/mcp/errors.rb +32 -0
  124. data/lib/textus/mcp/server.rb +127 -0
  125. data/lib/textus/mcp/session.rb +31 -0
  126. data/lib/textus/mcp/tool_schemas.rb +71 -0
  127. data/lib/textus/mcp/tools.rb +129 -0
  128. data/lib/textus/mcp.rb +6 -0
  129. data/lib/textus/schema/tools.rb +14 -10
  130. data/lib/textus/session.rb +84 -0
  131. data/lib/textus/store.rb +14 -9
  132. data/lib/textus/version.rb +1 -1
  133. data/lib/textus.rb +8 -1
  134. metadata +61 -36
  135. data/lib/textus/application/reads/audit.rb +0 -94
  136. data/lib/textus/application/reads/blame.rb +0 -82
  137. data/lib/textus/application/reads/deps.rb +0 -26
  138. data/lib/textus/application/reads/freshness.rb +0 -88
  139. data/lib/textus/application/reads/get.rb +0 -67
  140. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  141. data/lib/textus/application/reads/list.rb +0 -17
  142. data/lib/textus/application/reads/policy_explain.rb +0 -39
  143. data/lib/textus/application/reads/published.rb +0 -17
  144. data/lib/textus/application/reads/pulse.rb +0 -63
  145. data/lib/textus/application/reads/rdeps.rb +0 -27
  146. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  147. data/lib/textus/application/reads/stale.rb +0 -15
  148. data/lib/textus/application/reads/uid.rb +0 -23
  149. data/lib/textus/application/reads/validate_all.rb +0 -24
  150. data/lib/textus/application/reads/where.rb +0 -18
  151. data/lib/textus/application/refresh/all.rb +0 -52
  152. data/lib/textus/application/refresh/worker.rb +0 -116
  153. data/lib/textus/application/writes/accept.rb +0 -89
  154. data/lib/textus/application/writes/delete.rb +0 -33
  155. data/lib/textus/application/writes/mv.rb +0 -105
  156. data/lib/textus/application/writes/publish.rb +0 -81
  157. data/lib/textus/application/writes/put.rb +0 -37
  158. data/lib/textus/application/writes/reject.rb +0 -50
  159. data/lib/textus/infra/event_bus.rb +0 -27
  160. data/lib/textus/operations.rb +0 -176
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ class Manifest
3
+ # Authority over zones and roles derived from a Manifest::Data snapshot.
4
+ # Encapsulates the lookups previously living on Manifest itself
5
+ # (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
6
+ class Policy
7
+ def initialize(data)
8
+ @data = data
9
+ @zone_kinds_cache = {}
10
+ end
11
+
12
+ def zone_writers(zone_name)
13
+ @data.zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
14
+ end
15
+
16
+ def zone_readers
17
+ @data.zone_readers
18
+ end
19
+
20
+ def permission_for(zone_name)
21
+ Textus::Domain::Permission.new(
22
+ zone: zone_name,
23
+ write_policy: zone_writers(zone_name),
24
+ read_policy: @data.zone_readers[zone_name] || :all,
25
+ )
26
+ end
27
+
28
+ def zone_kinds(zone_name)
29
+ @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
30
+ k = role_kind(w)
31
+ acc << k if k
32
+ end.freeze
33
+ end
34
+
35
+ def role_mapping
36
+ @data.role_mapping
37
+ end
38
+
39
+ def role_kind(name)
40
+ @data.role_mapping[name]
41
+ end
42
+
43
+ def roles_with_kind(kind)
44
+ @data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -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,69 @@
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
-
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
46
-
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
32
 
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)
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)
94
36
 
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
41
 
107
- def rules_for(key)
108
- rules.for(key)
109
- end
110
-
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?
117
-
118
- Key::Grammar.validate!(key)
119
- end
42
+ private
43
+
44
+ def build(raw, root)
45
+ data = Manifest::Data.parse(raw, root: root)
46
+ composition = new(
47
+ data: data,
48
+ resolver: Manifest::Resolver.new(data),
49
+ policy: data.policy,
50
+ rules: Manifest::Rules.parse(raw["rules"] || []),
51
+ )
52
+ # Re-point entries' back-reference from Data to the composition
53
+ # record. Entries call `@manifest.policy.*` / `@manifest.resolver.*`
54
+ # at use time (see Entry::Base, Entry::Nested).
55
+ data.entries.each { |e| e.instance_variable_set(:@manifest, composition) }
56
+ composition
57
+ end
120
58
 
121
- private
59
+ def check_version!(raw, source)
60
+ return if raw["version"] == PROTOCOL
122
61
 
123
- def validate_declared_keys!
124
- @entries.each { |e| validate_key!(e.key) }
62
+ raise BadFrontmatter.new(
63
+ source,
64
+ "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
65
+ )
66
+ end
125
67
  end
126
68
  end
127
69
  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,127 @@
1
+ require "json"
2
+ require "digest"
3
+
4
+ module Textus
5
+ module MCP
6
+ # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
7
+ # message (NDJSON). Holds a single Session for the lifetime of stdin.
8
+ class Server
9
+ PROTOCOL_VERSION = "2024-11-05"
10
+ SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
11
+
12
+ def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
13
+ @store = store
14
+ @stdin = stdin
15
+ @stdout = stdout
16
+ @role = role
17
+ @session = nil
18
+ end
19
+
20
+ def run
21
+ @stdin.each_line do |line|
22
+ line = line.strip
23
+ next if line.empty?
24
+
25
+ handle_line(line)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def handle_line(line)
32
+ msg = JSON.parse(line)
33
+ rescue JSON::ParserError => e
34
+ emit_error(nil, -32_700, "parse error: #{e.message}")
35
+ else
36
+ dispatch(msg)
37
+ end
38
+
39
+ def dispatch(msg)
40
+ rid = msg["id"]
41
+ case msg["method"]
42
+ when "initialize" then handle_initialize(rid, msg["params"] || {})
43
+ when "tools/list" then handle_tools_list(rid)
44
+ when "tools/call" then handle_tools_call(rid, msg["params"] || {})
45
+ when "ping" then emit_result(rid, {})
46
+ when "shutdown" then emit_result(rid, nil)
47
+ when "notifications/initialized" then nil
48
+ else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
49
+ end
50
+ end
51
+
52
+ def handle_initialize(rid, _params)
53
+ proposer = @store.manifest.policy.roles_with_kind(:proposer).first
54
+ propose_zone = nil
55
+ if proposer
56
+ @store.manifest.data.zones.each do |zname, writers|
57
+ if writers.include?(proposer) && zname.include?("review")
58
+ propose_zone = zname
59
+ break
60
+ end
61
+ end
62
+ end
63
+
64
+ @session = Session.new(
65
+ role: @role,
66
+ cursor: @store.audit_log.latest_seq,
67
+ propose_zone: propose_zone,
68
+ manifest_etag: manifest_etag,
69
+ )
70
+
71
+ emit_result(rid, {
72
+ "protocolVersion" => PROTOCOL_VERSION,
73
+ "serverInfo" => SERVER_INFO,
74
+ "capabilities" => { "tools" => {} },
75
+ })
76
+ end
77
+
78
+ def handle_tools_list(rid)
79
+ emit_result(rid, { "tools" => ToolSchemas.all })
80
+ end
81
+
82
+ def handle_tools_call(rid, params)
83
+ unless @session
84
+ emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
85
+ return
86
+ end
87
+
88
+ @session.check_etag!(manifest_etag)
89
+
90
+ name = params["name"]
91
+ args = params["arguments"] || {}
92
+ result = Tools.call(name, session: @session, store: @store, args: args)
93
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
94
+
95
+ emit_result(rid, {
96
+ "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
97
+ "isError" => false,
98
+ })
99
+ rescue ContractDrift => e
100
+ emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
101
+ rescue CursorExpired => e
102
+ emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
103
+ rescue ToolError => e
104
+ emit_error(rid, ToolError::JSONRPC_CODE, e.message)
105
+ rescue StandardError => e
106
+ emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
107
+ end
108
+
109
+ def manifest_etag
110
+ Digest::SHA256.hexdigest(File.read(File.join(@store.root, "manifest.yaml")))
111
+ end
112
+
113
+ def emit_result(rid, result)
114
+ write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
115
+ end
116
+
117
+ def emit_error(rid, code, message)
118
+ write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
119
+ end
120
+
121
+ def write(obj)
122
+ @stdout.puts(JSON.dump(obj))
123
+ @stdout.flush
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,31 @@
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 #{@manifest_etag[0, 8]}, now #{observed_etag[0, 8]}); re-run boot",
27
+ )
28
+ end
29
+ end
30
+ end
31
+ 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