tools.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package server
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "regexp"
  7. "strings"
  8. "wikijs-mcp/internal/wikijs"
  9. "github.com/modelcontextprotocol/go-sdk/mcp"
  10. )
  11. var localePattern = regexp.MustCompile(`^[a-z]{2}(-[A-Z]{2})?$`)
  12. type handlers struct {
  13. wiki *wikijs.Client
  14. }
  15. type searchPagesInput struct {
  16. Query string `json:"query" jsonschema:"full-text search query"`
  17. Path string `json:"path,omitempty" jsonschema:"optional path to limit the search scope"`
  18. Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"`
  19. }
  20. func (h *handlers) searchPages(ctx context.Context, _ *mcp.CallToolRequest, in searchPagesInput) (*mcp.CallToolResult, wikijs.SearchResults, error) {
  21. if strings.TrimSpace(in.Query) == "" {
  22. return nil, wikijs.SearchResults{}, errors.New("query must not be empty")
  23. }
  24. if err := validateLocale(in.Locale, false); err != nil {
  25. return nil, wikijs.SearchResults{}, err
  26. }
  27. result, err := h.wiki.SearchPages(ctx, in.Query, in.Path, in.Locale)
  28. if err != nil {
  29. return nil, wikijs.SearchResults{}, err
  30. }
  31. return nil, result, nil
  32. }
  33. type pageByIDInput struct {
  34. ID int `json:"id" jsonschema:"numeric ID of the page"`
  35. }
  36. func (h *handlers) pageByID(ctx context.Context, _ *mcp.CallToolRequest, in pageByIDInput) (*mcp.CallToolResult, any, error) {
  37. if in.ID <= 0 {
  38. return nil, nil, errors.New("id must be a positive integer")
  39. }
  40. page, err := h.wiki.PageByID(ctx, in.ID)
  41. if err != nil {
  42. return nil, nil, err
  43. }
  44. if page == nil {
  45. return nullResult(), nil, nil
  46. }
  47. return nil, page, nil
  48. }
  49. type pageByPathInput struct {
  50. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  51. Locale string `json:"locale" jsonschema:"locale of the page, for example en or de-DE"`
  52. }
  53. func (h *handlers) pageByPath(ctx context.Context, _ *mcp.CallToolRequest, in pageByPathInput) (*mcp.CallToolResult, any, error) {
  54. if strings.TrimSpace(in.Path) == "" {
  55. return nil, nil, errors.New("path must not be empty")
  56. }
  57. if err := validateLocale(in.Locale, true); err != nil {
  58. return nil, nil, err
  59. }
  60. page, err := h.wiki.PageByPath(ctx, in.Path, in.Locale)
  61. if err != nil {
  62. return nil, nil, err
  63. }
  64. if page == nil {
  65. return nullResult(), nil, nil
  66. }
  67. return nil, page, nil
  68. }
  69. type listPagesInput struct {
  70. Limit *int `json:"limit,omitempty" jsonschema:"maximum number of pages to return"`
  71. Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"`
  72. Tags []string `json:"tags,omitempty" jsonschema:"optional tags to filter by"`
  73. }
  74. func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, []wikijs.PageListItem, error) {
  75. if in.Limit != nil && *in.Limit <= 0 {
  76. return nil, nil, errors.New("limit must be a positive integer")
  77. }
  78. if err := validateLocale(in.Locale, false); err != nil {
  79. return nil, nil, err
  80. }
  81. pages, err := h.wiki.ListPages(ctx, wikijs.ListOptions{
  82. Limit: in.Limit,
  83. Locale: in.Locale,
  84. Tags: in.Tags,
  85. })
  86. if err != nil {
  87. return nil, nil, err
  88. }
  89. return nil, pages, nil
  90. }
  91. type createPageInput struct {
  92. Title string `json:"title" jsonschema:"title of the page"`
  93. Content string `json:"content" jsonschema:"content of the page"`
  94. Description string `json:"description" jsonschema:"description of the page"`
  95. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  96. Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
  97. IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
  98. IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
  99. Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
  100. Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"`
  101. }
  102. type editPageInput struct {
  103. ID int `json:"id" jsonschema:"numeric ID of the page to update"`
  104. Title string `json:"title" jsonschema:"new title of the page"`
  105. Content string `json:"content" jsonschema:"new content of the page"`
  106. Description string `json:"description" jsonschema:"new description of the page"`
  107. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  108. Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
  109. IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
  110. IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
  111. Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
  112. Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"`
  113. }
  114. type mutationOutput struct {
  115. Succeeded bool `json:"succeeded" jsonschema:"whether the mutation succeeded"`
  116. Message string `json:"message" jsonschema:"result message from WikiJS"`
  117. }
  118. func (h *handlers) createPage(ctx context.Context, _ *mcp.CallToolRequest, in createPageInput) (*mcp.CallToolResult, mutationOutput, error) {
  119. if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
  120. return nil, mutationOutput{}, err
  121. }
  122. result, err := h.wiki.CreatePage(ctx, wikijs.CreatePageInput{
  123. Title: in.Title,
  124. Content: in.Content,
  125. Description: in.Description,
  126. Path: in.Path,
  127. Editor: defaultValue(in.Editor, "markdown"),
  128. IsPublished: boolValue(in.IsPublished, true),
  129. IsPrivate: boolValue(in.IsPrivate, false),
  130. Locale: defaultValue(in.Locale, "en"),
  131. Tags: nonNilStrings(in.Tags),
  132. })
  133. if err != nil {
  134. return nil, mutationOutput{}, err
  135. }
  136. if !result.Succeeded {
  137. return nil, mutationOutput{}, mutationError("create page", result)
  138. }
  139. return nil, mutationOutput{
  140. Succeeded: true,
  141. Message: defaultValue(result.Message, "Page created successfully"),
  142. }, nil
  143. }
  144. func (h *handlers) editPage(ctx context.Context, _ *mcp.CallToolRequest, in editPageInput) (*mcp.CallToolResult, mutationOutput, error) {
  145. if in.ID <= 0 {
  146. return nil, mutationOutput{}, errors.New("id must be a positive integer")
  147. }
  148. if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
  149. return nil, mutationOutput{}, err
  150. }
  151. result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{
  152. ID: in.ID,
  153. Title: in.Title,
  154. Content: in.Content,
  155. Description: in.Description,
  156. Path: in.Path,
  157. Editor: defaultValue(in.Editor, "markdown"),
  158. IsPublished: boolValue(in.IsPublished, true),
  159. IsPrivate: boolValue(in.IsPrivate, false),
  160. Locale: defaultValue(in.Locale, "en"),
  161. Tags: nonNilStrings(in.Tags),
  162. })
  163. if err != nil {
  164. return nil, mutationOutput{}, err
  165. }
  166. if !result.Succeeded {
  167. return nil, mutationOutput{}, mutationError("update page", result)
  168. }
  169. return nil, mutationOutput{
  170. Succeeded: true,
  171. Message: defaultValue(result.Message, "Page updated successfully"),
  172. }, nil
  173. }
  174. func validatePageInput(title, path, locale string) error {
  175. if strings.TrimSpace(title) == "" {
  176. return errors.New("title must not be empty")
  177. }
  178. if strings.TrimSpace(path) == "" {
  179. return errors.New("path must not be empty")
  180. }
  181. return validateLocale(locale, false)
  182. }
  183. func validateLocale(locale string, required bool) error {
  184. if locale == "" {
  185. if required {
  186. return errors.New("locale must not be empty")
  187. }
  188. return nil
  189. }
  190. if !localePattern.MatchString(locale) {
  191. return fmt.Errorf("invalid locale %q: use a format like en or de-DE", locale)
  192. }
  193. return nil
  194. }
  195. func mutationError(action string, result wikijs.MutationResult) error {
  196. message := defaultValue(result.Message, "unknown error")
  197. if result.ErrorCode != 0 {
  198. return fmt.Errorf("failed to %s: %s (error code %d)", action, message, result.ErrorCode)
  199. }
  200. return fmt.Errorf("failed to %s: %s", action, message)
  201. }
  202. func defaultValue(value, fallback string) string {
  203. if value == "" {
  204. return fallback
  205. }
  206. return value
  207. }
  208. func boolValue(value *bool, fallback bool) bool {
  209. if value == nil {
  210. return fallback
  211. }
  212. return *value
  213. }
  214. func nonNilStrings(values []string) []string {
  215. if values == nil {
  216. return []string{}
  217. }
  218. return values
  219. }
  220. // nullResult preserves the original server's "null" response for a page that
  221. // does not exist. A typed nil page would be coerced to an empty object by the
  222. // MCP SDK's output schema handling.
  223. func nullResult() *mcp.CallToolResult {
  224. return &mcp.CallToolResult{
  225. Content: []mcp.Content{&mcp.TextContent{Text: "null"}},
  226. }
  227. }