Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/synapses/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/SynapsesOS/synapses/internal/parser"
"github.com/SynapsesOS/synapses/internal/peer"
"github.com/SynapsesOS/synapses/internal/resolver"
"github.com/SynapsesOS/synapses/internal/scout"
"github.com/SynapsesOS/synapses/internal/store"
"github.com/SynapsesOS/synapses/internal/watcher"
)
Expand Down Expand Up @@ -196,6 +197,35 @@ func cmdStart(args []string) error {
}
}

// Optional: connect to synapses-scout web-search service.
var scoutCli *scout.Client
if cfg.Scout.URL != "" {
scoutCli = scout.NewClient(cfg.Scout.URL, cfg.Scout.TimeoutSec)
if scoutCli.Health(context.Background()) {
fmt.Fprintf(os.Stderr, "synapses: scout connected at %s\n", cfg.Scout.URL)
srv.SetScoutClient(scoutCli)
} else {
fmt.Fprintf(os.Stderr, "synapses: scout unreachable at %s (continuing without)\n", cfg.Scout.URL)
scoutCli = nil
}
}

// Autosubscribe: detect tech stack from manifest files and optionally enrich
// with official doc URLs via scout. Runs in the background — does not block startup.
go func() {
entries := scout.DetectTechStack(absPath)
if len(entries) == 0 {
return
}
if scoutCli != nil {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
entries = scout.EnrichWithDocs(ctx, scoutCli, entries)
cancel()
}
srv.SetTechStack(entries)
fmt.Fprintf(os.Stderr, "synapses: tech stack detected (%d deps)\n", len(entries))
}()

// Start the file watcher so the graph stays current as files change.
if !*noWatch {
w := parser.NewWalker()
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ type Config struct {
// When set, get_context returns LLM-enriched Context Packets, violations
// include plain-English explanations, and file changes are auto-ingested.
Brain BrainConfig `json:"brain,omitempty"`

// Scout configures the optional synapses-scout integration.
// When set, web_search, web_fetch, and web_deep_search MCP tools become
// available, giving AI agents real-time web data through the scout sidecar.
Scout ScoutConfig `json:"scout,omitempty"`
}

// BrainConfig describes the connection to a synapses-intelligence sidecar.
Expand All @@ -130,6 +135,16 @@ type BrainConfig struct {
EnableLLM bool `json:"enable_llm,omitempty"`
}

// ScoutConfig describes the connection to a synapses-scout sidecar.
type ScoutConfig struct {
// URL is the base URL of the scout service, e.g. "http://localhost:11436".
// Leave empty to disable scout integration.
URL string `json:"url,omitempty"`
// TimeoutSec is the per-request HTTP timeout. Defaults to 30 if URL is set.
// Scout fetches real web pages so a generous timeout is required.
TimeoutSec int `json:"timeout_sec,omitempty"`
}

// PeerConfig describes a remote synapses peer instance to connect to.
type PeerConfig struct {
// Name is a short, unique identifier used in MCP tool params and env var
Expand Down Expand Up @@ -476,6 +491,12 @@ func (c *Config) applyDefaults() {
c.Brain.EnableLLM = true // default true when URL is provided
}
}
// Scout defaults: if URL is set but timeout is zero, apply sensible default.
if c.Scout.URL != "" {
if c.Scout.TimeoutSec <= 0 {
c.Scout.TimeoutSec = 30
}
}
}

