Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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)
}
}