package mcp import ( "context" "encoding/json" "fmt" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/llm" "github.com/zzet/gortex/internal/llm/conversationlog" ) // registerLLMTools registers the `ask` MCP tool when an LLM service // has been attached via SetLLMService and the service is enabled // (model path configured). When either is missing, the tool is // silently absent from tools/list — clean degradation for builds / // deployments without an LLM. func (s *Server) registerLLMTools() { if s.llmService == nil || !s.llmService.Enabled() { return } s.addTool( mcp.NewTool("ask", mcp.WithDescription("Ask a research agent to navigate the gortex graph and return a synthesized answer. The agent runs on whichever LLM provider is configured (`llm.provider`): an in-process llama.cpp model, or a hosted Anthropic / OpenAI / Ollama backend. Use this instead of issuing many search_symbols / get_callers / contracts calls yourself when the question is open-ended or requires multi-hop reasoning across repos — the agent does that work and returns a filtered answer. Set chain=true for cross-system call-chain tracing (consumer → contract → provider → downstream). When `llm.routing` is enabled the agent is dispatched to a cheaper or more capable model by task complexity; the chosen `model` and `complexity` ride on the response."), mcp.WithString("question", mcp.Required(), mcp.Description("Natural-language question about the indexed codebase. Examples: \"who calls NewServer in the mcp package?\", \"trace the path from web's /v1/stats consumer to the gortex handler\".")), mcp.WithString("repo", mcp.Description("Optional repo-prefix scope (e.g. \"gortex-cloud\"). Restricts the agent's tool calls to one repo. Leave empty for cross-repo questions.")), mcp.WithString("project", mcp.Description("Optional project scope.")), mcp.WithString("ref", mcp.Description("Optional ref tag scope.")), mcp.WithBoolean("chain", mcp.Description("Enable cross-system chain mode: gives the agent the contracts + get_dependencies tools and a chain-tracing prompt. Use when the question is about how a request flows across repos. Default false.")), mcp.WithBoolean("include_transcript", mcp.Description("Include the agent's full step-by-step transcript in the response. Useful for debugging the agent's reasoning. Default false (compact response).")), ), s.handleAsk, ) } // handleAsk delegates a single MCP `ask` invocation to the LLM // service's RunAgent. The agent's typed AgentAnswer is JSON-marshaled // into the MCP text content block — that's the same shape the // existing handlers use for structured responses. func (s *Server) handleAsk(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { if s.llmService == nil || !s.llmService.Enabled() { return mcp.NewToolResultError("llm: service is not configured on this server"), nil } args := req.GetArguments() question, _ := args["question"].(string) repo, _ := args["repo"].(string) project, _ := args["project"].(string) ref, _ := args["ref"].(string) chain, _ := boolArg(args, "chain") includeTranscript, _ := boolArg(args, "include_transcript") // Label the turn for the conversation-log sink (no-op unless the sink // is opted in): the session id + scoped repo + the "ask" phase. sessionRepo := repo if sessionRepo == "" { sessionRepo, _ = s.sessionLocality(ctx) } ctx = conversationlog.WithMeta(ctx, conversationlog.Meta{ Session: SessionIDFromContext(ctx), Repo: sessionRepo, Phase: "ask", }) answer, err := s.llmService.RunAgent(ctx, llm.RunAgentOptions{ Question: question, Scope: llm.Scope{Repo: repo, Project: project, Ref: ref}, Chain: chain, IncludeTranscript: includeTranscript, }) if err != nil && answer == nil { return mcp.NewToolResultError(fmt.Sprintf("llm: %v", err)), nil } out, mErr := json.MarshalIndent(answer, "", " ") if mErr != nil { return mcp.NewToolResultError(fmt.Sprintf("llm: marshal answer: %v", mErr)), nil } return mcp.NewToolResultText(string(out)), nil }