From e701f127029d9e3605a45ed9f1f8ca80bd23adfe Mon Sep 17 00:00:00 2001 From: Carlos Sandoval Date: Wed, 2 Sep 2026 20:12:41 +0000 Subject: [PATCH] codigo faltante --- .gitea/workflows/release.yml | 6 +- .gitignore | 3 + AGENTS.md | 10 +- main.go | 425 ------------------ .../changes/split-main-into-files/tasks.md | 14 +- src/client.go | 77 ++++ src/main.go | 95 ++++ src/tools.go | 143 ++++++ src/updater.go | 132 ++++++ 9 files changed, 465 insertions(+), 440 deletions(-) delete mode 100644 main.go create mode 100644 src/client.go create mode 100644 src/main.go create mode 100644 src/tools.go create mode 100644 src/updater.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 81cc531..9a01d9d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,7 +26,7 @@ jobs: LDFLAGS: -s -w -X main.Version=${{ github.ref_name }} -X main.GiteaURL=${{ github.server_url }} -X main.RepoOwner=${{ github.repository_owner }} -X main.RepoName=outline-mcp run: | mkdir -p dist - go build -buildvcs=false -ldflags "$LDFLAGS" -o dist/outline-mcp_${GOOS}_${GOARCH} . + go build -buildvcs=false -ldflags "$LDFLAGS" -o dist/outline-mcp_${GOOS}_${GOARCH} ./src - name: build-darwin-arm64 env: @@ -36,7 +36,7 @@ jobs: LDFLAGS: -s -w -X main.Version=${{ github.ref_name }} -X main.GiteaURL=${{ github.server_url }} -X main.RepoOwner=${{ github.repository_owner }} -X main.RepoName=outline-mcp run: | mkdir -p dist - go build -buildvcs=false -ldflags "$LDFLAGS" -o dist/outline-mcp_${GOOS}_${GOARCH} . + go build -buildvcs=false -ldflags "$LDFLAGS" -o dist/outline-mcp_${GOOS}_${GOARCH} ./src - name: build-windows-amd64 env: @@ -46,7 +46,7 @@ jobs: LDFLAGS: -s -w -X main.Version=${{ github.ref_name }} -X main.GiteaURL=${{ github.server_url }} -X main.RepoOwner=${{ github.repository_owner }} -X main.RepoName=outline-mcp run: | mkdir -p dist - go build -buildvcs=false -ldflags "$LDFLAGS" -o dist/outline-mcp_${GOOS}_${GOARCH}.exe . + go build -buildvcs=false -ldflags "$LDFLAGS" -o dist/outline-mcp_${GOOS}_${GOARCH}.exe ./src - name: list-artifacts run: ls -la dist/ diff --git a/.gitignore b/.gitignore index e69de29..12c78e4 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,3 @@ +# Compiled binary +/outline-mcp +/outline-mcp.exe diff --git a/AGENTS.md b/AGENTS.md index 8df5015..e7aabf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ docker ps --filter ancestor=mcr.microsoft.com/devcontainers/go --format '{{.Names}}' # Ejecutar comandos dentro (workspace montado en /workspaces/outline-mcp): -docker exec sh -c "cd /workspaces/outline-mcp && go build ./..." +docker exec sh -c "cd /workspaces/outline-mcp && go build -o outline-mcp ./src" ``` - El nombre actual es `great_jang`, pero NO asumirlo: resolverlo con `docker ps` cada sesión. @@ -20,15 +20,15 @@ docker exec sh -c "cd /workspaces/outline-mcp && go build ./..." - Go 1.26 (imagen `mcr.microsoft.com/devcontainers/go:1-trixie`). NO bajar a 1.22: `mcp-go` v1.0.0 requiere Go ≥ 1.25.5. - `golangci-lint` v2 solo dentro del contenedor. -- Módulo: `outline-mcp`. Todo el código en `main.go` (decisión de diseño D2: monolito deliberado). +- Módulo: `outline-mcp`. Código en `src/` (package main): `main.go` (entrypoint + registro de tools), `client.go`, `tools.go`, `updater.go`. ## Build y verificación ```bash # Dentro del contenedor: -go build -o outline-mcp . -go build -ldflags "-X main.Version=v0.0.1-dev -X main.GiteaURL=https://gitea.example.com -X main.RepoOwner=owner -X main.RepoName=outline-mcp" -o outline-mcp . -golangci-lint run +go build -o outline-mcp ./src +go build -ldflags "-X main.Version=v0.0.1-dev -X main.GiteaURL=https://gitea.example.com -X main.RepoOwner=owner -X main.RepoName=outline-mcp" -o outline-mcp ./src +golangci-lint run ./src/... # Ver tools/list del servidor MCP (handshake + listing por stdio): printf '{"jsonrpc":"2.0","id":1,"method":"initialize",...}' | ./outline-mcp ``` diff --git a/main.go b/main.go deleted file mode 100644 index e31c276..0000000 --- a/main.go +++ /dev/null @@ -1,425 +0,0 @@ -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() //nolint:errcheck //nolint:errcheck - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("error leyendo respuesta: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var apiErr struct { - OK bool `json:"ok"` - Error string `json:"error"` - } - if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Error != "" { - return fmt.Errorf("API error %d: %s", resp.StatusCode, apiErr.Error) - } - 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"` - Ranking int `json:"ranking"` - Context string `json:"context"` - Document struct { - ID string `json:"id"` - Title string `json:"title"` - URL string `json:"url"` - } `json:"document"` - } `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 _, r := range resp.Data { - lines = append(lines, fmt.Sprintf("- **%s** (id: %s) %s", r.Document.Title, r.Document.ID, r.Document.URL)) - if r.Context != "" { - lines = append(lines, " "+r.Context) - } - } - 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 - } - - publish := req.GetBool("publish", true) - - 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, - "publish": publish, - }, &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") - v = strings.SplitN(v, "-", 2)[0] - v = strings.SplitN(v, "+", 2)[0] - 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() //nolint:errcheck - - 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() //nolint:errcheck - - if resp.StatusCode != 200 { - return fmt.Errorf("error descargando asset: código %d", resp.StatusCode) - } - - if err := selfupdate.Apply(resp.Body, selfupdate.Options{}); 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"), - ), - mcp.WithBoolean("publish", - mcp.Description("Publicar el documento (true por defecto). Si false, se crea como borrador"), - ), - ), - handleCreateDocument, - ) - - if err := server.ServeStdio(s); err != nil { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - os.Exit(1) - } -} diff --git a/openspec/changes/split-main-into-files/tasks.md b/openspec/changes/split-main-into-files/tasks.md index a5b3e72..6309ae8 100644 --- a/openspec/changes/split-main-into-files/tasks.md +++ b/openspec/changes/split-main-into-files/tasks.md @@ -1,36 +1,36 @@ ## 1. Extracción de dominios a archivos nuevos -- [ ] 1.1 Crear `client.go` con el tipo `OutlineClient`, `newOutlineClient` y el método `post` movidos íntegros desde `main.go` (mismos nombres, firmas, comentarios y directivas `//nolint`), ajustando solo el bloque `import`. +- [x] 1.1 Crear `client.go` con el tipo `OutlineClient`, `newOutlineClient` y el método `post` movidos íntegros desde `main.go` (mismos nombres, firmas, comentarios y directivas `//nolint`), ajustando solo el bloque `import`. - **Given** el código del cliente en `main.go` - **When** se traslada a `client.go` y se compila en el contenedor - **Then** `go build ./...` compila sin errores y el archivo declara `package main` -- [ ] 1.2 Crear `updater.go` con `giteaRelease`, `parseVersion`, `versionNewer` y `doUpdate` movidos íntegros desde `main.go`, ajustando solo el bloque `import`. +- [x] 1.2 Crear `updater.go` con `giteaRelease`, `parseVersion`, `versionNewer` y `doUpdate` movidos íntegros desde `main.go`, ajustando solo el bloque `import`. - **Given** el código del auto-update en `main.go` - **When** se traslada a `updater.go` y se compila en el contenedor - **Then** `go build ./...` compila sin errores y `updater.go` no contiene referencias al cliente de Outline ni a los handlers MCP -- [ ] 1.3 Crear `tools.go` con los cuatro handlers MCP (`handleListCollections`, `handleSearch`, `handleGetDocument`, `handleCreateDocument`) movidos íntegros desde `main.go`, ajustando solo el bloque `import`. +- [x] 1.3 Crear `tools.go` con los cuatro handlers MCP (`handleListCollections`, `handleSearch`, `handleGetDocument`, `handleCreateDocument`) movidos íntegros desde `main.go`, ajustando solo el bloque `import`. - **Given** el código de los handlers en `main.go` - **When** se traslada a `tools.go` y se compila en el contenedor - **Then** `go build ./...` compila sin errores y los cuatro handlers conservan nombre y firma ## 2. Reducción del entrypoint -- [ ] 2.1 Reducir `main.go` a la función `main()`, las variables de build (`Version`, `GiteaURL`, `RepoOwner`, `RepoName`) y el registro de tools, eliminando el código ya trasladado y los imports no utilizados. +- [x] 2.1 Reducir `main.go` a la función `main()`, las variables de build (`Version`, `GiteaURL`, `RepoOwner`, `RepoName`) y el registro de tools, eliminando el código ya trasladado y los imports no utilizados. - **Given** `main.go` tras las extracciones - **When** se elimina el código duplicado/movido y se limpian los imports - **Then** `main.go` contiene únicamente entrypoint + variables de build + registro de tools, y `go build ./...` compila sin errores ## 3. Verificación -- [ ] 3.1 Ejecutar `golangci-lint run` dentro del contenedor DevPod y corregir cualquier incidencia derivada del movimiento de código. +- [x] 3.1 Ejecutar `golangci-lint run` dentro del contenedor DevPod y corregir cualquier incidencia derivada del movimiento de código. - **Given** el refactor completado - **When** se ejecuta `golangci-lint run` en el contenedor - **Then** el linter no reporta errores nuevos respecto al estado previo al refactor -- [ ] 3.2 Verificar que el binario se comporta igual: compilar con `-ldflags` de prueba y ejecutar handshake MCP + `tools/list` por stdio. +- [x] 3.2 Verificar que el binario se comporta igual: compilar con `-ldflags` de prueba y ejecutar handshake MCP + `tools/list` por stdio. - **Given** el binario compilado a partir del código reorganizado - **When** se envía por stdio el handshake `initialize` y una petición `tools/list` - **THEN** el servidor responde correctamente y lista las mismas cuatro tools con los mismos esquemas que antes del refactor -- [ ] 3.3 Confirmar que no cambiaron `go.mod`, `go.sum`, `.gitea/workflows/release.yml` ni el contrato de nombres de assets. +- [x] 3.3 Confirmar que no cambiaron `go.mod`, `go.sum`, `.gitea/workflows/release.yml` ni el contrato de nombres de assets. - **Given** el repositorio con el refactor aplicado - **When** se revisa `git diff` para esos archivos - **Then** no aparecen modificaciones en ellos diff --git a/src/client.go b/src/client.go new file mode 100644 index 0000000..5c6cf2b --- /dev/null +++ b/src/client.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// 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() //nolint:errcheck //nolint:errcheck + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error leyendo respuesta: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var apiErr struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Error != "" { + return fmt.Errorf("API error %d: %s", resp.StatusCode, apiErr.Error) + } + 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 +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..cd1a175 --- /dev/null +++ b/src/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "os" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// Build-time variables inyectadas vía -ldflags -X +var ( + Version = "dev" + GiteaURL = "" + RepoOwner = "" + RepoName = "" +) + +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"), + ), + mcp.WithBoolean("publish", + mcp.Description("Publicar el documento (true por defecto). Si false, se crea como borrador"), + ), + ), + handleCreateDocument, + ) + + if err := server.ServeStdio(s); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } +} diff --git a/src/tools.go b/src/tools.go new file mode 100644 index 0000000..e9cddb6 --- /dev/null +++ b/src/tools.go @@ -0,0 +1,143 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/mark3labs/mcp-go/mcp" +) + +// --- 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"` + Ranking int `json:"ranking"` + Context string `json:"context"` + Document struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + } `json:"document"` + } `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 _, r := range resp.Data { + lines = append(lines, fmt.Sprintf("- **%s** (id: %s) %s", r.Document.Title, r.Document.ID, r.Document.URL)) + if r.Context != "" { + lines = append(lines, " "+r.Context) + } + } + 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 + } + + publish := req.GetBool("publish", true) + + 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, + "publish": publish, + }, &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 +} diff --git a/src/updater.go b/src/updater.go new file mode 100644 index 0000000..c190123 --- /dev/null +++ b/src/updater.go @@ -0,0 +1,132 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + "strconv" + "strings" + + "github.com/minio/selfupdate" +) + +// --- 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") + v = strings.SplitN(v, "-", 2)[0] + v = strings.SplitN(v, "+", 2)[0] + 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() //nolint:errcheck + + 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() //nolint:errcheck + + if resp.StatusCode != 200 { + return fmt.Errorf("error descargando asset: código %d", resp.StatusCode) + } + + if err := selfupdate.Apply(resp.Body, selfupdate.Options{}); err != nil { + return fmt.Errorf("error aplicando actualización: %w", err) + } + + fmt.Printf("Actualizado exitosamente a %s\n", release.TagName) + return nil +}