ソースを参照

Add WIKIJS_MAX_UPLOAD auto-detection and dedupe folder path logic

- internal/wikijs: new uploadLimit(ctx) probes the site config once per
  client and falls back to DefaultMaxUploadBytes (5 MiB) when the API
  key lacks the manage:system permission
- truncate() is now rune-safe: it decodes the last rune instead of
  splitting UTF-8 sequences at byte boundaries
- internal/server: validateAssetFolderPath delegates to
  wikijs.AssetFolderPathSegments, removing the duplicated slug regex
- tests: cover the upload limit probe, empty-file rejection, and the
  renamed contract between handler validation and the resolver
Paul Klumpp 3 日 前
親
コミット
a3c4cc1535

+ 13 - 4
README.md

@@ -14,7 +14,10 @@ single static binary.
 
 - `search_pages`, `get_page_by_id`, `get_page_by_path`, and `get_all_pages` for
   reading a wiki.
-- `create_page` and `edit_page` when editing is explicitly enabled.
+- `list_assets` and `list_asset_folders` to browse uploaded files, and
+  `upload_asset` / `create_asset_folder` when editing is explicitly enabled.
+- Every asset result includes an absolute URL and a ready-to-use markdown
+  reference.
 - Absolute page URLs in every result.
 - **stdio** transport for local MCP clients.
 - **Streamable HTTP** transport with bearer-token authentication required for
@@ -29,7 +32,8 @@ single static binary.
   dependencies.
 - A WikiJS instance and an API key. In WikiJS, open **Administration → API
   Access**, enable the API, create a key, and copy it. Read access is enough for
-  the four read tools; `create_page` and `edit_page` need write access.
+  the read tools; `create_page`, `edit_page`, and asset uploads need write
+  access (`write:assets` permission for uploads).
 
 ## Build
 
@@ -94,7 +98,8 @@ claude mcp add wikijs \
   -- /absolute/path/to/bin/wikijs-mcp
 ```
 
-Add `-e WIKIJS_ENABLE_EDIT=true` to expose the page-editing tools.
+Add `-e WIKIJS_ENABLE_EDIT=true` to expose the page-editing tools and asset
+uploads.
 
 ### Claude Desktop and Cursor
 
@@ -201,7 +206,7 @@ Notes for deployments:
 |----------|----------|---------|-------------|
 | `WIKIJS_URL` | yes | – | Base URL of the WikiJS instance |
 | `WIKIJS_API_KEY` | yes | – | WikiJS API key, sent as a bearer token |
-| `WIKIJS_ENABLE_EDIT` | no | `false` | Expose `create_page` and `edit_page` |
+| `WIKIJS_ENABLE_EDIT` | no | `false` | Expose `create_page`, `edit_page`, `upload_asset`, and `create_asset_folder` |
 | `WIKIJS_TIMEOUT` | no | `30s` | Timeout for one GraphQL request |
 | `MCP_TRANSPORT` | no | `stdio` | `stdio` or `http` |
 | `MCP_ADDR` | no | `127.0.0.1:8080` | HTTP listen address |
@@ -222,8 +227,12 @@ The four read tools are always available. Editing is disabled by default; set
 | `get_page_by_id` | `id` (required) | Full page by numeric ID, or `null`. |
 | `get_page_by_path` | `path`, `locale` (both required) | Full page by path and locale, or `null`. |
 | `get_all_pages` | `limit`, `locale`, `tags` | List pages with optional filters. Returns `{"pages": [...]}`. |
+| `list_asset_folders` | `parentFolder` | List the child folders of an asset folder (empty = root). |
+| `list_assets` | `folder`, `kind` | List the files in an asset folder. `kind` filters by `IMAGE` or `BINARY`. Each asset includes its absolute `url` and a ready-to-use `markdown` reference. |
+| `upload_asset` | `filename`, `content` or `contentB64`, `folder`, `mimeType` | Upload a file to an asset folder (empty = root). Returns the absolute `url` and a `markdown` reference to paste into a page. |
 | `create_page` | `title`, `content`, `description`, `path` (required); `editor`, `isPublished`, `isPrivate`, `locale`, `tags` | Create a page. |
 | `edit_page` | `id`, `title`, `content`, `description`, `path` (required); `editor`, `isPublished`, `isPrivate`, `locale`, `tags` | Update a page. |
+| `create_asset_folder` | `name` (required), `parentFolder` | Create a new asset folder; the slug is derived from the name. |
 
 Defaults for the write tools: `editor=markdown`, `isPublished=true`,
 `isPrivate=false`, `locale=en`, `tags=[]`.

+ 14 - 4
internal/server/contract_test.go

@@ -91,10 +91,20 @@ var toolArgContract = map[string]struct {
 	required []string
 	optional []string
 }{
-	"search_pages":     {required: []string{"query"}, optional: []string{"locale", "path"}},
-	"get_page_by_id":   {required: []string{"id"}},
-	"get_page_by_path": {required: []string{"locale", "path"}},
-	"get_all_pages":    {optional: []string{"limit", "locale", "tags"}},
+	"search_pages":       {required: []string{"query"}, optional: []string{"locale", "path"}},
+	"get_page_by_id":     {required: []string{"id"}},
+	"get_page_by_path":   {required: []string{"locale", "path"}},
+	"get_all_pages":      {optional: []string{"limit", "locale", "tags"}},
+	"list_asset_folders": {optional: []string{"parentFolder"}},
+	"list_assets":        {optional: []string{"folder", "kind"}},
+	"create_asset_folder": {
+		required: []string{"name"},
+		optional: []string{"parentFolder"},
+	},
+	"upload_asset": {
+		required: []string{"filename"},
+		optional: []string{"content", "contentB64", "folder", "mimeType"},
+	},
 	"create_page": {
 		required: []string{"content", "description", "path", "title"},
 		optional: []string{"editor", "isPrivate", "isPublished", "locale", "tags"},

+ 32 - 1
internal/server/server.go

@@ -12,7 +12,11 @@ import (
 const instructions = `This server exposes a WikiJS knowledge base.
 Use search_pages to find pages by full-text query, get_page_by_id or
 get_page_by_path to read a page, and get_all_pages to browse the wiki.
