package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"gitcode-mcp/internal/cache"
"gitcode-mcp/internal/config"
"gitcode-mcp/internal/gitcode"
"gitcode-mcp/internal/service"
"gitcode-mcp/internal/servicectl"
)
type repoStatusArgs struct {
RepoID string `json:"repo_id"`
}
type repoStatusResult struct {
RepoID string `json:"repo_id,omitempty"`
SelectedRepoID string `json:"selected_repo_id,omitempty"`
SuggestedRepoID string `json:"suggested_repo_id,omitempty"`
AvailableBindings []string `json:"available_bindings,omitempty"`
BindingState string `json:"binding_state"`
Runtime RuntimeContext `json:"runtime"`
Status *service.RepositoryStatus `json:"status,omitempty"`
Diagnostics []lifecycleDiagnostic `json:"diagnostics,omitempty"`
}
type lifecycleDiagnostic struct {
Code string `json:"code"`
ErrorClass string `json:"error_class,omitempty"`
Message string `json:"message"`
Remediation string `json:"remediation,omitempty"`
}
func (s *Server) callRepoStatus(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
var a repoStatusArgs
if err := json.Unmarshal(args, &a); err != nil {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "arguments must be a valid object"})
return
}
if strings.TrimSpace(a.RepoID) == "" {
result := repoStatusResult{BindingState: "nothing_bound", Runtime: s.runtimeContext, Diagnostics: []lifecycleDiagnostic{{Code: "nothing_bound", Message: "no repository binding requested", Remediation: "retry repo_status with repo_id set to the stable owner/name binding; CLI fallback: gitcode-mcp repo status --repo OWNER/NAME --format json"}}}
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: repoStatusText(result)}}, StructuredContent: result})
return
}
selected := strings.TrimSpace(a.RepoID)
status, err := s.svc.RepositoryStatus(ctx, service.RepositoryStatusRequest{RepoID: selected})
if err != nil {
if service.IsNotFound(err) {
diagnostic := missingBindingDiagnostic(selected, status)
result := repoStatusResult{RepoID: selected, SelectedRepoID: selected, SuggestedRepoID: status.SuggestedRepoID, AvailableBindings: status.AvailableBindings, BindingState: "missing", Runtime: s.runtimeContext, Status: &status, Diagnostics: []lifecycleDiagnostic{diagnostic}}
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: repoStatusText(result)}}, StructuredContent: result})
return
}
s.writeOperationalError(id, err, domainErrorContext{Operation: "repo_status", RepoID: selected})
return
}
result := repoStatusResult{RepoID: status.RepoID, SelectedRepoID: selected, BindingState: status.BindingState, Runtime: s.runtimeContext, Status: &status}
text := fmt.Sprintf(
"binding_state=%s binary_version=%s cache_schema=%d/%d issue_records=%d issue_comments=%d",
status.BindingState,
status.BinaryVersion,
status.CacheSchemaVersion,
status.ExpectedCacheSchemaVersion,
status.IssueRecords,
status.IssueComments,
)
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
}
func repoStatusText(result repoStatusResult) string {
parts := []string{"binding_state=" + result.BindingState}
if result.SelectedRepoID != "" {
parts = append(parts, "selected_repo_id="+result.SelectedRepoID)
}
if result.SuggestedRepoID != "" {
parts = append(parts, "suggested_repo_id="+result.SuggestedRepoID)
}
if result.Runtime.EffectiveCachePath != "" {
parts = append(parts, "effective_cache_path="+result.Runtime.EffectiveCachePath)
}
if result.Runtime.ConfigReference != "" {
parts = append(parts, "config_reference="+result.Runtime.ConfigReference)
}
if len(result.AvailableBindings) > 0 {
parts = append(parts, "available_bindings="+strings.Join(result.AvailableBindings, ","))
}
return strings.Join(parts, " ")
}
func missingBindingDiagnostic(selected string, status service.RepositoryStatus) lifecycleDiagnostic {
name := strings.TrimSpace(selected)
if strings.Contains(name, "/") {
name = "REPO"
}
remediation := fmt.Sprintf("add or select the stable owner/name binding; CLI fallback: gitcode-mcp repo add --repo OWNER/%s --owner OWNER --name %s --scopes issues,wiki", name, name)
if status.SuggestedRepoID != "" {
remediation = fmt.Sprintf("retry repo_status with repo_id=%q and use that stable repo_id for subsequent MCP calls; CLI fallback: gitcode-mcp repo status --repo %q --format json", status.SuggestedRepoID, status.SuggestedRepoID)
} else if owner, name, ok := strings.Cut(selected, "/"); ok && strings.TrimSpace(owner) != "" && strings.TrimSpace(name) != "" {
remediation = fmt.Sprintf("add the selected repository binding; CLI fallback: gitcode-mcp repo add --repo %q --owner %q --name %q --scopes issues,wiki", selected, strings.TrimSpace(owner), strings.TrimSpace(name))
} else if len(status.AvailableBindings) > 0 {
remediation = "retry repo_status with the intended stable owner/name repo_id from available_bindings; if none is correct, CLI fallback: gitcode-mcp repo add --repo OWNER/REPO --owner OWNER --name REPO --scopes issues,wiki"
}
return lifecycleDiagnostic{Code: "missing_repository_binding", ErrorClass: "missing_repository_binding", Message: fmt.Sprintf("selected repository binding %q is not configured in the effective cache", selected), Remediation: remediation}
}
type syncLiveArgs struct {
RepoID string `json:"repo_id"`
Issues bool `json:"issues,omitempty"`
Wiki bool `json:"wiki,omitempty"`
Comments bool `json:"comments,omitempty"`
IssueComments bool `json:"issue_comments,omitempty"`
PRComments bool `json:"pr_comments,omitempty"`
Pulls bool `json:"pulls,omitempty"`
RemoteAlias string `json:"remote_alias,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
MaxPages int `json:"max_pages,omitempty"`
MaxRecords int `json:"max_records,omitempty"`
PerPage int `json:"per_page,omitempty"`
Daemon bool `json:"daemon,omitempty"`
Detach bool `json:"detach,omitempty"`
}
type syncLiveCommentSelection struct {
Issue bool
PR bool
}
type syncLiveResult struct {
RepoID string `json:"repo_id"`
Collections []string `json:"collections"`
FreshCount int `json:"fresh_count"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
Results []service.SyncResult `json:"results,omitempty"`
Failures []service.ResourceError `json:"failures,omitempty"`
IssueComments *service.IssueCommentQueueSummary `json:"issue_comments,omitempty"`
Job *servicectl.Job `json:"job,omitempty"`
Diagnostics []lifecycleDiagnostic `json:"diagnostics,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}
func (s *Server) callSyncLive(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
var a syncLiveArgs
if err := json.Unmarshal(args, &a); err != nil {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "arguments must be a valid object"})
return
}
if strings.TrimSpace(a.RepoID) == "" {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "repo_id is required"})
return
}
if a.MaxPages < 0 || a.MaxRecords < 0 || a.PerPage < 0 {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "bounds must be non-negative"})
return
}
commentSelection, message := resolveSyncLiveCommentSelection(a)
if message != "" {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: message})
return
}
if message := syncLiveCommentSurfaceError(a); message != "" {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: message})
return
}
if message := syncLiveTargetError(a); message != "" {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: message})
return
}
selected := syncLiveCollections(a, commentSelection)
result := syncLiveResult{RepoID: a.RepoID, Collections: selected, GeneratedAt: time.Now().UTC()}
if a.Daemon || a.Detach {
if strings.TrimSpace(a.RemoteAlias) != "" {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "daemon sync_live supports collection selectors only; omit remote_alias"})
return
}
if len(result.Collections) == 0 {
result.Collections = []string{"issues", "wiki"}
}
client, err := serviceRPCClient()
if err != nil {
s.writeOperationalError(id, err, domainErrorContext{Operation: "sync_live", RepoID: a.RepoID, Subsystem: "service"})
return
}
var job servicectl.Job
if err := client.Call(ctx, "Jobs.StartSync", syncLiveJobRequest(a, commentSelection), &job); err != nil {
s.writeOperationalError(id, err, domainErrorContext{Operation: "sync_live", RepoID: a.RepoID, Subsystem: "service"})
return
}
result.Job = &job
text := fmt.Sprintf("job=%s status=%s collections=%s", job.ID, job.Status, strings.Join(result.Collections, ","))
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
return
}
if mode, ok := lifecycleProviderMode(s.svc); ok && mode != gitcode.ProviderModeLive {
if len(result.Collections) == 0 {
result.Collections = []string{"issues", "wiki"}
}
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{
Code: "mcp_live_not_configured",
ErrorClass: "configuration_error",
Message: "sync_live requires a live GitCode provider, but this MCP server is using " + string(mode),
Remediation: "restart the MCP server with live credentials available or pass --live explicitly",
})
text := fmt.Sprintf("fresh_count=0 collections=%s", strings.Join(result.Collections, ","))
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
return
}
if strings.TrimSpace(a.RemoteAlias) != "" {
if commentSelection.PR && syncLiveRemoteAliasSurface(a.RemoteAlias) == "pull_request" {
part, err := s.svc.BulkSyncPRComments(ctx, service.BulkSyncRequest{RepoID: a.RepoID, RemoteAlias: strings.TrimSpace(a.RemoteAlias), IdempotencyKey: strings.TrimSpace(a.IdempotencyKey)})
appendBulkSyncResult(&result, part)
result.Collections = []string{"pr_comments"}
if err != nil {
var contention cache.ErrLockContention
if part == nil && errors.As(err, &contention) {
s.writeOperationalError(id, err, domainErrorContext{Operation: "sync_live", RepoID: a.RepoID})
return
}
if partial, ok := extractLifecyclePartial(err); ok && partial.Diagnostic != "" {
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{Code: string(partial.Diagnostic), Message: "pr_comments sync returned a diagnostic"})
} else {
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{Code: "partial_sync", Message: err.Error()})
}
}
text := fmt.Sprintf("fresh_count=%d collections=pr_comments", result.FreshCount)
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
return
}
key := strings.TrimSpace(a.IdempotencyKey)
if key == "" {
key = fmt.Sprintf("mcp-sync-live-remote-%d", time.Now().UTC().UnixNano())
}
syncResult, err := s.svc.SyncToCache(ctx, service.SyncRequest{RepoID: a.RepoID, RemoteAlias: strings.TrimSpace(a.RemoteAlias), IdempotencyKey: key})
if err != nil {
s.writeOperationalError(id, err, domainErrorContext{Operation: "sync_live", RepoID: a.RepoID})
return
}
result.Results = append(result.Results, syncResult)
result.SuccessCount = 1
if syncResult.Freshness == service.FreshnessFresh || syncResult.Status == "succeeded" || syncResult.Status == "ok" {
result.FreshCount++
}
text := fmt.Sprintf("fresh_count=%d collections=remote_alias", result.FreshCount)
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
return
}
defaultCollections := len(selected) == 0
if len(selected) == 0 {
selected = []string{"issues", "wiki"}
result.Collections = selected
}
req := syncLiveBulkRequest(a)
var syncErr error
var admissionErr error
runBulk := func(collection string, fn func(context.Context, service.BulkSyncRequest) (*service.SyncResourcesResult, error)) {
if admissionErr != nil {
return
}
part, err := fn(ctx, req)
appendBulkSyncResult(&result, part)
if err != nil {
var contention cache.ErrLockContention
if part == nil && errors.As(err, &contention) {
admissionErr = err
return
}
syncErr = mergeLifecycleSyncError(syncErr, &result, err)
if partial, ok := extractLifecyclePartial(err); ok && partial.Diagnostic != "" {
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{Code: string(partial.Diagnostic), Message: collection + " sync returned a diagnostic"})
}
}
}
switch {
case a.Issues && a.Wiki && !a.Pulls && !commentSelection.Issue && !commentSelection.PR:
runBulk("all", s.svc.BulkSyncAll)
default:
if a.Issues || defaultCollections {
runBulk("issues", s.svc.BulkSyncIssues)
}
if commentSelection.Issue {
runBulk("issue_comments", s.svc.BulkSyncIssueComments)
}
if a.Wiki || defaultCollections {
runBulk("wiki", s.svc.BulkSyncWiki)
}
if a.Pulls {
runBulk("pulls", s.svc.BulkSyncPullRequests)
}
if commentSelection.PR {
runBulk("pr_comments", s.svc.BulkSyncPRComments)
}
}
if admissionErr != nil {
s.writeOperationalError(id, admissionErr, domainErrorContext{Operation: "sync_live", RepoID: a.RepoID})
return
}
if syncErr != nil {
if partial, ok := extractLifecyclePartial(syncErr); ok && partial.Diagnostic != "" {
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{Code: string(partial.Diagnostic), Message: "sync_live completed with a diagnostic"})
} else {
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{Code: "partial_sync", Message: syncErr.Error()})
}
}
text := fmt.Sprintf("fresh_count=%d collections=%s", result.FreshCount, strings.Join(result.Collections, ","))
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
}
func syncLiveBulkRequest(a syncLiveArgs) service.BulkSyncRequest {
perPage := a.PerPage
if perPage <= 0 {
perPage = 25
}
req := service.BulkSyncRequest{RepoID: a.RepoID, IdempotencyKey: strings.TrimSpace(a.IdempotencyKey), PerPage: perPage}
if a.MaxPages > 0 || a.MaxRecords > 0 {
req.Bounds = &service.SyncBounds{MaxPages: a.MaxPages, MaxRecords: a.MaxRecords}
}
return req
}
func syncLiveJobRequest(a syncLiveArgs, comments syncLiveCommentSelection) servicectl.StartSyncJobRequest {
return servicectl.StartSyncJobRequest{
RepoID: strings.TrimSpace(a.RepoID),
ProviderMode: string(gitcode.ProviderModeLive),
Issues: a.Issues,
Wiki: a.Wiki,
Pulls: a.Pulls,
IssueComments: comments.Issue,
PRComments: comments.PR,
IdempotencyKey: strings.TrimSpace(a.IdempotencyKey),
MaxPages: a.MaxPages,
MaxRecords: a.MaxRecords,
PerPage: a.PerPage,
}
}
func appendBulkSyncResult(dst *syncLiveResult, src *service.SyncResourcesResult) {
if dst == nil || src == nil {
return
}
dst.Results = append(dst.Results, src.Results...)
dst.Failures = append(dst.Failures, src.Failures...)
dst.SuccessCount = len(dst.Results)
dst.FailureCount = len(dst.Failures)
if src.IssueComments != nil {
queue := *src.IssueComments
dst.IssueComments = &queue
}
for _, syncResult := range src.Results {
if syncResult.Freshness == service.FreshnessFresh || syncResult.Status == "succeeded" || syncResult.Status == "ok" {
dst.FreshCount++
}
}
}
func mergeLifecycleSyncError(existing error, result *syncLiveResult, err error) error {
if err == nil {
return existing
}
if existing == nil {
return err
}
if result == nil {
return existing
}
return &service.PartialSyncError{Errors: result.Failures, SuccessCount: result.SuccessCount, FailureCount: result.FailureCount}
}
func extractLifecyclePartial(err error) (*service.PartialSyncError, bool) {
var partial *service.PartialSyncError
if err != nil && errors.As(err, &partial) {
return partial, true
}
return nil, false
}
func syncLiveCollections(a syncLiveArgs, comments syncLiveCommentSelection) []string {
var selected []string
if a.Issues {
selected = append(selected, "issues")
}
if comments.Issue {
selected = append(selected, "issue_comments")
}
if a.Wiki {
selected = append(selected, "wiki")
}
if a.Pulls {
selected = append(selected, "pulls")
}
if comments.PR {
selected = append(selected, "pr_comments")
}
return selected
}
func resolveSyncLiveCommentSelection(a syncLiveArgs) (syncLiveCommentSelection, string) {
selection := syncLiveCommentSelection{Issue: a.IssueComments, PR: a.PRComments}
if !a.Comments {
return selection, ""
}
if a.IssueComments || a.PRComments {
return syncLiveCommentSelection{}, "comments cannot be combined with issue_comments or pr_comments; use the explicit selectors only"
}
if strings.TrimSpace(a.RemoteAlias) != "" {
switch syncLiveRemoteAliasSurface(a.RemoteAlias) {
case "issue":
selection.Issue = true
case "pull_request":
selection.PR = true
}
return selection, ""
}
if a.Issues && a.Pulls {
return syncLiveCommentSelection{}, "comments is ambiguous when both issues and pulls are selected; use issue_comments=true and/or pr_comments=true"
}
if a.Issues {
selection.Issue = true
return selection, ""
}
selection.PR = true
return selection, ""
}
func syncLiveCommentSurfaceError(a syncLiveArgs) string {
if strings.TrimSpace(a.RemoteAlias) == "" || !(a.Comments || a.IssueComments || a.PRComments) {
return ""
}
if a.Issues || a.Wiki || a.Pulls {
return "comment sync with remote_alias cannot be combined with collection selectors; use remote_alias issue:N alone or run a collection sync without remote_alias"
}
switch syncLiveRemoteAliasSurface(a.RemoteAlias) {
case "issue":
if a.PRComments {
return "pr_comments cannot target issue aliases; use issue_comments=true or comments=true with issue:N"
}
return ""
case "pull_request":
if a.IssueComments {
return "issue_comments cannot target pull request aliases; use pr_comments=true with pr:N"
}
return ""
default:
return "comment sync with remote_alias supports issue:N or pr:N"
}
}
func syncLiveTargetError(a syncLiveArgs) string {
if strings.TrimSpace(a.RemoteAlias) == "" {
return ""
}
if a.MaxPages > 0 || a.MaxRecords > 0 || a.PerPage > 0 {
return "max_pages, max_records, and per_page apply to collection sync only; omit them for an exact remote_alias sync"
}
selected := ""
selectedCount := 0
if a.Issues {
selected, selectedCount = "issue", selectedCount+1
}
if a.Wiki {
selected, selectedCount = "wiki", selectedCount+1
}
if a.Pulls {
selected, selectedCount = "pull_request", selectedCount+1
}
if selectedCount == 0 {
return ""
}
if selectedCount > 1 {
return "an exact remote_alias target can have at most one matching collection selector"
}
surface := syncLiveRemoteAliasSurface(a.RemoteAlias)
if surface == "" {
return "collection selectors require a matching issue:N, wiki:SLUG, or pr:N remote_alias"
}
if selected != surface {
return fmt.Sprintf("remote_alias targets %s but the selected collection is %s", surface, selected)
}
return ""
}
func syncLiveRemoteAliasSurface(alias string) string {
remoteType, _, ok := strings.Cut(strings.TrimSpace(alias), ":")
if !ok {
return ""
}
switch strings.ToLower(strings.TrimSpace(remoteType)) {
case "issue", "issues":
return "issue"
case "wiki", "page", "remote":
return "wiki"
case "pull_request", "pull", "pulls", "pr":
return "pull_request"
default:
return ""
}
}
func lifecycleProviderMode(svc serviceInterface) (gitcode.ProviderMode, bool) {
if svc == nil {
return "", false
}
provider, ok := svc.(interface {
ProviderMode() gitcode.ProviderMode
})
if !ok {
return "", false
}
return provider.ProviderMode(), true
}
type indexRepoArgs struct {
RepoID string `json:"repo_id"`
Mode string `json:"mode,omitempty"`
Strict bool `json:"strict,omitempty"`
}
func (s *Server) callIndexRepo(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
var a indexRepoArgs
if err := json.Unmarshal(args, &a); err != nil {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "arguments must be a valid object"})
return
}
if strings.TrimSpace(a.RepoID) == "" {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "repo_id is required"})
return
}
mode := strings.TrimSpace(a.Mode)
if mode == "" {
mode = "full"
}
result, err := s.svc.Index(ctx, service.OperationRequest{RepoID: a.RepoID, Mode: mode, Strict: a.Strict})
if err != nil {
s.writeOperationalError(id, err, domainErrorContext{Operation: "index_repo", RepoID: a.RepoID})
return
}
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: fmt.Sprintf("index status=%s processed=%d", result.Status, result.ProcessedCount)}}, StructuredContent: result})
}
type authStatusResult struct {
Source string `json:"source"`
Present bool `json:"present"`
StoreMode string `json:"store_mode,omitempty"`
ErrorClass string `json:"error_class,omitempty"`
Remediation string `json:"remediation,omitempty"`
}
func (s *Server) callAuthStatus(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
var a map[string]any
if err := json.Unmarshal(args, &a); err != nil {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "arguments must be a valid object"})
return
}
_ = a
result := s.authStatus(ctx)
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: fmt.Sprintf("credential_present=%t source=%s", result.Present, result.Source)}}, StructuredContent: result})
}
func (s *Server) authStatus(ctx context.Context) authStatusResult {
if s.credentialResolver != nil {
result := s.credentialResolver.Status(ctx, config.EffectiveConfig{})
return authStatusResult{Source: result.Source, Present: result.Present, StoreMode: result.StoreMode, ErrorClass: result.ErrorClass, Remediation: result.Remediation}
}
present := strings.TrimSpace(os.Getenv(config.EnvToken)) != ""
source := "missing"
if present {
source = "env:" + config.EnvToken
}
return authStatusResult{Source: source, Present: present, StoreMode: "env"}
}
type doctorArgs struct {
RepoID string `json:"repo_id,omitempty"`
}
type doctorResult struct {
Status string `json:"status"`
RepoID string `json:"repo_id,omitempty"`
SelectedRepoID string `json:"selected_repo_id,omitempty"`
SuggestedRepoID string `json:"suggested_repo_id,omitempty"`
AvailableBindings []string `json:"available_bindings,omitempty"`
ToolAccess string `json:"tool_access"`
Cache *service.CacheStatusResult `json:"cache,omitempty"`
Repo *service.RepositoryStatus `json:"repo,omitempty"`
Auth *authStatusResult `json:"auth,omitempty"`
Sync *service.SyncStatusSummaryResult `json:"sync,omitempty"`
Index *service.StaleIndexResult `json:"index,omitempty"`
LiveProvider map[string]string `json:"live_provider,omitempty"`
Runtime RuntimeContext `json:"runtime"`
Diagnostics []lifecycleDiagnostic `json:"diagnostics"`
GeneratedAt time.Time `json:"generated_at"`
}
func (s *Server) callDoctor(ctx context.Context, id *json.RawMessage, args json.RawMessage) {
var a doctorArgs
if err := json.Unmarshal(args, &a); err != nil {
s.writeError(id, -32602, "Invalid params", &errorData{Code: "invalid_arguments", Message: "arguments must be a valid object"})
return
}
selected := strings.TrimSpace(a.RepoID)
result := doctorResult{Status: "ok", RepoID: selected, SelectedRepoID: selected, ToolAccess: string(s.activeToolAccess()), Runtime: s.runtimeContext, Diagnostics: []lifecycleDiagnostic{}, GeneratedAt: time.Now().UTC()}
text := "doctor status=ok"
if s.startupDiagnostic.present() {
result.Status = "degraded"
result.Diagnostics = append(result.Diagnostics, lifecycleDiagnostic{Code: s.startupDiagnostic.ErrorClass, ErrorClass: s.startupDiagnostic.ErrorClass, Message: s.startupDiagnostic.Message, Remediation: s.startupDiagnostic.Remediation})
text = "doctor status=degraded"
}
auth := s.authStatus(ctx)
result.Auth = &auth
if auth.Present {
result.LiveProvider = map[string]string{"status": "credential_configured", "source": auth.Source}
} else {
result.LiveProvider = map[string]string{"status": "not_configured", "source": auth.Source}
}
if s.svc != nil && strings.TrimSpace(a.RepoID) != "" {
repo, repoErr := s.svc.RepositoryStatus(ctx, service.RepositoryStatusRequest{RepoID: a.RepoID})
if repoErr == nil {
result.Repo = &repo
} else if service.IsNotFound(repoErr) {
result.Repo = &repo
result.SuggestedRepoID = repo.SuggestedRepoID
result.AvailableBindings = append([]string(nil), repo.AvailableBindings...)
result.Diagnostics = append(result.Diagnostics, missingBindingDiagnostic(strings.TrimSpace(a.RepoID), repo))
} else {
result.addDoctorDiagnostic("repo_status", repoErr, "check repository binding")
}
if !service.IsNotFound(repoErr) {
if cacheStatus, err := s.svc.CacheStatus(ctx, service.CacheStatusRequest{RepoID: a.RepoID}); err == nil {
result.Cache = &cacheStatus
} else {
result.addDoctorDiagnostic("cache_status", err, "check cache path and schema")
}
if syncStatus, err := s.svc.SyncStatus(ctx, service.ListSourcesRequest{RepoID: a.RepoID}); err == nil {
result.Sync = &syncStatus
} else {
result.addDoctorDiagnostic("sync_status", err, "run sync_live for this repository")
}
if indexStatus, err := s.svc.StaleIndex(ctx, service.StaleIndexRequest{RepoID: a.RepoID}); err == nil {
result.Index = &indexStatus
} else {
result.addDoctorDiagnostic("index_status", err, "run index_repo after syncing")
}
}
}
if len(result.Diagnostics) > 0 {
result.Status = "degraded"
text = "doctor status=degraded"
if result.Repo != nil && result.Repo.BindingState == "missing" {
text += " binding_state=missing selected_repo_id=" + result.RepoID
if result.Repo.SuggestedRepoID != "" {
text += " suggested_repo_id=" + result.Repo.SuggestedRepoID
}
if result.Runtime.EffectiveCachePath != "" {
text += " effective_cache_path=" + result.Runtime.EffectiveCachePath
}
}
}
s.writeToolResult(id, toolCallResult{Content: []toolContentItem{{Type: "text", Text: text}}, StructuredContent: result})
}
func (r *doctorResult) addDoctorDiagnostic(code string, err error, remediation string) {
if err == nil {
return
}
r.Diagnostics = append(r.Diagnostics, lifecycleDiagnostic{Code: code, Message: err.Error(), Remediation: remediation})
}