feat(files): introduce upload_file tool for staging files and enhance save_file documentation

This commit is contained in:
2026-03-31 00:30:56 +02:00
parent 3819eb4fee
commit acd780ac9c
7 changed files with 157 additions and 28 deletions

View File

@@ -6,6 +6,8 @@ import (
"encoding/base64"
"encoding/hex"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
@@ -65,6 +67,21 @@ type ListFilesInput struct {
Kind string `json:"kind,omitempty" jsonschema:"optional kind filter such as image, document, audio, or file"`
}
type UploadFileInput struct {
Name string `json:"name" jsonschema:"file name including extension, for example photo.png or note.pdf"`
ContentPath string `json:"content_path,omitempty" jsonschema:"absolute path to a file on the server; preferred for large files — no base64 overhead"`
ContentBase64 string `json:"content_base64,omitempty" jsonschema:"file contents encoded as base64 (≤10 MB); use content_path for larger files"`
MediaType string `json:"media_type,omitempty" jsonschema:"optional MIME type such as image/png, application/pdf, or audio/mpeg"`
Kind string `json:"kind,omitempty" jsonschema:"optional logical type such as image, document, audio, or file"`
ThoughtID string `json:"thought_id,omitempty" jsonschema:"optional thought id to link this file to immediately"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id"`
}
type UploadFileOutput struct {
File thoughttypes.StoredFile `json:"file"`
URI string `json:"uri" jsonschema:"amcs resource URI for this file, e.g. amcs://files/{id}; pass as content_uri in save_file to link without re-uploading"`
}
type ListFilesOutput struct {
Files []thoughttypes.StoredFile `json:"files"`
}
@@ -73,6 +90,60 @@ func NewFilesTool(db *store.DB, sessions *session.ActiveProjects) *FilesTool {
return &FilesTool{store: db, sessions: sessions}
}
func (t *FilesTool) Upload(ctx context.Context, req *mcp.CallToolRequest, in UploadFileInput) (*mcp.CallToolResult, UploadFileOutput, error) {
path := strings.TrimSpace(in.ContentPath)
b64 := strings.TrimSpace(in.ContentBase64)
if path != "" && b64 != "" {
return nil, UploadFileOutput{}, errInvalidInput("provide content_path or content_base64, not both")
}
var content []byte
var mediaTypeFromSource string
if path != "" {
if !filepath.IsAbs(path) {
return nil, UploadFileOutput{}, errInvalidInput("content_path must be an absolute path")
}
var err error
content, err = os.ReadFile(path)
if err != nil {
return nil, UploadFileOutput{}, errInvalidInput("cannot read content_path: " + err.Error())
}
} else {
if b64 == "" {
return nil, UploadFileOutput{}, errInvalidInput("content_path or content_base64 is required")
}
if len(b64) > maxBase64ToolBytes {
return nil, UploadFileOutput{}, errInvalidInput(
"content_base64 exceeds the 10 MB MCP tool limit; use content_path instead",
)
}
raw, dataURLMediaType := splitDataURL(b64)
var err error
content, err = decodeBase64(raw)
if err != nil {
return nil, UploadFileOutput{}, errInvalidInput("content_base64 must be valid base64")
}
mediaTypeFromSource = dataURLMediaType
}
out, err := t.SaveDecoded(ctx, req, SaveFileDecodedInput{
Name: in.Name,
Content: content,
MediaType: firstNonEmpty(strings.TrimSpace(in.MediaType), mediaTypeFromSource),
Kind: in.Kind,
ThoughtID: in.ThoughtID,
Project: in.Project,
})
if err != nil {
return nil, UploadFileOutput{}, err
}
uri := fileURIPrefix + out.File.ID.String()
return nil, UploadFileOutput{File: out.File, URI: uri}, nil
}
func (t *FilesTool) Save(ctx context.Context, req *mcp.CallToolRequest, in SaveFileInput) (*mcp.CallToolResult, SaveFileOutput, error) {
uri := strings.TrimSpace(in.ContentURI)
b64 := strings.TrimSpace(in.ContentBase64)
@@ -338,6 +409,12 @@ func decodeBase64(value string) ([]byte, error) {
}
}, value)
var candidates []string
candidates = append(candidates, cleaned)
if trimmed := strings.TrimRight(cleaned, "="); trimmed != cleaned && trimmed != "" {
candidates = append(candidates, trimmed)
}
encodings := []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
@@ -346,12 +423,14 @@ func decodeBase64(value string) ([]byte, error) {
}
var lastErr error
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(cleaned)
if err == nil {
return decoded, nil
for _, candidate := range candidates {
for _, encoding := range encodings {
decoded, err := encoding.DecodeString(candidate)
if err == nil {
return decoded, nil
}
lastErr = err
}
lastErr = err
}
return nil, lastErr