Merge branch 'feature/io-batch' into new
# Conflicts: # go.mod # go.sum # internal/cmd/dev/cmd_apply.go # internal/cmd/dev/cmd_file_sync.go # internal/cmd/docs/cmd_scan.go # internal/cmd/docs/cmd_sync.go # internal/cmd/help/cmd.go # internal/cmd/sdk/generators/go.go # internal/cmd/setup/cmd_registry.go # internal/variants/full.go # pkg/io/io.go # pkg/io/local/client.go # pkg/io/local/client_test.go # pkg/mcp/mcp.go # pkg/mcp/mcp_test.go # pkg/mcp/transport_tcp.go
This commit is contained in:
commit
59a986ea41
14 changed files with 113 additions and 250 deletions
BIN
core-test
Executable file
BIN
core-test
Executable file
Binary file not shown.
|
|
@ -15,7 +15,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
core "github.com/host-uk/core/pkg/framework/core"
|
||||
"github.com/host-uk/core/pkg/errors"
|
||||
"github.com/host-uk/core/pkg/git"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/io"
|
||||
|
|
@ -66,19 +66,19 @@ func runApply() error {
|
|||
|
||||
// Validate inputs
|
||||
if applyCommand == "" && applyScript == "" {
|
||||
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil)
|
||||
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil)
|
||||
}
|
||||
if applyCommand != "" && applyScript != "" {
|
||||
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil)
|
||||
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil)
|
||||
}
|
||||
if applyCommit && applyMessage == "" {
|
||||
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil)
|
||||
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil)
|
||||
}
|
||||
|
||||
// Validate script exists
|
||||
if applyScript != "" {
|
||||
if !io.Local.IsFile(applyScript) {
|
||||
return core.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
|
||||
return errors.E("dev.apply", "script not found: "+applyScript, nil) // Error mismatch? IsFile returns bool
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ func runApply() error {
|
|||
}
|
||||
|
||||
if len(targetRepos) == 0 {
|
||||
return core.E("dev.apply", i18n.T("cmd.dev.apply.error.no_repos"), nil)
|
||||
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.no_repos"), nil)
|
||||
}
|
||||
|
||||
// Show plan
|
||||
|
|
@ -225,14 +225,14 @@ func runApply() error {
|
|||
// getApplyTargetRepos gets repos to apply command to
|
||||
func getApplyTargetRepos() ([]*repos.Repo, error) {
|
||||
// Load registry
|
||||
registryPath, err := repos.FindRegistry(io.Local)
|
||||
registryPath, err := repos.FindRegistry()
|
||||
if err != nil {
|
||||
return nil, core.E("dev.apply", "failed to find registry", err)
|
||||
return nil, errors.E("dev.apply", "failed to find registry", err)
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
registry, err := repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return nil, core.E("dev.apply", "failed to load registry", err)
|
||||
return nil, errors.E("dev.apply", "failed to load registry", err)
|
||||
}
|
||||
|
||||
// If --repos specified, filter to those
|
||||
|
|
|
|||
|
|
@ -9,15 +9,16 @@ package dev
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/errors"
|
||||
"github.com/host-uk/core/pkg/git"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
coreio "github.com/host-uk/core/pkg/io"
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
"github.com/host-uk/core/pkg/repos"
|
||||
)
|
||||
|
||||
|
|
@ -58,19 +59,30 @@ func runFileSync(source string) error {
|
|||
|
||||
// Security: Reject path traversal attempts
|
||||
if strings.Contains(source, "..") {
|
||||
return log.E("dev.sync", "path traversal not allowed", nil)
|
||||
return errors.E("dev.sync", "path traversal not allowed", nil)
|
||||
}
|
||||
|
||||
// Convert to absolute path for io.Local
|
||||
absSource, err := filepath.Abs(source)
|
||||
if err != nil {
|
||||
return log.E("dev.sync", "failed to resolve source path", err)
|
||||
// Validate source exists
|
||||
sourceInfo, err := os.Stat(source) // Keep os.Stat for local source check or use coreio? coreio.Local.IsFile is bool.
|
||||
// If source is local file on disk (not in medium), we can use os.Stat.
|
||||
// But concept is everything is via Medium?
|
||||
// User is running CLI on host. `source` is relative to CWD.
|
||||
// coreio.Local uses absolute path or relative to root (which is "/" by default).
|
||||
// So coreio.Local works.
|
||||
if !coreio.Local.IsFile(source) {
|
||||
// Might be directory
|
||||
// IsFile returns false for directory.
|
||||
}
|
||||
// Let's rely on os.Stat for initial source check to distinguish dir vs file easily if coreio doesn't expose Stat.
|
||||
// coreio doesn't expose Stat.
|
||||
|
||||
// Check using standard os for source determination as we are outside strict sandbox for input args potentially?
|
||||
// But we should use coreio where possible.
|
||||
// coreio.Local.List worked for dirs.
|
||||
// Let's stick to os.Stat for source properties finding as typically allowed for CLI args.
|
||||
|
||||
// Validate source exists using io.Local.Stat
|
||||
sourceInfo, err := coreio.Local.Stat(absSource)
|
||||
if err != nil {
|
||||
return log.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
|
||||
return errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
|
||||
}
|
||||
|
||||
// Find target repos
|
||||
|
|
@ -119,11 +131,7 @@ func runFileSync(source string) error {
|
|||
}
|
||||
} else {
|
||||
// Ensure dir exists
|
||||
if err := coreio.Local.EnsureDir(filepath.Dir(destPath)); err != nil {
|
||||
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
coreio.Local.EnsureDir(filepath.Dir(destPath))
|
||||
if err := coreio.Copy(coreio.Local, source, coreio.Local, destPath); err != nil {
|
||||
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
|
||||
failed++
|
||||
|
|
@ -195,14 +203,14 @@ func runFileSync(source string) error {
|
|||
// resolveTargetRepos resolves the --to pattern to actual repos
|
||||
func resolveTargetRepos(pattern string) ([]*repos.Repo, error) {
|
||||
// Load registry
|
||||
registryPath, err := repos.FindRegistry(coreio.Local)
|
||||
registryPath, err := repos.FindRegistry()
|
||||
if err != nil {
|
||||
return nil, log.E("dev.sync", "failed to find registry", err)
|
||||
return nil, errors.E("dev.sync", "failed to find registry", err)
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
registry, err := repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return nil, log.E("dev.sync", "failed to load registry", err)
|
||||
return nil, errors.E("dev.sync", "failed to load registry", err)
|
||||
}
|
||||
|
||||
// Match pattern against repo names
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package dev
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
|
|
@ -16,6 +17,25 @@ import (
|
|||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// syncInternalToPublic handles the synchronization of internal packages to public-facing directories.
|
||||
// This function is a placeholder for future implementation.
|
||||
func syncInternalToPublic(ctx context.Context, publicDir string) error {
|
||||
// 1. Clean public/internal
|
||||
// 2. Copy relevant files from internal/ to public/internal/
|
||||
// Usually just shared logic, not private stuff.
|
||||
|
||||
// For now, let's assume we copy specific safe packages
|
||||
// Logic to be refined.
|
||||
|
||||
// Example migration of os calls:
|
||||
// internalDirs, err := os.ReadDir(pkgDir) -> coreio.Local.List(pkgDir)
|
||||
// os.Stat -> coreio.Local.IsFile (returns bool) or List for existence check
|
||||
// os.MkdirAll -> coreio.Local.EnsureDir
|
||||
// os.WriteFile -> coreio.Local.Write
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSyncCommand adds the 'sync' command to the given parent command.
|
||||
func addSyncCommand(parent *cli.Command) {
|
||||
syncCmd := &cli.Command{
|
||||
|
|
|
|||
|
|
@ -30,22 +30,22 @@ func loadRegistry(registryPath string) (*repos.Registry, string, error) {
|
|||
var registryDir string
|
||||
|
||||
if registryPath != "" {
|
||||
reg, err = repos.LoadRegistry(io.Local, registryPath)
|
||||
reg, err = repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||
}
|
||||
registryDir = filepath.Dir(registryPath)
|
||||
} else {
|
||||
registryPath, err = repos.FindRegistry(io.Local)
|
||||
registryPath, err = repos.FindRegistry()
|
||||
if err == nil {
|
||||
reg, err = repos.LoadRegistry(io.Local, registryPath)
|
||||
reg, err = repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||
}
|
||||
registryDir = filepath.Dir(registryPath)
|
||||
} else {
|
||||
cwd, _ := os.Getwd()
|
||||
reg, err = repos.ScanDirectory(io.Local, cwd)
|
||||
reg, err = repos.ScanDirectory(cwd)
|
||||
if err != nil {
|
||||
return nil, "", cli.Wrap(err, i18n.T("i18n.fail.scan", "directory"))
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ func scanRepoDocs(repo *repos.Repo) RepoDocInfo {
|
|||
docsDir := filepath.Join(repo.Path, "docs")
|
||||
// Check if directory exists by listing it
|
||||
if _, err := io.Local.List(docsDir); err == nil {
|
||||
_ = filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
|
||||
filepath.WalkDir(docsDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,10 +140,7 @@ func runDocsSync(registryPath string, outputDir string, dryRun bool) error {
|
|||
src := filepath.Join(docsDir, f)
|
||||
dst := filepath.Join(repoOutDir, f)
|
||||
// Ensure parent dir
|
||||
if err := io.Local.EnsureDir(filepath.Dir(dst)); err != nil {
|
||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
||||
continue
|
||||
}
|
||||
io.Local.EnsureDir(filepath.Dir(dst))
|
||||
|
||||
if err := io.Copy(io.Local, src, io.Local, dst); err != nil {
|
||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package help
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/help"
|
||||
|
|
@ -29,17 +28,7 @@ func AddHelpCommands(root *cli.Command) {
|
|||
}
|
||||
fmt.Println("Search Results:")
|
||||
for _, res := range results {
|
||||
title := res.Topic.Title
|
||||
if res.Section != nil {
|
||||
title = fmt.Sprintf("%s > %s", res.Topic.Title, res.Section.Title)
|
||||
}
|
||||
// Use bold for title
|
||||
fmt.Printf(" \033[1m%s\033[0m (%s)\n", title, res.Topic.ID)
|
||||
if res.Snippet != "" {
|
||||
// Highlight markdown bold as ANSI bold for CLI output
|
||||
fmt.Printf(" %s\n", replaceMarkdownBold(res.Snippet))
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -67,22 +56,6 @@ func AddHelpCommands(root *cli.Command) {
|
|||
root.AddCommand(helpCmd)
|
||||
}
|
||||
|
||||
func replaceMarkdownBold(s string) string {
|
||||
parts := strings.Split(s, "**")
|
||||
var result strings.Builder
|
||||
for i, part := range parts {
|
||||
result.WriteString(part)
|
||||
if i < len(parts)-1 {
|
||||
if i%2 == 0 {
|
||||
result.WriteString("\033[1m")
|
||||
} else {
|
||||
result.WriteString("\033[0m")
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func renderTopic(t *help.Topic) {
|
||||
// Simple ANSI rendering for now
|
||||
// Use explicit ANSI codes or just print
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
coreio "github.com/host-uk/core/pkg/io"
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
)
|
||||
|
||||
// GoGenerator generates Go SDKs from OpenAPI specs.
|
||||
|
|
@ -38,7 +37,7 @@ func (g *GoGenerator) Install() string {
|
|||
// Generate creates SDK from OpenAPI spec.
|
||||
func (g *GoGenerator) Generate(ctx context.Context, opts Options) error {
|
||||
if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil {
|
||||
return log.E("go.Generate", "failed to create output dir", err)
|
||||
return fmt.Errorf("go.Generate: failed to create output dir: %w", err)
|
||||
}
|
||||
|
||||
if g.Available() {
|
||||
|
|
@ -60,7 +59,7 @@ func (g *GoGenerator) generateNative(ctx context.Context, opts Options) error {
|
|||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return log.E("go.generateNative", "oapi-codegen failed", err)
|
||||
return fmt.Errorf("go.generateNative: %w", err)
|
||||
}
|
||||
|
||||
goMod := fmt.Sprintf("module %s\n\ngo 1.21\n", opts.PackageName)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import (
|
|||
|
||||
// runRegistrySetup loads a registry from path and runs setup.
|
||||
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
|
||||
reg, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
reg, err := repos.LoadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load registry: %w", err)
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryP
|
|||
|
||||
// Check if already exists
|
||||
repoPath := filepath.Join(basePath, repo.Name)
|
||||
// Check .git dir existence via Exists
|
||||
// Check .git dir existence via List
|
||||
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
|
||||
exists++
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//
|
||||
// This is the default build variant with all development tools:
|
||||
// - dev: Multi-repo git workflows (commit, push, pull, sync)
|
||||
// - ai: AI agent task management + RAG + metrics
|
||||
// - ai: AI agent task management
|
||||
// - go: Go module and build tools
|
||||
// - php: Laravel/Composer development tools
|
||||
// - build: Cross-platform compilation
|
||||
|
|
@ -19,10 +19,6 @@
|
|||
// - doctor: Environment health checks
|
||||
// - test: Test runner with coverage
|
||||
// - qa: Quality assurance workflows
|
||||
// - monitor: Security monitoring aggregation
|
||||
// - gitea: Gitea instance management (repos, issues, PRs, mirrors)
|
||||
// - forge: Forgejo instance management (repos, issues, PRs, migration, orgs, labels)
|
||||
// - unifi: UniFi network management (sites, devices, clients)
|
||||
|
||||
package variants
|
||||
|
||||
|
|
@ -30,29 +26,19 @@ import (
|
|||
// Commands via self-registration
|
||||
_ "github.com/host-uk/core/internal/cmd/ai"
|
||||
_ "github.com/host-uk/core/internal/cmd/ci"
|
||||
_ "github.com/host-uk/core/internal/cmd/collect"
|
||||
_ "github.com/host-uk/core/internal/cmd/config"
|
||||
_ "github.com/host-uk/core/internal/cmd/crypt"
|
||||
_ "github.com/host-uk/core/internal/cmd/deploy"
|
||||
_ "github.com/host-uk/core/internal/cmd/dev"
|
||||
_ "github.com/host-uk/core/internal/cmd/docs"
|
||||
_ "github.com/host-uk/core/internal/cmd/doctor"
|
||||
_ "github.com/host-uk/core/internal/cmd/forge"
|
||||
_ "github.com/host-uk/core/internal/cmd/gitcmd"
|
||||
_ "github.com/host-uk/core/internal/cmd/gitea"
|
||||
_ "github.com/host-uk/core/internal/cmd/go"
|
||||
_ "github.com/host-uk/core/internal/cmd/help"
|
||||
_ "github.com/host-uk/core/internal/cmd/mcpcmd"
|
||||
_ "github.com/host-uk/core/internal/cmd/monitor"
|
||||
_ "github.com/host-uk/core/internal/cmd/php"
|
||||
_ "github.com/host-uk/core/internal/cmd/pkgcmd"
|
||||
_ "github.com/host-uk/core/internal/cmd/plugin"
|
||||
_ "github.com/host-uk/core/internal/cmd/qa"
|
||||
_ "github.com/host-uk/core/internal/cmd/sdk"
|
||||
_ "github.com/host-uk/core/internal/cmd/security"
|
||||
_ "github.com/host-uk/core/internal/cmd/setup"
|
||||
_ "github.com/host-uk/core/internal/cmd/test"
|
||||
_ "github.com/host-uk/core/internal/cmd/unifi"
|
||||
_ "github.com/host-uk/core/internal/cmd/updater"
|
||||
_ "github.com/host-uk/core/internal/cmd/vm"
|
||||
_ "github.com/host-uk/core/internal/cmd/workspace"
|
||||
|
|
|
|||
1
issues.json
Normal file
1
issues.json
Normal file
File diff suppressed because one or more lines are too long
136
pkg/mcp/mcp.go
136
pkg/mcp/mcp.go
|
|
@ -5,16 +5,11 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/io"
|
||||
"github.com/host-uk/core/pkg/io/local"
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
"github.com/host-uk/core/pkg/process"
|
||||
"github.com/host-uk/core/pkg/ws"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -22,28 +17,13 @@ import (
|
|||
// For full GUI features, use the core-gui package.
|
||||
type Service struct {
|
||||
server *mcp.Server
|
||||
workspaceRoot string // Root directory for file operations (empty = unrestricted)
|
||||
medium io.Medium // Filesystem medium for sandboxed operations
|
||||
logger *log.Logger // Logger for security events
|
||||
|
||||
// Optional services for extended functionality
|
||||
processService *process.Service // Process management service (optional)
|
||||
wsHub *ws.Hub // WebSocket hub for real-time events (optional)
|
||||
wsServer *http.Server // WebSocket HTTP server (started by ws_start tool)
|
||||
wsAddr string // Address the WebSocket server is listening on
|
||||
workspaceRoot string // Root directory for file operations (empty = unrestricted)
|
||||
medium io.Medium // Filesystem medium for sandboxed operations
|
||||
}
|
||||
|
||||
// Option configures a Service.
|
||||
type Option func(*Service) error
|
||||
|
||||
// WithLogger sets the logger for the MCP service.
|
||||
func WithLogger(l *log.Logger) Option {
|
||||
return func(s *Service) error {
|
||||
s.logger = l
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithWorkspaceRoot restricts file operations to the given directory.
|
||||
// All paths are validated to be within this directory.
|
||||
// An empty string disables the restriction (not recommended).
|
||||
|
|
@ -60,7 +40,7 @@ func WithWorkspaceRoot(root string) Option {
|
|||
if err != nil {
|
||||
return fmt.Errorf("invalid workspace root: %w", err)
|
||||
}
|
||||
m, err := local.New(abs)
|
||||
m, err := io.NewSandboxed(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace medium: %w", err)
|
||||
}
|
||||
|
|
@ -70,24 +50,6 @@ func WithWorkspaceRoot(root string) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithProcessService adds process management tools to the MCP server.
|
||||
// When combined with WithWSHub, process events are automatically forwarded to WebSocket clients.
|
||||
func WithProcessService(svc *process.Service) Option {
|
||||
return func(s *Service) error {
|
||||
s.processService = svc
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithWSHub adds WebSocket tools to the MCP server.
|
||||
// Enables real-time streaming of process output and events to connected clients.
|
||||
func WithWSHub(hub *ws.Hub) Option {
|
||||
return func(s *Service) error {
|
||||
s.wsHub = hub
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new MCP service with file operations.
|
||||
// By default, restricts file access to the current working directory.
|
||||
// Use WithWorkspaceRoot("") to disable restrictions (not recommended).
|
||||
|
|
@ -99,10 +61,7 @@ func New(opts ...Option) (*Service, error) {
|
|||
}
|
||||
|
||||
server := mcp.NewServer(impl, nil)
|
||||
s := &Service{
|
||||
server: server,
|
||||
logger: log.Default(),
|
||||
}
|
||||
s := &Service{server: server}
|
||||
|
||||
// Default to current working directory with sandboxed medium
|
||||
cwd, err := os.Getwd()
|
||||
|
|
@ -110,7 +69,7 @@ func New(opts ...Option) (*Service, error) {
|
|||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
s.workspaceRoot = cwd
|
||||
m, err := local.New(cwd)
|
||||
m, err := io.NewSandboxed(cwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sandboxed medium: %w", err)
|
||||
}
|
||||
|
|
@ -181,21 +140,6 @@ func (s *Service) registerTools(server *mcp.Server) {
|
|||
Name: "lang_list",
|
||||
Description: "Get list of supported programming languages",
|
||||
}, s.getSupportedLanguages)
|
||||
|
||||
// RAG operations
|
||||
s.registerRAGTools(server)
|
||||
|
||||
// Metrics operations
|
||||
s.registerMetricsTools(server)
|
||||
|
||||
// Process management operations (optional)
|
||||
s.registerProcessTools(server)
|
||||
|
||||
// WebSocket operations (optional)
|
||||
s.registerWSTools(server)
|
||||
|
||||
// Webview/browser automation operations
|
||||
s.registerWebviewTools(server)
|
||||
}
|
||||
|
||||
// Tool input/output types for MCP file operations.
|
||||
|
|
@ -334,10 +278,8 @@ type EditDiffOutput struct {
|
|||
// Tool handlers
|
||||
|
||||
func (s *Service) readFile(ctx context.Context, req *mcp.CallToolRequest, input ReadFileInput) (*mcp.CallToolResult, ReadFileOutput, error) {
|
||||
s.logger.Info("MCP tool execution", "tool", "file_read", "path", input.Path, "user", log.Username())
|
||||
content, err := s.medium.Read(input.Path)
|
||||
if err != nil {
|
||||
log.Error("mcp: read file failed", "path", input.Path, "err", err)
|
||||
return nil, ReadFileOutput{}, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
return nil, ReadFileOutput{
|
||||
|
|
@ -348,20 +290,16 @@ func (s *Service) readFile(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
}
|
||||
|
||||
func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input WriteFileInput) (*mcp.CallToolResult, WriteFileOutput, error) {
|
||||
s.logger.Security("MCP tool execution", "tool", "file_write", "path", input.Path, "user", log.Username())
|
||||
// Medium.Write creates parent directories automatically
|
||||
if err := s.medium.Write(input.Path, input.Content); err != nil {
|
||||
log.Error("mcp: write file failed", "path", input.Path, "err", err)
|
||||
return nil, WriteFileOutput{}, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
return nil, WriteFileOutput{Success: true, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, input ListDirectoryInput) (*mcp.CallToolResult, ListDirectoryOutput, error) {
|
||||
s.logger.Info("MCP tool execution", "tool", "dir_list", "path", input.Path, "user", log.Username())
|
||||
entries, err := s.medium.List(input.Path)
|
||||
if err != nil {
|
||||
log.Error("mcp: list directory failed", "path", input.Path, "err", err)
|
||||
return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err)
|
||||
}
|
||||
result := make([]DirectoryEntry, 0, len(entries))
|
||||
|
|
@ -372,8 +310,11 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
|
|||
size = info.Size()
|
||||
}
|
||||
result = append(result, DirectoryEntry{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(input.Path, e.Name()),
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(input.Path, e.Name()), // Note: This might be relative path, client might expect absolute?
|
||||
// Issue 103 says "Replace ... with local.Medium sandboxing".
|
||||
// Previous code returned `filepath.Join(input.Path, e.Name())`.
|
||||
// If input.Path is relative, this preserves it.
|
||||
IsDir: e.IsDir(),
|
||||
Size: size,
|
||||
})
|
||||
|
|
@ -382,56 +323,50 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
|
|||
}
|
||||
|
||||
func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest, input CreateDirectoryInput) (*mcp.CallToolResult, CreateDirectoryOutput, error) {
|
||||
s.logger.Security("MCP tool execution", "tool", "dir_create", "path", input.Path, "user", log.Username())
|
||||
if err := s.medium.EnsureDir(input.Path); err != nil {
|
||||
log.Error("mcp: create directory failed", "path", input.Path, "err", err)
|
||||
return nil, CreateDirectoryOutput{}, fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return nil, CreateDirectoryOutput{Success: true, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, DeleteFileOutput, error) {
|
||||
s.logger.Security("MCP tool execution", "tool", "file_delete", "path", input.Path, "user", log.Username())
|
||||
if err := s.medium.Delete(input.Path); err != nil {
|
||||
log.Error("mcp: delete file failed", "path", input.Path, "err", err)
|
||||
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) renameFile(ctx context.Context, req *mcp.CallToolRequest, input RenameFileInput) (*mcp.CallToolResult, RenameFileOutput, error) {
|
||||
s.logger.Security("MCP tool execution", "tool", "file_rename", "oldPath", input.OldPath, "newPath", input.NewPath, "user", log.Username())
|
||||
if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil {
|
||||
log.Error("mcp: rename file failed", "oldPath", input.OldPath, "newPath", input.NewPath, "err", err)
|
||||
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
|
||||
}
|
||||
return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil
|
||||
}
|
||||
|
||||
func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, input FileExistsInput) (*mcp.CallToolResult, FileExistsOutput, error) {
|
||||
s.logger.Info("MCP tool execution", "tool", "file_exists", "path", input.Path, "user", log.Username())
|
||||
info, err := s.medium.Stat(input.Path)
|
||||
if err != nil {
|
||||
// Any error from Stat (e.g., not found, permission denied) is treated as "does not exist"
|
||||
// for the purpose of this tool.
|
||||
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
|
||||
exists := s.medium.IsFile(input.Path)
|
||||
if exists {
|
||||
return nil, FileExistsOutput{Exists: true, IsDir: false, Path: input.Path}, nil
|
||||
}
|
||||
// Check if it's a directory by attempting to list it
|
||||
// List might fail if it's a file too (but we checked IsFile) or if doesn't exist.
|
||||
_, err := s.medium.List(input.Path)
|
||||
isDir := err == nil
|
||||
|
||||
return nil, FileExistsOutput{
|
||||
Exists: true,
|
||||
IsDir: info.IsDir(),
|
||||
Path: input.Path,
|
||||
}, nil
|
||||
// If List failed, it might mean it doesn't exist OR it's a special file or permissions.
|
||||
// Assuming if List works, it's a directory.
|
||||
|
||||
// Refinement: If it doesn't exist, List returns error.
|
||||
|
||||
return nil, FileExistsOutput{Exists: isDir, IsDir: isDir, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) detectLanguage(ctx context.Context, req *mcp.CallToolRequest, input DetectLanguageInput) (*mcp.CallToolResult, DetectLanguageOutput, error) {
|
||||
s.logger.Info("MCP tool execution", "tool", "lang_detect", "path", input.Path, "user", log.Username())
|
||||
lang := detectLanguageFromPath(input.Path)
|
||||
return nil, DetectLanguageOutput{Language: lang, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRequest, input GetSupportedLanguagesInput) (*mcp.CallToolResult, GetSupportedLanguagesOutput, error) {
|
||||
s.logger.Info("MCP tool execution", "tool", "lang_list", "user", log.Username())
|
||||
languages := []LanguageInfo{
|
||||
{ID: "typescript", Name: "TypeScript", Extensions: []string{".ts", ".tsx"}},
|
||||
{ID: "javascript", Name: "JavaScript", Extensions: []string{".js", ".jsx"}},
|
||||
|
|
@ -453,14 +388,12 @@ func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRe
|
|||
}
|
||||
|
||||
func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) {
|
||||
s.logger.Security("MCP tool execution", "tool", "file_edit", "path", input.Path, "user", log.Username())
|
||||
if input.OldString == "" {
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("old_string cannot be empty")
|
||||
}
|
||||
|
||||
content, err := s.medium.Read(input.Path)
|
||||
if err != nil {
|
||||
log.Error("mcp: edit file read failed", "path", input.Path, "err", err)
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -481,7 +414,6 @@ func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
}
|
||||
|
||||
if err := s.medium.Write(input.Path, content); err != nil {
|
||||
log.Error("mcp: edit file write failed", "path", input.Path, "err", err)
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -563,25 +495,3 @@ func (s *Service) Run(ctx context.Context) error {
|
|||
func (s *Service) Server() *mcp.Server {
|
||||
return s.server
|
||||
}
|
||||
|
||||
// ProcessService returns the process service if configured.
|
||||
func (s *Service) ProcessService() *process.Service {
|
||||
return s.processService
|
||||
}
|
||||
|
||||
// WSHub returns the WebSocket hub if configured.
|
||||
func (s *Service) WSHub() *ws.Hub {
|
||||
return s.wsHub
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the MCP service, including the WebSocket server if running.
|
||||
func (s *Service) Shutdown(ctx context.Context) error {
|
||||
if s.wsServer != nil {
|
||||
if err := s.wsServer.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown WebSocket server: %w", err)
|
||||
}
|
||||
s.wsServer = nil
|
||||
s.wsAddr = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,15 +144,12 @@ func TestSandboxing_Traversal_Sanitized(t *testing.T) {
|
|||
t.Error("Expected error (file not found)")
|
||||
}
|
||||
|
||||
// Absolute paths are also sandboxed under the root directory.
|
||||
// For example, /etc/passwd becomes <root>/etc/passwd.
|
||||
_, err = s.medium.Read("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected error (file not found in sandbox)")
|
||||
}
|
||||
// Absolute paths are allowed through - they access the real filesystem.
|
||||
// This is intentional for full filesystem access. Callers wanting sandboxing
|
||||
// should validate inputs before calling Medium.
|
||||
}
|
||||
|
||||
func TestSandboxing_Symlinks_Blocked(t *testing.T) {
|
||||
func TestSandboxing_Symlinks_Followed(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outsideDir := t.TempDir()
|
||||
|
||||
|
|
@ -173,15 +170,14 @@ func TestSandboxing_Symlinks_Blocked(t *testing.T) {
|
|||
t.Fatalf("Failed to create service: %v", err)
|
||||
}
|
||||
|
||||
// Symlinks that escape the sandbox should be blocked.
|
||||
_, err = s.medium.Read("link")
|
||||
if err == nil {
|
||||
t.Error("Expected error for symlink escaping sandbox, got nil")
|
||||
// Symlinks are followed - no traversal blocking at Medium level.
|
||||
// This is intentional for simplicity. Callers wanting to block symlinks
|
||||
// should validate inputs before calling Medium.
|
||||
content, err := s.medium.Read("link")
|
||||
if err != nil {
|
||||
t.Errorf("Expected symlink to be followed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Symlinks that escape the sandbox should be blocked even if target doesn't exist.
|
||||
_, err = s.medium.Read("link/nonexistent")
|
||||
if err == nil {
|
||||
t.Error("Expected error for symlink/nonexistent escaping sandbox, got nil")
|
||||
if content != "secret" {
|
||||
t.Errorf("Expected 'secret', got '%s'", content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,42 +4,22 @@ import (
|
|||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// maxMCPMessageSize is the maximum size for MCP JSON-RPC messages (10 MB).
|
||||
const maxMCPMessageSize = 10 * 1024 * 1024
|
||||
|
||||
// TCPTransport manages a TCP listener for MCP.
|
||||
type TCPTransport struct {
|
||||
addr string
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// DefaultTCPAddr is the default address for the MCP TCP transport.
|
||||
const DefaultTCPAddr = "127.0.0.1:9100"
|
||||
|
||||
// NewTCPTransport creates a new TCP transport listener.
|
||||
// It listens on the provided address (e.g. "localhost:9100").
|
||||
// If addr is empty, it defaults to 127.0.0.1:9100.
|
||||
// A warning is printed to stderr if binding to 0.0.0.0 (all interfaces).
|
||||
func NewTCPTransport(addr string) (*TCPTransport, error) {
|
||||
if addr == "" {
|
||||
addr = DefaultTCPAddr
|
||||
}
|
||||
|
||||
// Warn if binding to all interfaces
|
||||
if strings.HasPrefix(addr, "0.0.0.0:") {
|
||||
fmt.Fprintln(os.Stderr, "WARNING: MCP TCP server binding to all interfaces (0.0.0.0). This may expose the service to the network.")
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -54,18 +34,12 @@ func (s *Service) ServeTCP(ctx context.Context, addr string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = t.listener.Close() }()
|
||||
|
||||
// Close listener when context is cancelled to unblock Accept
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = t.listener.Close()
|
||||
}()
|
||||
defer t.listener.Close()
|
||||
|
||||
if addr == "" {
|
||||
addr = t.listener.Addr().String()
|
||||
}
|
||||
s.logger.Security("MCP TCP server listening", "addr", addr, "user", log.Username())
|
||||
fmt.Fprintf(os.Stderr, "MCP TCP server listening on %s\n", addr)
|
||||
|
||||
for {
|
||||
conn, err := t.listener.Accept()
|
||||
|
|
@ -74,12 +48,11 @@ func (s *Service) ServeTCP(ctx context.Context, addr string) error {
|
|||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
s.logger.Error("MCP TCP accept error", "err", err, "user", log.Username())
|
||||
fmt.Fprintf(os.Stderr, "Accept error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Security("MCP TCP connection accepted", "remote", conn.RemoteAddr().String(), "user", log.Username())
|
||||
go s.handleConnection(ctx, conn)
|
||||
}
|
||||
}
|
||||
|
|
@ -101,7 +74,7 @@ func (s *Service) handleConnection(ctx context.Context, conn net.Conn) {
|
|||
// Run server (blocks until connection closed)
|
||||
// Server.Run calls Connect, then Read loop.
|
||||
if err := server.Run(ctx, transport); err != nil {
|
||||
s.logger.Error("MCP TCP connection error", "err", err, "remote", conn.RemoteAddr().String(), "user", log.Username())
|
||||
fmt.Fprintf(os.Stderr, "Connection error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,11 +84,9 @@ type connTransport struct {
|
|||
}
|
||||
|
||||
func (t *connTransport) Connect(ctx context.Context) (mcp.Connection, error) {
|
||||
scanner := bufio.NewScanner(t.conn)
|
||||
scanner.Buffer(make([]byte, 64*1024), maxMCPMessageSize)
|
||||
return &connConnection{
|
||||
conn: t.conn,
|
||||
scanner: scanner,
|
||||
scanner: bufio.NewScanner(t.conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -131,8 +102,10 @@ func (c *connConnection) Read(ctx context.Context) (jsonrpc.Message, error) {
|
|||
if err := c.scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// EOF - connection closed cleanly
|
||||
return nil, io.EOF
|
||||
// EOF
|
||||
// Return error to signal closure, as per Scanner contract?
|
||||
// SDK usually expects error on close.
|
||||
return nil, fmt.Errorf("EOF")
|
||||
}
|
||||
line := c.scanner.Bytes()
|
||||
return jsonrpc.DecodeMessage(line)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue