package server import ( "context" "encoding/base64" "errors" "fmt" "mime" "regexp" "strings" "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs" "github.com/modelcontextprotocol/go-sdk/mcp" ) var localePattern = regexp.MustCompile(`^[a-z]{2}(-[A-Z]{2})?$`) type handlers struct { wiki *wikijs.Client } type searchPagesInput struct { Query string `json:"query" jsonschema:"full-text search query"` Path string `json:"path,omitempty" jsonschema:"optional path to limit the search scope"` Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"` } func (h *handlers) searchPages(ctx context.Context, _ *mcp.CallToolRequest, in searchPagesInput) (*mcp.CallToolResult, wikijs.SearchResults, error) { if strings.TrimSpace(in.Query) == "" { return nil, wikijs.SearchResults{}, errors.New("query must not be empty") } if err := validateLocale(in.Locale, false); err != nil { return nil, wikijs.SearchResults{}, err } result, err := h.wiki.SearchPages(ctx, in.Query, in.Path, in.Locale) if err != nil { return nil, wikijs.SearchResults{}, err } return nil, result, nil } type pageByIDInput struct { ID int `json:"id" jsonschema:"numeric ID of the page"` } func (h *handlers) pageByID(ctx context.Context, _ *mcp.CallToolRequest, in pageByIDInput) (*mcp.CallToolResult, any, error) { if in.ID <= 0 { return nil, nil, errors.New("id must be a positive integer") } page, err := h.wiki.PageByID(ctx, in.ID) if err != nil { return nil, nil, err } if page == nil { return nullResult(), nil, nil } return nil, page, nil } type pageByPathInput struct { Path string `json:"path" jsonschema:"path of the page, for example /dev/go"` Locale string `json:"locale" jsonschema:"locale of the page, for example en or de-DE"` } func (h *handlers) pageByPath(ctx context.Context, _ *mcp.CallToolRequest, in pageByPathInput) (*mcp.CallToolResult, any, error) { if strings.TrimSpace(in.Path) == "" { return nil, nil, errors.New("path must not be empty") } if err := validateLocale(in.Locale, true); err != nil { return nil, nil, err } page, err := h.wiki.PageByPath(ctx, in.Path, in.Locale) if err != nil { return nil, nil, err } if page == nil { return nullResult(), nil, nil } return nil, page, nil } type listPagesInput struct { Limit *int `json:"limit,omitempty" jsonschema:"maximum number of pages to return"` Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"` Tags []string `json:"tags,omitempty" jsonschema:"optional tags to filter by"` } // listPagesOutput wraps the page list in an object. The MCP spec requires // outputSchema.type to be the string "object" whenever a structured output // schema is declared, so this tool must not return a bare array: strict // clients reject the array-typed schema at tools/list time. type listPagesOutput struct { Pages []wikijs.PageListItem `json:"pages" jsonschema:"matching pages"` } func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, listPagesOutput, error) { if in.Limit != nil && *in.Limit <= 0 { return nil, listPagesOutput{}, errors.New("limit must be a positive integer") } if err := validateLocale(in.Locale, false); err != nil { return nil, listPagesOutput{}, err } pages, err := h.wiki.ListPages(ctx, wikijs.ListOptions{ Limit: in.Limit, Locale: in.Locale, Tags: in.Tags, }) if err != nil { return nil, listPagesOutput{}, err } return nil, listPagesOutput{Pages: pages}, nil } type createPageInput struct { Title string `json:"title" jsonschema:"title of the page"` Content string `json:"content" jsonschema:"content of the page"` Description string `json:"description" jsonschema:"description of the page"` Path string `json:"path" jsonschema:"path of the page, for example /dev/go"` Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"` IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"` IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"` Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"` Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"` } type editPageInput struct { ID int `json:"id" jsonschema:"numeric ID of the page to update"` Title string `json:"title" jsonschema:"new title of the page"` Content string `json:"content" jsonschema:"new content of the page"` Description string `json:"description" jsonschema:"new description of the page"` Path string `json:"path" jsonschema:"path of the page, for example /dev/go"` Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"` IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"` IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"` Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"` Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"` } type mutationOutput struct { Succeeded bool `json:"succeeded" jsonschema:"whether the mutation succeeded"` Message string `json:"message" jsonschema:"result message from WikiJS"` } func (h *handlers) createPage(ctx context.Context, _ *mcp.CallToolRequest, in createPageInput) (*mcp.CallToolResult, mutationOutput, error) { if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil { return nil, mutationOutput{}, err } result, err := h.wiki.CreatePage(ctx, wikijs.CreatePageInput{ Title: in.Title, Content: in.Content, Description: in.Description, Path: in.Path, Editor: defaultValue(in.Editor, "markdown"), IsPublished: boolValue(in.IsPublished, true), IsPrivate: boolValue(in.IsPrivate, false), Locale: defaultValue(in.Locale, "en"), Tags: nonNilStrings(in.Tags), }) if err != nil { return nil, mutationOutput{}, err } if !result.Succeeded { return nil, mutationOutput{}, mutationError("create page", result) } return nil, mutationOutput{ Succeeded: true, Message: defaultValue(result.Message, "Page created successfully"), }, nil } func (h *handlers) editPage(ctx context.Context, _ *mcp.CallToolRequest, in editPageInput) (*mcp.CallToolResult, mutationOutput, error) { if in.ID <= 0 { return nil, mutationOutput{}, errors.New("id must be a positive integer") } if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil { return nil, mutationOutput{}, err } // Read the current page so that fields the caller did not explicitly set // (publish state, privacy, locale, tags) are preserved instead of being // overwritten with defaults. The pages.update mutation requires all of // them, so "leave unchanged" is only possible by re-sending the existing // values. existing, err := h.wiki.PageByID(ctx, in.ID) if err != nil { return nil, mutationOutput{}, fmt.Errorf("failed to load page: %w", err) } if existing == nil { return nil, mutationOutput{}, fmt.Errorf("failed to update page: Page not found") } path := in.Path if strings.TrimSpace(path) == "" { path = existing.Path } locale := in.Locale if strings.TrimSpace(locale) == "" { locale = existing.Locale } if strings.TrimSpace(locale) == "" { locale = "en" } isPublished := existing.IsPublished if in.IsPublished != nil { isPublished = *in.IsPublished } isPrivate := existing.IsPrivate if in.IsPrivate != nil { isPrivate = *in.IsPrivate } tags := existingTags(existing.Tags) if in.Tags != nil { tags = nonNilStrings(in.Tags) } result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{ ID: in.ID, Title: in.Title, Content: in.Content, Description: in.Description, Path: path, Editor: defaultValue(in.Editor, "markdown"), IsPublished: isPublished, IsPrivate: isPrivate, Locale: locale, Tags: tags, }) if err != nil { return nil, mutationOutput{}, err } if !result.Succeeded { return nil, mutationOutput{}, mutationError("update page", result) } return nil, mutationOutput{ Succeeded: true, Message: defaultValue(result.Message, "Page updated successfully"), }, nil } // validateAssetFolderPath delegates to the wikijs client so that the handler // and the folder resolver share one definition of a valid asset folder path. func validateAssetFolderPath(folderPath string) error { _, err := wikijs.AssetFolderPathSegments(folderPath) return err } func validatePageInput(title, path, locale string) error { if strings.TrimSpace(title) == "" { return errors.New("title must not be empty") } if strings.TrimSpace(path) == "" { return errors.New("path must not be empty") } return validateLocale(locale, false) } func validateLocale(locale string, required bool) error { if locale == "" { if required { return errors.New("locale must not be empty") } return nil } if !localePattern.MatchString(locale) { return fmt.Errorf("invalid locale %q: use a format like en or de-DE", locale) } return nil } func mutationError(action string, result wikijs.MutationResult) error { message := defaultValue(result.Message, "unknown error") if result.ErrorCode != 0 { return fmt.Errorf("failed to %s: %s (error code %d)", action, message, result.ErrorCode) } return fmt.Errorf("failed to %s: %s", action, message) } func defaultValue(value, fallback string) string { if value == "" { return fallback } return value } func boolValue(value *bool, fallback bool) bool { if value == nil { return fallback } return *value } func nonNilStrings(values []string) []string { if values == nil { return []string{} } return values } // existingTags converts the tags of an existing page (Tag structs) into the // string slice expected by UpdatePageInput, preserving them when the caller // did not pass tags. func existingTags(tags []wikijs.Tag) []string { out := make([]string, 0, len(tags)) for _, t := range tags { if t.Tag != "" { out = append(out, t.Tag) } } return out } // nullResult preserves the original server's "null" response for a page that // does not exist. A typed nil page would be coerced to an empty object by the // MCP SDK's output schema handling. func nullResult() *mcp.CallToolResult { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: "null"}}, } } type listAssetFoldersInput struct { ParentFolder string `json:"parentFolder,omitempty" jsonschema:"optional parent asset folder path, for example images (empty means the root folder)"` } type listAssetFoldersOutput struct { Folders []wikijs.AssetFolder `json:"folders" jsonschema:"child folders of the parent folder"` } func (h *handlers) listAssetFolders(ctx context.Context, _ *mcp.CallToolRequest, in listAssetFoldersInput) (*mcp.CallToolResult, listAssetFoldersOutput, error) { if err := validateAssetFolderPath(in.ParentFolder); err != nil { return nil, listAssetFoldersOutput{}, err } folders, err := h.wiki.ListAssetFolders(ctx, in.ParentFolder) if err != nil { return nil, listAssetFoldersOutput{}, err } return nil, listAssetFoldersOutput{Folders: folders}, nil } type listAssetsInput struct { Folder string `json:"folder,omitempty" jsonschema:"optional asset folder path, for example images (empty means the root folder)"` Kind string `json:"kind,omitempty" jsonschema:"optional asset kind filter: IMAGE or BINARY (default ALL)"` } // assetOutput enriches an WikiJS asset with a ready-to-use markdown reference. type assetOutput struct { wikijs.Asset Markdown string `json:"markdown" jsonschema:"ready-to-use markdown reference for the asset"` } type listAssetsOutput struct { Assets []assetOutput `json:"assets" jsonschema:"assets in the folder"` } func (h *handlers) listAssets(ctx context.Context, _ *mcp.CallToolRequest, in listAssetsInput) (*mcp.CallToolResult, listAssetsOutput, error) { if err := validateAssetFolderPath(in.Folder); err != nil { return nil, listAssetsOutput{}, err } if in.Kind != "" && in.Kind != "IMAGE" && in.Kind != "BINARY" { return nil, listAssetsOutput{}, fmt.Errorf("invalid kind %q: use IMAGE or BINARY", in.Kind) } assets, err := h.wiki.ListAssets(ctx, in.Folder) if err != nil { return nil, listAssetsOutput{}, err } out := []assetOutput{} for _, asset := range assets { if in.Kind != "" && asset.Kind != in.Kind { continue } out = append(out, assetOutput{Asset: asset, Markdown: assetMarkdown(asset)}) } return nil, listAssetsOutput{Assets: out}, nil } type createAssetFolderInput struct { Name string `json:"name" jsonschema:"name of the new asset folder; the URL slug is derived from it"` ParentFolder string `json:"parentFolder,omitempty" jsonschema:"optional parent asset folder path, for example images (empty means the root folder)"` } func (h *handlers) createAssetFolder(ctx context.Context, _ *mcp.CallToolRequest, in createAssetFolderInput) (*mcp.CallToolResult, mutationOutput, error) { if strings.TrimSpace(in.Name) == "" { return nil, mutationOutput{}, errors.New("name must not be empty") } if err := validateAssetFolderPath(in.ParentFolder); err != nil { return nil, mutationOutput{}, err } slug := deriveAssetSlug(in.Name) if slug == "" { return nil, mutationOutput{}, errors.New("name contains no characters usable for a folder slug") } result, err := h.wiki.CreateAssetFolder(ctx, in.ParentFolder, slug, in.Name) if err != nil { return nil, mutationOutput{}, err } if !result.Succeeded { return nil, mutationOutput{}, mutationError("create asset folder", result) } return nil, mutationOutput{ Succeeded: true, Message: defaultValue(result.Message, fmt.Sprintf("Asset folder %q created", slug)), }, nil } type uploadAssetInput struct { Filename string `json:"filename" jsonschema:"name of the file to upload, for example diagram.png"` Content string `json:"content,omitempty" jsonschema:"text content of the file, preserved as-is including whitespace (files must not be empty)"` ContentB64 string `json:"contentB64,omitempty" jsonschema:"base64-encoded content of the file, for example for images (alternative to content)"` Folder string `json:"folder,omitempty" jsonschema:"optional asset folder path, for example images (empty means the root folder)"` MimeType string `json:"mimeType,omitempty" jsonschema:"optional MIME type, for example image/png (default: guessed from the file extension)"` } type uploadAssetOutput struct { Succeeded bool `json:"succeeded" jsonschema:"whether the upload succeeded"` Message string `json:"message" jsonschema:"result message"` URL string `json:"url,omitempty" jsonschema:"absolute URL of the uploaded asset"` Markdown string `json:"markdown,omitempty" jsonschema:"ready-to-use markdown reference for the asset, paste this into a page"` } func (h *handlers) uploadAsset(ctx context.Context, _ *mcp.CallToolRequest, in uploadAssetInput) (*mcp.CallToolResult, uploadAssetOutput, error) { if strings.TrimSpace(in.Filename) == "" { return nil, uploadAssetOutput{}, errors.New("filename must not be empty") } if err := validateAssetFolderPath(in.Folder); err != nil { return nil, uploadAssetOutput{}, err } // Presence, not emptiness, decides which source wins: an intentionally // empty file would otherwise be indistinguishable from an omitted field. hasContent := in.Content != "" hasB64 := strings.TrimSpace(in.ContentB64) != "" if hasContent == hasB64 { return nil, uploadAssetOutput{}, errors.New("provide exactly one of content or contentB64") } if in.MimeType != "" { if _, _, err := mime.ParseMediaType(in.MimeType); err != nil { return nil, uploadAssetOutput{}, fmt.Errorf("invalid mimeType %q: %v", in.MimeType, err) } } var data []byte switch { case hasContent: data = []byte(in.Content) default: decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(in.ContentB64)) if err != nil { return nil, uploadAssetOutput{}, fmt.Errorf("contentB64 is not valid base64: %v", err) } data = decoded } result, uploaded, err := h.wiki.UploadAsset(ctx, in.Folder, in.Filename, data, in.MimeType) if err != nil { return nil, uploadAssetOutput{}, err } if !result.Succeeded { return nil, uploadAssetOutput{}, mutationError("upload asset", result) } return nil, uploadAssetOutput{ Succeeded: true, Message: fmt.Sprintf("Asset %q uploaded", uploaded.Filename), URL: uploaded.URL, Markdown: assetMarkdown(wikijs.Asset{FileName: uploaded.Filename, URL: uploaded.URL}), }, nil } // assetMarkdown builds the markdown reference WikiJS's asset UI generates: // images are embedded with an image reference, other files as plain links. func assetMarkdown(asset wikijs.Asset) string { if asset.Kind == "" || asset.Kind == "IMAGE" { return fmt.Sprintf("![%s](%s)", asset.FileName, asset.URL) } return fmt.Sprintf("[%s](%s)", asset.FileName, asset.URL) } func deriveAssetSlug(name string) string { name = strings.ToLower(strings.TrimSpace(name)) var b strings.Builder previousDash := false for _, r := range name { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_': b.WriteRune(r) previousDash = false case r == '-': if !previousDash { b.WriteRune(r) } previousDash = true default: if b.Len() > 0 && !previousDash { b.WriteByte('-') previousDash = true } } } return strings.Trim(b.String(), "-") }