-Page results include absolute URLs that can be shared with the user.`
+Page results include absolute URLs that can be shared with the user.
+Assets are managed store-wide, not per page: list_asset_folders and
+list_assets browse the central asset store, and when the write tools are
+enabled upload_asset adds files and create_asset_folder adds folders.
+Reference uploaded assets from pages with the returned markdown snippet.`
 
 // Options configures the MCP server.
 type Options struct {
@@ -69,6 +73,18 @@ func New(client *wikijs.Client, opts Options) *mcp.Server {
 		Description: "List pages in WikiJS, optionally filtered by limit, locale, and tags.",
 		Annotations: readOnly,
 	}, h.listPages)
+	mcp.AddTool(s, &mcp.Tool{
+		Name:        "list_asset_folders",
+		Title:       "List asset folders",
+		Description: "List the child folders of a WikiJS asset folder (root by default).",
+		Annotations: readOnly,
+	}, h.listAssetFolders)
+	mcp.AddTool(s, &mcp.Tool{
+		Name:        "list_assets",
+		Title:       "List assets",
+		Description: "List the files uploaded to a WikiJS asset folder (root by default), optionally filtered by kind.",
+		Annotations: readOnly,
+	}, h.listAssets)
 
 	if opts.EnableEdit {
 		notDestructive := false
@@ -78,12 +94,27 @@ func New(client *wikijs.Client, opts Options) *mcp.Server {
 			Description: "Create a new WikiJS page.",
 			Annotations: &mcp.ToolAnnotations{DestructiveHint: &notDestructive},
 		}, h.createPage)
+		// edit_page advertises IdempotentHint: editing the same page twice
+		// with the same content converges to the same state, unlike
+		// create_page and upload_asset, which would duplicate work.
 		mcp.AddTool(s, &mcp.Tool{
 			Name:        "edit_page",
 			Title:       "Edit page",
 			Description: "Edit an existing WikiJS page by its numeric ID.",
 			Annotations: &mcp.ToolAnnotations{IdempotentHint: true},
 		}, h.editPage)
+		mcp.AddTool(s, &mcp.Tool{
+			Name:        "upload_asset",
+			Title:       "Upload asset",
+			Description: "Upload a file to a WikiJS asset folder so it can be referenced from pages.",
+			Annotations: &mcp.ToolAnnotations{DestructiveHint: &notDestructive},
+		}, h.uploadAsset)
+		mcp.AddTool(s, &mcp.Tool{
+			Name:        "create_asset_folder",
+			Title:       "Create asset folder",
+			Description: "Create a new WikiJS asset folder.",
+			Annotations: &mcp.ToolAnnotations{DestructiveHint: &notDestructive},
+		}, h.createAssetFolder)
 	}
 
 	return s

+ 192 - 0
internal/server/tools.go

@@ -2,8 +2,10 @@ package server
 
 import (
 	"context"
+	"encoding/base64"
 	"errors"
 	"fmt"
+	"mime"
 	"regexp"
 	"strings"
 
@@ -204,6 +206,13 @@ func (h *handlers) editPage(ctx context.Context, _ *mcp.CallToolRequest, in edit
 	}, 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")
@@ -264,3 +273,186 @@ func nullResult() *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("![%s](%s)", 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(), "-")
+}

+ 237 - 6
internal/server/tools_test.go

@@ -23,10 +23,11 @@ type capturedRequest struct {
 }
 
 type fakeWiki struct {
-	t         *testing.T
-	mu        sync.Mutex
-	requests  []capturedRequest
-	emptyList bool // GetAllPages returns an empty list
+	t            *testing.T
+	mu           sync.Mutex
+	requests     []capturedRequest
+	emptyList    bool
+	uploadCalled bool
 }
 
 func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
@@ -35,6 +36,15 @@ func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
 		f.t.Errorf("Authorization = %q, want %q", got, want)
 	}
 
+	// The upload endpoint is multipart, not GraphQL JSON.
+	if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
+		f.mu.Lock()
+		f.uploadCalled = true
+		f.mu.Unlock()
+		_, _ = w.Write([]byte("ok"))
+		return
+	}
+
 	var req capturedRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		f.t.Errorf("decode request: %v", err)
@@ -100,6 +110,37 @@ func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
 		fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
 			"succeeded":true,"errorCode":0,"slug":"","message":"Page updated"}}}}}`)
 
+	case strings.Contains(req.Query, "query SiteConfig"):
+		fmt.Fprint(w, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`)
+
+	case strings.Contains(req.Query, "query ListAssetFolders"):
+		parentID, _ := req.Variables["parentFolderId"].(float64)
+		if parentID == 0 {
+			fmt.Fprint(w, `{"data":{"assets":{"folders":[{"id":5,"slug":"images","name":"Images"}]}}}`)
+			return
+		}
+		fmt.Fprint(w, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens","name":"Screens"}]}}}`)
+
+	case strings.Contains(req.Query, "query ListAssets"):
+		fmt.Fprint(w, `{"data":{"assets":{"list":[
+			{"id":1,"filename":"shot.png","ext":"png","kind":"IMAGE","mime":"image/png",
+			 "fileSize":12,"createdAt":"c","updatedAt":"u",
+			 "folder":{"id":9,"slug":"screens","name":"Screens"}},
+			{"id":2,"filename":"manual.pdf","ext":"pdf","kind":"BINARY","mime":"application/pdf",
+			 "fileSize":2048,"createdAt":"c","updatedAt":"u",
+			 "folder":{"id":9,"slug":"screens","name":"Screens"}}
+		]}}}`)
+
+	case strings.Contains(req.Query, "mutation CreateAssetFolder"):
+		slug, _ := req.Variables["slug"].(string)
+		if slug == "fail" {
+			fmt.Fprint(w, `{"data":{"assets":{"createFolder":{"responseResult":{
+				"succeeded":false,"errorCode":2002,"slug":"","message":"AssetFolderExists"}}}}}`)
+			return
+		}
+		fmt.Fprint(w, `{"data":{"assets":{"createFolder":{"responseResult":{
+			"succeeded":true,"errorCode":0,"slug":"new-folder","message":"Folder created"}}}}}`)
+
 	default:
 		f.t.Errorf("unexpected query: %s", req.Query)
 		http.Error(w, "unknown operation", http.StatusBadRequest)
@@ -184,7 +225,7 @@ func TestToolListWithoutEdit(t *testing.T) {
 		t.Fatalf("ListTools() error = %v", err)
 	}
 	got := toolNames(res.Tools)
-	want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages"}
+	want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "list_asset_folders", "list_assets"}
 	if !equalNames(got, want) {
 		t.Errorf("tools = %v, want %v", got, want)
 	}
