a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
171 lines
6.5 KiB
Go
171 lines
6.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/exporter"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
func (s *Server) registerExportTools() {
|
|
s.addTool(
|
|
mcp.NewTool("export_graph",
|
|
mcp.WithDescription("Export the graph to Cypher (Neo4j/Memgraph), GraphML (Gephi/yEd/Cytoscape), or Mermaid. With output_path / output_dir the daemon writes the export to disk; otherwise it is returned inline."),
|
|
mcp.WithString("format", mcp.Description("cypher (default) | graphml | mermaid")),
|
|
mcp.WithString("output_path", mcp.Description("Absolute path to write the export to; omit to return inline")),
|
|
mcp.WithString("output_dir", mcp.Description("Absolute directory for mermaid scope=all (one file per scope)")),
|
|
mcp.WithString("repo", mcp.Description("Filter to one repo prefix (default: all)")),
|
|
mcp.WithString("kinds", mcp.Description("Comma-separated node kinds to include (default: all)")),
|
|
mcp.WithString("languages", mcp.Description("Comma-separated languages to include (default: all)")),
|
|
mcp.WithBoolean("no_synthetic", mcp.Description("Drop synthetic stub nodes for unresolved/external endpoints (default: keep them)")),
|
|
mcp.WithString("scope", mcp.Description("(mermaid) architecture (default) | communities | processes | all")),
|
|
mcp.WithNumber("min_community", mcp.Description("(mermaid) minimum community size (default: 3)")),
|
|
mcp.WithNumber("max_communities", mcp.Description("(mermaid) maximum communities (default: 20)")),
|
|
),
|
|
s.handleExportGraph,
|
|
)
|
|
}
|
|
|
|
// confineCallerPaths reports whether caller-supplied filesystem paths in the
|
|
// current request must be confined to an indexed repository root.
|
|
//
|
|
// It is true for an MCP agent session — the prompt-injection surface, where a
|
|
// tool's path argument can be attacker-influenced — and false for the local
|
|
// control / CLI channel, which is operator-driven and may write anywhere it
|
|
// likes (e.g. `gortex export --out /any/path`).
|
|
//
|
|
// The discriminator is the session cwd: the MCP proxy stamps every agent
|
|
// session with the connecting client's working directory (daemon.Handshake.CWD)
|
|
// before any tool call, and an agent cannot clear it from a tool-call frame.
|
|
// The control / CLI channel and the embedded stdio server carry none.
|
|
func (s *Server) confineCallerPaths(ctx context.Context) bool {
|
|
return SessionCWDFromContext(ctx) != ""
|
|
}
|
|
|
|
func (s *Server) handleExportGraph(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
g := s.graph
|
|
if g == nil {
|
|
return mcp.NewToolResultError("export: graph is not initialised"), nil
|
|
}
|
|
args := req.GetArguments()
|
|
format := strings.ToLower(stringArgOrDefault(args, "format", "cypher"))
|
|
|
|
opts := exporter.Options{
|
|
Repo: stringArg(args, "repo"),
|
|
Languages: splitCSVArg(stringArg(args, "languages")),
|
|
DropSynthetic: boolArgValue(args, "no_synthetic"),
|
|
}
|
|
for _, k := range splitCSVArg(stringArg(args, "kinds")) {
|
|
opts.Kinds = append(opts.Kinds, graph.NodeKind(strings.ToLower(k)))
|
|
}
|
|
mermaidOpts := exporter.MermaidOpts{
|
|
Scope: stringArgOrDefault(args, "scope", "architecture"),
|
|
MaxCommunities: intArgOrDefault(args, "max_communities", 20),
|
|
MinCommunity: intArgOrDefault(args, "min_community", 3),
|
|
Kinds: opts.Kinds,
|
|
Languages: opts.Languages,
|
|
}
|
|
|
|
outputPath := stringArg(args, "output_path")
|
|
outputDir := stringArg(args, "output_dir")
|
|
|
|
// Confine caller-named output paths to indexed repository roots when the
|
|
// request comes from an MCP agent — a prompt-injected agent must not be
|
|
// able to drive the daemon into writing (or MkdirAll-creating) a tree
|
|
// outside any indexed repo. The local CLI / control channel is
|
|
// operator-driven and exempt: `gortex export --out/--out-dir` asks for the
|
|
// write in the user's own name and may target any path.
|
|
if s.confineCallerPaths(ctx) {
|
|
for _, p := range []string{outputPath, outputDir} {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("export: resolve output path %q: %v", p, err)), nil
|
|
}
|
|
if err := s.guardSymlinkWithinRepo(abs); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mermaid multi-file: one file per scope under output_dir.
|
|
if format == "mermaid" && outputDir != "" {
|
|
scopes := []string{"architecture", "communities", "processes"}
|
|
if mermaidOpts.Scope != "all" {
|
|
scopes = []string{mermaidOpts.Scope}
|
|
}
|
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("mkdir out-dir: %v", err)), nil
|
|
}
|
|
var total exporter.Stats
|
|
for _, sc := range scopes {
|
|
scopeOpts := mermaidOpts
|
|
scopeOpts.Scope = sc
|
|
p := filepath.Join(outputDir, sc+".mermaid")
|
|
f, err := os.Create(p)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("create %q: %v", p, err)), nil
|
|
}
|
|
st, werr := exporter.WriteMermaid(f, g, scopeOpts)
|
|
_ = f.Close()
|
|
if werr != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("write mermaid %s: %v", sc, werr)), nil
|
|
}
|
|
total.NodesWritten += st.NodesWritten
|
|
total.EdgesWritten += st.EdgesWritten
|
|
total.BytesWritten += st.BytesWritten
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"output_dir": outputDir,
|
|
"files": len(scopes),
|
|
"nodes": total.NodesWritten,
|
|
"edges": total.EdgesWritten,
|
|
"bytes": total.BytesWritten,
|
|
})
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
st, err := exportByFormat(&buf, g, format, opts, mermaidOpts)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if outputPath != "" {
|
|
if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("write %q: %v", outputPath, err)), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"output_path": outputPath,
|
|
"nodes": st.NodesWritten,
|
|
"edges": st.EdgesWritten,
|
|
"bytes": st.BytesWritten,
|
|
})
|
|
}
|
|
// Inline (stdout) — the rendered export is the text content.
|
|
return mcp.NewToolResultText(buf.String()), nil
|
|
}
|
|
|
|
|
|
|
|
func exportByFormat(w io.Writer, g graph.Store, format string, opts exporter.Options, mermaidOpts exporter.MermaidOpts) (exporter.Stats, error) {
|
|
switch format {
|
|
case "cypher":
|
|
return exporter.WriteCypher(w, g, opts)
|
|
case "graphml":
|
|
return exporter.WriteGraphML(w, g, opts)
|
|
case "mermaid":
|
|
return exporter.WriteMermaid(w, g, mermaidOpts)
|
|
default:
|
|
return exporter.Stats{}, fmt.Errorf("unknown format %q (expected cypher | graphml | mermaid)", format)
|
|
}
|
|
}
|