diff --git a/AGENTS.md b/AGENTS.md
index 03b5bd7d..0f987c4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -184,6 +184,7 @@ Rules that follow from this:
- `exclude_assignee_user_ids` on the task tools is `excludeResponsiblePartyIds`, and exclusion is not the mirror of inclusion. A task is dropped when any one of the listed users is assigned to it, even when it also carries assignees nobody excluded, and a user reached only through a team, company or job-role assignment is not matched — the same limit `responsiblePartyIds` has. The rule belongs in the parameter description at every layer, because a response cannot show which rows were removed or why. `TestTaskExcludeAssigneesReachesTheWire` asserts it alongside `responsiblePartyIds`: the two parameter names share a prefix and the mocks answer the same body either way.
- A v3 list endpoint may **default its own filters**, and an unfiltered call is then not an exhaustive one. `GET /projects/api/v3/allocations.json` answers a request carrying neither `startDate` nor `endDate` with today through 30 days from today, and the response says nothing about the range having been narrowed — so "all allocations on this project" silently comes back as one month. Do not paper over it by defaulting the window in the handler: that reorders results for every existing caller. Say it in the tool description instead, in the imperative, and pin the omission on the query string (`TestAllocationListWindowIsNotDefaultedLocally`). When wiring a new list tool, check the endpoint for filters that carry a default before writing "omit to get everything".
- Some endpoints serve a **different response shape per API minor version**, selected by the `Teamwork-Version` request header. The allocations endpoints render `projectId`, `assignedUserID`, `createdBy`, `updatedBy` and `deletedBy` as bare identifiers by default and as relationship objects under a later version. Neither this repo nor the SDK sends that header anywhere, so model the default shape — and do not start sending it, because it is a request-wide switch that would move the shape of every other endpoint at the same time.
+- `twprojects-download_file` reads a file's content through the SDK's `FileDownload`, which fetches the address `File.DownloadURL` reports with the engine's own session. That route lives on the web application rather than under `/projects/api/v3`, takes the same Bearer token, and answers 302 to a signed storage URL the HTTP client follows by itself — so the second hop is a `presigned.IsURL` request like the upload's PUT, and `LoggingRoundTripper` elides its *response* body for the same reason it elides the upload's request body: the content is the customer's file, under the file's own content type, so a CSV or Markdown attachment looks loggable. The tool caps the content at `maxDownloadBytes` (checked against `Content-Length` first, then by reading one byte past the cap) and refuses larger files with a result pointing at the `downloadURL` from `twprojects-get_file`; text comes back as text, images as image content, anything else as an embedded resource blob, with the media type settled from the storage header and then the file extension, since storage answers `application/octet-stream` for anything the uploader's client did not recognise. The file ID comes from the item the file is attached to: a task's and a message reply's `attachments`, and a comment's `files`, each a `{id, type: "files"}` relationship the v3 responses fill on every row and the SDK models carry — so the `get_*` tools show them through the typed round-trip, and `TestTaskGetKeepsAttachments`, `TestCommentGetKeepsFiles` and `TestMessageReplyGetKeepsAttachments` pin that they are not dropped there. A `downloadURL` in a get or list response needs the caller's own session, which is why those descriptions tell the model to hand it to the user rather than fetch it. The `TestFileDownload*` tests build their own engine: the shared mocks answer without headers, and the content type is what picks the content block.
- Colours are typed, and the only choice is whether the field can be blank. `twapi.HexColor` takes six hexadecimal digits with or without the leading `#` (`^#?([0-9a-f]{6})$`), stores them lower-cased and always encodes with the `#`, so an endpoint that omits the sign (the allocation endpoints do) no longer costs the whole response. `twapi.OptionalHexColor` adds the blank: it reads `null`, `""` and a bare `"#"` as unset and encodes unset back as `""`, which keeps its declared `string` type accurate for the reflected output schemas. Reach for it wherever a colour is only sometimes set — the project update endpoints report none for a project nobody has rated — and pair it with `omitempty` on a *request* field, because the endpoints reject both `""` and the bare `"#"` that a plain `HexColor` with no value encodes to. A plain `string` is no longer the right answer for a colour anywhere.
- Typing a colour changes what a tool returns only on the paths that re-encode the typed response, and the compiler catches none of it. `twprojects-get_allocation` marshals the SDK struct, so it answers `"#3c8f7c"`, while `list_allocations` and a `fields` selection on the get stream the body and answer the `"3c8f7c"` the endpoint sent. The same split applies to a custom field's choice colours. `TestAllocationColorCarriesTheLeadingSign` pins all three allocation paths, because the divergence is invisible in a test that only checks the call succeeded.
- A dropdown, multiselect, status or rating custom field whose choice carries no colour used to fail `twprojects-get_custom_field` outright. The endpoint models that colour with its own non-optional type, so it answers an unset one with the bare `"#"`, which the SDK's `twapi.HexColor` refused — and the error propagates out of `CustomField`'s own `UnmarshalJSON`, taking the whole field with it. The choice colours are `twapi.OptionalHexColor` from twapi-go-sdk v1.29.0, so the colour reads as unset and its key is left out; `TestCustomFieldChoiceColorIsOptional` pins it. The write half needed the same type plus `omitempty`, because an omitted `color` used to reach the endpoint as that same `"#"`, which it rejects.
diff --git a/docs/index.html b/docs/index.html
index 789d96d7..85b099f4 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -4,9 +4,9 @@
+ read
+ twprojects-download_file
+ Read the content of a file stored in Teamwork.com: text files come back as text, images as an image, and anything else as a base64 resource with its media type.
+
readtwprojects-get_commentGet comment.
+
+ read
+ twprojects-get_file
+ Get a file's details: name, size, uploader, version history, the tasks, messages and comments it is attached to, and its downloadURL.
+
readtwprojects-get_link
@@ -688,6 +701,11 @@
Content
twprojects-list_commentsList comments.
+
+ read
+ twprojects-list_files
+ List the files in a project's files area, or across every project when no project_id is given.
+
readtwprojects-list_links
@@ -2165,7 +2183,7 @@
Deletes are not published
Read-only is one flag
-
Running a server with -read-only drops every write tool, leaving the 108 read tools listed above. Scopes narrow it further: a token carries one scope per product.
+
Running a server with -read-only drops every write tool, leaving the 111 read tools listed above. Scopes narrow it further: a token carries one scope per product.
diff --git a/docs/tool-reference.md b/docs/tool-reference.md
index ea88f231..28b42686 100644
--- a/docs/tool-reference.md
+++ b/docs/tool-reference.md
@@ -14,6 +14,7 @@ Comments, notebooks, milestones, tags, and activity feeds in Teamwork.com.
|---|---|---|---|---|
| Activity | — | — | ✓ | — |
| Comment | ✓ | ✓ | ✓ | ✓ |
+| File | — | ✓ | ✓ | — |
| Milestone | ✓ | ✓ | ✓ | ✓ |
| Notebook | ✓ | ✓ | ✓ | ✓ |
| Tag | ✓ | ✓ | ✓ | ✓ |
@@ -21,7 +22,7 @@ Comments, notebooks, milestones, tags, and activity feeds in Teamwork.com.
| Message Reply | ✓ | ✓ | ✓ | ✓ |
| Link | ✓ | ✓ | ✓ | ✓ |
-**Other actions:** `count_milestones`, `search`
+**Other actions:** `count_milestones`, `download_file`, `search`
### People — `twprojects-people`
diff --git a/go.mod b/go.mod
index 15d90ce7..957849e7 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
github.com/sonh/qs v0.7.0
github.com/teamwork/desksdkgo v1.1.1
github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb
- github.com/teamwork/twapi-go-sdk v1.29.2
+ github.com/teamwork/twapi-go-sdk v1.30.0
)
require (
diff --git a/go.sum b/go.sum
index e12cc144..e08c60b1 100644
--- a/go.sum
+++ b/go.sum
@@ -165,8 +165,8 @@ github.com/teamwork/desksdkgo v1.1.1 h1:ivmBqxTnTYgZrjpGvWXaK9DEdFyH1qL60FSY7HMX
github.com/teamwork/desksdkgo v1.1.1/go.mod h1:Mgvw83q8iqHr7Sm9xV1iI/T89o3ObaPU3ChMJheRzwA=
github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb h1:bQluDjySZeC5etnWgjk4WFRy0PvzGDw8XEBd4JJYWCQ=
github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb/go.mod h1:jfE0RLsZuk/3Glzs5bJ95pNb92emV7uXZYgoGSLQ76I=
-github.com/teamwork/twapi-go-sdk v1.29.2 h1:oUR/FQsAs73tuoVLprV5BegbC5PpoboXjhgfM4OaLR8=
-github.com/teamwork/twapi-go-sdk v1.29.2/go.mod h1:5aKvss5ZuvwWlxJqzC4rO5QwziNcLbgLFfRbzP1eu0E=
+github.com/teamwork/twapi-go-sdk v1.30.0 h1:edJU3PIuZTthfa73bUsmT1nISfgHjf3dZgTcv8c/Y30=
+github.com/teamwork/twapi-go-sdk v1.30.0/go.mod h1:5aKvss5ZuvwWlxJqzC4rO5QwziNcLbgLFfRbzP1eu0E=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
diff --git a/internal/twprojects/comments_test.go b/internal/twprojects/comments_test.go
index 6dfd4965..07d84c8a 100644
--- a/internal/twprojects/comments_test.go
+++ b/internal/twprojects/comments_test.go
@@ -351,3 +351,29 @@ func TestCommentListByUsers(t *testing.T) {
t.Errorf("expected userIds=123,456 in the outgoing query but got %q", got)
}
}
+
+// TestCommentGetKeepsFiles pins that the typed round-trip keeps the file relationships the
+// response carries: the ID in them is what twprojects-download_file takes.
+func TestCommentGetKeepsFiles(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"comments":{"id":123,"files":[{"id":555,"type":"files"}]}}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodCommentGet.String(), map[string]any{
+ "id": float64(123),
+ }, testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) {
+ testutil.CheckMessage(t, result)
+ text := result.(*mcp.CallToolResult).Content[0].(*mcp.TextContent).Text
+ var payload struct {
+ Entity struct {
+ Files []struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ } `json:"files"`
+ } `json:"comments"`
+ }
+ if err := json.Unmarshal([]byte(text), &payload); err != nil {
+ t.Fatalf("failed to decode the response: %v", err)
+ }
+ if len(payload.Entity.Files) != 1 || payload.Entity.Files[0].ID != 555 || payload.Entity.Files[0].Type != "files" {
+ t.Errorf("expected the file relationship to survive the round-trip, got %q", text)
+ }
+ }))
+}
diff --git a/internal/twprojects/count_test.go b/internal/twprojects/count_test.go
index 38e48a88..007cfab3 100644
--- a/internal/twprojects/count_test.go
+++ b/internal/twprojects/count_test.go
@@ -37,6 +37,7 @@ var countOnlyToolCases = []struct {
{method: twprojects.MethodCustomItemList.String(), args: map[string]any{"project_id": float64(123)}},
{method: twprojects.MethodCustomItemFieldList.String(), args: map[string]any{"custom_item_id": float64(123)}},
{method: twprojects.MethodCustomItemRecordList.String(), args: map[string]any{"custom_item_id": float64(123)}},
+ {method: twprojects.MethodFileList.String()},
{method: twprojects.MethodJobRoleList.String()},
{method: twprojects.MethodMessageList.String()},
{method: twprojects.MethodMessageReplyList.String()},
diff --git a/internal/twprojects/files.go b/internal/twprojects/files.go
index 643f0e7e..eac7646f 100644
--- a/internal/twprojects/files.go
+++ b/internal/twprojects/files.go
@@ -5,10 +5,14 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
+ "io"
+ "mime"
+ "net/http"
"path"
"strconv"
"strings"
"time"
+ "unicode/utf8"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -27,6 +31,9 @@ const (
MethodFileCreate toolsets.Method = "twprojects-create_file"
MethodUploadURLCreate toolsets.Method = "twprojects-create_upload_url"
MethodProjectFileAdd toolsets.Method = "twprojects-add_project_file"
+ MethodFileGet toolsets.Method = "twprojects-get_file"
+ MethodFileList toolsets.Method = "twprojects-list_files"
+ MethodFileDownload toolsets.Method = "twprojects-download_file"
)
// maxAttachmentBytes caps the decoded size of an inline attachment.
@@ -554,3 +561,506 @@ func sanitizeFileName(name string) (string, error) {
}
return name, nil
}
+
+// maxDownloadBytes caps the content twprojects-download_file returns inline.
+//
+// The content travels back inside the JSON-RPC response, base64-encoded when it
+// is not text, and everything the client keeps of it lands in the model's
+// context. Ten megabytes is already well past what any client passes on
+// intact; the cap exists so a request for a large archive fails with a result
+// the caller can act on instead of a response nothing downstream can hold.
+const maxDownloadBytes = 10 << 20
+
+var (
+ fileGetOutputSchema *jsonschema.Schema
+ fileListOutputSchema *jsonschema.Schema
+ fileDownloadOutputSchema *jsonschema.Schema
+)
+
+// fileOrdering is the order-by vocabulary of the files list endpoint.
+var fileOrdering = newOrdering("files",
+ projects.FileOrderByName,
+ projects.FileOrderByProjectName,
+ projects.FileOrderByCategoryName,
+ projects.FileOrderByDateUploaded,
+ projects.FileOrderBySize,
+ projects.FileOrderByID,
+)
+
+// fileDownloadResult describes the content twprojects-download_file returns
+// beside it. It exists to generate the published output schema.
+type fileDownloadResult struct {
+ // Name is the file name the server suggests for the content.
+ Name string `json:"name"`
+
+ // MIMEType is the media type of the content.
+ MIMEType string `json:"mimeType"`
+
+ // Size is the number of bytes in the content.
+ Size int64 `json:"size"`
+}
+
+func init() {
+ var err error
+
+ // generate the output schemas only once
+ fileGetOutputSchema, err = jsonschema.For[projects.FileGetResponse](
+ helpers.WithDateTypeSchema(&jsonschema.ForOptions{}),
+ )
+ if err != nil {
+ panic(fmt.Sprintf("failed to generate JSON schema for FileGetResponse: %v", err))
+ }
+ helpers.WithMetaWebLinkSchema(fileGetOutputSchema)
+ fileListOutputSchema, err = jsonschema.For[projects.FileListResponse](
+ helpers.WithDateTypeSchema(&jsonschema.ForOptions{}),
+ )
+ if err != nil {
+ panic(fmt.Sprintf("failed to generate JSON schema for FileListResponse: %v", err))
+ }
+ helpers.WithMetaWebLinkSchema(fileListOutputSchema)
+ fileDownloadOutputSchema, err = jsonschema.For[fileDownloadResult](&jsonschema.ForOptions{})
+ if err != nil {
+ panic(fmt.Sprintf("failed to generate JSON schema for fileDownloadResult: %v", err))
+ }
+}
+
+// FileGet retrieves a file's details in Teamwork.com.
+func FileGet(engine *twapi.Engine) toolsets.ToolWrapper {
+ return toolsets.ToolWrapper{
+ Tool: &mcp.Tool{
+ Name: string(MethodFileGet),
+ Description: fmt.Sprintf("Get a file's details: name, size, uploader, version history, the tasks, "+
+ "messages and comments it is attached to, and its downloadURL. The downloadURL needs the "+
+ "caller's own Teamwork session, so hand it to a signed-in user rather than fetching it; use %s "+
+ "to read the content here.", MethodFileDownload),
+ Annotations: &mcp.ToolAnnotations{
+ Title: "Get File",
+ ReadOnlyHint: true,
+ DestructiveHint: new(false),
+ OpenWorldHint: new(false),
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "id": {
+ Type: "integer",
+ Description: "The ID of the file to get.",
+ },
+ "version": {
+ Description: "The version number whose details, size and downloadURL are returned. Omit for " +
+ "the latest version.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "integer", Minimum: new(1.0)},
+ {Type: "null"},
+ },
+ },
+ "include_versions": {
+ Description: "Whether to include the file's whole version history under versions. " +
+ "Defaults to false.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "boolean"},
+ {Type: "null"},
+ },
+ },
+ "fields": helpers.FieldsSchema[projects.File]("file"),
+ },
+ Required: []string{"id"},
+ },
+ OutputSchema: helpers.WithOptionalFields(fileGetOutputSchema),
+ },
+ Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var fileGetRequest projects.FileGetRequest
+
+ // The uploader is the one relation a reader of a file always wants
+ // resolved to a name.
+ fileGetRequest.Include = []projects.FileRequestSideload{
+ projects.FileRequestSideloadUsers,
+ }
+
+ var arguments map[string]any
+ if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil {
+ return helpers.NewToolResultTextError("failed to decode request: %s", err.Error()), nil
+ }
+ err := helpers.ParamGroup(arguments,
+ helpers.RequiredNumericParam(&fileGetRequest.Path.ID, "id"),
+ helpers.OptionalNumericParam(&fileGetRequest.Version, "version"),
+ helpers.OptionalParam(&fileGetRequest.IncludeVersions, "include_versions"),
+ helpers.OptionalFieldsParam[projects.File](&fileGetRequest.Fields.File, "fields"),
+ )
+ if err != nil {
+ return helpers.NewToolResultTextError("invalid parameters: %s", err.Error()), nil
+ }
+
+ if len(fileGetRequest.Fields.File) > 0 {
+ // A selection names what the caller wants; the sideload would return
+ // the bulk it exists to avoid.
+ fileGetRequest.Include = nil
+ return helpers.NewRawToolResult(ctx, engine, fileGetRequest, "failed to get file",
+ helpers.WebLinkerWithIDPathBuilder("/app/files"),
+ )
+ }
+
+ file, err := projects.FileGet(ctx, engine, fileGetRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to get file")
+ }
+
+ encoded, err := json.Marshal(file)
+ if err != nil {
+ return nil, err
+ }
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{
+ Text: string(helpers.WebLinker(ctx, encoded,
+ helpers.WebLinkerWithIDPathBuilder("/app/files"),
+ )),
+ },
+ },
+ StructuredContent: helpers.StructuredWebLinker(ctx, file,
+ helpers.WebLinkerWithIDPathBuilder("/app/files"),
+ ),
+ }, nil
+ },
+ }
+}
+
+// FileList lists files in Teamwork.com.
+func FileList(engine *twapi.Engine) toolsets.ToolWrapper {
+ return toolsets.ToolWrapper{
+ Tool: &mcp.Tool{
+ Name: string(MethodFileList),
+ Description: fmt.Sprintf("List the files in a project's files area, or across every project when no "+
+ "project_id is given. Files attached to tasks, comments and messages live there too, so task_id "+
+ "answers \"what is attached to this task\". Deleted files are left out unless show_deleted is "+
+ "true. Each row carries a downloadURL that needs the caller's own Teamwork session; use %s to "+
+ "read the content here.", MethodFileDownload),
+ Annotations: &mcp.ToolAnnotations{
+ Title: "List Files",
+ ReadOnlyHint: true,
+ DestructiveHint: new(false),
+ OpenWorldHint: new(false),
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "project_id": {
+ Description: "The ID of the project whose files area to list. Omit to list files across " +
+ "every project the caller can access.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "integer"},
+ {Type: "null"},
+ },
+ },
+ "task_id": {
+ Description: "Only files attached to this task.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "integer"},
+ {Type: "null"},
+ },
+ },
+ "ids": {
+ Description: "Only files with these IDs.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "array", Items: &jsonschema.Schema{Type: "integer"}},
+ {Type: "null"},
+ },
+ },
+ "category_id": {
+ Description: "Only files in this file category.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "integer"},
+ {Type: "null"},
+ },
+ },
+ "tag_ids": {
+ Description: "Only files carrying any of these tags.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "array", Items: &jsonschema.Schema{Type: "integer"}},
+ {Type: "null"},
+ },
+ },
+ "user_ids": {
+ Description: "Only files uploaded by these users.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "array", Items: &jsonschema.Schema{Type: "integer"}},
+ {Type: "null"},
+ },
+ },
+ "search_term": {
+ Description: "Only files whose name contains this term. Set search_all_fields to also match " +
+ "the extension, the category, the original name and the uploader's name.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "string"},
+ {Type: "null"},
+ },
+ },
+ "search_all_fields": {
+ Description: "Whether search_term also matches the file extension, the file category, the " +
+ "original file name and the name of the latest uploader. Defaults to false.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "boolean"},
+ {Type: "null"},
+ },
+ },
+ "uploaded_after": helpers.DateFilterSchema("Only files whose selected version was uploaded on " +
+ "or after this day (YYYY-MM-DD). The day itself is included."),
+ "uploaded_before": helpers.DateFilterSchema("Only files whose selected version was uploaded " +
+ "before this day (YYYY-MM-DD). The bound is the first instant of the day, so files " +
+ "uploaded during it are excluded; name the following day to include it."),
+ "updated_after": helpers.DateTimeFilterSchema("Only files changed strictly after this instant, " +
+ "on the file or on its selected version."),
+ "show_deleted": {
+ Description: "Whether to also list deleted files. Defaults to false.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "boolean"},
+ {Type: "null"},
+ },
+ },
+ "skip_external_files": {
+ Description: "Whether to leave out files that live in a linked cloud storage provider " +
+ "(Google Drive, Dropbox, Box, OneDrive, SharePoint) and list uploads only. Defaults to " +
+ "false. A row's fileSource tells the two apart.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "boolean"},
+ {Type: "null"},
+ },
+ },
+ "order_by": fileOrdering.orderBySchema(),
+ "order_mode": orderModeSchema(),
+ "page": helpers.PageSchema(),
+ "page_size": helpers.PageSizeSchema(),
+ "verbose": helpers.VerboseSchema(),
+ "count_only": helpers.CountOnlySchema("files"),
+ "fields": helpers.FieldsSchema[projects.File]("file"),
+ },
+ Required: []string{},
+ },
+ OutputSchema: helpers.WithCountOnlySchema(helpers.WithOptionalFields(fileListOutputSchema)),
+ },
+ Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var fileListRequest projects.FileListRequest
+
+ var arguments map[string]any
+ if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil {
+ return helpers.NewToolResultTextError("failed to decode request: %s", err.Error()), nil
+ }
+ verbose := true
+ var countOnly bool
+ err := helpers.ParamGroup(arguments,
+ helpers.OptionalNumericParam(&fileListRequest.Path.ProjectID, "project_id"),
+ helpers.OptionalNumericParam(&fileListRequest.Filters.TaskID, "task_id"),
+ helpers.OptionalNumericListParam(&fileListRequest.Filters.IDs, "ids"),
+ helpers.OptionalNumericParam(&fileListRequest.Filters.CategoryID, "category_id"),
+ helpers.OptionalNumericListParam(&fileListRequest.Filters.TagIDs, "tag_ids"),
+ helpers.OptionalNumericListParam(&fileListRequest.Filters.UserIDs, "user_ids"),
+ helpers.OptionalParam(&fileListRequest.Filters.SearchTerm, "search_term"),
+ helpers.OptionalParam(&fileListRequest.Filters.SearchAllFields, "search_all_fields"),
+ helpers.OptionalDatePointerParam(&fileListRequest.Filters.UploadedStartDate, "uploaded_after"),
+ helpers.OptionalDatePointerParam(&fileListRequest.Filters.UploadedEndDate, "uploaded_before"),
+ helpers.OptionalTimePointerParam(&fileListRequest.Filters.UpdatedAfter, "updated_after"),
+ helpers.OptionalParam(&fileListRequest.Filters.ShowDeleted, "show_deleted"),
+ helpers.OptionalParam(&fileListRequest.Filters.SkipExternalFiles, "skip_external_files"),
+ fileOrdering.param(&fileListRequest.Filters.OrderBy, &fileListRequest.Filters.OrderMode),
+ helpers.OptionalNumericParam(&fileListRequest.Filters.Page, "page"),
+ helpers.OptionalNumericParam(&fileListRequest.Filters.PageSize, "page_size"),
+ helpers.OptionalParam(&verbose, "verbose"),
+ helpers.OptionalParam(&countOnly, "count_only"),
+ helpers.OptionalFieldsParam[projects.File](&fileListRequest.Filters.Fields.Files, "fields"),
+ )
+ if err != nil {
+ return helpers.NewToolResultTextError("invalid parameters: %s", err.Error()), nil
+ }
+
+ if countOnly {
+ return helpers.NewCountToolResult(ctx, engine, fileListRequest, "failed to count files")
+ }
+
+ switch {
+ case len(fileListRequest.Filters.Fields.Files) > 0:
+ // An explicit selection is answered as is, with no sideloads.
+ case verbose:
+ fileListRequest.Filters.Include = []projects.FileRequestSideload{
+ projects.FileRequestSideloadUsers,
+ projects.FileRequestSideloadProjects,
+ }
+ default:
+ fileListRequest.Filters.Fields.Files = []projects.FileField{
+ projects.FileFieldID,
+ projects.FileFieldDisplayName,
+ projects.FileFieldSize,
+ }
+ }
+
+ resp, err := twapi.ExecuteRaw(ctx, engine, fileListRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to list files")
+ }
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ if resp.StatusCode != http.StatusOK {
+ return helpers.HandleAPIError(twapi.NewHTTPError(resp, "failed to list files"), "failed to list files")
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ linked := helpers.WebLinker(ctx, body, helpers.WebLinkerWithIDPathBuilder("/app/files"))
+ result := &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: string(linked)},
+ },
+ }
+ var structured any
+ if err := json.Unmarshal(linked, &structured); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+ result.StructuredContent = structured
+ return result, nil
+ },
+ }
+}
+
+// FileDownload returns the content of a file in Teamwork.com.
+func FileDownload(engine *twapi.Engine) toolsets.ToolWrapper {
+ return toolsets.ToolWrapper{
+ Tool: &mcp.Tool{
+ Name: string(MethodFileDownload),
+ Description: fmt.Sprintf("Read the content of a file stored in Teamwork.com: text files come back as "+
+ "text, images as an image, and anything else as a base64 resource with its media type. Files "+
+ "over %d MB are refused; point the user at the file's downloadURL from %s instead. The file ID "+
+ "is in a task's or message reply's attachments and a comment's files (each {id, type: "+
+ "\"files\"}), or comes from %s.", maxDownloadBytes>>20, MethodFileGet, MethodFileList),
+ Annotations: &mcp.ToolAnnotations{
+ Title: "Download File",
+ ReadOnlyHint: true,
+ DestructiveHint: new(false),
+ OpenWorldHint: new(false),
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "id": {
+ Type: "integer",
+ Description: "The ID of the file to download.",
+ },
+ "version": {
+ Description: "The version number to download. Omit for the latest version.",
+ AnyOf: []*jsonschema.Schema{
+ {Type: "integer", Minimum: new(1.0)},
+ {Type: "null"},
+ },
+ },
+ },
+ Required: []string{"id"},
+ },
+ OutputSchema: fileDownloadOutputSchema,
+ },
+ Handler: func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var fileDownloadRequest projects.FileDownloadRequest
+
+ var arguments map[string]any
+ if err := json.Unmarshal(request.Params.Arguments, &arguments); err != nil {
+ return helpers.NewToolResultTextError("failed to decode request: %s", err.Error()), nil
+ }
+ err := helpers.ParamGroup(arguments,
+ helpers.RequiredNumericParam(&fileDownloadRequest.Path.ID, "id"),
+ helpers.OptionalNumericParam(&fileDownloadRequest.Version, "version"),
+ )
+ if err != nil {
+ return helpers.NewToolResultTextError("invalid parameters: %s", err.Error()), nil
+ }
+
+ download, err := projects.FileDownload(ctx, engine, fileDownloadRequest)
+ if err != nil {
+ return helpers.HandleAPIError(err, "failed to download file")
+ }
+ defer func() {
+ _ = download.Body.Close()
+ }()
+
+ if download.Size > maxDownloadBytes {
+ return helpers.NewToolResultTextError("file is %d bytes, over the %d MB limit; share its downloadURL "+
+ "from %s with the user instead", download.Size, maxDownloadBytes>>20, MethodFileGet), nil
+ }
+ // One byte past the cap is enough to know the body does not fit, without
+ // holding all of it for a server that did not announce the length.
+ content, err := io.ReadAll(io.LimitReader(download.Body, maxDownloadBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("failed to read file content: %w", err)
+ }
+ if len(content) > maxDownloadBytes {
+ return helpers.NewToolResultTextError("file is over the %d MB limit; share its downloadURL from %s "+
+ "with the user instead", maxDownloadBytes>>20, MethodFileGet), nil
+ }
+
+ mimeType := downloadMIMEType(download.ContentType, download.Name)
+ result := fileDownloadResult{
+ Name: download.Name,
+ MIMEType: mimeType,
+ Size: int64(len(content)),
+ }
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ return nil, err
+ }
+
+ var payload mcp.Content
+ switch {
+ case strings.HasPrefix(mimeType, "image/"):
+ payload = &mcp.ImageContent{Data: content, MIMEType: mimeType}
+ case isTextualMIMEType(mimeType) && utf8.Valid(content):
+ payload = &mcp.TextContent{Text: string(content)}
+ default:
+ payload = &mcp.EmbeddedResource{
+ Resource: &mcp.ResourceContents{
+ URI: fmt.Sprintf("twprojects://files/%d", fileDownloadRequest.Path.ID),
+ MIMEType: mimeType,
+ Blob: content,
+ },
+ }
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: string(encoded)},
+ payload,
+ },
+ StructuredContent: result,
+ }, nil
+ },
+ }
+}
+
+// downloadMIMEType settles the media type of a download. The storage service
+// answers with the type the file was uploaded under, which is a generic
+// octet-stream for anything the uploader's client did not recognise, so the
+// file name's extension is the fallback.
+func downloadMIMEType(contentType, name string) string {
+ mimeType, _, err := mime.ParseMediaType(contentType)
+ if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
+ return mimeType
+ }
+ if byExtension := mime.TypeByExtension(path.Ext(name)); byExtension != "" {
+ if parsed, _, err := mime.ParseMediaType(byExtension); err == nil {
+ return parsed
+ }
+ }
+ return "application/octet-stream"
+}
+
+// isTextualMIMEType reports whether content of the given media type is worth
+// returning as text rather than as a base64 blob.
+func isTextualMIMEType(mimeType string) bool {
+ switch {
+ case strings.HasPrefix(mimeType, "text/"):
+ return true
+ case mimeType == "application/json", mimeType == "application/xml":
+ return true
+ case strings.HasSuffix(mimeType, "+json"), strings.HasSuffix(mimeType, "+xml"):
+ return true
+ }
+ return false
+}
diff --git a/internal/twprojects/files_test.go b/internal/twprojects/files_test.go
index 65959792..41601823 100644
--- a/internal/twprojects/files_test.go
+++ b/internal/twprojects/files_test.go
@@ -11,6 +11,8 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/teamwork/mcp/internal/testutil"
"github.com/teamwork/mcp/internal/twprojects"
+ pkgtestutil "github.com/teamwork/mcp/pkg/testutil"
+ twapi "github.com/teamwork/twapi-go-sdk"
)
// presignedUploadURL is the URL the reservation step hands back. It carries the
@@ -724,3 +726,278 @@ func TestTaskAttachmentsOmittedWithoutEither(t *testing.T) {
t.Errorf("expected no attachments key, got %s", (*recorded)[0].Body)
}
}
+
+func TestFileListReachesTheWire(t *testing.T) {
+ mcpServer, requestURL := testutil.ProjectsMCPServerMockWithRequestURL(t, http.StatusOK, []byte(`{"files":[]}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileList.String(), map[string]any{
+ "project_id": float64(777),
+ "task_id": float64(12345),
+ "ids": []any{float64(1), float64(2)},
+ "category_id": float64(9),
+ "tag_ids": []any{float64(3)},
+ "user_ids": []any{float64(4)},
+ "search_term": "plan",
+ "search_all_fields": true,
+ "uploaded_after": "2026-08-01",
+ "uploaded_before": "2026-08-31",
+ "updated_after": "2026-08-15T10:00:00Z",
+ "show_deleted": true,
+ "skip_external_files": true,
+ "page": float64(2),
+ "page_size": float64(25),
+ })
+
+ if !strings.HasSuffix(requestURL.Path, "/projects/api/v3/projects/777/files.json") {
+ t.Errorf("expected the project-scoped route, got %s", requestURL.Path)
+ }
+ query := requestURL.Query()
+ for key, want := range map[string]string{
+ "taskId": "12345",
+ "ids": "1,2",
+ "categoryId": "9",
+ "tagIds": "3",
+ "userIds": "4",
+ "searchTerm": "plan",
+ "searchAllFields": "true",
+ "uploadedStartDate": "2026-08-01",
+ "uploadedEndDate": "2026-08-31",
+ "updatedAfter": "2026-08-15T10:00:00Z",
+ "showDeleted": "true",
+ "skipExternalFiles": "true",
+ "page": "2",
+ "pageSize": "25",
+ } {
+ if got := query.Get(key); got != want {
+ t.Errorf("expected %s=%q on the query string, got %q", key, want, got)
+ }
+ }
+ // verbose is the default, and it is what sideloads the uploader and project.
+ if got := query.Get("include"); got != "users,projects" {
+ t.Errorf("expected include=users,projects, got %q", got)
+ }
+}
+
+func TestFileListUnscopedUsesTheGlobalRoute(t *testing.T) {
+ mcpServer, requestURL := testutil.ProjectsMCPServerMockWithRequestURL(t, http.StatusOK, []byte(`{"files":[]}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileList.String(), map[string]any{
+ "verbose": false,
+ })
+
+ if !strings.HasSuffix(requestURL.Path, "/projects/api/v3/files.json") {
+ t.Errorf("expected the global files route, got %s", requestURL.Path)
+ }
+ query := requestURL.Query()
+ if got := query.Get("fields[files]"); got != "id,displayName,size" {
+ t.Errorf("expected the terse field set, got %q", got)
+ }
+ if query.Has("include") {
+ t.Errorf("expected no sideloads when verbose is false, got %q", query.Get("include"))
+ }
+}
+
+func TestFileGetReachesTheWire(t *testing.T) {
+ mcpServer, requestURL := testutil.ProjectsMCPServerMockWithRequestURL(t, http.StatusOK,
+ []byte(`{"file":{"id":12345}}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileGet.String(), map[string]any{
+ "id": float64(12345),
+ "version": float64(2),
+ "include_versions": true,
+ })
+
+ if !strings.HasSuffix(requestURL.Path, "/projects/api/v3/files/12345.json") {
+ t.Errorf("expected the single file route, got %s", requestURL.Path)
+ }
+ query := requestURL.Query()
+ for key, want := range map[string]string{
+ "version": "2",
+ "getVersions": "true",
+ "include": "users",
+ } {
+ if got := query.Get(key); got != want {
+ t.Errorf("expected %s=%q on the query string, got %q", key, want, got)
+ }
+ }
+}
+
+func TestFileGetSelectionDropsTheSideload(t *testing.T) {
+ mcpServer, requestURL := testutil.ProjectsMCPServerMockWithRequestURL(t, http.StatusOK,
+ []byte(`{"file":{"id":12345,"displayName":"plan.md"}}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileGet.String(), map[string]any{
+ "id": float64(12345),
+ "fields": []any{"displayName"},
+ })
+
+ query := requestURL.Query()
+ if got := query.Get("fields[files]"); got != "displayName,id" {
+ t.Errorf("expected the selection plus id, got %q", got)
+ }
+ if query.Has("include") {
+ t.Errorf("expected no sideload under a selection, got %q", query.Get("include"))
+ }
+}
+
+// fileDownloadMock answers the download route with the given body and headers,
+// as storage does once the redirect has been followed. The engine mocks build
+// their responses without headers, and the content type is what decides how the
+// tool returns the bytes, so this one sets them itself.
+func fileDownloadMock(t *testing.T, contentType, disposition string, body []byte) *mcp.Server {
+ t.Helper()
+
+ engine := twapi.NewEngine(testutil.ProjectsSessionMock{},
+ twapi.WithMiddleware(func(twapi.HTTPClient) twapi.HTTPClient {
+ return twapi.HTTPClientFunc(func(*http.Request) (*http.Response, error) {
+ resp := pkgtestutil.NewMockHTTPResponse(http.StatusOK, body)
+ resp.ContentLength = int64(len(body))
+ resp.Header.Set("Content-Type", contentType)
+ if disposition != "" {
+ resp.Header.Set("Content-Disposition", disposition)
+ }
+ return resp, nil
+ })
+ }),
+ )
+ return pkgtestutil.MCPServer(t, twprojects.DefaultToolsetGroup(false, true, engine))
+}
+
+// downloadContents runs the download tool and returns the result's content
+// blocks, failing the test on an error result.
+func downloadContents(t *testing.T, mcpServer *mcp.Server, args map[string]any) []mcp.Content {
+ t.Helper()
+
+ var contents []mcp.Content
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileDownload.String(), args,
+ testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) {
+ testutil.CheckMessage(t, result)
+ contents = result.(*mcp.CallToolResult).Content
+ }),
+ )
+ return contents
+}
+
+func TestFileDownloadReturnsTextAsText(t *testing.T) {
+ mcpServer := fileDownloadMock(t, "text/markdown; charset=utf-8", `attachment; filename="plan.md"`,
+ []byte("# Plan\n"))
+ contents := downloadContents(t, mcpServer, map[string]any{"id": float64(12345)})
+
+ if len(contents) != 2 {
+ t.Fatalf("expected a description and the content, got %d blocks", len(contents))
+ }
+ description, ok := contents[0].(*mcp.TextContent)
+ if !ok {
+ t.Fatalf("expected the first block to be text, got %T", contents[0])
+ }
+ var meta struct {
+ Name string `json:"name"`
+ MIMEType string `json:"mimeType"`
+ Size int64 `json:"size"`
+ }
+ if err := json.Unmarshal([]byte(description.Text), &meta); err != nil {
+ t.Fatalf("failed to decode the description: %v", err)
+ }
+ if meta.Name != "plan.md" || meta.MIMEType != "text/markdown" || meta.Size != 7 {
+ t.Errorf("unexpected description %+v", meta)
+ }
+ text, ok := contents[1].(*mcp.TextContent)
+ if !ok {
+ t.Fatalf("expected the content to be text, got %T", contents[1])
+ }
+ if text.Text != "# Plan\n" {
+ t.Errorf("expected the file's text, got %q", text.Text)
+ }
+}
+
+func TestFileDownloadReturnsImagesAsImages(t *testing.T) {
+ png := []byte("\x89PNG\r\n\x1a\n")
+ mcpServer := fileDownloadMock(t, "image/png", `attachment; filename="logo.png"`, png)
+ contents := downloadContents(t, mcpServer, map[string]any{"id": float64(12345)})
+
+ if len(contents) != 2 {
+ t.Fatalf("expected a description and the content, got %d blocks", len(contents))
+ }
+ image, ok := contents[1].(*mcp.ImageContent)
+ if !ok {
+ t.Fatalf("expected the content to be an image, got %T", contents[1])
+ }
+ if image.MIMEType != "image/png" || !bytes.Equal(image.Data, png) {
+ t.Errorf("expected the PNG bytes under image/png, got %s with %d bytes", image.MIMEType, len(image.Data))
+ }
+}
+
+func TestFileDownloadReturnsBinariesAsResources(t *testing.T) {
+ pdf := []byte("%PDF-1.7\n")
+ mcpServer := fileDownloadMock(t, "application/pdf", `attachment; filename="report.pdf"`, pdf)
+ contents := downloadContents(t, mcpServer, map[string]any{"id": float64(12345), "version": float64(3)})
+
+ if len(contents) != 2 {
+ t.Fatalf("expected a description and the content, got %d blocks", len(contents))
+ }
+ resource, ok := contents[1].(*mcp.EmbeddedResource)
+ if !ok {
+ t.Fatalf("expected the content to be an embedded resource, got %T", contents[1])
+ }
+ if resource.Resource.MIMEType != "application/pdf" || !bytes.Equal(resource.Resource.Blob, pdf) {
+ t.Errorf("expected the PDF bytes under application/pdf, got %s with %d bytes",
+ resource.Resource.MIMEType, len(resource.Resource.Blob))
+ }
+ if resource.Resource.URI != "twprojects://files/12345" {
+ t.Errorf("expected the resource to be addressed by file ID, got %q", resource.Resource.URI)
+ }
+}
+
+func TestFileDownloadFallsBackToTheExtension(t *testing.T) {
+ // Storage answers with the type the file was uploaded under, which is a
+ // generic octet-stream when the uploader's client did not know better.
+ mcpServer := fileDownloadMock(t, "application/octet-stream", `attachment; filename="data.csv"`,
+ []byte("a,b\n1,2\n"))
+ contents := downloadContents(t, mcpServer, map[string]any{"id": float64(12345)})
+
+ if len(contents) != 2 {
+ t.Fatalf("expected a description and the content, got %d blocks", len(contents))
+ }
+ if _, ok := contents[1].(*mcp.TextContent); !ok {
+ t.Errorf("expected a CSV to come back as text, got %T", contents[1])
+ }
+}
+
+func TestFileDownloadRefusesOversizedFiles(t *testing.T) {
+ // Declared size first: the body is never read when the server announces it
+ // does not fit.
+ engineTooBig := twapi.NewEngine(testutil.ProjectsSessionMock{},
+ twapi.WithMiddleware(func(twapi.HTTPClient) twapi.HTTPClient {
+ return twapi.HTTPClientFunc(func(*http.Request) (*http.Response, error) {
+ resp := pkgtestutil.NewMockHTTPResponse(http.StatusOK, nil)
+ resp.ContentLength = 11 << 20
+ resp.Header.Set("Content-Type", "application/zip")
+ return resp, nil
+ })
+ }),
+ )
+ mcpServer := pkgtestutil.MCPServer(t, twprojects.DefaultToolsetGroup(false, true, engineTooBig))
+
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileDownload.String(),
+ map[string]any{"id": float64(12345)},
+ testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) {
+ toolResult := result.(*mcp.CallToolResult)
+ if !toolResult.IsError {
+ t.Fatal("expected an error result for a file over the limit")
+ }
+ text := toolResult.Content[0].(*mcp.TextContent).Text
+ if !strings.Contains(text, "limit") || !strings.Contains(text, twprojects.MethodFileGet.String()) {
+ t.Errorf("expected the error to name the limit and the tool carrying the downloadURL, got %q", text)
+ }
+ }),
+ )
+}
+
+func TestFileDownloadAPIFailureIsAToolResult(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusNotFound, []byte(`{"errors":[{"title":"not found"}]}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodFileDownload.String(),
+ map[string]any{"id": float64(12345)},
+ testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) {
+ toolResult := result.(*mcp.CallToolResult)
+ if !toolResult.IsError {
+ t.Fatal("expected a 404 to surface as an error tool result")
+ }
+ }),
+ )
+}
diff --git a/internal/twprojects/message_replies_test.go b/internal/twprojects/message_replies_test.go
index af74f81a..6b6c3cb7 100644
--- a/internal/twprojects/message_replies_test.go
+++ b/internal/twprojects/message_replies_test.go
@@ -131,3 +131,30 @@ func TestMessageReplyList(t *testing.T) {
"page_size": float64(10),
})
}
+
+// TestMessageReplyGetKeepsAttachments pins that the typed round-trip keeps the file relationships the
+// response carries: the ID in them is what twprojects-download_file takes.
+func TestMessageReplyGetKeepsAttachments(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK,
+ []byte(`{"messageReply":{"id":123,"attachments":[{"id":555,"type":"files"}]}}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodMessageReplyGet.String(), map[string]any{
+ "id": float64(123),
+ }, testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) {
+ testutil.CheckMessage(t, result)
+ text := result.(*mcp.CallToolResult).Content[0].(*mcp.TextContent).Text
+ var payload struct {
+ Entity struct {
+ Files []struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ } `json:"attachments"`
+ } `json:"messageReply"`
+ }
+ if err := json.Unmarshal([]byte(text), &payload); err != nil {
+ t.Fatalf("failed to decode the response: %v", err)
+ }
+ if len(payload.Entity.Files) != 1 || payload.Entity.Files[0].ID != 555 || payload.Entity.Files[0].Type != "files" {
+ t.Errorf("expected the file relationship to survive the round-trip, got %q", text)
+ }
+ }))
+}
diff --git a/internal/twprojects/ordering_test.go b/internal/twprojects/ordering_test.go
index 5222c3e6..5f231c6d 100644
--- a/internal/twprojects/ordering_test.go
+++ b/internal/twprojects/ordering_test.go
@@ -80,6 +80,8 @@ var orderingToolCases = []orderingToolCase{{
method: twprojects.MethodCustomItemFieldList.String(),
args: map[string]any{"custom_item_id": float64(123)},
orderModeOnly: true,
+}, {
+ method: twprojects.MethodFileList.String(),
}, {
method: twprojects.MethodJobRoleList.String(),
orderModeOnly: true,
diff --git a/internal/twprojects/project_updates.go b/internal/twprojects/project_updates.go
index 7ee73167..3e895fc1 100644
--- a/internal/twprojects/project_updates.go
+++ b/internal/twprojects/project_updates.go
@@ -184,7 +184,7 @@ func ProjectStatusUpdateList(engine *twapi.Engine) toolsets.ToolWrapper {
// fields when both are needed.
filters.Fields.ProjectUpdates = []projects.ProjectStatusUpdateField{
projects.ProjectStatusUpdateFieldID,
- projects.ProjectStatusUpdateFieldProjectID,
+ projects.ProjectStatusUpdateFieldProject,
projects.ProjectStatusUpdateFieldHealth,
projects.ProjectStatusUpdateFieldHealthLabel,
projects.ProjectStatusUpdateFieldColor,
diff --git a/internal/twprojects/tasks_test.go b/internal/twprojects/tasks_test.go
index 25939b91..6cdcede8 100644
--- a/internal/twprojects/tasks_test.go
+++ b/internal/twprojects/tasks_test.go
@@ -797,3 +797,29 @@ func TestTaskNotifyReachesTheWire(t *testing.T) {
})
}
}
+
+// TestTaskGetKeepsAttachments pins that the typed round-trip keeps the file relationships the
+// response carries: the ID in them is what twprojects-download_file takes.
+func TestTaskGetKeepsAttachments(t *testing.T) {
+ mcpServer := mcpServerMock(t, http.StatusOK, []byte(`{"task":{"id":123,"attachments":[{"id":555,"type":"files"}]}}`))
+ testutil.ExecuteToolRequest(t, mcpServer, twprojects.MethodTaskGet.String(), map[string]any{
+ "id": float64(123),
+ }, testutil.ExecuteToolRequestWithCheckMessage(func(t *testing.T, result mcp.Result) {
+ testutil.CheckMessage(t, result)
+ text := result.(*mcp.CallToolResult).Content[0].(*mcp.TextContent).Text
+ var payload struct {
+ Entity struct {
+ Files []struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+ } `json:"attachments"`
+ } `json:"task"`
+ }
+ if err := json.Unmarshal([]byte(text), &payload); err != nil {
+ t.Fatalf("failed to decode the response: %v", err)
+ }
+ if len(payload.Entity.Files) != 1 || payload.Entity.Files[0].ID != 555 || payload.Entity.Files[0].Type != "files" {
+ t.Errorf("expected the file relationship to survive the round-trip, got %q", text)
+ }
+ }))
+}
diff --git a/internal/twprojects/tools.go b/internal/twprojects/tools.go
index 5e9babcd..6e6f7a99 100644
--- a/internal/twprojects/tools.go
+++ b/internal/twprojects/tools.go
@@ -288,6 +288,9 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool
ActivityList(engine),
CommentGet(engine),
CommentList(engine),
+ FileDownload(engine),
+ FileGet(engine),
+ FileList(engine),
MilestoneGet(engine),
MilestoneList(engine),
NotebookGet(engine),
diff --git a/internal/twprojects/tools_sparse_fields_test.go b/internal/twprojects/tools_sparse_fields_test.go
index 35e18b62..6b2001a9 100644
--- a/internal/twprojects/tools_sparse_fields_test.go
+++ b/internal/twprojects/tools_sparse_fields_test.go
@@ -60,6 +60,13 @@ var fieldsToolCases = []fieldsToolCase{{
method: twprojects.MethodCustomFieldValueList.String(),
args: map[string]any{"entity": "task", "entity_id": float64(123)},
attributes: attributesOf[projects.CustomFieldValueField, projects.CustomFieldValue],
+}, {
+ method: twprojects.MethodFileList.String(),
+ attributes: attributesOf[projects.FileField, projects.File],
+}, {
+ method: twprojects.MethodFileGet.String(),
+ args: map[string]any{"id": float64(123)},
+ attributes: attributesOf[projects.FileField, projects.File],
}, {
method: twprojects.MethodJobRoleList.String(),
attributes: attributesOf[projects.JobRoleField, projects.JobRole],
diff --git a/pkg/network/roundtripper.go b/pkg/network/roundtripper.go
index 97bc2a7b..3b53a0ab 100644
--- a/pkg/network/roundtripper.go
+++ b/pkg/network/roundtripper.go
@@ -96,7 +96,10 @@ func (lrt *LoggingRoundTripper) RoundTrip(r *http.Request) (*http.Response, erro
var loggedResponseBody string
if resp.Body != nil {
- if contentType := resp.Header.Get("Content-Type"); !logsafe.IsTextualContentType(contentType) {
+ // A download's body is the customer's file too, fetched from storage
+ // under a content type that is the file's own.
+ contentType := resp.Header.Get("Content-Type")
+ if toStorage || !logsafe.IsTextualContentType(contentType) {
loggedResponseBody = logsafe.ElidedBody(resp.ContentLength, contentType)
} else {
respBody, err := io.ReadAll(resp.Body)
diff --git a/pkg/network/roundtripper_test.go b/pkg/network/roundtripper_test.go
index 1d0f8d4b..c8278ece 100644
--- a/pkg/network/roundtripper_test.go
+++ b/pkg/network/roundtripper_test.go
@@ -112,6 +112,39 @@ func TestRoundTripRedactsPresignedURLInResponse(t *testing.T) {
}
}
+func TestRoundTripElidesPresignedDownload(t *testing.T) {
+ // A download is the upload in reverse: the body arrives from storage under
+ // the file's own content type, so a CSV or markdown file looks loggable.
+ request, err := http.NewRequest(http.MethodGet, presignedUploadURL, nil)
+ if err != nil {
+ t.Fatalf("failed to build the request: %v", err)
+ }
+
+ response := newResponse("text/markdown", "# Plan\n\nConfidential notes")
+ roundTripper, logged := logging(&stubTransport{response: response})
+ resp, err := roundTripper.RoundTrip(request)
+ if err != nil {
+ t.Fatalf("round trip failed: %v", err)
+ }
+
+ output := logged.String()
+ if strings.Contains(output, "Confidential") {
+ t.Errorf("expected the downloaded body to be elided, got %q", output)
+ }
+ if !strings.Contains(output, "elided") {
+ t.Errorf("expected an elision marker in the log, got %q", output)
+ }
+
+ // Eliding is a logging decision: the body still has to reach the caller.
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("failed to read the body: %v", err)
+ }
+ if !strings.Contains(string(body), "Confidential") {
+ t.Errorf("expected the body to reach the caller intact, got %q", body)
+ }
+}
+
func TestRoundTripStillLogsAPIBodies(t *testing.T) {
request, err := http.NewRequest(http.MethodPost,
"https://example.com/projects/api/v3/tasks.json", strings.NewReader(`{"task":{"name":"example"}}`))