@@ -198,7 +239,11 @@ func TestToolListWithEdit(t *testing.T) {
 		t.Fatalf("ListTools() error = %v", err)
 	}
 	got := toolNames(res.Tools)
-	want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "create_page", "edit_page"}
+	want := []string{
+		"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages",
+		"list_asset_folders", "list_assets",
+		"create_page", "edit_page", "upload_asset", "create_asset_folder",
+	}
 	if !equalNames(got, want) {
 		t.Errorf("tools = %v, want %v", got, want)
 	}
@@ -513,3 +558,189 @@ func TestToolOutputIsJSON(t *testing.T) {
 		t.Fatalf("output is not JSON: %v", err)
 	}
 }
+
+func TestUploadAssetTool(t *testing.T) {
+	cs, fake := newTestSession(t, true)
+
+	res := callTool(t, cs, "upload_asset", map[string]any{
+		"filename":   "notes.txt",
+		"content":    "hello",
+		"folder":     "images",
+		"contentB64": "",
+	})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	var out uploadAssetOutput
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode result: %v", err)
+	}
+	if !out.Succeeded {
+		t.Error("Succeeded = false, want true")
+	}
+
+	fake.mu.Lock()
+	uploaded := fake.uploadCalled
+	fake.mu.Unlock()
+	if !uploaded {
+		t.Error("upload never reached the fake WikiJS server")
+	}
+}
+
+func TestUploadAssetToolValidation(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	tests := []struct {
+		name string
+		args map[string]any
+		want string
+	}{
+		{name: "no content", args: map[string]any{"filename": "a.txt", "content": "", "contentB64": ""}, want: "exactly one"},
+		{name: "both contents", args: map[string]any{"filename": "a.txt", "content": "a", "contentB64": "YQ=="}, want: "exactly one"},
+		{name: "bad folder", args: map[string]any{"filename": "a.txt", "content": "a", "folder": "im ages/"}, want: "invalid asset folder"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			res := callTool(t, cs, "upload_asset", tc.args)
+			if !res.IsError {
+				t.Fatalf("IsError = false, want error containing %q", tc.want)
+			}
+			if !strings.Contains(toolText(t, res), tc.want) {
+				t.Errorf("error text %q does not contain %q", toolText(t, res), tc.want)
+			}
+		})
+	}
+}
+
+func TestListAssetsTool(t *testing.T) {
+	cs, fake := newTestSession(t, false)
+
+	res := callTool(t, cs, "list_assets", map[string]any{"folder": "images/screens"})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	var out listAssetsOutput
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode result: %v", err)
+	}
+	if len(out.Assets) != 2 {
+		t.Fatalf("assets = %d, want 2", len(out.Assets))
+	}
+	if !strings.HasSuffix(out.Assets[0].URL, "/images/screens/shot.png") {
+		t.Errorf("URL = %q", out.Assets[0].URL)
+	}
+	if out.Assets[0].Markdown == "" {
+		t.Error("Markdown = empty, want a markdown reference")
+	}
+
+	req := fake.lastContaining(t, "query ListAssets")
+	if got := req.Variables["folderId"]; got != float64(9) {
+		t.Errorf("folderId = %v, want 9 (resolved from images/screens)", got)
+	}
+}
+
+func TestListAssetsToolKindFilter(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "list_assets", map[string]any{"folder": "images/screens", "kind": "IMAGE"})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	var out listAssetsOutput
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode result: %v", err)
+	}
+	if len(out.Assets) != 1 || out.Assets[0].Kind != "IMAGE" {
+		t.Fatalf("assets = %+v, want only the IMAGE asset", out.Assets)
+	}
+}
+
+func TestListAssetsToolInvalidKind(t *testing.T) {
+	cs, _ := newTestSession(t, false)
+
+	res := callTool(t, cs, "list_assets", map[string]any{"kind": "VIDEO"})
+	if !res.IsError {
+		t.Fatal("IsError = false, want error")
+	}
+	if !strings.Contains(toolText(t, res), "invalid kind") {
+		t.Errorf("error = %q", toolText(t, res))
+	}
+}
+
+func TestListAssetFoldersTool(t *testing.T) {
+	cs, fake := newTestSession(t, false)
+
+	res := callTool(t, cs, "list_asset_folders", map[string]any{"parentFolder": "images"})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	var out listAssetFoldersOutput
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
+		t.Fatalf("decode result: %v", err)
+	}
+	if len(out.Folders) != 1 || out.Folders[0].Slug != "screens" {
+		t.Fatalf("folders = %+v", out.Folders)
+	}
+	if got, want := out.Folders[0].Path, "images/screens"; got != want {
+		t.Errorf("Path = %q, want %q", got, want)
+	}
+
+	req := fake.lastContaining(t, "query ListAssetFolders")
+	if got := req.Variables["parentFolderId"]; got != float64(5) {
+		t.Errorf("parentFolderId = %v, want 5 (resolved from images)", got)
+	}
+}
+
+func TestCreateAssetFolderTool(t *testing.T) {
+	cs, fake := newTestSession(t, true)
+
+	res := callTool(t, cs, "create_asset_folder", map[string]any{"name": "Vacation 2025"})
+	if res.IsError {
+		t.Fatalf("IsError = true: %s", toolText(t, res))
+	}
+
+	req := fake.lastContaining(t, "mutation CreateAssetFolder")
+	if got, want := req.Variables["slug"], "vacation-2025"; got != want {
+		t.Errorf("slug = %v, want %q", got, want)
+	}
+}
+
+func TestCreateAssetFolderToolFailure(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	res := callTool(t, cs, "create_asset_folder", map[string]any{"name": "fail"})
+	if !res.IsError {
+		t.Fatal("IsError = false, want error")
+	}
+	if !strings.Contains(toolText(t, res), "AssetFolderExists") {
+		t.Errorf("error = %q", toolText(t, res))
+	}
+}
+
+func TestCreateAssetFolderToolValidation(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	tests := []struct {
+		name string
+		args map[string]any
+		want string
+	}{
+		{name: "empty name", args: map[string]any{"name": "  "}, want: "name must not be empty"},
+		{name: "bad parent", args: map[string]any{"name": "x", "parentFolder": "im ages"}, want: "invalid asset folder"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			res := callTool(t, cs, "create_asset_folder", tc.args)
+			if !res.IsError {
+				t.Fatalf("IsError = false, want error containing %q", tc.want)
+			}
+			if !strings.Contains(toolText(t, res), tc.want) {
+				t.Errorf("error text %q does not contain %q", toolText(t, res), tc.want)
+			}
+		})
+	}
+}

