package server import ( "context" "errors" "fmt" "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 } result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{ ID: in.ID, 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("update page", result) } return nil, mutationOutput{ Succeeded: true, Message: defaultValue(result.Message, "Page updated successfully"), }, nil } 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 } // 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"}}, } }