| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- 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
- }
- 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
- }
- // 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
- }
- // 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("", 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(), "-")
- }
|