sxn 0.3.0 → 0.4.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/CHANGELOG.md +16 -13
- data/Gemfile.lock +6 -1
- data/bin/sxn-mcp +58 -0
- data/docs/MCP_IMPLEMENTATION.md +425 -0
- data/lib/sxn/CLI.rb +7 -0
- data/lib/sxn/commands/mcp.rb +219 -0
- data/lib/sxn/commands/sessions.rb +1 -4
- data/lib/sxn/commands/templates.rb +1 -5
- data/lib/sxn/commands.rb +1 -0
- data/lib/sxn/mcp/prompts/workflow_prompts.rb +107 -0
- data/lib/sxn/mcp/resources/session_resources.rb +145 -0
- data/lib/sxn/mcp/server.rb +127 -0
- data/lib/sxn/mcp/tools/base_tool.rb +96 -0
- data/lib/sxn/mcp/tools/projects.rb +144 -0
- data/lib/sxn/mcp/tools/rules.rb +108 -0
- data/lib/sxn/mcp/tools/sessions.rb +375 -0
- data/lib/sxn/mcp/tools/templates.rb +119 -0
- data/lib/sxn/mcp/tools/worktrees.rb +168 -0
- data/lib/sxn/mcp.rb +20 -0
- data/lib/sxn/version.rb +1 -1
- data/lib/sxn.rb +1 -0
- data/sxn.gemspec +86 -0
- data/test.txt +1 -0
- metadata +31 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "English"
|
|
6
|
+
|
|
7
|
+
module Sxn
|
|
8
|
+
module Commands
|
|
9
|
+
# Manage MCP server for Claude Code integration
|
|
10
|
+
class MCP < Thor
|
|
11
|
+
include Thor::Actions
|
|
12
|
+
|
|
13
|
+
def initialize(args = ARGV, local_options = {}, config = {})
|
|
14
|
+
super
|
|
15
|
+
@ui = Sxn::UI::Output.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "install", "Install sxn MCP server to Claude Code"
|
|
19
|
+
option :project, type: :boolean, aliases: "-p", desc: "Install to project .mcp.json instead of user config"
|
|
20
|
+
def install
|
|
21
|
+
sxn_mcp_path = find_sxn_mcp_executable
|
|
22
|
+
|
|
23
|
+
if sxn_mcp_path.nil?
|
|
24
|
+
@ui.error("Could not find sxn-mcp executable")
|
|
25
|
+
@ui.recovery_suggestion("Ensure the sxn gem is properly installed")
|
|
26
|
+
exit(1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if options[:project]
|
|
30
|
+
install_to_project(sxn_mcp_path)
|
|
31
|
+
else
|
|
32
|
+
install_to_claude_code(sxn_mcp_path)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
desc "uninstall", "Remove sxn MCP server from Claude Code"
|
|
37
|
+
option :project, type: :boolean, aliases: "-p", desc: "Remove from project .mcp.json"
|
|
38
|
+
def uninstall
|
|
39
|
+
if options[:project]
|
|
40
|
+
uninstall_from_project
|
|
41
|
+
else
|
|
42
|
+
uninstall_from_claude_code
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "status", "Check if sxn MCP server is installed"
|
|
47
|
+
def status
|
|
48
|
+
@ui.section("MCP Server Status")
|
|
49
|
+
|
|
50
|
+
claude_installed = check_claude_installation
|
|
51
|
+
project_installed = check_project_installation
|
|
52
|
+
|
|
53
|
+
@ui.newline
|
|
54
|
+
@ui.key_value("Claude Code (user)", claude_installed ? "Installed" : "Not installed")
|
|
55
|
+
@ui.key_value("Project (.mcp.json)", project_installed ? "Installed" : "Not installed")
|
|
56
|
+
|
|
57
|
+
sxn_mcp_path = find_sxn_mcp_executable
|
|
58
|
+
@ui.newline
|
|
59
|
+
if sxn_mcp_path
|
|
60
|
+
@ui.key_value("Executable", sxn_mcp_path)
|
|
61
|
+
else
|
|
62
|
+
@ui.warning("sxn-mcp executable not found in PATH")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@ui.newline
|
|
66
|
+
display_install_commands unless claude_installed || project_installed
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "server", "Run the MCP server directly (for testing)"
|
|
70
|
+
option :transport, type: :string, default: "stdio", enum: %w[stdio http], desc: "Transport type"
|
|
71
|
+
option :port, type: :numeric, default: 3000, desc: "Port for HTTP transport"
|
|
72
|
+
def server
|
|
73
|
+
require "sxn/mcp"
|
|
74
|
+
|
|
75
|
+
mcp_server = Sxn::MCP::Server.new
|
|
76
|
+
|
|
77
|
+
case options[:transport]
|
|
78
|
+
when "stdio"
|
|
79
|
+
@ui.info("Starting MCP server with STDIO transport...")
|
|
80
|
+
mcp_server.run_stdio
|
|
81
|
+
when "http"
|
|
82
|
+
@ui.info("Starting MCP server on port #{options[:port]}...")
|
|
83
|
+
mcp_server.run_http(port: options[:port])
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def find_sxn_mcp_executable
|
|
90
|
+
# Check if sxn-mcp is in PATH
|
|
91
|
+
path = `which sxn-mcp 2>/dev/null`.strip
|
|
92
|
+
return path unless path.empty?
|
|
93
|
+
|
|
94
|
+
# Check relative to this gem's installation
|
|
95
|
+
gem_bin_path = File.expand_path("../../../bin/sxn-mcp", __dir__)
|
|
96
|
+
return gem_bin_path if File.exist?(gem_bin_path)
|
|
97
|
+
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def install_to_claude_code(sxn_mcp_path)
|
|
102
|
+
unless claude_cli_available?
|
|
103
|
+
@ui.error("Claude CLI not found")
|
|
104
|
+
@ui.recovery_suggestion("Install Claude Code CLI first: https://claude.ai/code")
|
|
105
|
+
exit(1)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@ui.progress_start("Installing sxn MCP server to Claude Code")
|
|
109
|
+
|
|
110
|
+
# claude mcp add --transport stdio sxn -- /path/to/sxn-mcp
|
|
111
|
+
success = system("claude", "mcp", "add", "--transport", "stdio", "sxn", "--", sxn_mcp_path)
|
|
112
|
+
|
|
113
|
+
if success
|
|
114
|
+
@ui.progress_done
|
|
115
|
+
@ui.success("sxn MCP server installed to Claude Code")
|
|
116
|
+
@ui.info("Restart Claude Code to use the new server")
|
|
117
|
+
else
|
|
118
|
+
@ui.progress_failed
|
|
119
|
+
@ui.error("Failed to install MCP server")
|
|
120
|
+
@ui.recovery_suggestion("Try running manually: claude mcp add --transport stdio sxn -- #{sxn_mcp_path}")
|
|
121
|
+
exit(1)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def install_to_project(sxn_mcp_path)
|
|
126
|
+
mcp_json_path = File.join(Dir.pwd, ".mcp.json")
|
|
127
|
+
|
|
128
|
+
mcp_config = if File.exist?(mcp_json_path)
|
|
129
|
+
JSON.parse(File.read(mcp_json_path))
|
|
130
|
+
else
|
|
131
|
+
{ "mcpServers" => {} }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
mcp_config["mcpServers"] ||= {}
|
|
135
|
+
mcp_config["mcpServers"]["sxn"] = {
|
|
136
|
+
"command" => sxn_mcp_path,
|
|
137
|
+
"args" => [],
|
|
138
|
+
"env" => { "SXN_WORKSPACE" => "${PWD}" }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
File.write(mcp_json_path, JSON.pretty_generate(mcp_config))
|
|
142
|
+
|
|
143
|
+
@ui.success("Created/updated .mcp.json with sxn MCP server")
|
|
144
|
+
@ui.info("This file can be version controlled to share with your team")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def uninstall_from_claude_code
|
|
148
|
+
unless claude_cli_available?
|
|
149
|
+
@ui.error("Claude CLI not found")
|
|
150
|
+
exit(1)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
@ui.progress_start("Removing sxn MCP server from Claude Code")
|
|
154
|
+
|
|
155
|
+
success = system("claude", "mcp", "remove", "sxn")
|
|
156
|
+
|
|
157
|
+
if success
|
|
158
|
+
@ui.progress_done
|
|
159
|
+
@ui.success("sxn MCP server removed from Claude Code")
|
|
160
|
+
else
|
|
161
|
+
@ui.progress_failed
|
|
162
|
+
@ui.error("Failed to remove MCP server (it may not be installed)")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def uninstall_from_project
|
|
167
|
+
mcp_json_path = File.join(Dir.pwd, ".mcp.json")
|
|
168
|
+
|
|
169
|
+
unless File.exist?(mcp_json_path)
|
|
170
|
+
@ui.info("No .mcp.json file found in current directory")
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
mcp_config = JSON.parse(File.read(mcp_json_path))
|
|
175
|
+
|
|
176
|
+
if mcp_config.dig("mcpServers", "sxn")
|
|
177
|
+
mcp_config["mcpServers"].delete("sxn")
|
|
178
|
+
|
|
179
|
+
if mcp_config["mcpServers"].empty?
|
|
180
|
+
File.delete(mcp_json_path)
|
|
181
|
+
@ui.success("Removed .mcp.json file (no servers remaining)")
|
|
182
|
+
else
|
|
183
|
+
File.write(mcp_json_path, JSON.pretty_generate(mcp_config))
|
|
184
|
+
@ui.success("Removed sxn from .mcp.json")
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
@ui.info("sxn not found in .mcp.json")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def check_claude_installation
|
|
192
|
+
return false unless claude_cli_available?
|
|
193
|
+
|
|
194
|
+
output = `claude mcp get sxn 2>/dev/null`
|
|
195
|
+
$CHILD_STATUS.success? && !output.strip.empty?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def check_project_installation
|
|
199
|
+
mcp_json_path = File.join(Dir.pwd, ".mcp.json")
|
|
200
|
+
return false unless File.exist?(mcp_json_path)
|
|
201
|
+
|
|
202
|
+
mcp_config = JSON.parse(File.read(mcp_json_path))
|
|
203
|
+
!mcp_config.dig("mcpServers", "sxn").nil?
|
|
204
|
+
rescue JSON::ParserError
|
|
205
|
+
false
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def claude_cli_available?
|
|
209
|
+
system("which claude > /dev/null 2>&1")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def display_install_commands
|
|
213
|
+
@ui.subsection("Install Commands")
|
|
214
|
+
@ui.command_example("sxn mcp install", "Install to Claude Code (user scope)")
|
|
215
|
+
@ui.command_example("sxn mcp install --project", "Install to project .mcp.json")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -329,10 +329,7 @@ module Sxn
|
|
|
329
329
|
|
|
330
330
|
@ui.progress_done
|
|
331
331
|
|
|
332
|
-
# Apply project
|
|
333
|
-
apply_project_rules(project_name, session_name)
|
|
334
|
-
|
|
335
|
-
# Apply template-specific rule overrides if defined (in addition to project defaults)
|
|
332
|
+
# Apply project-specific rule overrides if defined
|
|
336
333
|
apply_template_rules(session_name, project_name, worktree[:path], project_config["rules"]) if project_config["rules"]
|
|
337
334
|
end
|
|
338
335
|
|
|
@@ -62,11 +62,7 @@ module Sxn
|
|
|
62
62
|
else
|
|
63
63
|
projects.each do |project_config|
|
|
64
64
|
project_name = project_config["name"]
|
|
65
|
-
project =
|
|
66
|
-
@project_manager.get_project(project_name)
|
|
67
|
-
rescue Sxn::ProjectNotFoundError
|
|
68
|
-
nil
|
|
69
|
-
end
|
|
65
|
+
project = @project_manager.get_project(project_name)
|
|
70
66
|
status = project ? "✓" : "✗ (not found)"
|
|
71
67
|
|
|
72
68
|
details = []
|
data/lib/sxn/commands.rb
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sxn
|
|
4
|
+
module MCP
|
|
5
|
+
module Prompts
|
|
6
|
+
# Guided new session creation workflow
|
|
7
|
+
class NewSession < ::MCP::Prompt
|
|
8
|
+
prompt_name "new-session"
|
|
9
|
+
description "Guided workflow for creating a new development session"
|
|
10
|
+
|
|
11
|
+
arguments [
|
|
12
|
+
::MCP::Prompt::Argument.new(
|
|
13
|
+
name: "task_description",
|
|
14
|
+
description: "Brief description of what you're working on",
|
|
15
|
+
required: false
|
|
16
|
+
),
|
|
17
|
+
::MCP::Prompt::Argument.new(
|
|
18
|
+
name: "projects",
|
|
19
|
+
description: "Comma-separated list of projects to include",
|
|
20
|
+
required: false
|
|
21
|
+
)
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def template(args = {}, _server_context: nil)
|
|
26
|
+
# Handle both string and symbol keys
|
|
27
|
+
task_description = args["task_description"] || args[:task_description]
|
|
28
|
+
projects = args["projects"] || args[:projects]
|
|
29
|
+
project_list = projects ? projects.split(",").map(&:strip).join(", ") : "Not specified"
|
|
30
|
+
|
|
31
|
+
<<~PROMPT
|
|
32
|
+
# Create a New Development Session
|
|
33
|
+
|
|
34
|
+
Help me create a new sxn development session.
|
|
35
|
+
|
|
36
|
+
## Task Information
|
|
37
|
+
- Description: #{task_description || "Not provided"}
|
|
38
|
+
- Requested projects: #{project_list}
|
|
39
|
+
|
|
40
|
+
## Steps to Complete
|
|
41
|
+
|
|
42
|
+
1. **Generate session name** based on the task description
|
|
43
|
+
2. **Create the session** using sxn_sessions_create
|
|
44
|
+
3. **Add worktrees** for each requested project using sxn_worktrees_add
|
|
45
|
+
4. **Navigate to the session** using sxn_sessions_swap
|
|
46
|
+
|
|
47
|
+
## Guidelines
|
|
48
|
+
- Session names should be descriptive but concise (e.g., "user-auth", "api-refactor")
|
|
49
|
+
- Use alphanumeric characters, hyphens, and underscores only
|
|
50
|
+
- Apply project rules automatically when creating worktrees
|
|
51
|
+
PROMPT
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Multi-repo setup workflow
|
|
57
|
+
class MultiRepoSetup < ::MCP::Prompt
|
|
58
|
+
prompt_name "multi-repo-setup"
|
|
59
|
+
description "Set up a multi-repository development environment"
|
|
60
|
+
|
|
61
|
+
arguments [
|
|
62
|
+
::MCP::Prompt::Argument.new(
|
|
63
|
+
name: "feature_name",
|
|
64
|
+
description: "Name of the feature being developed across repos",
|
|
65
|
+
required: true
|
|
66
|
+
),
|
|
67
|
+
::MCP::Prompt::Argument.new(
|
|
68
|
+
name: "repos",
|
|
69
|
+
description: "Comma-separated list of repository names to include",
|
|
70
|
+
required: false
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
def template(args = {}, _server_context: nil)
|
|
76
|
+
# Handle both string and symbol keys
|
|
77
|
+
feature_name = args["feature_name"] || args[:feature_name]
|
|
78
|
+
repos = args["repos"] || args[:repos]
|
|
79
|
+
repo_list = repos ? repos.split(",").map(&:strip) : []
|
|
80
|
+
|
|
81
|
+
<<~PROMPT
|
|
82
|
+
# Multi-Repository Development Setup
|
|
83
|
+
|
|
84
|
+
Set up a coordinated development environment for: **#{feature_name}**
|
|
85
|
+
|
|
86
|
+
## Repositories to Include
|
|
87
|
+
#{repo_list.empty? ? "- (Will use sxn_projects_list to find available projects)" : repo_list.map { |r| "- #{r}" }.join("\n")}
|
|
88
|
+
|
|
89
|
+
## Setup Process
|
|
90
|
+
|
|
91
|
+
1. **Check registered projects** with sxn_projects_list
|
|
92
|
+
2. **Create the session** with sxn_sessions_create
|
|
93
|
+
- Name: #{feature_name.downcase.gsub(/\s+/, "-")}
|
|
94
|
+
3. **Add worktrees** for each repository with sxn_worktrees_add
|
|
95
|
+
4. **Apply rules** with sxn_rules_apply for each project
|
|
96
|
+
5. **Navigate** using sxn_sessions_swap
|
|
97
|
+
|
|
98
|
+
## Best Practices
|
|
99
|
+
- Use the same branch name across all repos
|
|
100
|
+
- Apply rules to copy environment files
|
|
101
|
+
PROMPT
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sxn
|
|
4
|
+
module MCP
|
|
5
|
+
module Resources
|
|
6
|
+
# Factory methods for creating MCP Resource objects
|
|
7
|
+
module SessionResources
|
|
8
|
+
class << self
|
|
9
|
+
# Build all resources for the server
|
|
10
|
+
def build_all(server_context)
|
|
11
|
+
[
|
|
12
|
+
build_current_session(server_context),
|
|
13
|
+
build_all_sessions(server_context),
|
|
14
|
+
build_all_projects(server_context)
|
|
15
|
+
].compact
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def build_current_session(server_context)
|
|
21
|
+
return nil unless server_context[:config_manager]
|
|
22
|
+
|
|
23
|
+
::MCP::Resource.new(
|
|
24
|
+
uri: "sxn://session/current",
|
|
25
|
+
name: "Current Session",
|
|
26
|
+
description: "Information about the currently active sxn session",
|
|
27
|
+
mime_type: "application/json"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_all_sessions(server_context)
|
|
32
|
+
return nil unless server_context[:config_manager]
|
|
33
|
+
|
|
34
|
+
::MCP::Resource.new(
|
|
35
|
+
uri: "sxn://sessions",
|
|
36
|
+
name: "All Sessions",
|
|
37
|
+
description: "Summary of all sxn sessions",
|
|
38
|
+
mime_type: "application/json"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_all_projects(server_context)
|
|
43
|
+
return nil unless server_context[:config_manager]
|
|
44
|
+
|
|
45
|
+
::MCP::Resource.new(
|
|
46
|
+
uri: "sxn://projects",
|
|
47
|
+
name: "Registered Projects",
|
|
48
|
+
description: "All registered projects in the sxn workspace",
|
|
49
|
+
mime_type: "application/json"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resource content readers
|
|
56
|
+
module ResourceContentReader
|
|
57
|
+
class << self
|
|
58
|
+
def read_content(uri, server_context)
|
|
59
|
+
case uri
|
|
60
|
+
when "sxn://session/current"
|
|
61
|
+
read_current_session(server_context)
|
|
62
|
+
when "sxn://sessions"
|
|
63
|
+
read_all_sessions(server_context)
|
|
64
|
+
when "sxn://projects"
|
|
65
|
+
read_all_projects(server_context)
|
|
66
|
+
else
|
|
67
|
+
JSON.generate({ error: "Unknown resource: #{uri}" })
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def read_current_session(server_context)
|
|
74
|
+
config_manager = server_context[:config_manager]
|
|
75
|
+
return JSON.generate({ error: "sxn not initialized" }) unless config_manager
|
|
76
|
+
|
|
77
|
+
session_manager = server_context[:session_manager]
|
|
78
|
+
session = session_manager.current_session
|
|
79
|
+
|
|
80
|
+
unless session
|
|
81
|
+
return JSON.generate({
|
|
82
|
+
current_session: nil,
|
|
83
|
+
message: "No active session"
|
|
84
|
+
})
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
JSON.generate({
|
|
88
|
+
name: session[:name],
|
|
89
|
+
status: session[:status],
|
|
90
|
+
path: session[:path],
|
|
91
|
+
created_at: session[:created_at],
|
|
92
|
+
default_branch: session[:default_branch],
|
|
93
|
+
worktrees: session[:worktrees],
|
|
94
|
+
projects: session[:projects]
|
|
95
|
+
})
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
JSON.generate({ error: e.message })
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def read_all_sessions(server_context)
|
|
101
|
+
config_manager = server_context[:config_manager]
|
|
102
|
+
return JSON.generate({ error: "sxn not initialized" }) unless config_manager
|
|
103
|
+
|
|
104
|
+
session_manager = server_context[:session_manager]
|
|
105
|
+
sessions = session_manager.list_sessions
|
|
106
|
+
|
|
107
|
+
JSON.generate({
|
|
108
|
+
total: sessions.length,
|
|
109
|
+
sessions: sessions.map do |s|
|
|
110
|
+
{
|
|
111
|
+
name: s[:name],
|
|
112
|
+
status: s[:status],
|
|
113
|
+
worktree_count: s[:worktrees].keys.length
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
})
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
JSON.generate({ error: e.message })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_all_projects(server_context)
|
|
122
|
+
config_manager = server_context[:config_manager]
|
|
123
|
+
return JSON.generate({ error: "sxn not initialized" }) unless config_manager
|
|
124
|
+
|
|
125
|
+
project_manager = server_context[:project_manager]
|
|
126
|
+
projects = project_manager.list_projects
|
|
127
|
+
|
|
128
|
+
JSON.generate({
|
|
129
|
+
total: projects.length,
|
|
130
|
+
projects: projects.map do |p|
|
|
131
|
+
{
|
|
132
|
+
name: p[:name],
|
|
133
|
+
type: p[:type],
|
|
134
|
+
path: p[:path]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
})
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
JSON.generate({ error: e.message })
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sxn
|
|
4
|
+
module MCP
|
|
5
|
+
# Main MCP server class that orchestrates tools, resources, and prompts
|
|
6
|
+
class Server
|
|
7
|
+
attr_reader :workspace_path, :config_manager
|
|
8
|
+
|
|
9
|
+
def initialize(workspace_path: nil)
|
|
10
|
+
@workspace_path = workspace_path || ENV["SXN_WORKSPACE"] || discover_workspace
|
|
11
|
+
@config_manager = initialize_config_manager
|
|
12
|
+
@server = build_mcp_server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Run the server with STDIO transport (for Claude Code integration)
|
|
16
|
+
def run_stdio
|
|
17
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(@server)
|
|
18
|
+
transport.open
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Run the server with HTTP transport (for web-based integrations)
|
|
22
|
+
def run_http(port: 3000)
|
|
23
|
+
transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
|
24
|
+
transport.run(port: port)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def discover_workspace
|
|
30
|
+
# Try to find .sxn directory in current or parent directories
|
|
31
|
+
current = Dir.pwd
|
|
32
|
+
loop do
|
|
33
|
+
sxn_path = File.join(current, ".sxn")
|
|
34
|
+
return current if File.directory?(sxn_path)
|
|
35
|
+
|
|
36
|
+
parent = File.dirname(current)
|
|
37
|
+
break if parent == current
|
|
38
|
+
|
|
39
|
+
current = parent
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fall back to current directory
|
|
43
|
+
Dir.pwd
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize_config_manager
|
|
47
|
+
Sxn::Core::ConfigManager.new(@workspace_path)
|
|
48
|
+
rescue Sxn::ConfigurationError
|
|
49
|
+
# Allow server to start even if sxn isn't initialized
|
|
50
|
+
# Tools will return appropriate errors
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_mcp_server
|
|
55
|
+
context = build_context
|
|
56
|
+
|
|
57
|
+
server = ::MCP::Server.new(
|
|
58
|
+
name: "sxn",
|
|
59
|
+
version: Sxn::VERSION,
|
|
60
|
+
tools: registered_tools,
|
|
61
|
+
resources: registered_resources(context),
|
|
62
|
+
prompts: registered_prompts,
|
|
63
|
+
server_context: context
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Set up resource read handler
|
|
67
|
+
server.resources_read_handler do |params|
|
|
68
|
+
uri = params[:uri]
|
|
69
|
+
content = Resources::ResourceContentReader.read_content(uri, context)
|
|
70
|
+
[{ uri: uri, text: content, mimeType: "application/json" }]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
server
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_context
|
|
77
|
+
{
|
|
78
|
+
config_manager: @config_manager,
|
|
79
|
+
session_manager: @config_manager && Sxn::Core::SessionManager.new(@config_manager),
|
|
80
|
+
project_manager: @config_manager && Sxn::Core::ProjectManager.new(@config_manager),
|
|
81
|
+
worktree_manager: @config_manager && Sxn::Core::WorktreeManager.new(@config_manager),
|
|
82
|
+
template_manager: @config_manager && Sxn::Core::TemplateManager.new(@config_manager),
|
|
83
|
+
rules_manager: @config_manager && Sxn::Core::RulesManager.new(@config_manager),
|
|
84
|
+
workspace_path: @workspace_path
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def registered_tools
|
|
89
|
+
[
|
|
90
|
+
# Session tools
|
|
91
|
+
Tools::Sessions::ListSessions,
|
|
92
|
+
Tools::Sessions::CreateSession,
|
|
93
|
+
Tools::Sessions::GetSession,
|
|
94
|
+
Tools::Sessions::DeleteSession,
|
|
95
|
+
Tools::Sessions::ArchiveSession,
|
|
96
|
+
Tools::Sessions::ActivateSession,
|
|
97
|
+
Tools::Sessions::SwapSession,
|
|
98
|
+
# Worktree tools
|
|
99
|
+
Tools::Worktrees::ListWorktrees,
|
|
100
|
+
Tools::Worktrees::AddWorktree,
|
|
101
|
+
Tools::Worktrees::RemoveWorktree,
|
|
102
|
+
# Project tools
|
|
103
|
+
Tools::Projects::ListProjects,
|
|
104
|
+
Tools::Projects::AddProject,
|
|
105
|
+
Tools::Projects::GetProject,
|
|
106
|
+
# Template tools
|
|
107
|
+
Tools::Templates::ListTemplates,
|
|
108
|
+
Tools::Templates::ApplyTemplate,
|
|
109
|
+
# Rules tools
|
|
110
|
+
Tools::Rules::ListRules,
|
|
111
|
+
Tools::Rules::ApplyRules
|
|
112
|
+
]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def registered_resources(context)
|
|
116
|
+
Resources::SessionResources.build_all(context)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def registered_prompts
|
|
120
|
+
[
|
|
121
|
+
Prompts::NewSession,
|
|
122
|
+
Prompts::MultiRepoSetup
|
|
123
|
+
]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|