tools.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. package server
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "regexp"
  7. "strings"
  8. "gogs.netdome.biz/paul/go-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. // listPagesOutput wraps the page list in an object. The MCP spec requires
  75. // outputSchema.type to be the string "object" whenever a structured output
  76. // schema is declared, so this tool must not return a bare array: strict
  77. // clients reject the array-typed schema at tools/list time.
  78. type listPagesOutput struct {
  79. Pages []wikijs.PageListItem `json:"pages" jsonschema:"matching pages"`
  80. }
  81. func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, listPagesOutput, error) {
  82. if in.Limit != nil && *in.Limit <= 0 {
  83. return nil, listPagesOutput{}, errors.New("limit must be a positive integer")
  84. }
  85. if err := validateLocale(in.Locale, false); err != nil {
  86. return nil, listPagesOutput{}, err
  87. }
  88. pages, err := h.wiki.ListPages(ctx, wikijs.ListOptions{
  89. Limit: in.Limit,
  90. Locale: in.Locale,
  91. Tags: in.Tags,
  92. })
  93. if err != nil {
  94. return nil, listPagesOutput{}, err
  95. }
  96. return nil, listPagesOutput{Pages: pages}, nil
  97. }
  98. type createPageInput struct {
  99. Title string `json:"title" jsonschema:"title of the page"`
  100. Content string `json:"content" jsonschema:"content of the page"`
  101. Description string `json:"description" jsonschema:"description of the page"`
  102. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  103. Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
  104. IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
  105. IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
  106. Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
  107. Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"`
  108. }
  109. type editPageInput struct {
  110. ID int `json:"id" jsonschema:"numeric ID of the page to update"`
  111. Title string `json:"title" jsonschema:"new title of the page"`
  112. Content string `json:"content" jsonschema:"new content of the page"`
  113. Description string `json:"description" jsonschema:"new description of the page"`
  114. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  115. Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
  116. IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
  117. IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
  118. Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
  119. Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"`
  120. }
  121. type mutationOutput struct {
  122. Succeeded bool `json:"succeeded" jsonschema:"whether the mutation succeeded"`
  123. Message string `json:"message" jsonschema:"result message from WikiJS"`
  124. }
  125. func (h *handlers) createPage(ctx context.Context, _ *mcp.CallToolRequest, in createPageInput) (*mcp.CallToolResult, mutationOutput, error) {
  126. if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
  127. return nil, mutationOutput{}, err
  128. }
  129. result, err := h.wiki.CreatePage(ctx, wikijs.CreatePageInput{
  130. Title: in.Title,
  131. Content: in.Content,
  132. Description: in.Description,
  133. Path: in.Path,
  134. Editor: defaultValue(in.Editor, "markdown"),
  135. IsPublished: boolValue(in.IsPublished, true),
  136. IsPrivate: boolValue(in.IsPrivate, false),
  137. Locale: defaultValue(in.Locale, "en"),
  138. Tags: nonNilStrings(in.Tags),
  139. })
  140. if err != nil {
  141. return nil, mutationOutput{}, err
  142. }
  143. if !result.Succeeded {
  144. return nil, mutationOutput{}, mutationError("create page", result)
  145. }
  146. return nil, mutationOutput{
  147. Succeeded: true,
  148. Message: defaultValue(result.Message, "Page created successfully"),
  149. }, nil
  150. }
  151. func (h *handlers) editPage(ctx context.Context, _ *mcp.CallToolRequest, in editPageInput) (*mcp.CallToolResult, mutationOutput, error) {
  152. if in.ID <= 0 {
  153. return nil, mutationOutput{}, errors.New("id must be a positive integer")
  154. }
  155. if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
  156. return nil, mutationOutput{}, err
  157. }
  158. result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{
  159. ID: in.ID,
  160. Title: in.Title,
  161. Content: in.Content,
  162. Description: in.Description,
  163. Path: in.Path,
  164. Editor: defaultValue(in.Editor, "markdown"),
  165. IsPublished: boolValue(in.IsPublished, true),
  166. IsPrivate: boolValue(in.IsPrivate, false),
  167. Locale: defaultValue(in.Locale, "en"),
  168. Tags: nonNilStrings(in.Tags),
  169. })
  170. if err != nil {
  171. return nil, mutationOutput{}, err
  172. }
  173. if !result.Succeeded {
  174. return nil, mutationOutput{}, mutationError("update page", result)
  175. }
  176. return nil, mutationOutput{
  177. Succeeded: true,
  178. Message: defaultValue(result.Message, "Page updated successfully"),
  179. }, nil
  180. }
  181. func validatePageInput(title, path, locale string) error {
  182. if strings.TrimSpace(title) == "" {
  183. return errors.New("title must not be empty")
  184. }
  185. if strings.TrimSpace(path) == "" {
  186. return errors.New("path must not be empty")
  187. }
  188. return validateLocale(locale, false)
  189. }
  190. func validateLocale(locale string, required bool) error {
  191. if locale == "" {
  192. if required {
  193. return errors.New("locale must not be empty")
  194. }
  195. return nil
  196. }
  197. if !localePattern.MatchString(locale) {
  198. return fmt.Errorf("invalid locale %q: use a format like en or de-DE", locale)
  199. }
  200. return nil
  201. }
  202. func mutationError(action string, result wikijs.MutationResult) error {
  203. message := defaultValue(result.Message, "unknown error")
  204. if result.ErrorCode != 0 {
  205. return fmt.Errorf("failed to %s: %s (error code %d)", action, message, result.ErrorCode)
  206. }
  207. return fmt.Errorf("failed to %s: %s", action, message)
  208. }
  209. func defaultValue(value, fallback string) string {
  210. if value == "" {
  211. return fallback
  212. }
  213. return value
  214. }
  215. func boolValue(value *bool, fallback bool) bool {
  216. if value == nil {
  217. return fallback
  218. }
  219. return *value
  220. }
  221. func nonNilStrings(values []string) []string {
  222. if values == nil {
  223. return []string{}
  224. }
  225. return values
  226. }
  227. // nullResult preserves the original server's "null" response for a page that
  228. // does not exist. A typed nil page would be coerced to an empty object by the
  229. // MCP SDK's output schema handling.
  230. func nullResult() *mcp.CallToolResult {
  231. return &mcp.CallToolResult{
  232. Content: []mcp.Content{&mcp.TextContent{Text: "null"}},
  233. }
  234. }