Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,21 @@ func newServer(b backend.Backend, readOnly bool) *mcp.Server {
Content: []mcp.Content{&mcp.TextContent{Text: "Vault reloaded successfully"}},
}, nil
})

// --- Attachment tools (Obsidian-specific, write enabled) ---
attachments := tools.NewAttachments(vaultClient)
mcp.AddTool(srv, &mcp.Tool{
Name: "upload_attachment",
Description: "Upload a binary file (PDF, image, audio, etc) to the vault at the given path. Parent directories are auto-created. The path must be relative to the vault root and cannot end in .md (use create_page for markdown). Content is base64-encoded.",
}, attachments.Upload)
mcp.AddTool(srv, &mcp.Tool{
Name: "delete_attachment",
Description: "Delete a binary attachment by path (relative to vault root). Cannot delete .md files (use delete_page).",
}, attachments.Delete)
mcp.AddTool(srv, &mcp.Tool{
Name: "list_attachments",
Description: "List non-.md files (binaries, attachments) in a folder relative to vault root. Set recursive=true to walk sub-folders. Returns entries with path, size, and modification time.",
}, attachments.List)
}

return srv
Expand Down
70 changes: 70 additions & 0 deletions tools/attachments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package tools

import (
"context"
"encoding/base64"
"fmt"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/skridlevsky/graphthulhu/types"
"github.com/skridlevsky/graphthulhu/vault"
)

// Attachments implements binary attachment MCP tools (Obsidian-only).
type Attachments struct {
client *vault.Client
}

// NewAttachments creates a new Attachments tool handler.
func NewAttachments(c *vault.Client) *Attachments {
return &Attachments{client: c}
}

// Upload writes a binary attachment to the vault.
func (a *Attachments) Upload(ctx context.Context, req *mcp.CallToolRequest, input types.UploadAttachmentInput) (*mcp.CallToolResult, any, error) {
if input.Path == "" {
return errorResult("path is required"), nil, nil
}
data, err := base64.StdEncoding.DecodeString(input.ContentBase64)
if err != nil {
return errorResult(fmt.Sprintf("invalid base64 content: %v", err)), nil, nil
}
result, err := a.client.UploadAttachment(input.Path, data)
if err != nil {
return errorResult(fmt.Sprintf("upload_attachment failed: %v", err)), nil, nil
}
res, err := jsonTextResult(result)
return res, nil, err
}

// Delete removes a binary attachment from the vault.
func (a *Attachments) Delete(ctx context.Context, req *mcp.CallToolRequest, input types.DeleteAttachmentInput) (*mcp.CallToolResult, any, error) {
if input.Path == "" {
return errorResult("path is required"), nil, nil
}
if err := a.client.DeleteAttachment(input.Path); err != nil {
return errorResult(fmt.Sprintf("delete_attachment failed: %v", err)), nil, nil
}
res, err := jsonTextResult(map[string]any{
"deleted": true,
"path": input.Path,
})
return res, nil, err
}

// List returns non-.md files in a folder.
func (a *Attachments) List(ctx context.Context, req *mcp.CallToolRequest, input types.ListAttachmentsInput) (*mcp.CallToolResult, any, error) {
if input.Folder == "" {
return errorResult("folder is required"), nil, nil
}
entries, err := a.client.ListAttachments(input.Folder, input.Recursive)
if err != nil {
return errorResult(fmt.Sprintf("list_attachments failed: %v", err)), nil, nil
}
res, err := jsonTextResult(map[string]any{
"folder": input.Folder,
"count": len(entries),
"entries": entries,
})
return res, nil, err
}
160 changes: 160 additions & 0 deletions tools/attachments_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package tools

import (
"context"
"encoding/base64"
"strings"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/skridlevsky/graphthulhu/types"
"github.com/skridlevsky/graphthulhu/vault"
)

func newTestAttachments(t *testing.T) (*Attachments, *vault.Client) {
t.Helper()
dir := t.TempDir()
c := vault.New(dir)
return NewAttachments(c), c
}

func resultText(t *testing.T, r *mcp.CallToolResult) string {
t.Helper()
if r == nil || len(r.Content) == 0 {
return ""
}
tc, ok := r.Content[0].(*mcp.TextContent)
if !ok {
t.Fatalf("content[0] is not TextContent: %T", r.Content[0])
}
return tc.Text
}

func TestAttachmentsUpload_HappyPath(t *testing.T) {
a, _ := newTestAttachments(t)
input := types.UploadAttachmentInput{
Path: "Attach/note.png",
ContentBase64: base64.StdEncoding.EncodeToString([]byte("fake-png-bytes")),
}
res, _, err := a.Upload(context.Background(), nil, input)
if err != nil {
t.Fatalf("err: %v", err)
}
if res.IsError {
t.Fatalf("IsError=true: %s", resultText(t, res))
}
text := resultText(t, res)
if !strings.Contains(text, `"path": "Attach/note.png"`) {
t.Errorf("response missing path: %s", text)
}
if !strings.Contains(text, `"size": 14`) {
t.Errorf("response missing size: %s", text)
}
}

