Skip to content
Merged
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
5 changes: 4 additions & 1 deletion internal/mcp/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ func NewCapabilitiesDescriptor(coLocated, tunnelOpenAI, uploadFile, vaultPutFile
InputSchema: toolargs.ToolSchemaFor[wizard.NoInput](),
Handler: func(ctx context.Context, request model.ToolRequest) (model.ToolResult, error) {
report := CurrentCapabilities(coLocated, tunnelOpenAI, uploadFile, vaultPutFile, downloadFile, vaultGetFile, dropWired, draftXFile, maxBytes)
return model.ToolResult{StructuredContent: report, Text: "Pinner capabilities."}, nil
// Text carries the same canonical JSON as StructuredContent so a
// text-only MCP client still sees the source/sink mode data instead
// of an unhelpful stub ("Pinner capabilities.").
return model.ToolResult{StructuredContent: report, Text: toolargs.ResultJSONText(report)}, nil
},
}
}
4 changes: 4 additions & 0 deletions internal/mcp/capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ func TestCapabilitiesDescriptorSerializes(t *testing.T) {
require.Equal(t, []any{"local", "drop"}, m["download_sink_modes"])
require.Equal(t, true, m["download_file"])
require.Equal(t, true, m["vault_get_file"])
// The text-only channel must carry the report JSON (not a bare stub) so a
// plain-text MCP client still learns the transport and source modes.
require.Contains(t, res.Text, `"transport":`)
require.Contains(t, res.Text, `"source_modes":`)
}

func TestCapabilitiesDescriptorIsDirectVisible(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/mcp/core/transfer/upload_curl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ func TestCurlUploadToolDescriptor(t *testing.T) {
require.Contains(t, curlCmd, "curl")
require.Contains(t, curlCmd, url)

// The text-only channel must carry the same actionable data (url + curl
// command) as StructuredContent, so a plain-text MCP client that renders no
// widget still receives what it needs to complete the upload.
require.Contains(t, res.Text, url)
require.Contains(t, res.Text, "curl")
require.Contains(t, res.Text, "upload_status")

// Invalid TTL is rejected.
_, err = desc.Handler(context.Background(), model.ToolRequest{Arguments: map[string]any{"source": map[string]any{"mode": "mint"}, "ttl": "not-a-duration"}})
require.Error(t, err)
Expand Down
23 changes: 15 additions & 8 deletions internal/mcp/core/transfer/upload_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,22 @@ func NewUploadFileDescriptor(coLocated, tunnelOpenAI bool, pathFn UploadFileHand
return model.ToolResult{}, merr
}
curlCmd := fmt.Sprintf("curl -sS -T <your-file> %q", url)
sc := map[string]any{
"url": url,
"curl_command": curlCmd,
"upload_handle_poll": "upload_status",
"ttl": ttl.String(),
"max_bytes": hp.maxBytes,
}
// Text carries the same JSON as StructuredContent so a text-only
// MCP client (which renders no widget) still sees the actual
// presigned URL and curl command, not just the prose instruction.
// The upload_handle itself is only produced by the presigned PUT's
// 202 response body, so it cannot appear here — the tool describes
// where to get it (curl's response) and which tool to poll with.
return model.ToolResult{
StructuredContent: map[string]any{
"url": url,
"curl_command": curlCmd,
"upload_handle_poll": "upload_status",
"ttl": ttl.String(),
"max_bytes": hp.maxBytes,
},
Text: "One-time upload endpoint minted. Run the curl command with your file, then poll upload_status with the returned upload_handle.",
StructuredContent: sc,
Text: toolargs.ResultJSONText(sc) + " Run the curl command with your file; the upload_handle comes back in that curl response, then poll upload_status with it.",
}, nil
default: // TransportOpenAI
if in.Source.Mode != SourceURL && in.Source.Mode != SourceData {
Expand Down
21 changes: 12 additions & 9 deletions internal/mcp/upload/upload_ipfs_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,18 @@ func ipfsUploadSubmitDescriptor(hp *transfer.Upload) model.ToolDescriptor {
if url == "" {
return model.ToolResult{}, fmt.Errorf("failed to mint one-time upload endpoint")
}
sc := map[string]any{
"url": url,
"ttl": ttl.String(),
"max_bytes": hp.MaxBytes(),
"poll_tool": "ipfs_upload_status",
"response_body": "the 202 body carries an upload_handle the app passes to poll_tool",
}
return model.ToolResult{
StructuredContent: map[string]any{
"url": url,
"ttl": ttl.String(),
"max_bytes": hp.MaxBytes(),
"poll_tool": "ipfs_upload_status",
"response_body": "the 202 body carries an upload_handle the app passes to poll_tool",
},
Text: "One-time upload endpoint minted. PUT the file bytes and poll for the CID.",
StructuredContent: sc,
// Text carries the same JSON so a text-only client sees the
// actual presigned URL plus poll instructions, not a stub.
Text: toolargs.ResultJSONText(sc) + " PUT the file bytes and poll for the CID.",
}, nil
},
}
Expand Down Expand Up @@ -122,7 +125,7 @@ func ipfsUploadStatusDescriptor(hp *transfer.Upload) model.ToolDescriptor {
if err != nil {
return model.ToolResult{}, err
}
return model.ToolResult{StructuredContent: task, Text: "Upload status."}, nil
return model.ToolResult{StructuredContent: task, Text: toolargs.ResultJSONText(task)}, nil
},
}
}
Expand Down
46 changes: 46 additions & 0 deletions internal/mcp/upload/upload_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,52 @@ func TestAsyncUploadStatusToolMissingHandle(t *testing.T) {
require.ErrorContains(t, err, "handle is required")
}

