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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 25 additions & 7 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Teamwork.com MCP Server — Tool Reference</title>
<meta name="description" content="Every tool the Teamwork.com MCP server exposes to an AI client: 210 tools across 14 toolsets and 4 products, generated from the server's own registry.">
<meta name="description" content="Every tool the Teamwork.com MCP server exposes to an AI client: 213 tools across 14 toolsets and 4 products, generated from the server's own registry.">
<meta property="og:title" content="Teamwork.com MCP Server — Tool Reference">
<meta property="og:description" content="210 tools across 4 Teamwork.com products, generated from the server's own toolset registry.">
<meta property="og:description" content="213 tools across 4 Teamwork.com products, generated from the server's own toolset registry.">
<meta property="og:type" content="website">
<link rel="icon" href="data:image/svg+xml,image/svg&#43;xml,%3Csvg%20width=%2252%22%20height=%2250%22%20viewBox=%220%200%2052%2050%22%20fill=%22none%22%20xmlns=%22http:%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Cellipse%20cx=%2226.2857%22%20cy=%2225%22%20rx=%2225.7143%22%20ry=%2225%22%20fill=%22%231D1C39%22%2F%3E%0A%3Cpath%20d=%22M27.7099%2020.8746C29.0831%2020.8746%2029.8737%2020.1463%2029.8737%2018.8922C29.8737%2017.7189%2029.0415%2016.9098%2027.7515%2016.9098H24.6305V14.6847C24.6305%2013.0664%2023.4237%2012.2168%2022.2586%2012.2168C21.0934%2012.2168%2019.8867%2013.0664%2019.8867%2014.6847V30.1392C19.8867%2033.8612%2021.4263%2035.52%2024.8386%2035.52C26.6695%2035.52%2027.7515%2035.0749%2028.5421%2034.5895C29.1247%2034.2658%2029.3744%2033.578%2029.3744%2033.0521C29.3744%2032.0407%2028.7086%2030.9483%2027.7099%2030.9483C27.585%2030.9483%2027.4186%2030.9888%2027.2937%2031.0292C27.2521%2031.0697%2027.1689%2031.0697%2027.1273%2031.1102C26.8776%2031.2315%2026.5031%2031.3934%2026.0037%2031.3934C25.3795%2031.3934%2024.6721%2031.1911%2024.6721%2029.5323V20.8746H27.7099Z%22%20fill=%22white%22%2F%3E%0A%3Cpath%20d=%22M37.863%2027.3887C35.5743%2027.3887%2033.7017%2029.2092%2033.7017%2031.4344C33.7017%2033.6595%2035.5743%2035.48%2037.863%2035.48C40.1517%2035.48%2042.0243%2033.6595%2042.0243%2031.4344C42.0243%2029.1688%2040.1517%2027.3887%2037.863%2027.3887Z%22%20fill=%22%23FF22B1%22%2F%3E%0A%3C%2Fsvg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
Expand Down Expand Up @@ -473,8 +473,8 @@ <h1>Your Teamwork.com site, as tools an AI client can <span class="accent">disco
<div class="stats">
<div class="stat"><span class="stat__n">4</span><span class="stat__l">Products</span></div>
<div class="stat"><span class="stat__n">14</span><span class="stat__l">Toolsets</span></div>
<div class="stat"><span class="stat__n">210</span><span class="stat__l">Tools</span></div>
<div class="stat"><span class="stat__n">108&#8239;/&#8239;102</span><span class="stat__l">Read / write</span></div>
<div class="stat"><span class="stat__n">213</span><span class="stat__l">Tools</span></div>
<div class="stat"><span class="stat__n">111&#8239;/&#8239;102</span><span class="stat__l">Read / write</span></div>
</div>
</div>
</div>
Expand Down Expand Up @@ -544,7 +544,7 @@ <h2>The tool catalogue</h2>
<button class="chip" type="button" data-access-filter="read" aria-pressed="false">Read</button>
<button class="chip" type="button" data-access-filter="write" aria-pressed="false">Write</button>
</div>
<span class="controls__count" id="tool-count" aria-live="polite">210 tools</span>
<span class="controls__count" id="tool-count" aria-live="polite">213 tools</span>
</div>
</div>
</div>
Expand All @@ -555,7 +555,7 @@ <h2>The tool catalogue</h2>
<div class="product__head">
<h3 class="product__name">Projects</h3>
<span class="product__scope">twprojects-&#8203;* &middot; scope projects</span>
<span class="product__counts">136 tools &middot; 6 toolsets</span>
<span class="product__counts">139 tools &middot; 6 toolsets</span>
</div>
<div class="toolset" data-toolset id="twprojects-content">
<div class="toolset__head">
Expand All @@ -579,6 +579,9 @@ <h4 class="toolset__title">Content</h4>
<tr>
<th scope="row">Comment</th><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td>
</tr>
<tr>
<th scope="row">File</th><td class="no" aria-label="no">&mdash;</td><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td><td class="no" aria-label="no">&mdash;</td>
</tr>
<tr>
<th scope="row">Milestone</th><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td><td class="yes" aria-label="yes">&check;</td>
</tr>
Expand Down Expand Up @@ -643,11 +646,21 @@ <h4 class="toolset__title">Content</h4>
<span class="tool__name">twprojects-create_tag</span>
<span class="tool__desc">Create tag.</span>
</div>
<div class="tool" data-search="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." data-access="read">
<span class="badge badge--read">read</span>
<span class="tool__name">twprojects-download_file</span>
<span class="tool__desc">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.</span>
</div>
<div class="tool" data-search="twprojects-get_comment get comment." data-access="read">
<span class="badge badge--read">read</span>
<span class="tool__name">twprojects-get_comment</span>
<span class="tool__desc">Get comment.</span>
</div>
<div class="tool" data-search="twprojects-get_file get a file&#39;s details: name, size, uploader, version history, the tasks, messages and comments it is attached to, and its downloadurl." data-access="read">
<span class="badge badge--read">read</span>
<span class="tool__name">twprojects-get_file</span>
<span class="tool__desc">Get a file&#39;s details: name, size, uploader, version history, the tasks, messages and comments it is attached to, and its downloadURL.</span>
</div>
<div class="tool" data-search="twprojects-get_link get link." data-access="read">
<span class="badge badge--read">read</span>
<span class="tool__name">twprojects-get_link</span>
Expand Down Expand Up @@ -688,6 +701,11 @@ <h4 class="toolset__title">Content</h4>
<span class="tool__name">twprojects-list_comments</span>
<span class="tool__desc">List comments.</span>
</div>
<div class="tool" data-search="twprojects-list_files list the files in a project&#39;s files area, or across every project when no project_id is given." data-access="read">
<span class="badge badge--read">read</span>
<span class="tool__name">twprojects-list_files</span>
<span class="tool__desc">List the files in a project&#39;s files area, or across every project when no project_id is given.</span>
</div>
<div class="tool" data-search="twprojects-list_links list links." data-access="read">
<span class="badge badge--read">read</span>
<span class="tool__name">twprojects-list_links</span>
Expand Down Expand Up @@ -2165,7 +2183,7 @@ <h3>Deletes are not published</h3>
</div>
<div class="notes__item">
<h3>Read-only is one flag</h3>
<p>Running a server with <code>-read-only</code> drops every write tool, leaving the 108 read tools listed above. Scopes narrow it further: a token carries one scope per product.</p>
<p>Running a server with <code>-read-only</code> drops every write tool, leaving the 111 read tools listed above. Scopes narrow it further: a token carries one scope per product.</p>
</div>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ Comments, notebooks, milestones, tags, and activity feeds in Teamwork.com.
|---|---|---|---|---|
| Activity | — | — | ✓ | — |
| Comment | ✓ | ✓ | ✓ | ✓ |
| File | — | ✓ | ✓ | — |
| Milestone | ✓ | ✓ | ✓ | ✓ |
| Notebook | ✓ | ✓ | ✓ | ✓ |
| Tag | ✓ | ✓ | ✓ | ✓ |
| Message | ✓ | ✓ | ✓ | ✓ |
| Message Reply | ✓ | ✓ | ✓ | ✓ |
| Link | ✓ | ✓ | ✓ | ✓ |

**Other actions:** `count_milestones`, `search`
**Other actions:** `count_milestones`, `download_file`, `search`

### People — `twprojects-people`

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
26 changes: 26 additions & 0 deletions internal/twprojects/comments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}))
}
1 change: 1 addition & 0 deletions internal/twprojects/count_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down
Loading