func TestAttachmentsUpload_InvalidBase64(t *testing.T) {
a, _ := newTestAttachments(t)
input := types.UploadAttachmentInput{
Path: "Attach/note.png",
ContentBase64: "!!!not-base64!!!",
}
res, _, _ := a.Upload(context.Background(), nil, input)
if !res.IsError {
t.Fatal("IsError should be true on invalid base64")
}
if !strings.Contains(resultText(t, res), "invalid base64") {
t.Errorf("error message missing 'invalid base64': %s", resultText(t, res))
}
}

func TestAttachmentsUpload_EmptyPath(t *testing.T) {
a, _ := newTestAttachments(t)
res, _, _ := a.Upload(context.Background(), nil, types.UploadAttachmentInput{Path: ""})
if !res.IsError {
t.Fatal("IsError should be true on empty path")
}
}

func TestAttachmentsUpload_RejectsMarkdown(t *testing.T) {
a, _ := newTestAttachments(t)
input := types.UploadAttachmentInput{
Path: "page.md",
ContentBase64: base64.StdEncoding.EncodeToString([]byte("x")),
}
res, _, _ := a.Upload(context.Background(), nil, input)
if !res.IsError {
t.Fatal("IsError should be true on .md path")
}
if !strings.Contains(resultText(t, res), ".md") {
t.Errorf("error message should mention .md: %s", resultText(t, res))
}
}

func TestAttachmentsDelete_HappyPath(t *testing.T) {
a, c := newTestAttachments(t)
if _, err := c.UploadAttachment("Attach/note.png", []byte("x")); err != nil {
t.Fatalf("seed: %v", err)
}

res, _, err := a.Delete(context.Background(), nil, types.DeleteAttachmentInput{Path: "Attach/note.png"})
if err != nil || res.IsError {
t.Fatalf("delete: err=%v isError=%v body=%s", err, res.IsError, resultText(t, res))
}
if !strings.Contains(resultText(t, res), `"deleted": true`) {
t.Errorf("response missing deleted=true: %s", resultText(t, res))
}
}

func TestAttachmentsDelete_NotFound(t *testing.T) {
a, _ := newTestAttachments(t)
res, _, _ := a.Delete(context.Background(), nil, types.DeleteAttachmentInput{Path: "missing.bin"})
if !res.IsError {
t.Fatal("IsError should be true when not found")
}
}

func TestAttachmentsDelete_EmptyPath(t *testing.T) {
a, _ := newTestAttachments(t)
res, _, _ := a.Delete(context.Background(), nil, types.DeleteAttachmentInput{Path: ""})
if !res.IsError {
t.Fatal("IsError should be true on empty path")
}
}

func TestAttachmentsList_HappyPath(t *testing.T) {
a, c := newTestAttachments(t)
if _, err := c.UploadAttachment("Attach/a.png", []byte("a")); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := c.UploadAttachment("Attach/b.pdf", []byte("bb")); err != nil {
t.Fatalf("seed: %v", err)
}

res, _, err := a.List(context.Background(), nil, types.ListAttachmentsInput{Folder: "Attach"})
if err != nil || res.IsError {
t.Fatalf("list: err=%v isError=%v body=%s", err, res.IsError, resultText(t, res))
}
text := resultText(t, res)
if !strings.Contains(text, `"count": 2`) {
t.Errorf("response missing count=2: %s", text)
}
if !strings.Contains(text, `"path": "Attach/a.png"`) || !strings.Contains(text, `"path": "Attach/b.pdf"`) {
t.Errorf("entries missing in response: %s", text)
}
}

func TestAttachmentsList_EmptyFolder(t *testing.T) {
a, _ := newTestAttachments(t)
res, _, _ := a.List(context.Background(), nil, types.ListAttachmentsInput{Folder: ""})
if !res.IsError {
t.Fatal("IsError should be true on empty folder")
}
}

func TestAttachmentsList_FolderNotFound(t *testing.T) {
a, _ := newTestAttachments(t)
res, _, _ := a.List(context.Background(), nil, types.ListAttachmentsInput{Folder: "missing"})
if !res.IsError {
t.Fatal("IsError should be true when folder missing")
}
}
16 changes: 16 additions & 0 deletions types/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,19 @@ type JournalSearchInput struct {
From string `json:"from,omitempty" jsonschema:"Start date filter (YYYY-MM-DD)"`
To string `json:"to,omitempty" jsonschema:"End date filter (YYYY-MM-DD)"`
}

// --- Attachment tool inputs (Obsidian-only) ---

type UploadAttachmentInput struct {
Path string `json:"path" jsonschema:"Path relative to vault root, e.g. '00_Inbox/email-2026-04-28/Attachements/invoice.pdf'. Parent directories are auto-created. Cannot end in .md (use create_page for markdown)."`
ContentBase64 string `json:"contentBase64" jsonschema:"File content, base64-encoded (standard alphabet, not URL-safe)."`
}

type DeleteAttachmentInput struct {
Path string `json:"path" jsonschema:"Path relative to vault root. Cannot end in .md (use delete_page)."`
}

type ListAttachmentsInput struct {
Folder string `json:"folder" jsonschema:"Folder path relative to vault root. Required (no default global scan)."`
Recursive bool `json:"recursive,omitempty" jsonschema:"Walk sub-folders. Default: false."`
}
Loading
Loading