func TestAsyncUploadToolsTextCarriesData(t *testing.T) {
// Text-only MCP clients read only the Text channel. upload_file_async,
// upload_status, upload_cancel, and upload_list must put their actionable
// data there (not a bare stub) so such clients can use the handle.
release := make(chan struct{})
mgr := transfer.NewUploadTaskManager(func(ctx context.Context, reader io.Reader, size int64, name string, wait bool) (any, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-release:
return map[string]any{"cid": "QmText"}, nil
}
}, 0)
descs := NewAsyncUploadTools(mgr)
byName := map[string]model.ToolDescriptor{}
for _, d := range descs {
byName[d.Name] = d
}

// Seed a task directly so no network fetch is involved, then drive the
// status/list/cancel tools over that handle.
handle, err := mgr.Start(context.Background(), io.NopCloser(strings.NewReader("x")), 1, "x.txt", false)
require.NoError(t, err)

// upload_status: Text carries the task data, not "Upload status."
status, err := byName["upload_status"].Handler(context.Background(), model.ToolRequest{Arguments: map[string]any{"handle": handle}})
require.NoError(t, err)
require.NotEqual(t, "Upload status.", status.Text)
require.Contains(t, status.Text, handle)

// upload_list: Text carries the tracked uploads, not "Uploads."
list, err := byName["upload_list"].Handler(context.Background(), model.ToolRequest{Arguments: map[string]any{}})
require.NoError(t, err)
require.NotEqual(t, "Uploads.", list.Text)
require.Contains(t, list.Text, handle)

// upload_cancel: Text carries the cancelled handle (task is still running,
// so it is cancellable).
cancel, err := byName["upload_cancel"].Handler(context.Background(), model.ToolRequest{Arguments: map[string]any{"handle": handle}})
require.NoError(t, err)
require.NotEqual(t, "Upload cancelled.", cancel.Text)
require.Contains(t, cancel.Text, handle)

close(release)
}

