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

190 lines
7.3 KiB
Go

package mcp
import (
"context"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/docs"
"github.com/zzet/gortex/internal/wiki"
)
// registerWikiTools registers `generate_wiki` and `generate_docs`.
// Wired from server.go alongside the other tool registries.
func (s *Server) registerWikiTools() {
s.addTool(
mcp.NewTool("generate_wiki",
mcp.WithDescription("Generate a markdown wiki of the indexed graph: per-community pages, process pages with Mermaid sequenceDiagrams, contracts, hotspots, cycles, semantic. Output is written under output_dir (default 'wiki'); the per-repo index page content is returned inline for quick preview."),
mcp.WithString("output_dir", mcp.Description("Output directory (default: wiki)")),
mcp.WithString("format", mcp.Description("markdown (default) | html")),
mcp.WithString("repo", mcp.Description("Per-repo slug under wiki/ (default: 'repo')")),
mcp.WithString("project", mcp.Description("Project label (multi-repo mode hint)")),
mcp.WithString("workspace", mcp.Description("Restrict emitted nodes to this WorkspaceID")),
mcp.WithNumber("min_community", mcp.Description("Minimum community size to document (default: 3)")),
mcp.WithNumber("max_communities", mcp.Description("Max communities to document (default: 20)")),
mcp.WithBoolean("no_processes", mcp.Description("Skip process pages")),
mcp.WithBoolean("no_contracts", mcp.Description("Skip contracts page")),
mcp.WithBoolean("no_docs", mcp.Description("Skip docs bundle inside the wiki")),
mcp.WithBoolean("force", mcp.Description("Suppress 'already exists' diagnostics (writer is always idempotent)")),
mcp.WithBoolean("wikilinks", mcp.Description("Use [[wikilink]] style links")),
mcp.WithBoolean("enhance", mcp.Description("Enrich narrative sections via the daemon's configured LLM provider (no-op when no provider is configured)")),
),
s.handleGenerateWiki,
)
s.addTool(
mcp.NewTool("generate_docs",
mcp.WithDescription("Generate a 'living docs' bundle: recent file changes, per-author ownership, stale code older than 365 days, blame summary. Returns markdown (default) or JSON inline; pass output_path to write to disk instead."),
mcp.WithString("output_path", mcp.Description("Path to write the bundle to; omit to return inline")),
mcp.WithString("format", mcp.Description("markdown (default) | json")),
mcp.WithString("since", mcp.Description("Window for recent changes (Go duration string, e.g. 24h, 7d). Default 168h.")),
mcp.WithNumber("top", mcp.Description("Cap each section's row count (default: 20)")),
mcp.WithString("include", mcp.Description("Comma-separated section list: recent,ownership,stale,blame")),
mcp.WithString("path_prefix", mcp.Description("Filter ownership/stale rows to this file prefix")),
mcp.WithString("workspace", mcp.Description("Restrict nodes to this WorkspaceID")),
mcp.WithBoolean("run_blame", mcp.Description("Re-run git blame across indexed repos before rendering")),
),
s.handleGenerateDocs,
)
}
func (s *Server) handleGenerateWiki(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
opts := wiki.Options{
OutputDir: stringArgOrDefault(args, "output_dir", "wiki"),
Format: stringArgOrDefault(args, "format", "markdown"),
Wikilinks: boolArgValue(args, "wikilinks"),
Repo: stringArgOrDefault(args, "repo", "repo"),
Project: stringArg(args, "project"),
WorkspaceID: stringArg(args, "workspace"),
MinCommunity: intArgOrDefault(args, "min_community", 3),
MaxCommunities: intArgOrDefault(args, "max_communities", 20),
NoProcesses: boolArgValue(args, "no_processes"),
NoContracts: boolArgValue(args, "no_contracts"),
NoDocs: boolArgValue(args, "no_docs"),
Force: boolArgValue(args, "force"),
}
g := s.graph
if g == nil {
return mcp.NewToolResultError("wiki: graph is not initialised"), nil
}
communities := analysis.DetectCommunities(g)
processes := analysis.DiscoverProcesses(g)
hotspots := analysis.FindHotspots(g, communities, 0)
cycles := analysis.DetectCycles(g, communities, "")
var contractList []contracts.Contract
if reg := s.effectiveContractRegistry(); reg != nil {
contractList = reg.All()
}
// Optional docs bundle inside the wiki.
var docsMarkdown string
if !opts.NoDocs {
bundle, err := docs.Generate(docs.Deps{Graph: g, History: s.docsHistoryProvider()}, docs.Options{
WorkspaceID: opts.WorkspaceID,
})
if err == nil {
docsMarkdown = docs.RenderMarkdown(bundle)
}
}
// LLM narrative enhancement reuses the daemon's already-constructed
// provider; a no-op when no provider is configured.
if boolArgValue(args, "enhance") && s.llmService != nil && s.llmService.Enabled() {
opts.Enhance = true
opts.Enhancer = wiki.NewClaudeCLIEnhancer(s.llmService.Provider(), wiki.NewEnhanceCache(wiki.DefaultEnhanceCacheDir()))
}
gen := wiki.New(wiki.Inputs{
Graph: g,
Communities: communities,
Processes: processes,
Hotspots: hotspots,
Cycles: cycles,
Contracts: contractList,
DocsBundle: docsMarkdown,
}, opts)
result, _, err := gen.Generate(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"output_dir": result.OutputDir,
"files": result.Files,
"index_md_content": result.IndexMarkdown,
"repo_index_md_content": result.RepoIndexMarkdown,
"file_count": len(result.Files),
})
}
func (s *Server) handleGenerateDocs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
g := s.graph
if g == nil {
return mcp.NewToolResultError("docs: graph is not initialised"), nil
}
include := stringArg(args, "include")
var sections []string
if include != "" {
for _, part := range strings.Split(include, ",") {
p := strings.TrimSpace(strings.ToLower(part))
if p != "" {
sections = append(sections, p)
}
}
}
sinceDur, _ := parseDurationArg(args, "since")
opts := docs.Options{
Since: sinceDur,
Top: intArgOrDefault(args, "top", 20),
Sections: sections,
PathPrefix: stringArg(args, "path_prefix"),
WorkspaceID: stringArg(args, "workspace"),
IncludeBlame: boolArgValue(args, "run_blame"),
}
bundle, err := docs.Generate(docs.Deps{Graph: g, History: s.docsHistoryProvider()}, opts)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
format := stringArgOrDefault(args, "format", "markdown")
output := ""
if format == "json" {
data, jerr := docs.RenderJSON(bundle)
if jerr != nil {
return mcp.NewToolResultError(jerr.Error()), nil
}
output = string(data)
} else {
output = docs.RenderMarkdown(bundle)
}
outputPath := stringArg(args, "output_path")
if outputPath != "" {
if err := writeWikiFile(outputPath, []byte(output)); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("write %q: %v", outputPath, err)), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"output_path": outputPath,
"bytes": len(output),
"sections": bundle.Sections,
})
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"content": output,
"bytes": len(output),
"sections": bundle.Sections,
"format": format,
})
}