feat(files): introduce upload_file tool for staging files and enhance save_file documentation
This commit is contained in:
@@ -224,7 +224,7 @@ func (c *Client) ExtractMetadata(ctx context.Context, input string) (thoughttype
|
||||
attrs := []any{
|
||||
slog.String("provider", c.name),
|
||||
slog.String("model", model),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
slog.String("duration", formatLogDuration(time.Since(start))),
|
||||
}
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("error", err.Error()))
|
||||
@@ -298,6 +298,18 @@ func (c *Client) ExtractMetadata(ctx context.Context, input string) (thoughttype
|
||||
return heuristic, nil
|
||||
}
|
||||
|
||||
func formatLogDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
|
||||
totalMilliseconds := d.Milliseconds()
|
||||
minutes := totalMilliseconds / 60000
|
||||
seconds := (totalMilliseconds / 1000) % 60
|
||||
milliseconds := totalMilliseconds % 1000
|
||||
return fmt.Sprintf("%02d:%02d:%03d", minutes, seconds, milliseconds)
|
||||
}
|
||||
|
||||
func (c *Client) extractMetadataWithModel(ctx context.Context, input, model string) (thoughttypes.ThoughtMetadata, error) {
|
||||
if c.shouldBypassModel(model) {
|
||||
return thoughttypes.ThoughtMetadata{}, fmt.Errorf("%s metadata: model %q temporarily bypassed after repeated empty responses", c.name, model)
|
||||
|
||||
@@ -47,7 +47,7 @@ func logToolCall[In any, Out any](logger *slog.Logger, toolName string, handler
|
||||
result, out, err := handler(ctx, req, in)
|
||||
|
||||
completionAttrs := append([]any{}, attrs...)
|
||||
completionAttrs = append(completionAttrs, slog.Duration("duration", time.Since(start)))
|
||||
completionAttrs = append(completionAttrs, slog.String("duration", formatLogDuration(time.Since(start))))
|
||||
if err != nil {
|
||||
completionAttrs = append(completionAttrs, slog.String("error", err.Error()))
|
||||
logger.Error("mcp tool completed", completionAttrs...)
|
||||
@@ -70,6 +70,18 @@ func truncateArgs(args any) string {
|
||||
return string(b[:maxLoggedArgBytes]) + fmt.Sprintf("… (%d bytes total)", len(b))
|
||||
}
|
||||
|
||||
func formatLogDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
|
||||
totalMilliseconds := d.Milliseconds()
|
||||
minutes := totalMilliseconds / 60000
|
||||
seconds := (totalMilliseconds / 1000) % 60
|
||||
milliseconds := totalMilliseconds % 1000
|
||||
return fmt.Sprintf("%02d:%02d:%03d", minutes, seconds, milliseconds)
|
||||
}
|
||||
|
||||
func setToolSchemas[In any, Out any](tool *mcp.Tool) error {
|
||||
if tool.InputSchema == nil {
|
||||
inputSchema, err := jsonschema.For[In](toolSchemaOptions)
|
||||
|
||||
@@ -134,9 +134,14 @@ func New(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet) http.Handle
|
||||
Description: "A stored file. Read a file's raw binary content by its id. Use load_file for metadata.",
|
||||
}, toolSet.Files.ReadResource)
|
||||
|
||||
addTool(server, logger, &mcp.Tool{
|
||||
Name: "upload_file",
|
||||
Description: "Stage a file and get an amcs://files/{id} resource URI. Provide content_path (absolute server-side path, no size limit) or content_base64 (≤10 MB). Optionally link immediately with thought_id/project, or omit them and pass the returned URI to save_file later.",
|
||||
}, toolSet.Files.Upload)
|
||||
|
||||
addTool(server, logger, &mcp.Tool{
|
||||
Name: "save_file",
|
||||
Description: "Store a file and optionally link it to a thought. Supply either content_base64 (≤10 MB) or content_uri (amcs://files/{id} from a prior POST /files upload). For files larger than 10 MB, upload via POST /files first and pass the returned URI as content_uri.",
|
||||
Description: "Store a file and optionally link it to a thought. Supply either content_base64 (≤10 MB) or content_uri (amcs://files/{id} from a prior upload_file or POST /files call). For files larger than 10 MB, use upload_file with content_path first.",
|
||||
}, toolSet.Files.Save)
|
||||
|
||||
addTool(server, logger, &mcp.Tool{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ func TestDecodeBase64AcceptsWhitespaceAndMultipleVariants(t *testing.T) {
|
||||
}{
|
||||
{name: "standard with whitespace", input: "aG V s\nbG8=", want: "hello"},
|
||||
{name: "raw standard", input: "aGVsbG8", want: "hello"},
|
||||
{name: "standard with extra padding", input: "aGVsbG8==", want: "hello"},
|
||||
{name: "standard url-safe payload", input: "--8=", want: string([]byte{0xfb, 0xef})},
|
||||
{name: "raw url-safe payload", input: "--8", want: string([]byte{0xfb, 0xef})},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user