package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "runtime" "strconv" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/minio/selfupdate" ) // Build-time variables inyectadas vía -ldflags -X var ( Version = "dev" GiteaURL = "" RepoOwner = "" RepoName = "" ) // OutlineClient es el cliente HTTP para la API de Outline. type OutlineClient struct { BaseURL string APIKey string HTTPClient *http.Client } // newOutlineClient crea un cliente desde las variables de entorno. func newOutlineClient() (*OutlineClient, error) { baseURL := os.Getenv("OUTLINE_URL") apiKey := os.Getenv("OUTLINE_API_KEY") if baseURL == "" || apiKey == "" { return nil, fmt.Errorf("OUTLINE_URL y OUTLINE_API_KEY deben estar definidos") } return &OutlineClient{ BaseURL: strings.TrimRight(baseURL, "/"), APIKey: apiKey, HTTPClient: &http.Client{}, }, nil } // post realiza una petición POST autenticada a la API de Outline. func (c *OutlineClient) post(ctx context.Context, path string, payload any, result any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("error serializando payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(body)) if err != nil { return fmt.Errorf("error creando petición: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.APIKey) resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("error ejecutando petición: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("error leyendo respuesta: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) } if result != nil { if err := json.Unmarshal(respBody, result); err != nil { return fmt.Errorf("error parseando respuesta: %w", err) } } return nil } // --- Herramientas MCP --- func handleListCollections(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := newOutlineClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } var resp struct { Data []struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` } `json:"data"` } if err := client.post(ctx, "/api/collections.list", map[string]any{}, &resp); err != nil { return mcp.NewToolResultError(err.Error()), nil } var lines []string for _, c := range resp.Data { lines = append(lines, fmt.Sprintf("- **%s** (id: %s): %s", c.Name, c.ID, c.Description)) } if len(lines) == 0 { return mcp.NewToolResultText("No se encontraron colecciones."), nil } return mcp.NewToolResultText(strings.Join(lines, "\n")), nil } func handleSearch(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := newOutlineClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } query := req.GetString("query", "") if query == "" { return mcp.NewToolResultError("El parámetro query es requerido."), nil } var resp struct { Data []struct { ID string `json:"id"` Title string `json:"title"` URL string `json:"url"` } `json:"data"` } if err := client.post(ctx, "/api/documents.search", map[string]any{"query": query}, &resp); err != nil { return mcp.NewToolResultError(err.Error()), nil } if len(resp.Data) == 0 { return mcp.NewToolResultText("No se encontraron documentos para la búsqueda."), nil } var lines []string for _, d := range resp.Data { lines = append(lines, fmt.Sprintf("- **%s** (id: %s) %s", d.Title, d.ID, d.URL)) } return mcp.NewToolResultText(strings.Join(lines, "\n")), nil } func handleGetDocument(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := newOutlineClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } id := req.GetString("id", "") if id == "" { return mcp.NewToolResultError("El parámetro id es requerido."), nil } var resp struct { Data struct { ID string `json:"id"` Title string `json:"title"` Text string `json:"text"` } `json:"data"` } if err := client.post(ctx, "/api/documents.info", map[string]any{"id": id}, &resp); err != nil { return mcp.NewToolResultError(err.Error()), nil } if resp.Data.ID == "" { return mcp.NewToolResultError("Documento no encontrado."), nil } result := fmt.Sprintf("# %s\n\n%s", resp.Data.Title, resp.Data.Text) return mcp.NewToolResultText(result), nil } func handleCreateDocument(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := newOutlineClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } title := req.GetString("title", "") text := req.GetString("text", "") collectionID := req.GetString("collection_id", "") if title == "" || text == "" || collectionID == "" { return mcp.NewToolResultError("Los parámetros title, text y collection_id son requeridos."), nil } var resp struct { Data struct { ID string `json:"id"` Title string `json:"title"` } `json:"data"` } if err := client.post(ctx, "/api/documents.create", map[string]any{ "title": title, "text": text, "collectionId": collectionID, }, &resp); err != nil { return mcp.NewToolResultError(err.Error()), nil } return mcp.NewToolResultText(fmt.Sprintf("Documento creado: **%s** (id: %s)", resp.Data.Title, resp.Data.ID)), nil } // --- Auto-update --- type giteaRelease struct { TagName string `json:"tag_name"` Assets []struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } `json:"assets"` } func parseVersion(v string) (int, int, int, error) { v = strings.TrimPrefix(v, "v") parts := strings.SplitN(v, ".", 3) if len(parts) != 3 { return 0, 0, 0, fmt.Errorf("formato de versión inválido: %s", v) } major, err := strconv.Atoi(parts[0]) if err != nil { return 0, 0, 0, err } minor, err := strconv.Atoi(parts[1]) if err != nil { return 0, 0, 0, err } patch, err := strconv.Atoi(parts[2]) if err != nil { return 0, 0, 0, err } return major, minor, patch, nil } func versionNewer(latest, current string) (bool, error) { lMaj, lMin, lPat, err := parseVersion(latest) if err != nil { return false, err } cMaj, cMin, cPat, err := parseVersion(current) if err != nil { return false, err } if lMaj > cMaj { return true, nil } if lMaj == cMaj && lMin > cMin { return true, nil } if lMaj == cMaj && lMin == cMin && lPat > cPat { return true, nil } return false, nil } func doUpdate() error { if GiteaURL == "" || RepoOwner == "" || RepoName == "" { return fmt.Errorf("GiteaURL, RepoOwner y RepoName deben estar definidos vía -ldflags") } url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/latest", GiteaURL, RepoOwner, RepoName) resp, err := http.Get(url) if err != nil { return fmt.Errorf("error consultando Gitea: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("Gitea respondió con código %d", resp.StatusCode) } var release giteaRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return fmt.Errorf("error parseando respuesta de Gitea: %w", err) } newer, err := versionNewer(release.TagName, Version) if err != nil { return fmt.Errorf("error comparando versiones: %w", err) } if !newer { fmt.Println("Ya estás en la última versión:", Version) return nil } ext := "" if runtime.GOOS == "windows" { ext = ".exe" } assetName := fmt.Sprintf("outline-mcp_%s_%s%s", runtime.GOOS, runtime.GOARCH, ext) var downloadURL string for _, a := range release.Assets { if a.Name == assetName { downloadURL = a.BrowserDownloadURL break } } if downloadURL == "" { return fmt.Errorf("no se encontró asset para la plataforma %s/%s", runtime.GOOS, runtime.GOARCH) } fmt.Printf("Actualizando de %s a %s...\n", Version, release.TagName) resp, err = http.Get(downloadURL) if err != nil { return fmt.Errorf("error descargando asset: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("error descargando asset: código %d", resp.StatusCode) } if err := selfupdate.Apply(resp.Body); err != nil { return fmt.Errorf("error aplicando actualización: %w", err) } fmt.Printf("Actualizado exitosamente a %s\n", release.TagName) return nil } // --- Main --- func main() { if len(os.Args) > 1 { switch os.Args[1] { case "version": fmt.Println(Version) return case "update": if err := doUpdate(); err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(1) } return } } s := server.NewMCPServer( "outline-mcp", Version, server.WithToolCapabilities(true), ) s.AddTool( mcp.NewTool("outline_list_collections", mcp.WithDescription("Lista todas las colecciones de Outline con su ID, nombre y descripción"), ), handleListCollections, ) s.AddTool( mcp.NewTool("outline_search", mcp.WithDescription("Busca documentos en Outline por texto"), mcp.WithString("query", mcp.Required(), mcp.Description("Texto de búsqueda"), ), ), handleSearch, ) s.AddTool( mcp.NewTool("outline_get_document", mcp.WithDescription("Obtiene el contenido de un documento de Outline por su ID"), mcp.WithString("id", mcp.Required(), mcp.Description("ID del documento"), ), ), handleGetDocument, ) s.AddTool( mcp.NewTool("outline_create_document", mcp.WithDescription("Crea un nuevo documento en Outline"), mcp.WithString("title", mcp.Required(), mcp.Description("Título del documento"), ), mcp.WithString("text", mcp.Required(), mcp.Description("Contenido del documento en Markdown"), ), mcp.WithString("collection_id", mcp.Required(), mcp.Description("ID de la colección destino"), ), ), handleCreateDocument, ) if err := server.ServeStdio(s); err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(1) } }