tools.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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. // Read the current page so that fields the caller did not explicitly set
  161. // (publish state, privacy, locale, tags) are preserved instead of being
  162. // overwritten with defaults. The pages.update mutation requires all of
  163. // them, so "leave unchanged" is only possible by re-sending the existing
  164. // values.
  165. existing, err := h.wiki.PageByID(ctx, in.ID)
  166. if err != nil {
  167. return nil, mutationOutput{}, fmt.Errorf("failed to load page: %w", err)
  168. }
  169. if existing == nil {
  170. return nil, mutationOutput{}, fmt.Errorf("failed to update page: Page not found")
  171. }
  172. path := in.Path
  173. if strings.TrimSpace(path) == "" {
  174. path = existing.Path
  175. }
  176. locale := in.Locale
  177. if strings.TrimSpace(locale) == "" {
  178. locale = existing.Locale
  179. }
  180. if strings.TrimSpace(locale) == "" {
  181. locale = "en"
  182. }
  183. isPublished := existing.IsPublished
  184. if in.IsPublished != nil {
  185. isPublished = *in.IsPublished
  186. }
  187. isPrivate := existing.IsPrivate
  188. if in.IsPrivate != nil {
  189. isPrivate = *in.IsPrivate
  190. }
  191. tags := existingTags(existing.Tags)
  192. if in.Tags != nil {
  193. tags = nonNilStrings(in.Tags)
  194. }
  195. result, err := h.wiki.UpdatePage(ctx, wikijs.UpdatePageInput{
  196. ID: in.ID,
  197. Title: in.Title,
  198. Content: in.Content,
  199. Description: in.Description,
  200. Path: path,
  201. Editor: defaultValue(in.Editor, "markdown"),
  202. IsPublished: isPublished,
  203. IsPrivate: isPrivate,
  204. Locale: locale,
  205. Tags: tags,
  206. })
  207. if err != nil {
  208. return nil, mutationOutput{}, err
  209. }
  210. if !result.Succeeded {
  211. return nil, mutationOutput{}, mutationError("update page", result)
  212. }
  213. return nil, mutationOutput{
  214. Succeeded: true,
  215. Message: defaultValue(result.Message, "Page updated successfully"),
  216. }, nil
  217. }
  218. // validateAssetFolderPath delegates to the wikijs client so that the handler
  219. // and the folder resolver share one definition of a valid asset folder path.
  220. func validateAssetFolderPath(folderPath string) error {
  221. _, err := wikijs.AssetFolderPathSegments(folderPath)
  222. return err
  223. }
  224. func validatePageInput(title, path, locale string) error {
  225. if strings.TrimSpace(title) == "" {
  226. return errors.New("title must not be empty")
  227. }
  228. if strings.TrimSpace(path) == "" {
  229. return errors.New("path must not be empty")
  230. }
  231. return validateLocale(locale, false)
  232. }
  233. func validateLocale(locale string, required bool) error {
  234. if locale == "" {
  235. if required {
  236. return errors.New("locale must not be empty")
  237. }
  238. return nil
  239. }
  240. if !localePattern.MatchString(locale) {
  241. return fmt.Errorf("invalid locale %q: use a format like en or de-DE", locale)
  242. }
  243. return nil
  244. }
  245. func mutationError(action string, result wikijs.MutationResult) error {
  246. message := defaultValue(result.Message, "unknown error")
  247. if result.ErrorCode != 0 {
  248. return fmt.Errorf("failed to %s: %s (error code %d)", action, message, result.ErrorCode)
  249. }
  250. return fmt.Errorf("failed to %s: %s", action, message)
  251. }
  252. func defaultValue(value, fallback string) string {
  253. if value == "" {
  254. return fallback
  255. }
  256. return value
  257. }
  258. func boolValue(value *bool, fallback bool) bool {
  259. if value == nil {
  260. return fallback
  261. }
  262. return *value
  263. }
  264. func nonNilStrings(values []string) []string {
  265. if values == nil {
  266. return []string{}
  267. }
  268. return values
  269. }
  270. // existingTags converts the tags of an existing page (Tag structs) into the
  271. // string slice expected by UpdatePageInput, preserving them when the caller
  272. // did not pass tags.
  273. func existingTags(tags []wikijs.Tag) []string {
  274. out := make([]string, 0, len(tags))
  275. for _, t := range tags {
  276. if t.Tag != "" {
  277. out = append(out, t.Tag)
  278. }
  279. }
  280. return out
  281. }
  282. // nullResult preserves the original server's "null" response for a page that
  283. // does not exist. A typed nil page would be coerced to an empty object by the
  284. // MCP SDK's output schema handling.
  285. func nullResult() *mcp.CallToolResult {
  286. return &mcp.CallToolResult{
  287. Content: []mcp.Content{&mcp.TextContent{Text: "null"}},
  288. }
  289. }
  290. type listAssetFoldersInput struct {
  291. ParentFolder string `json:"parentFolder,omitempty" jsonschema:"optional parent asset folder path, for example images (empty means the root folder)"`
  292. }
  293. type listAssetFoldersOutput struct {
  294. Folders []wikijs.AssetFolder `json:"folders" jsonschema:"child folders of the parent folder"`
  295. }
  296. func (h *handlers) listAssetFolders(ctx context.Context, _ *mcp.CallToolRequest, in listAssetFoldersInput) (*mcp.CallToolResult, listAssetFoldersOutput, error) {
  297. if err := validateAssetFolderPath(in.ParentFolder); err != nil {
  298. return nil, listAssetFoldersOutput{}, err
  299. }
  300. folders, err := h.wiki.ListAssetFolders(ctx, in.ParentFolder)
  301. if err != nil {
  302. return nil, listAssetFoldersOutput{}, err
  303. }
  304. return nil, listAssetFoldersOutput{Folders: folders}, nil
  305. }
  306. type listAssetsInput struct {
  307. Folder string `json:"folder,omitempty" jsonschema:"optional asset folder path, for example images (empty means the root folder)"`
  308. Kind string `json:"kind,omitempty" jsonschema:"optional asset kind filter: IMAGE or BINARY (default ALL)"`
  309. }
  310. // assetOutput enriches an WikiJS asset with a ready-to-use markdown reference.
  311. type assetOutput struct {
  312. wikijs.Asset
  313. Markdown string `json:"markdown" jsonschema:"ready-to-use markdown reference for the asset"`
  314. }
  315. type listAssetsOutput struct {
  316. Assets []assetOutput `json:"assets" jsonschema:"assets in the folder"`
  317. }
  318. func (h *handlers) listAssets(ctx context.Context, _ *mcp.CallToolRequest, in listAssetsInput) (*mcp.CallToolResult, listAssetsOutput, error) {
  319. if err := validateAssetFolderPath(in.Folder); err != nil {
  320. return nil, listAssetsOutput{}, err
  321. }
  322. if in.Kind != "" && in.Kind != "IMAGE" && in.Kind != "BINARY" {
  323. return nil, listAssetsOutput{}, fmt.Errorf("invalid kind %q: use IMAGE or BINARY", in.Kind)
  324. }
  325. assets, err := h.wiki.ListAssets(ctx, in.Folder)
  326. if err != nil {
  327. return nil, listAssetsOutput{}, err
  328. }
  329. out := []assetOutput{}
  330. for _, asset := range assets {
  331. if in.Kind != "" && asset.Kind != in.Kind {
  332. continue
  333. }
  334. out = append(out, assetOutput{Asset: asset, Markdown: assetMarkdown(asset)})
  335. }
  336. return nil, listAssetsOutput{Assets: out}, nil
  337. }
  338. type createAssetFolderInput struct {
  339. Name string `json:"name" jsonschema:"name of the new asset folder; the URL slug is derived from it"`
  340. ParentFolder string `json:"parentFolder,omitempty" jsonschema:"optional parent asset folder path, for example images (empty means the root folder)"`
  341. }
  342. func (h *handlers) createAssetFolder(ctx context.Context, _ *mcp.CallToolRequest, in createAssetFolderInput) (*mcp.CallToolResult, mutationOutput, error) {
  343. if strings.TrimSpace(in.Name) == "" {
  344. return nil, mutationOutput{}, errors.New("name must not be empty")
  345. }
  346. if err := validateAssetFolderPath(in.ParentFolder); err != nil {
  347. return nil, mutationOutput{}, err
  348. }
  349. slug := deriveAssetSlug(in.Name)
  350. if slug == "" {
  351. return nil, mutationOutput{}, errors.New("name contains no characters usable for a folder slug")
  352. }
  353. result, err := h.wiki.CreateAssetFolder(ctx, in.ParentFolder, slug, in.Name)
  354. if err != nil {
  355. return nil, mutationOutput{}, err
  356. }
  357. if !result.Succeeded {
  358. return nil, mutationOutput{}, mutationError("create asset folder", result)
  359. }
  360. return nil, mutationOutput{
  361. Succeeded: true,
  362. Message: defaultValue(result.Message, fmt.Sprintf("Asset folder %q created", slug)),
  363. }, nil
  364. }
  365. type uploadAssetInput struct {
  366. Filename string `json:"filename" jsonschema:"name of the file to upload, for example diagram.png"`
  367. Content string `json:"content,omitempty" jsonschema:"text content of the file, preserved as-is including whitespace (files must not be empty)"`
  368. ContentB64 string `json:"contentB64,omitempty" jsonschema:"base64-encoded content of the file, for example for images (alternative to content)"`
  369. Folder string `json:"folder,omitempty" jsonschema:"optional asset folder path, for example images (empty means the root folder)"`
  370. MimeType string `json:"mimeType,omitempty" jsonschema:"optional MIME type, for example image/png (default: guessed from the file extension)"`
  371. }
  372. type uploadAssetOutput struct {
  373. Succeeded bool `json:"succeeded" jsonschema:"whether the upload succeeded"`
  374. Message string `json:"message" jsonschema:"result message"`
  375. URL string `json:"url,omitempty" jsonschema:"absolute URL of the uploaded asset"`
  376. Markdown string `json:"markdown,omitempty" jsonschema:"ready-to-use markdown reference for the asset, paste this into a page"`
  377. }
  378. func (h *handlers) uploadAsset(ctx context.Context, _ *mcp.CallToolRequest, in uploadAssetInput) (*mcp.CallToolResult, uploadAssetOutput, error) {
  379. if strings.TrimSpace(in.Filename) == "" {
  380. return nil, uploadAssetOutput{}, errors.New("filename must not be empty")
  381. }
  382. if err := validateAssetFolderPath(in.Folder); err != nil {
  383. return nil, uploadAssetOutput{}, err
  384. }
  385. // Presence, not emptiness, decides which source wins: an intentionally
  386. // empty file would otherwise be indistinguishable from an omitted field.
  387. hasContent := in.Content != ""
  388. hasB64 := strings.TrimSpace(in.ContentB64) != ""
  389. if hasContent == hasB64 {
  390. return nil, uploadAssetOutput{}, errors.New("provide exactly one of content or contentB64")
  391. }
  392. if in.MimeType != "" {
  393. if _, _, err := mime.ParseMediaType(in.MimeType); err != nil {
  394. return nil, uploadAssetOutput{}, fmt.Errorf("invalid mimeType %q: %v", in.MimeType, err)
  395. }
  396. }
  397. var data []byte
  398. switch {
  399. case hasContent:
  400. data = []byte(in.Content)
  401. default:
  402. decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(in.ContentB64))
  403. if err != nil {
  404. return nil, uploadAssetOutput{}, fmt.Errorf("contentB64 is not valid base64: %v", err)
  405. }
  406. data = decoded
  407. }
  408. result, uploaded, err := h.wiki.UploadAsset(ctx, in.Folder, in.Filename, data, in.MimeType)
  409. if err != nil {
  410. return nil, uploadAssetOutput{}, err
  411. }
  412. if !result.Succeeded {
  413. return nil, uploadAssetOutput{}, mutationError("upload asset", result)
  414. }
  415. return nil, uploadAssetOutput{
  416. Succeeded: true,
  417. Message: fmt.Sprintf("Asset %q uploaded", uploaded.Filename),
  418. URL: uploaded.URL,
  419. Markdown: assetMarkdown(wikijs.Asset{FileName: uploaded.Filename, URL: uploaded.URL}),
  420. }, nil
  421. }
  422. // assetMarkdown builds the markdown reference WikiJS's asset UI generates:
  423. // images are embedded with an image reference, other files as plain links.
  424. func assetMarkdown(asset wikijs.Asset) string {
  425. if asset.Kind == "" || asset.Kind == "IMAGE" {
  426. return fmt.Sprintf("![%s](%s)", asset.FileName, asset.URL)
  427. }
  428. return fmt.Sprintf("[%s](%s)", asset.FileName, asset.URL)
  429. }
  430. func deriveAssetSlug(name string) string {
  431. name = strings.ToLower(strings.TrimSpace(name))
  432. var b strings.Builder
  433. previousDash := false
  434. for _, r := range name {
  435. switch {
  436. case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
  437. b.WriteRune(r)
  438. previousDash = false
  439. case r == '-':
  440. if !previousDash {
  441. b.WriteRune(r)
  442. }
  443. previousDash = true
  444. default:
  445. if b.Len() > 0 && !previousDash {
  446. b.WriteByte('-')
  447. previousDash = true
  448. }
  449. }
  450. }
  451. return strings.Trim(b.String(), "-")
  452. }