func TestUploadTaskManagerTTLEviction(t *testing.T) {
mgr := transfer.NewUploadTaskManager(func(ctx context.Context, reader io.Reader, size int64, name string, wait bool) (any, error) {
io.Copy(io.Discard, reader)
Expand Down
11 changes: 7 additions & 4 deletions internal/mcp/upload/upload_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func NewAsyncUploadTools(mgr *transfer.UploadTaskManager) []model.ToolDescriptor
// unsafe.
return model.ToolResult{}, err
}
return model.ToolResult{StructuredContent: map[string]any{"handle": id}, Text: "Async upload started."}, nil
return model.ToolResult{StructuredContent: map[string]any{"handle": id}, Text: toolargs.ResultJSONText(map[string]any{"handle": id})}, nil
},
},
{
Expand All @@ -89,7 +89,7 @@ func NewAsyncUploadTools(mgr *transfer.UploadTaskManager) []model.ToolDescriptor
if err != nil {
return model.ToolResult{}, err
}
return model.ToolResult{StructuredContent: task, Text: "Upload status."}, nil
return model.ToolResult{StructuredContent: task, Text: toolargs.ResultJSONText(task)}, nil
},
},
{
Expand All @@ -109,7 +109,7 @@ func NewAsyncUploadTools(mgr *transfer.UploadTaskManager) []model.ToolDescriptor
if err := mgr.Cancel(in.Handle); err != nil {
return model.ToolResult{}, err
}
return model.ToolResult{StructuredContent: map[string]any{"handle": in.Handle, "cancelled": true}, Text: "Upload cancelled."}, nil
return model.ToolResult{StructuredContent: map[string]any{"handle": in.Handle, "cancelled": true}, Text: toolargs.ResultJSONText(map[string]any{"handle": in.Handle, "cancelled": true})}, nil
},
},
{
Expand All @@ -120,7 +120,10 @@ func NewAsyncUploadTools(mgr *transfer.UploadTaskManager) []model.ToolDescriptor
InputSchema: toolargs.ToolSchemaFor[wizard.NoInput](),
Handler: func(ctx context.Context, request model.ToolRequest) (model.ToolResult, error) {
tasks := mgr.List()
return model.ToolResult{StructuredContent: map[string]any{"uploads": tasks}, Text: "Uploads."}, nil
// Text carries the same JSON as StructuredContent so a text-only
// client sees the tracked handles/status instead of a stub.
sc := map[string]any{"uploads": tasks}
return model.ToolResult{StructuredContent: sc, Text: toolargs.ResultJSONText(sc)}, nil
},
},
}
Expand Down
19 changes: 11 additions & 8 deletions internal/mcp/vault/vault_put_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,18 @@ func NewVaultPutFileDescriptor(coLocated, tunnelOpenAI bool, pathFn LocalPathVau
return model.ToolResult{}, merr
}
curlCmd := fmt.Sprintf("curl -sS -T <your-file> %q", url)
sc := map[string]any{
"url": url,
"vault_path": in.VaultPath,
"curl_command": curlCmd,
"ttl": ttl.String(),
"max_bytes": vu.MaxBytes(),
}
return model.ToolResult{
StructuredContent: map[string]any{
"url": url,
"vault_path": in.VaultPath,
"curl_command": curlCmd,
"ttl": ttl.String(),
"max_bytes": vu.MaxBytes(),
},
Text: "One-time vault upload endpoint minted. Run the curl command with your file; the vault write completes synchronously and the response carries the vault result.",
StructuredContent: sc,
// Text carries the same JSON so a text-only client sees the
// actual presigned URL and curl command, not just prose.
Text: toolargs.ResultJSONText(sc) + " Run the curl command with your file; the vault write completes synchronously and the response carries the vault result.",
}, nil
default: // TransportOpenAI
if in.Source.Mode != transfer.SourceURL && in.Source.Mode != transfer.SourceData {
Expand Down
6 changes: 4 additions & 2 deletions tests/sunpeak/mcp-e2e/meta-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ test('capabilities tool returns declared capabilities', async ({ mcp }) => {
const result = await mcp.callTool('capabilities', {});
expect(result).not.toBeError();

// text content is the human label; the capability report is structured.
expect(result).toHaveTextContent('Pinner capabilities');
// text content carries the report JSON (same data as the structured payload)
// so a text-only client sees the transport + source modes, not a stub label.
expect(result).toHaveTextContent('transport');
expect(result).toHaveTextContent('source_modes');

// Locked from a live probe: stdio transport advertises only `path` sourcing.
expect(result).toHaveStructuredContent({ transport: 'stdio' });
Expand Down
Loading