tools.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. package server
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "mime"
  8. "regexp"
  9. "strings"
  10. "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs"
  11. "github.com/modelcontextprotocol/go-sdk/mcp"
  12. )
  13. var localePattern = regexp.MustCompile(`^[a-z]{2}(-[A-Z]{2})?$`)
  14. type handlers struct {
  15. wiki *wikijs.Client
  16. }
  17. type searchPagesInput struct {
  18. Query string `json:"query" jsonschema:"full-text search query"`
  19. Path string `json:"path,omitempty" jsonschema:"optional path to limit the search scope"`
  20. Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"`
  21. }
  22. func (h *handlers) searchPages(ctx context.Context, _ *mcp.CallToolRequest, in searchPagesInput) (*mcp.CallToolResult, wikijs.SearchResults, error) {
  23. if strings.TrimSpace(in.Query) == "" {
  24. return nil, wikijs.SearchResults{}, errors.New("query must not be empty")
  25. }
  26. if err := validateLocale(in.Locale, false); err != nil {
  27. return nil, wikijs.SearchResults{}, err
  28. }
  29. result, err := h.wiki.SearchPages(ctx, in.Query, in.Path, in.Locale)
  30. if err != nil {
  31. return nil, wikijs.SearchResults{}, err
  32. }
  33. return nil, result, nil
  34. }
  35. type pageByIDInput struct {
  36. ID int `json:"id" jsonschema:"numeric ID of the page"`
  37. }
  38. func (h *handlers) pageByID(ctx context.Context, _ *mcp.CallToolRequest, in pageByIDInput) (*mcp.CallToolResult, any, error) {
  39. if in.ID <= 0 {
  40. return nil, nil, errors.New("id must be a positive integer")
  41. }
  42. page, err := h.wiki.PageByID(ctx, in.ID)
  43. if err != nil {
  44. return nil, nil, err
  45. }
  46. if page == nil {
  47. return nullResult(), nil, nil
  48. }
  49. return nil, page, nil
  50. }
  51. type pageByPathInput struct {
  52. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  53. Locale string `json:"locale" jsonschema:"locale of the page, for example en or de-DE"`
  54. }
  55. func (h *handlers) pageByPath(ctx context.Context, _ *mcp.CallToolRequest, in pageByPathInput) (*mcp.CallToolResult, any, error) {
  56. if strings.TrimSpace(in.Path) == "" {
  57. return nil, nil, errors.New("path must not be empty")
  58. }
  59. if err := validateLocale(in.Locale, true); err != nil {
  60. return nil, nil, err
  61. }
  62. page, err := h.wiki.PageByPath(ctx, in.Path, in.Locale)
  63. if err != nil {
  64. return nil, nil, err
  65. }
  66. if page == nil {
  67. return nullResult(), nil, nil
  68. }
  69. return nil, page, nil
  70. }
  71. type listPagesInput struct {
  72. Limit *int `json:"limit,omitempty" jsonschema:"maximum number of pages to return"`
  73. Locale string `json:"locale,omitempty" jsonschema:"optional locale, for example en or de-DE"`
  74. Tags []string `json:"tags,omitempty" jsonschema:"optional tags to filter by"`
  75. }
  76. // listPagesOutput wraps the page list in an object. The MCP spec requires
  77. // outputSchema.type to be the string "object" whenever a structured output
  78. // schema is declared, so this tool must not return a bare array: strict
  79. // clients reject the array-typed schema at tools/list time.
  80. type listPagesOutput struct {
  81. Pages []wikijs.PageListItem `json:"pages" jsonschema:"matching pages"`
  82. }
  83. func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, listPagesOutput, error) {
  84. if in.Limit != nil && *in.Limit <= 0 {
  85. return nil, listPagesOutput{}, errors.New("limit must be a positive integer")
  86. }
  87. if err := validateLocale(in.Locale, false); err != nil {
  88. return nil, listPagesOutput{}, err
  89. }
  90. pages, err := h.wiki.ListPages(ctx, wikijs.ListOptions{
  91. Limit: in.Limit,
  92. Locale: in.Locale,
  93. Tags: in.Tags,
  94. })
  95. if err != nil {
  96. return nil, listPagesOutput{}, err
  97. }
  98. return nil, listPagesOutput{Pages: pages}, nil
  99. }
  100. type createPageInput struct {
  101. Title string `json:"title" jsonschema:"title of the page"`
  102. Content string `json:"content" jsonschema:"content of the page"`
  103. Description string `json:"description" jsonschema:"description of the page"`
  104. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  105. Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
  106. IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
  107. IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
  108. Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
  109. Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"`
  110. }
  111. type editPageInput struct {
  112. ID int `json:"id" jsonschema:"numeric ID of the page to update"`
  113. Title string `json:"title" jsonschema:"new title of the page"`
  114. Content string `json:"content" jsonschema:"new content of the page"`
  115. Description string `json:"description" jsonschema:"new description of the page"`
  116. Path string `json:"path" jsonschema:"path of the page, for example /dev/go"`
  117. Editor string `json:"editor,omitempty" jsonschema:"editor type: markdown or visual (default markdown)"`
  118. IsPublished *bool `json:"isPublished,omitempty" jsonschema:"whether the page is published (default true)"`
  119. IsPrivate *bool `json:"isPrivate,omitempty" jsonschema:"whether the page is private (default false)"`
  120. Locale string `json:"locale,omitempty" jsonschema:"locale of the page (default en)"`
  121. Tags []string `json:"tags,omitempty" jsonschema:"tags for the page"`
  122. }
  123. type mutationOutput struct {
  124. Succeeded bool `json:"succeeded" jsonschema:"whether the mutation succeeded"`
  125. Message string `json:"message" jsonschema:"result message from WikiJS"`
  126. }
  127. func (h *handlers) createPage(ctx context.Context, _ *mcp.CallToolRequest, in createPageInput) (*mcp.CallToolResult, mutationOutput, error) {
  128. if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
  129. return nil, mutationOutput{}, err
  130. }
  131. result, err := h.wiki.CreatePage(ctx, wikijs.CreatePageInput{
  132. Title: in.Title,
  133. Content: in.Content,
  134. Description: in.Description,
  135. Path: in.Path,
  136. Editor: defaultValue(in.Editor, "markdown"),
  137. IsPublished: boolValue(in.IsPublished, true),
  138. IsPrivate: boolValue(in.IsPrivate, false),
  139. Locale: defaultValue(in.Locale, "en"),
  140. Tags: nonNilStrings(in.Tags),
  141. })
  142. if err != nil {
  143. return nil, mutationOutput{}, err
  144. }
  145. if !result.Succeeded {
  146. return nil, mutationOutput{}, mutationError("create page", result)
  147. }
  148. return nil, mutationOutput{
  149. Succeeded: true,
  150. Message: defaultValue(result.Message, "Page created successfully"),
  151. }, nil
  152. }
  153. func (h *handlers) editPage(ctx context.Context, _ *mcp.CallToolRequest, in editPageInput) (*mcp.CallToolResult, mutationOutput, error) {
  154. if in.ID <= 0 {
  155. return nil, mutationOutput{}, errors.New("id must be a positive integer")
  156. }
  157. if err := validatePageInput(in.Title, in.Path, in.Locale); err != nil {
  158. return nil, mutationOutput{}, err
  159. }
  160. result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{
  161. ID: in.ID,
  162. Title: in.Title,
  163. Content: in.Content,
  164. Description: in.Description,
  165. Path: in.Path,
  166. Editor: defaultValue(in.Editor, "markdown"),
  167. IsPublished: boolValue(in.IsPublished, true),
  168. IsPrivate: boolValue(in.IsPrivate, false),
  169. Locale: defaultValue(in.Locale, "en"),
  170. Tags: nonNilStrings(in.Tags),
  171. })
  172. if err != nil {
  173. return nil, mutationOutput{}, err
  174. }
  175. if !result.Succeeded {
  176. return nil, mutationOutput{}, mutationError("update page", result)
  177. }
  178. return nil, mutationOutput{
  179. Succeeded: true,
  180. Message: defaultValue(result.Message, "Page updated successfully"),
  181. }, nil
  182. }
  183. // validateAssetFolderPath delegates to the wikijs client so that the handler
  184. // and the folder resolver share one definition of a valid asset folder path.
  185. func validateAssetFolderPath(folderPath string) error {
  186. _, err := wikijs.AssetFolderPathSegments(folderPath)
  187. return err
  188. }
  189. func validatePageInput(title, path, locale string) error {
  190. if strings.TrimSpace(title) == "" {
  191. return errors.New("title must not be empty")
  192. }
  193. if strings.TrimSpace(path) == "" {
  194. return errors.New("path must not be empty")
  195. }
  196. return validateLocale(locale, false)
  197. }
  198. func validateLocale(locale string, required bool) error {
  199. if locale == "" {
  200. if required {
  201. return errors.New("locale must not be empty")
  202. }
  203. return nil
  204. }
  205. if !localePattern.MatchString(locale) {
  206. return fmt.Errorf("invalid locale %q: use a format like en or de-DE", locale)
  207. }
  208. return nil
  209. }
  210. func mutationError(action string, result wikijs.MutationResult) error {
  211. message := defaultValue(result.Message, "unknown error")
  212. if result.ErrorCode != 0 {
  213. return fmt.Errorf("failed to %s: %s (error code %d)", action, message, result.ErrorCode)
  214. }
  215. return fmt.Errorf("failed to %s: %s", action, message)
  216. }
  217. func defaultValue(value, fallback string) string {
  218. if value == "" {
  219. return fallback
  220. }
  221. return value
  222. }
  223. func boolValue(value *bool, fallback bool) bool {
  224. if value == nil {
  225. return fallback
  226. }
  227. return *value
  228. }
  229. func nonNilStrings(values []string) []string {
  230. if values == nil {
  231. return []string{}
  232. }
  233. return values
  234. }
  235. // nullResult preserves the original server's "null" response for a page that
  236. // does not exist. A typed nil page would be coerced to an empty object by the
  237. // MCP SDK's output schema handling.
  238. func nullResult() *mcp.CallToolResult {
  239. return &mcp.CallToolResult{
  240. Content: []mcp.Content{&mcp.TextContent{Text: "null"}},
  241. }
  242. }
  243. type listAssetFoldersInput struct {
  244. ParentFolder string `json:"parentFolder,omitempty" jsonschema:"optional parent asset folder path, for example images (empty means the root folder)"`
  245. }
  246. type listAssetFoldersOutput struct {
  247. Folders []wikijs.AssetFolder `json:"folders" jsonschema:"child folders of the parent folder"`
  248. }
  249. func (h *handlers) listAssetFolders(ctx context.Context, _ *mcp.CallToolRequest, in listAssetFoldersInput) (*mcp.CallToolResult, listAssetFoldersOutput, error) {
  250. if err := validateAssetFolderPath(in.ParentFolder); err != nil {
  251. return nil, listAssetFoldersOutput{}, err
  252. }
  253. folders, err := h.wiki.ListAssetFolders(ctx, in.ParentFolder)
  254. if err != nil {
  255. return nil, listAssetFoldersOutput{}, err
  256. }
  257. return nil, listAssetFoldersOutput{Folders: folders}, nil
  258. }
  259. type listAssetsInput struct {
  260. Folder string `json:"folder,omitempty" jsonschema:"optional asset folder path, for example images (empty means the root folder)"`
  261. Kind string `json:"kind,omitempty" jsonschema:"optional asset kind filter: IMAGE or BINARY (default ALL)"`
  262. }
  263. // assetOutput enriches an WikiJS asset with a ready-to-use markdown reference.
  264. type assetOutput struct {
  265. wikijs.Asset
  266. Markdown string `json:"markdown" jsonschema:"ready-to-use markdown reference for the asset"`
  267. }
  268. type listAssetsOutput struct {
  269. Assets []assetOutput `json:"assets" jsonschema:"assets in the folder"`
  270. }
  271. func (h *handlers) listAssets(ctx context.Context, _ *mcp.CallToolRequest, in listAssetsInput) (*mcp.CallToolResult, listAssetsOutput, error) {
  272. if err := validateAssetFolderPath(in.Folder); err != nil {
  273. return nil, listAssetsOutput{}, err
  274. }
  275. if in.Kind != "" && in.Kind != "IMAGE" && in.Kind != "BINARY" {
  276. return nil, listAssetsOutput{}, fmt.Errorf("invalid kind %q: use IMAGE or BINARY", in.Kind)
  277. }
  278. assets, err := h.wiki.ListAssets(ctx, in.Folder)
  279. if err != nil {
  280. return nil, listAssetsOutput{}, err
  281. }
  282. out := []assetOutput{}
  283. for _, asset := range assets {
  284. if in.Kind != "" && asset.Kind != in.Kind {
  285. continue
  286. }
  287. out = append(out, assetOutput{Asset: asset, Markdown: assetMarkdown(asset)})
  288. }
  289. return nil, listAssetsOutput{Assets: out}, nil
  290. }
  291. type createAssetFolderInput struct {
  292. Name string `json:"name" jsonschema:"name of the new asset folder; the URL slug is derived from it"`
  293. ParentFolder string `json:"parentFolder,omitempty" jsonschema:"optional parent asset folder path, for example images (empty means the root folder)"`
  294. }
  295. func (h *handlers) createAssetFolder(ctx context.Context, _ *mcp.CallToolRequest, in createAssetFolderInput) (*mcp.CallToolResult, mutationOutput, error) {
  296. if strings.TrimSpace(in.Name) == "" {
  297. return nil, mutationOutput{}, errors.New("name must not be empty")
  298. }
  299. if err := validateAssetFolderPath(in.ParentFolder); err != nil {
  300. return nil, mutationOutput{}, err
  301. }
  302. slug := deriveAssetSlug(in.Name)
  303. if slug == "" {
  304. return nil, mutationOutput{}, errors.New("name contains no characters usable for a folder slug")
  305. }
  306. result, err := h.wiki.CreateAssetFolder(ctx, in.ParentFolder, slug, in.Name)
  307. if err != nil {
  308. return nil, mutationOutput{}, err
  309. }
  310. if !result.Succeeded {
  311. return nil, mutationOutput{}, mutationError("create asset folder", result)
  312. }
  313. return nil, mutationOutput{
  314. Succeeded: true,
  315. Message: defaultValue(result.Message, fmt.Sprintf("Asset folder %q created", slug)),
  316. }, nil
  317. }
  318. type uploadAssetInput struct {
  319. Filename string `json:"filename" jsonschema:"name of the file to upload, for example diagram.png"`
  320. Content string `json:"content,omitempty" jsonschema:"text content of the file, preserved as-is including whitespace (files must not be empty)"`
  321. ContentB64 string `json:"contentB64,omitempty" jsonschema:"base64-encoded content of the file, for example for images (alternative to content)"`
  322. Folder string `json:"folder,omitempty" jsonschema:"optional asset folder path, for example images (empty means the root folder)"`
  323. MimeType string `json:"mimeType,omitempty" jsonschema:"optional MIME type, for example image/png (default: guessed from the file extension)"`
  324. }
  325. type uploadAssetOutput struct {
  326. Succeeded bool `json:"succeeded" jsonschema:"whether the upload succeeded"`
  327. Message string `json:"message" jsonschema:"result message"`
  328. URL string `json:"url,omitempty" jsonschema:"absolute URL of the uploaded asset"`
  329. Markdown string `json:"markdown,omitempty" jsonschema:"ready-to-use markdown reference for the asset, paste this into a page"`
  330. }
  331. func (h *handlers) uploadAsset(ctx context.Context, _ *mcp.CallToolRequest, in uploadAssetInput) (*mcp.CallToolResult, uploadAssetOutput, error) {
  332. if strings.TrimSpace(in.Filename) == "" {
  333. return nil, uploadAssetOutput{}, errors.New("filename must not be empty")
  334. }
  335. if err := validateAssetFolderPath(in.Folder); err != nil {
  336. return nil, uploadAssetOutput{}, err
  337. }
  338. // Presence, not emptiness, decides which source wins: an intentionally
  339. // empty file would otherwise be indistinguishable from an omitted field.
  340. hasContent := in.Content != ""
  341. hasB64 := strings.TrimSpace(in.ContentB64) != ""
  342. if hasContent == hasB64 {
  343. return nil, uploadAssetOutput{}, errors.New("provide exactly one of content or contentB64")
  344. }
  345. if in.MimeType != "" {
  346. if _, _, err := mime.ParseMediaType(in.MimeType); err != nil {
  347. return nil, uploadAssetOutput{}, fmt.Errorf("invalid mimeType %q: %v", in.MimeType, err)
  348. }
  349. }
  350. var data []byte
  351. switch {
  352. case hasContent:
  353. data = []byte(in.Content)
  354. default:
  355. decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(in.ContentB64))
  356. if err != nil {
  357. return nil, uploadAssetOutput{}, fmt.Errorf("contentB64 is not valid base64: %v", err)
  358. }
  359. data = decoded
  360. }
  361. result, uploaded, err := h.wiki.UploadAsset(ctx, in.Folder, in.Filename, data, in.MimeType)
  362. if err != nil {
  363. return nil, uploadAssetOutput{}, err
  364. }
  365. if !result.Succeeded {
  366. return nil, uploadAssetOutput{}, mutationError("upload asset", result)
  367. }
  368. return nil, uploadAssetOutput{
  369. Succeeded: true,
  370. Message: fmt.Sprintf("Asset %q uploaded", uploaded.Filename),
  371. URL: uploaded.URL,
  372. Markdown: assetMarkdown(wikijs.Asset{FileName: uploaded.Filename, URL: uploaded.URL}),
  373. }, nil
  374. }
  375. // assetMarkdown builds the markdown reference WikiJS's asset UI generates:
  376. // images are embedded with an image reference, other files as plain links.
  377. func assetMarkdown(asset wikijs.Asset) string {
  378. if asset.Kind == "" || asset.Kind == "IMAGE" {
  379. return fmt.Sprintf("![%s](%s)", asset.FileName, asset.URL)
  380. }
  381. return fmt.Sprintf("[%s](%s)", asset.FileName, asset.URL)
  382. }
  383. func deriveAssetSlug(name string) string {
  384. name = strings.ToLower(strings.TrimSpace(name))
  385. var b strings.Builder
  386. previousDash := false
  387. for _, r := range name {
  388. switch {
  389. case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
  390. b.WriteRune(r)
  391. previousDash = false
  392. case r == '-':
  393. if !previousDash {
  394. b.WriteRune(r)
  395. }
  396. previousDash = true
  397. default:
  398. if b.Len() > 0 && !previousDash {
  399. b.WriteByte('-')
  400. previousDash = true
  401. }
  402. }
  403. }
  404. return strings.Trim(b.String(), "-")
  405. }