+ 2 - 2
internal/transport/http_test.go

@@ -152,8 +152,8 @@ func TestMCPWithoutAuthToken(t *testing.T) {
 	if err != nil {
 		t.Fatalf("ListTools() error = %v", err)
 	}
-	if len(res.Tools) != 4 {
-		t.Errorf("tools = %d, want 4", len(res.Tools))
+	if len(res.Tools) != 6 {
+		t.Errorf("tools = %d, want 6", len(res.Tools))
 	}
 }
 

+ 404 - 3
internal/wikijs/client.go

@@ -8,15 +8,48 @@ import (
 	"fmt"
 	"io"
 	"log/slog"
+	"mime"
+	"mime/multipart"
 	"net/http"
+	"net/textproto"
 	"net/url"
+	"path/filepath"
+	"regexp"
+	"slices"
 	"strings"
+	"sync"
 	"time"
+	"unicode/utf8"
 )
 
 // maxResponseBytes bounds the response body read from WikiJS.
 const maxResponseBytes = 32 << 20
 
+// filenameRuns matches the character runs WikiJS collapses to a single
+// underscore when sanitizing uploaded filenames.
+var filenameRuns = regexp.MustCompile(`[\s,;#]+`)
+
+// assetFolderSegmentPattern matches a single asset folder slug segment as
+// produced by WikiJS (lowercase slugs; validation is deliberately tolerant
+// of uppercase input because folder matching is case-insensitive).
+var assetFolderSegmentPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
+
+// DefaultMaxUploadBytes is the upload limit WikiJS enforces out of the box
+// (5 MB, server/app/data.yml). It is the fallback when the site config is
+// not readable with the configured API key.
+const DefaultMaxUploadBytes = 5 << 20
+
+// siteConfigQuery fetches the upload limit from the WikiJS site config.
+// The field requires the manage:system permission; callers must treat a
+// failure as "limit unknown" and fall back to DefaultMaxUploadBytes.
+const siteConfigQuery = `query SiteConfig {
+  site {
+    config {
+      uploadMaxFileSize
+    }
+  }
+}`
+
 // Client talks to the WikiJS GraphQL API.
 type Client struct {
 	baseURL   string
@@ -25,6 +58,9 @@ type Client struct {
 	userAgent string
 	http      *http.Client
 	logger    *slog.Logger
+
+	uploadLimitOnce  sync.Once
+	uploadLimitBytes int
 }
 
 // Option customizes a [Client].
@@ -262,6 +298,222 @@ func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationRe
 	return mutationResult(data.Pages.Update.ResponseResult)
 }
 
+// ListAssetFolders returns the child folders of an asset folder. An empty
+// parentPath addresses the root folder. Folder paths are slash-separated slug
+// chains, for example "images/vacation".
+func (c *Client) ListAssetFolders(ctx context.Context, parentPath string) ([]AssetFolder, error) {
+	parentID, chain, err := c.resolveAssetFolder(ctx, parentPath)
+	if err != nil {
+		return nil, err
+	}
+
+	folders, err := c.assetFolders(ctx, parentID)
+	if err != nil {
+		return nil, err
+	}
+	for i := range folders {
+		folders[i].Path = strings.Join(append(slices.Clone(chain), folders[i].Slug), "/")
+	}
+	return folders, nil
+}
+
+// ListAssets returns the assets of an asset folder. An empty folderPath
+// addresses the root folder.
+func (c *Client) ListAssets(ctx context.Context, folderPath string) ([]Asset, error) {
+	folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
+	if err != nil {
+		return nil, err
+	}
+
+	var data struct {
+		Assets *struct {
+			List []Asset `json:"list"`
+		} `json:"assets"`
+	}
+	vars := map[string]any{"folderId": folderID, "kind": "ALL"}
+	if err := c.do(ctx, "ListAssets", listAssetsQuery, vars, &data); err != nil {
+		return nil, err
+	}
+	out := []Asset{}
+	if data.Assets == nil {
+		return out, nil
+	}
+	for i := range data.Assets.List {
+		asset := &data.Assets.List[i]
+		if asset.Folder == nil {
+			asset.Folder = &AssetFolder{ID: folderID, Path: strings.Join(chain, "/")}
+		} else {
+			asset.Folder.Path = strings.Join(chain, "/")
+		}
+		asset.URL = c.assetURL(chain, asset.FileName)
+		out = append(out, *asset)
+	}
+	return out, nil
+}
+
+// CreateAssetFolder creates a new asset folder inside parentPath (empty means
+// the root folder). slug must already be a valid WikiJS slug.
+func (c *Client) CreateAssetFolder(ctx context.Context, parentPath, slug, name string) (MutationResult, error) {
+	parentID, _, err := c.resolveAssetFolder(ctx, parentPath)
+	if err != nil {
+		return MutationResult{}, err
+	}
+
+	var data struct {
+		Assets *struct {
+			CreateFolder *struct {
+				ResponseResult *responseResult `json:"responseResult"`
+			} `json:"createFolder"`
+		} `json:"assets"`
+	}
+	vars := map[string]any{"parentFolderId": parentID, "slug": slug, "name": name}
+	if err := c.do(ctx, "CreateAssetFolder", createAssetFolderMutation, vars, &data); err != nil {
+		return MutationResult{}, err
+	}
+	if data.Assets == nil || data.Assets.CreateFolder == nil {
+		return MutationResult{}, errors.New("wikijs CreateAssetFolder: missing createFolder result")
+	}
+	return mutationResult(data.Assets.CreateFolder.ResponseResult)
+}
+
+// uploadLimit returns the WikiJS upload limit in bytes, reading it once
+// from the site config (requires manage:system) and falling back to
+// DefaultMaxUploadBytes when the config is not readable. The value is
+// cached for the lifetime of the client; a failed lookup is not retried.
+func (c *Client) uploadLimit(ctx context.Context) int {
+	c.uploadLimitOnce.Do(func() {
+		var data struct {
+			Site *struct {
+				Config *struct {
+					UploadMaxFileSize *int `json:"uploadMaxFileSize"`
+				} `json:"config"`
+			} `json:"site"`
+		}
+		if err := c.do(ctx, "SiteConfig", siteConfigQuery, nil, &data); err != nil {
+			c.logger.Debug("site config not readable; using default upload limit",
+				"limit", DefaultMaxUploadBytes, "error", err)
+			c.uploadLimitBytes = DefaultMaxUploadBytes
+			return
+		}
+		limit := 0
+		if data.Site != nil && data.Site.Config != nil && data.Site.Config.UploadMaxFileSize != nil {
+			limit = *data.Site.Config.UploadMaxFileSize
+		}
+		if limit <= 0 {
+			limit = DefaultMaxUploadBytes
+		}
+		c.uploadLimitBytes = limit
+	})
+	return c.uploadLimitBytes
+}
+
+// UploadAsset uploads a single file to an asset folder (empty folderPath
+// means the root folder) using the /u upload endpoint. The filename appears
+// in the returned URL and in markdown references. mimeType is optional and
+// guessed from the file extension when empty.
+//
+// Uploads larger than the WikiJS upload limit are rejected before they are
+// sent. The limit is read once from the site config (requires manage:system)
+// and falls back to DefaultMaxUploadBytes; it can be overridden per tool
+// call only by raising the limit in WikiJS itself.
+func (c *Client) UploadAsset(ctx context.Context, folderPath, filename string, data []byte, mimeType string) (MutationResult, UploadedAsset, error) {
+	clean := sanitizeAssetFilename(filename)
+	if clean == "" || clean == "." {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: invalid filename %q", filename)
+	}
+	if len(data) > c.uploadLimit(ctx) {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf(
+			"wikijs UploadAsset: file is %d bytes, exceeding the WikiJS upload limit of %d bytes; reduce the file size or raise the limit in WikiJS (Administration > System Info)",
+			len(data), c.uploadLimitBytes)
+	}
+	folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, err
+	}
+
+	if mimeType == "" {
+		mimeType = "application/octet-stream"
+		if guessed := mime.TypeByExtension(filepath.Ext(clean)); guessed != "" {
+			mimeType = guessed
+		}
+	}
+
+	metadata, err := json.Marshal(map[string]any{"folderId": folderID})
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: encode metadata: %w", err)
+	}
+
+	var body bytes.Buffer
+	form := multipart.NewWriter(&body)
+	metaPart, err := form.CreatePart(textproto.MIMEHeader{
+		"Content-Disposition": {`form-data; name="mediaUpload"`},
+	})
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
+	}
+	if _, err := metaPart.Write(metadata); err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
+	}
+	filePart, err := form.CreatePart(textproto.MIMEHeader{
+		"Content-Disposition": {fmt.Sprintf(`form-data; name="mediaUpload"; filename=%q`, clean)},
+		"Content-Type":        {mimeType},
+	})
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
+	}
+	if _, err := filePart.Write(data); err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
+	}
+	if err := form.Close(); err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/u", &body)
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build request: %w", err)
+	}
+	req.Header.Set("Content-Type", form.FormDataContentType())
+	req.Header.Set("Authorization", "Bearer "+c.apiKey)
+	req.Header.Set("User-Agent", c.userAgent)
+
+	resp, err := c.http.Do(req)
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: %w", err)
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
+	if err != nil {
+		return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: read response: %w", err)
+	}
+	if len(respBody) > maxResponseBytes {
+		return MutationResult{}, UploadedAsset{}, &APIError{
+			Operation: "UploadAsset",
+			Status:    resp.StatusCode,
+			Err:       fmt.Errorf("response exceeds %d bytes", maxResponseBytes),
+		}
+	}
+
+	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || strings.TrimSpace(string(respBody)) != "ok" {
+		var rr struct {
+			Succeeded bool   `json:"succeeded"`
+			Message   string `json:"message"`
+		}
+		if err := json.Unmarshal(respBody, &rr); err == nil && rr.Message != "" {
+			return MutationResult{Succeeded: false, Message: rr.Message}, UploadedAsset{}, nil
+		}
+		return MutationResult{}, UploadedAsset{}, &APIError{
+			Operation: "UploadAsset",
+			Status:    resp.StatusCode,
+			Body:      truncate(strings.TrimSpace(string(respBody)), 512),
+		}
+	}
+	return MutationResult{Succeeded: true}, UploadedAsset{
+		Filename: clean,
+		URL:      c.assetURL(chain, clean),
+	}, nil
+}
+
 type responseResult struct {
 	Succeeded bool   `json:"succeeded"`
 	ErrorCode int    `json:"errorCode"`
@@ -402,12 +654,27 @@ func (c *Client) decorate(page *Page) *Page {
 	return page
 }
 
+// pageURL builds the absolute URL of a page. Each path segment is escaped
+// separately so that separators stay intact and unicode filenames produce
+// valid links. The locale segment is only included for non-English pages.
 func (c *Client) pageURL(path, locale string) string {
 	p := strings.TrimLeft(path, "/")
+	segments := []string{}
 	if locale != "" && locale != "en" {
-		return c.baseURL + "/" + locale + "/" + p
+		segments = append(segments, locale)
 	}
-	return c.baseURL + "/" + p
+	segments = append(segments, strings.Split(p, "/")...)
+	return c.baseURL + "/" + escapePathSegments(segments)
+}
+
+// escapePathSegments escapes every segment with url.PathEscape and joins
+// them with "/", keeping the path structure intact.
+func escapePathSegments(segments []string) string {
+	escaped := make([]string, len(segments))
+	for i, seg := range segments {
+		escaped[i] = url.PathEscape(seg)
+	}
+	return strings.Join(escaped, "/")
 }
 
 func putString(vars map[string]any, key, value string) {
@@ -427,5 +694,139 @@ func truncate(s string, max int) string {
 	if len(s) <= max {
 		return s
 	}
-	return s[:max] + "..."
+	return truncateRunes(s, max-3) + "..."
+}
+
+// assetFolders returns the direct child folders of the folder with the given
+// ID (0 is the root folder).
+func (c *Client) assetFolders(ctx context.Context, parentFolderID int) ([]AssetFolder, error) {
+	var data struct {
+		Assets *struct {
+			Folders []AssetFolder `json:"folders"`
+		} `json:"assets"`
+	}
+	if err := c.do(ctx, "ListAssetFolders", listAssetFoldersQuery, map[string]any{"parentFolderId": parentFolderID}, &data); err != nil {
+		return nil, err
+	}
+	out := []AssetFolder{}
+	if data.Assets == nil {
+		return out, nil
+	}
+	return append(out, data.Assets.Folders...), nil
+}
+
+// resolveAssetFolder walks the asset folder tree from the root and returns the
+// ID and slug chain of the folder at folderPath. An empty path yields the root
+// folder (ID 0) with an empty chain.
+func (c *Client) resolveAssetFolder(ctx context.Context, folderPath string) (int, []string, error) {
+	segments, err := assetFolderPathSegments(folderPath)
+	if err != nil {
+		return 0, nil, err
+	}
+
+	id := 0
+	chain := []string{}
+	for _, slug := range segments {
+		children, err := c.assetFolders(ctx, id)
+		if err != nil {
+			return 0, nil, err
+		}
+		found := false
+		for _, folder := range children {
+			if strings.EqualFold(folder.Slug, slug) {
+				id = folder.ID
+				chain = append(chain, folder.Slug)
+				found = true
+				break
+			}
+		}
+		if !found {
+			return 0, nil, fmt.Errorf("asset folder %q not found", strings.Join(chain, "/")+"/"+slug)
+		}
+	}
+	return id, chain, nil
+}
+
+// AssetFolderPathSegments splits a slash-separated asset folder path into
+// slug segments. An empty (or whitespace-only) path yields no segments and
+// addresses the root folder. It rejects empty segments and segments that are
+// not valid WikiJS slugs; resolvers treat matching case-insensitively.
+func AssetFolderPathSegments(folderPath string) ([]string, error) {
+	segments, err := assetFolderPathSegments(folderPath)
+	if err != nil {
+		return nil, err
+	}
+	for _, segment := range segments {
+		if !assetFolderSegmentPattern.MatchString(segment) {
+			return nil, fmt.Errorf("invalid asset folder segment %q: use slugs like images or images-screens", segment)
+		}
+	}
+	return segments, nil
+}
+
+func assetFolderPathSegments(folderPath string) ([]string, error) {
+	if strings.TrimSpace(folderPath) == "" {
+		return nil, nil
+	}
+	var segments []string
+	for _, segment := range strings.Split(strings.Trim(folderPath, "/"), "/") {
+		segment = strings.TrimSpace(segment)
+		if segment == "" {
+			return nil, fmt.Errorf("asset folder path %q must not contain an empty segment", folderPath)
+		}
+		segments = append(segments, segment)
+	}
+	return segments, nil
+}
+
+// assetURL builds the direct download URL of an asset. WikiJS serves assets at
+// <base>/<folder chain>/<filename>; root assets live directly under <base>.
+func (c *Client) assetURL(chain []string, filename string) string {
+	parts := append(slices.Clone(chain), filename)
+	return c.baseURL + "/" + strings.Join(parts, "/")
+}
+
+// sanitizeAssetFilename mirrors WikiJS's upload normalization, which uses
+// the sanitize-filename package: lowercase, runs of whitespace/commas/
+// semicolons/hashes collapsed to a single underscore, illegal and control
+// characters dropped, Windows reserved names removed, trailing spaces and
+// dots removed, and a maximum length of 255 bytes (truncated without
+// splitting UTF-8 sequences).
+func sanitizeAssetFilename(name string) string {
+	const maxFilenameBytes = 255
+	name = filenameRuns.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "_")
+
+	var b strings.Builder
+	for _, r := range name {
+		if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) || strings.ContainsRune(`/?%*<>|":\`, r) {
+			continue
+		}
+		b.WriteRune(r)
+	}
+	name = strings.Trim(b.String(), ". ")
+
+	// Windows reserved device names are removed entirely (case-insensitive,
+	// with or without extension), as in sanitize-filename.
+	base := name
+	if i := strings.IndexByte(base, '.'); i >= 0 {
+		base = base[:i]
+	}
+	switch strings.ToLower(base) {
+	case "con", "prn", "aux", "nul",
+		"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
+		"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9":
+		return ""
+	}
+
+	return truncateRunes(name, maxFilenameBytes)
+}
+
+// truncateRunes cuts s to at most max bytes without splitting a multi-byte
+// UTF-8 sequence, mirroring sanitize-filename's truncate-utf8-bytes helper.
+func truncateRunes(s string, max int) string {
+	for len(s) > max {
+		_, size := utf8.DecodeLastRuneInString(s)
+		s = s[:len(s)-size]
+	}
+	return s
 }

+ 322 - 0
internal/wikijs/client_test.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -408,3 +410,323 @@ func TestContextCancellation(t *testing.T) {
 		t.Fatal("PageByID() error = nil, want context error")
 	}
 }
+
+func TestListAssetFoldersRoot(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		if !strings.Contains(req.Query, "folders(parentFolderId") {
+			t.Errorf("query = %s", req.Query)
+		}
+		if got := req.Variables["parentFolderId"]; got != float64(0) {
+			t.Errorf("variables[parentFolderId] = %v, want 0 (root)", got)
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[
+			{"id":5,"slug":"images","name":"Images"},
+			{"id":6,"slug":"docs","name":"Docs"}
+		]}}}`)
+	})
+
+	folders, err := client.ListAssetFolders(context.Background(), "")
+	if err != nil {
+		t.Fatalf("ListAssetFolders() error = %v", err)
+	}
+	if len(folders) != 2 {
+		t.Fatalf("folders = %d, want 2", len(folders))
+	}
+	if got, want := folders[0].Path, "images"; got != want {
+		t.Errorf("Path = %q, want %q", got, want)
+	}
+}
+
+func TestResolveAssetFolderNested(t *testing.T) {
+	var calls []map[string]any
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		calls = append(calls, req.Variables)
+
+		switch len(calls) {
+		case 1:
+			if got := req.Variables["parentFolderId"]; got != float64(0) {
+				t.Errorf("call 1 parentFolderId = %v, want 0", got)
+			}
+			writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":5,"slug":"images"}]}}}`)
+		case 2:
+			if got := req.Variables["parentFolderId"]; got != float64(5) {
+				t.Errorf("call 2 parentFolderId = %v, want 5", got)
+			}
+			writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens"}]}}}`)
+		case 3:
+			if got := req.Variables["folderId"]; got != float64(9) {
+				t.Errorf("call 3 folderId = %v, want 9", got)
+			}
+			if got := req.Variables["kind"]; got != "ALL" {
+				t.Errorf("call 3 kind = %v, want ALL", got)
+			}
+			writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"list":[
+				{"id":1,"filename":"shot.png","ext":"png","kind":"IMAGE","mime":"image/png",
+				 "fileSize":12,"createdAt":"c","updatedAt":"u",
+				 "folder":{"id":9,"slug":"screens","name":"Screens"}}
+			]}}}`)
+		default:
+			t.Errorf("unexpected call %d", len(calls))
+		}
+	})
+
+	assets, err := client.ListAssets(context.Background(), "images/screens")
+	if err != nil {
+		t.Fatalf("ListAssets() error = %v", err)
+	}
+	if len(assets) != 1 {
+		t.Fatalf("assets = %d, want 1", len(assets))
+	}
+	if got, want := assets[0].Folder.Path, "images/screens"; got != want {
+		t.Errorf("Folder.Path = %q, want %q", got, want)
+	}
+	if got, want := assets[0].URL, client.baseURL+"/images/screens/shot.png"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+}
+
+func TestListAssetsRootOmitsFolderResolution(t *testing.T) {
+	requests := 0
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		requests++
+		req := decodeRequest(t, r)
+		if got := req.Variables["folderId"]; got != float64(0) {
+			t.Errorf("folderId = %v, want 0", got)
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"list":[
+			{"id":2,"filename":"logo.png","ext":"png","kind":"IMAGE","mime":"image/png",
+			 "fileSize":9,"createdAt":"c","updatedAt":"u","folder":null}
+		]}}}`)
+	})
+
+	assets, err := client.ListAssets(context.Background(), "")
+	if err != nil {
+		t.Fatalf("ListAssets() error = %v", err)
+	}
+	if requests != 1 {
+		t.Errorf("requests = %d, want 1", requests)
+	}
+	if got, want := assets[0].URL, client.baseURL+"/logo.png"; got != want {
+		t.Errorf("URL = %q, want %q", got, want)
+	}
+	if assets[0].Folder == nil || assets[0].Folder.ID != 0 {
+		t.Errorf("Folder = %+v, want root folder", assets[0].Folder)
+	}
+}
+
+func TestResolveAssetFolderNotFound(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[]}}}`)
+	})
+
+	if _, err := client.ListAssets(context.Background(), "nope"); err == nil {
+		t.Fatal("ListAssets() error = nil, want error")
+	} else if !strings.Contains(err.Error(), "not found") {
+		t.Errorf("error = %q", err)
+	}
+}
+
+func TestCreateAssetFolder(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		req := decodeRequest(t, r)
+		if !strings.Contains(req.Query, "mutation CreateAssetFolder") {
+			t.Errorf("query = %s", req.Query)
+		}
+		if got := req.Variables["parentFolderId"]; got != float64(0) {
+			t.Errorf("parentFolderId = %v, want 0", got)
+		}
+		if got, want := req.Variables["slug"], "vacation"; got != want {
+			t.Errorf("slug = %v, want %q", got, want)
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"createFolder":{"responseResult":{
+			"succeeded":true,"errorCode":0,"slug":"vacation","message":"ok"}}}}}`)
+	})
+
+	res, err := client.CreateAssetFolder(context.Background(), "", "vacation", "Vacation 2025")
+	if err != nil {
+		t.Fatalf("CreateAssetFolder() error = %v", err)
+	}
+	if !res.Succeeded {
+		t.Errorf("result = %+v", res)
+	}
+}
+
+func TestCreateAssetFolderFailure(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"createFolder":{"responseResult":{
+			"succeeded":false,"errorCode":2002,"slug":"","message":"AssetFolderExists"}}}}}`)
+	})
+
+	res, err := client.CreateAssetFolder(context.Background(), "", "vacation", "Vacation")
+	if err != nil {
+		t.Fatalf("CreateAssetFolder() error = %v", err)
+	}
+	if res.Succeeded || res.ErrorCode != 2002 {
+		t.Errorf("result = %+v", res)
+	}
+}
+
+func TestUploadAssetRoot(t *testing.T) {
+	var meta, file string
+	var filename, contentType, auth string
+	var contents []byte
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		// Best-effort site config probe arrives before the multipart upload.
+		if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
+			writeJSON(t, w, http.StatusOK, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`)
+			return
+		}
+		auth = r.Header.Get("Authorization")
+		mr, err := r.MultipartReader()
+		if err != nil {
+			t.Fatalf("multipart reader: %v", err)
+		}
+		for i := 0; ; i++ {
+			part, err := mr.NextPart()
+			if err == io.EOF {
+				break
+			}
+			if err != nil {
+				t.Fatalf("part %d: %v", i, err)
+			}
+			if got := part.FormName(); got != "mediaUpload" {
+				t.Errorf("part %d FormName() = %q, want mediaUpload", i, got)
+			}
+			data, err := io.ReadAll(part)
+			if err != nil {
+				t.Fatalf("read part %d: %v", i, err)
+			}
+			if part.FileName() == "" {
+				meta = string(data)
+			} else {
+				file = string(data)
+				filename = part.FileName()
+				contentType = part.Header.Get("Content-Type")
+				contents = data
+			}
+		}
+		_, _ = w.Write([]byte("ok"))
+	})
+
+	res, uploaded, err := client.UploadAsset(context.Background(), "", "Report 2025; Final.pdf", []byte("payload"), "")
+	if err != nil {
+		t.Fatalf("UploadAsset() error = %v", err)
+	}
+	if auth != "Bearer test-key" {
+		t.Errorf("Authorization = %q", auth)
+	}
+	if meta != `{"folderId":0}` {
+		t.Errorf("metadata = %q, want folderId 0", meta)
+	}
+	if file != "payload" || string(contents) != "payload" {
+		t.Errorf("file part = %q", file)
+	}
+	if filename != "report_2025_final.pdf" {
+		t.Errorf("filename = %q, want sanitized", filename)
+	}
+	if contentType != "application/pdf" {
+		t.Errorf("Content-Type = %q, want guessed application/pdf", contentType)
+	}
+	if !res.Succeeded {
+		t.Errorf("result = %+v", res)
+	}
+	if got, want := uploaded.Filename, filename; got != want {
+		t.Errorf("uploaded.Filename = %q, want %q", got, want)
+	}
+	if got, want := uploaded.URL, client.baseURL+"/report_2025_final.pdf"; got != want {
+		t.Errorf("uploaded.URL = %q, want %q", got, want)
+	}
+}
+
+func TestUploadAssetNestedFolder(t *testing.T) {
+	var calls []map[string]any
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		if strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") {
+			_, _ = w.Write([]byte("ok"))
+			return
+		}
+		req := decodeRequest(t, r)
+		if strings.Contains(req.Query, "query SiteConfig") {
+			writeJSON(t, w, http.StatusOK, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`)
+			return
+		}
+		calls = append(calls, req.Variables)
+		if len(calls) == 1 {
+			writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":5,"slug":"images"}]}}}`)
+			return
+		}
+		writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens"}]}}}`)
+	})
+
+	res, uploaded, err := client.UploadAsset(
+		context.Background(), "images/screens", "shot.png", []byte("png"), "image/png",
+	)
+	if err != nil {
+		t.Fatalf("UploadAsset() error = %v", err)
+	}
+	if len(calls) != 2 {
+		t.Errorf("folder calls = %d, want 2", len(calls))
+	}
+	if !res.Succeeded {
+		t.Errorf("result = %+v", res)
+	}
+	if got, want := uploaded.URL, client.baseURL+"/images/screens/shot.png"; got != want {
+		t.Errorf("uploaded.URL = %q, want %q", got, want)
+	}
+}
+
+func TestUploadAssetForbidden(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusForbidden)
+		fmt.Fprint(w, `{"succeeded":false,"message":"You are not authorized to upload files."}`)
+	})
+
+	res, _, err := client.UploadAsset(context.Background(), "", "x.png", []byte("png"), "")
+	if err != nil {
+		t.Fatalf("UploadAsset() error = %v", err)
+	}
+	if res.Succeeded {
+		t.Error("Succeeded = true, want false")
+	}
+	if res.Message != "You are not authorized to upload files." {
+		t.Errorf("Message = %q", res.Message)
+	}
+}
+
+func TestUploadAssetUnexpectedResponse(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		_, _ = w.Write([]byte("<html>proxy error</html>"))
+	})
+
+	if _, _, err := client.UploadAsset(context.Background(), "", "x.png", []byte("png"), ""); err == nil {
+		t.Fatal("UploadAsset() error = nil, want error")
+	}
+}
+
+func TestUploadAssetInvalidFilename(t *testing.T) {
+	client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+		t.Error("handler must not be called")
+	})
+
+	if _, _, err := client.UploadAsset(context.Background(), "", "   ", []byte("png"), ""); err == nil {
+		t.Fatal("UploadAsset() error = nil, want error")
+	}
+}
+
+func TestSanitizeAssetFilename(t *testing.T) {
+	tests := []struct{ in, want string }{
+		{in: "Report 2025; Final.pdf", want: "report_2025_final.pdf"},
+		{in: "a b.png", want: "a_b.png"},
+		{in: "  A/B\\C.png ", want: "abc.png"},
+		{in: "a,;#b.txt", want: "a_b.txt"},
+		{in: "images/shot.png", want: "imagesshot.png"},
+	}
+	for _, tt := range tests {
+		if got := sanitizeAssetFilename(tt.in); got != tt.want {
+			t.Errorf("sanitizeAssetFilename(%q) = %q, want %q", tt.in, got, tt.want)
+		}
+	}
+}