func defaultConfig() *Config {
Expand Down
144 changes: 144 additions & 0 deletions internal/mcp/scout_tools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package mcp

import (
"context"
"fmt"

mcpgo "github.com/mark3labs/mcp-go/mcp"

"github.com/SynapsesOS/synapses/internal/scout"
)

// getScoutClient type-asserts the stored scoutClient to *scout.Client.
// Returns nil if no scout client is configured.
func (s *Server) getScoutClient() *scout.Client {
if s.scoutClient == nil {
return nil
}
sc, _ := s.scoutClient.(*scout.Client)
return sc
}

// handleWebSearch calls scout POST /v1/search and returns structured results.
func (s *Server) handleWebSearch(
ctx context.Context,
req mcpgo.CallToolRequest,
) (*mcpgo.CallToolResult, error) {
sc := s.getScoutClient()
if sc == nil {
return mcpgo.NewToolResultError("scout unavailable: configure scout.url in synapses.json"), nil
}

query, _ := req.Params.Arguments["query"].(string)
if query == "" {
return mcpgo.NewToolResultError("query is required"), nil
}

maxResults := 5
if v, ok := req.Params.Arguments["max_results"].(float64); ok && v > 0 {
maxResults = int(v)
}
region, _ := req.Params.Arguments["region"].(string)
timelimit, _ := req.Params.Arguments["timelimit"].(string)

resp := sc.Search(ctx, scout.SearchRequest{
Query: query,
MaxResults: maxResults,
Region: region,
Timelimit: timelimit,
})
if resp == nil {
return mcpgo.NewToolResultError("scout search failed: service unreachable or timed out"), nil
}

return jsonResult(map[string]interface{}{
"query": resp.Query,
"hits": resp.Hits,
"count": resp.Count,
})
}

// handleWebFetch calls scout POST /v1/fetch and returns extracted Markdown content.
// Input may be a URL or a plain-text query — scout auto-routes.
func (s *Server) handleWebFetch(
ctx context.Context,
req mcpgo.CallToolRequest,
) (*mcpgo.CallToolResult, error) {
sc := s.getScoutClient()
if sc == nil {
return mcpgo.NewToolResultError("scout unavailable: configure scout.url in synapses.json"), nil
}

input, _ := req.Params.Arguments["input"].(string)
if input == "" {
return mcpgo.NewToolResultError("input is required (URL or search query)"), nil
}

forceRefresh, _ := req.Params.Arguments["force_refresh"].(bool)

resp := sc.Fetch(ctx, scout.FetchRequest{
Input: input,
ForceRefresh: forceRefresh,
})
if resp == nil {
return mcpgo.NewToolResultError(fmt.Sprintf("scout fetch failed for input=%q: service unreachable or timed out", input)), nil
}

result := map[string]interface{}{
"url": resp.URL,
"title": resp.Title,
"content_type": resp.ContentType,
"content_md": resp.ContentMD,
"word_count": resp.WordCount,
"cached": resp.Cached,
}
if resp.Fragment != nil {
result["summary"] = resp.Fragment.Summary
result["tags"] = resp.Fragment.Tags
}

return jsonResult(result)
}

// handleWebDeepSearch calls scout POST /v1/deep-search for multi-query
// orchestrated search with fan-out and deduplication.
func (s *Server) handleWebDeepSearch(
ctx context.Context,
req mcpgo.CallToolRequest,
) (*mcpgo.CallToolResult, error) {
sc := s.getScoutClient()
if sc == nil {
return mcpgo.NewToolResultError("scout unavailable: configure scout.url in synapses.json"), nil
}

query, _ := req.Params.Arguments["query"].(string)
if query == "" {
return mcpgo.NewToolResultError("query is required"), nil
}

maxResults := 10
if v, ok := req.Params.Arguments["max_results"].(float64); ok && v > 0 {
maxResults = int(v)
}
region, _ := req.Params.Arguments["region"].(string)
timelimit, _ := req.Params.Arguments["timelimit"].(string)

resp := sc.DeepSearch(ctx, scout.DeepSearchRequest{
Query: query,
MaxResults: maxResults,
Region: region,
Timelimit: timelimit,
})
if resp == nil {
return mcpgo.NewToolResultError("scout deep-search failed: service unreachable or timed out"), nil
}

return jsonResult(map[string]interface{}{
"query": resp.Query,
"expanded_queries": resp.ExpandedQueries,
"hits": resp.Hits,
"count": resp.Count,
"total_raw_hits": resp.TotalRawHits,
"deduplicated": resp.DeduplicatedCount,
})
}
97 changes: 96 additions & 1 deletion internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type ChangeSource interface {

const (
serverName = "synapses"
serverVersion = "0.3.1"
serverVersion = "0.5.0"
)

// packetCacheEntry holds a cached context packet with an expiry time.
Expand All @@ -43,6 +43,8 @@ type Server struct {
changeSource ChangeSource // nil if started without a file watcher
peerManager interface{} // *peer.PeerManager — set via SetPeerManager; nil if no peers configured
brainClient interface{} // *brain.Client — set via SetBrainClient; nil if brain not configured
scoutClient interface{} // *scout.Client — set via SetScoutClient; nil if scout not configured
techStack interface{} // []scout.TechStackEntry — set via SetTechStack after autosubscribe
rulesMu sync.RWMutex // protects s.config.Rules for concurrent dynamic upserts

// Context-packet cache: 20 slots max, 30s TTL. Keyed by "entityName:depth".
Expand Down Expand Up @@ -182,6 +184,20 @@ func (s *Server) SetBrainClient(bc interface{}) {
s.brainClient = bc
}

// SetScoutClient wires a *scout.Client into the server so that web_search,
// web_fetch, and web_deep_search tools are functional.
// Using interface{} avoids an import cycle (scout imports only stdlib).
func (s *Server) SetScoutClient(sc interface{}) {
s.scoutClient = sc
}

// SetTechStack stores the detected tech stack entries ([]scout.TechStackEntry)
// so that get_project_identity can surface them as tech_stack.
// Called from cmdStart after autosubscribe detection completes.
func (s *Server) SetTechStack(ts interface{}) {
s.techStack = ts
}

// ServeStdio starts the MCP server on stdin/stdout. This call blocks until
// the client disconnects or the process receives a signal.
func (s *Server) ServeStdio() error {
Expand Down Expand Up @@ -626,4 +642,83 @@ func (s *Server) registerTools() {
s.handleGetWorkingState,
)

// ── Web Tools (requires synapses-scout sidecar) ──────────────────────────

// web_search
s.mcp.AddTool(
mcp.NewTool(
"web_search",
mcp.WithDescription(
"Searches the web via the synapses-scout sidecar and returns structured results. "+
"Use this to find framework docs, API references, error solutions, or any "+
"information that requires real-time data. "+
"Requires scout.url in synapses.json (default: http://localhost:11436).",
),
mcp.WithString("query",
mcp.Required(),
mcp.Description("The search query."),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum results to return. Default 5."),
),
mcp.WithString("region",
mcp.Description("Optional region code for localised results, e.g. 'us-en'."),
),
mcp.WithString("timelimit",
mcp.Description("Optional time limit, e.g. 'd' (day), 'w' (week), 'm' (month)."),
),
),
s.handleWebSearch,
)

// web_fetch
s.mcp.AddTool(
mcp.NewTool(
"web_fetch",
mcp.WithDescription(
"Fetches and extracts a URL (or performs a search if given a query) via "+
"synapses-scout, returning the content as Markdown. "+
"Use this to read documentation pages, GitHub READMEs, or any web content "+
"that an agent needs to reason about. "+
"Requires scout.url in synapses.json (default: http://localhost:11436).",
),
mcp.WithString("input",
mcp.Required(),
mcp.Description("A URL to fetch or a plain-text search query (scout auto-routes)."),
),
mcp.WithBoolean("force_refresh",
mcp.Description("When true, bypasses the scout cache and re-fetches. Default false."),
),
),
s.handleWebFetch,
)

// web_deep_search
s.mcp.AddTool(
mcp.NewTool(
"web_deep_search",
mcp.WithDescription(
"Performs an orchestrated multi-query search via synapses-scout: expands the "+
"original query into sub-queries, fans out searches, deduplicates, and returns "+
"a richer result set than web_search. Use this for research tasks where "+
"a single query is unlikely to capture all relevant results. "+
"Requires scout.url in synapses.json (default: http://localhost:11436).",
),
mcp.WithString("query",
mcp.Required(),
mcp.Description("The research query to expand and search."),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum deduplicated results to return. Default 10."),
),
mcp.WithString("region",
mcp.Description("Optional region code for localised results, e.g. 'us-en'."),
),
mcp.WithString("timelimit",
mcp.Description("Optional time limit, e.g. 'd' (day), 'w' (week), 'm' (month)."),
),
),
s.handleWebDeepSearch,
)

}
4 changes: 4 additions & 0 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ func (s *Server) handleGetProjectIdentity(
"7. release_claims → free scope when done",
},
}
// Autosubscribe: surface detected tech stack (populated by cmdStart after indexing).
if s.techStack != nil {
out["tech_stack"] = s.techStack
}
return jsonResult(out)
}

Expand Down
Loading
Loading