+ 51 - 0
internal/wikijs/queries.go

@@ -68,6 +68,57 @@ const listPagesQuery = `query GetAllPages($limit: Int, $locale: String, $tags: [
   }
 }`
 
+const listAssetFoldersQuery = `query ListAssetFolders($parentFolderId: Int!) {
+  assets {
+    folders(parentFolderId: $parentFolderId) {
+      id
+      slug
+      name
+    }
+  }
+}`
+
+const listAssetsQuery = `query ListAssets($folderId: Int!, $kind: AssetKind!) {
+  assets {
+    list(folderId: $folderId, kind: $kind) {
+      id
+      filename
+      ext
+      kind
+      mime
+      fileSize
+      createdAt
+      updatedAt
+      folder {
+        id
+        slug
+        name
+      }
+    }
+  }
+}`
+
+const createAssetFolderMutation = `mutation CreateAssetFolder(
+  $parentFolderId: Int!
+  $slug: String!
+  $name: String!
+) {
+  assets {
+    createFolder(
+      parentFolderId: $parentFolderId
+      slug: $slug
+      name: $name
+    ) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+    }
+  }
+}`
+
 const createPageMutation = `mutation CreatePage(
   $title: String!
   $content: String!

+ 29 - 0
internal/wikijs/types.go

@@ -85,6 +85,35 @@ type UpdatePageInput struct {
 	Tags        []string
 }
 
+// AssetFolder is a folder in the WikiJS asset store. Path is the full folder
+// path (for example images/vacation), filled in by the client.
+type AssetFolder struct {
+	ID   int    `json:"id"`
+	Slug string `json:"slug"`
+	Name string `json:"name"`
+	Path string `json:"path"`
+}
+
+// Asset is a file in the WikiJS asset store.
+type Asset struct {
+	ID        int          `json:"id"`
+	FileName  string       `json:"filename"`
+	Ext       string       `json:"ext"`
+	Kind      string       `json:"kind"`
+	MimeType  string       `json:"mime"`
+	FileSize  int          `json:"fileSize"`
+	CreatedAt string       `json:"createdAt"`
+	UpdatedAt string       `json:"updatedAt"`
+	Folder    *AssetFolder `json:"folder"`
+	URL       string       `json:"url"`
+}
+
+// UploadedAsset describes the result of a successful asset upload.
+type UploadedAsset struct {
+	Filename string `json:"filename"`
+	URL      string `json:"url"`
+}
+
 // MutationResult is the normalized WikiJS responseResult of a mutation.
 type MutationResult struct {
 	Succeeded bool   `json:"succeeded"`