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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ Common variables (subset; see command READMEs for complete lists):
- Run `go test ./internal/twprojects` until green.
- `docs/tool-reference.md` is generated by `cmd/docs-gen` and guarded by `go test`; ordinary tool add/remove/rename is caught automatically (regenerate with `go run ./cmd/docs-gen`), but two changes need a manual edit to `cmd/docs-gen/main.go`: flipping `allowDelete` to `true` on a shipped server, or adding a new product package (register it in `products()`).

## MCP SDK upgrades and protocol compatibility
- The SDK (`github.com/modelcontextprotocol/go-sdk`) keeps its Go API stable but does change wire behaviour between minor versions. Read `docs/mcpgodebug.md` in the module cache (`$(go env GOMODCACHE)/github.com/modelcontextprotocol/go-sdk@<version>/docs/mcpgodebug.md`) before and after any bump: it lists every behaviour change and the `MCPGODEBUG=<param>=1` escape hatch that restores the old behaviour. Escape hatches are removed two minor versions later, so they are stopgaps, never permanent settings.
- Old clients must keep working. When the SDK adds a protocol method that replaces an older one, support both: `server/discover` (SEP-2575) and `initialize` are both in `auth.methodsWhitelist` for exactly this reason. Never remove a legacy entry from that whitelist.
- `internal/request.ResponseWriter` must keep its `Flush()` and `Unwrap()` methods. The SDK writes Server-Sent Events through `http.ResponseController`, which resolves the flusher by unwrapping — every streamable HTTP response on `/` is an SSE stream. Without them flushing silently degrades to a no-op and streaming clients hang. It also caps the body it captures for logging, because SSE streams live as long as the request.
- Cacheable results (SEP-2549: `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, `server/discover`) default to `cacheScope: "public"`. Anything whose content varies per token — `tools/list` is filtered by OAuth scope — must be set to `cacheScopePrivate` in the receiving middleware in `internal/config/config.go`, or a shared proxy can serve one tenant's response to another.
- Do not advertise capabilities the server does not honour. `logging` is deliberately omitted, and the `listChanged` flags are deliberately false: advertising them invites clients to open a long-lived `subscriptions/listen` stream that a stateless, load-balanced deployment would hold open with nothing to send. `TestCapabilitiesOmitLogging` and `TestCapabilitiesOmitListChanged` guard this.
- Do not add a manual keepalive ping loop. `mcp.ServerOptions.KeepAlive` in `config.NewMCPServer` already drives pings. In `cmd/mcp-stdio` a hand-rolled loop is actively harmful: on failure it has no request to reply to, so the error is written onto stdout, which is the protocol stream.
- Pin limits the SDK also enforces. `mcp.StreamableHTTPOptions.MaxRequestBodyBytes` is set explicitly in `cmd/mcp-http/main.go`; left at zero the SDK applies its own default (4 MiB), silently tightening the limit `limitBodyMiddleware` advertises.
- Client-visible protocol changes belong in the "Protocol Compatibility" section of `cmd/mcp-http/README.md`.

## Security considerations for agents
- Never print or commit bearer tokens. Use env vars only. Redact tokens in logs.
- Prefer running the STDIO server with `-read-only` by default during development.
Expand Down
42 changes: 42 additions & 0 deletions cmd/mcp-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,48 @@ The server can be configured using the following environment variables:
| `DD_ENV` | Environment for Datadog APM | _(uses TW_MCP_ENV)_ | `staging`, `production` |
| `DD_VERSION` | Version for Datadog APM | _(uses TW_MCP_VERSION)_ | `v1.0.0` |

## 🔄 Protocol Compatibility

The server negotiates the highest protocol revision the client also supports, so
clients on older spec revisions keep working unchanged. Both handshakes are
served: `server/discover` (SEP-2575) for stateless clients and `initialize` for
everything older. Both bypass authentication, so pre-auth connector setup works
either way.

Two wire-level behaviours changed with the `2026-07-28` revision, and the server
runs stateless, so they apply here:

| Behaviour | Before | Now | Spec basis |
|-----------|--------|-----|------------|
| `Mcp-Session-Id` on responses | A session ID was generated and echoed back | Not sent; an incoming one is ignored | Session IDs are optional; a client that never receives one never sends one |
| `DELETE /` (session termination) | `204 No Content` | `405 Method Not Allowed` | Servers MAY refuse session termination |

If a client turns out to depend on the old behaviour, the SDK ships an escape
hatch that restores it without a code change:

```bash
MCPGODEBUG=allowsessionsinstateless=1
```

Set it on the deployment and the server reads `Mcp-Session-Id`, echoes it back,
and answers `DELETE` with `204` again, exactly as before. It is a temporary
compatibility parameter: the SDK removes it in **v1.9.0**, so treat it as a
stopgap while the client is fixed, not a permanent setting. `MCPGODEBUG` takes a
comma-separated list, and the full set of parameters for the pinned SDK version
is documented at
<https://go.sdk.modelcontextprotocol.io/mcpgodebug/> (also in-tree at
`docs/mcpgodebug.md` of the `modelcontextprotocol/go-sdk` module).

Two further notes for clients:

- The `logging` capability is no longer advertised. It was deprecated by
SEP-2577 and this server never sent a `notifications/message`, so no client
loses functionality. Clients on pre-`2026-07-28` revisions can still call
`logging/setLevel`; on `2026-07-28` the SDK rejects it with `-32601`, as the
revision removed the method.
- `tools/list` responses carry `cacheScope: "private"`. The tool list is filtered
per OAuth token scope, so shared intermediaries must not cache it.

## 🧪 Testing

### MCP HTTP CLI
Expand Down
4 changes: 4 additions & 0 deletions cmd/mcp-http/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func main() {
}, &mcp.StreamableHTTPOptions{
Stateless: true,
DisableLocalhostProtection: resources.Info.Environment == "dev",
// Pin the body limit to the one limitBodyMiddleware already enforces.
// Left at zero the SDK applies its own DefaultMaxRequestBodyBytes (4 MiB),
// which would silently tighten the limit clients have been coded against.
MaxRequestBodyBytes: maxBodySize,
})
mcpSSEServer := mcp.NewSSEHandler(func(*http.Request) *mcp.Server {
return mcpServer
Expand Down
21 changes: 4 additions & 17 deletions cmd/mcp-stdio/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"log/slog"
"os"
"time"

"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
Expand Down Expand Up @@ -90,22 +89,10 @@ func main() {
exit(exitCodeSetupFailure)
}

go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
if err := ss.Ping(pingCtx, nil); err != nil {
mcpError(resources.Logger(), fmt.Errorf("failed to ping: %s", err), jsonRPCErrorCodeInternalError)
}
cancel()
case <-ctx.Done():
return
}
}
}()
// Keepalive pings are driven by the SDK itself, via
// mcp.ServerOptions.KeepAlive in config.NewMCPServer. Do not add a manual
// ping loop here: on failure it has no request to reply to, so mcpError
// writes an id-less JSON-RPC error onto stdout, which is the protocol stream.

if err := ss.Wait(); err != nil {
mcpError(resources.Logger(), fmt.Errorf("failed to serve: %s", err), jsonRPCErrorCodeInternalError)
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ require (
github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0
github.com/localit-io/tiktoken-go v0.2.1
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/teamwork/desksdkgo v1.0.1
github.com/teamwork/spacessdkgo v0.0.0-20260518181558-a6af69d00abb
github.com/teamwork/twapi-go-sdk v1.20.4
Expand Down Expand Up @@ -87,6 +87,7 @@ require (
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/minio/simdjson-go v0.4.5 h1:r4IQwjRGmWCQ2VeMc7fGiilu1z5du0gJ/I/FsKwgo5A=
github.com/minio/simdjson-go v0.4.5/go.mod h1:eoNz0DcLQRyEDeaPr4Ru6JpjlZPzbA0IodxVJk8lO8E=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -286,6 +286,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand Down
10 changes: 10 additions & 0 deletions internal/auth/bypass.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ var methodsWhitelist = []string{
"resources/list",
"resources/templates/list",
"prompts/list",

// "server/discover" (SEP-2575) is the stateless replacement for the
// "initialize" handshake: clients probe capabilities and supported protocol
// versions with it before they hold a token. It has to bypass authentication
// for the same reason "initialize" does, otherwise the pre-auth connector
// setup flow answers 401. The legacy entries above are kept so clients on
// older spec revisions keep working unchanged.
//
// https://modelcontextprotocol.io/seps/2575-stateless-mcp
"server/discover",
}

// Bypass checks if the protocol method can bypass authentication.
Expand Down
79 changes: 79 additions & 0 deletions internal/auth/bypass_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package auth

import (
"testing"
)

func TestBypassMethod(t *testing.T) {
tests := []struct {
method string
want bool
}{
// SEP-2575 stateless capability discovery, the replacement for the
// "initialize" handshake. Clients probe it before holding a token.
{method: "server/discover", want: true},

// legacy spec revisions must keep working unchanged
{method: "initialize", want: true},
{method: "notifications/initialized", want: true},
{method: "logging/setLevel", want: true},
{method: "tools/list", want: true},
{method: "resources/list", want: true},
{method: "resources/templates/list", want: true},
{method: "prompts/list", want: true},

// everything else must stay authenticated
{method: "tools/call", want: false},
{method: "resources/read", want: false},
{method: "prompts/get", want: false},
{method: "server/discovery", want: false},
{method: "", want: false},
}

for _, tt := range tests {
t.Run(tt.method, func(t *testing.T) {
if got := BypassMethod(tt.method); got != tt.want {
t.Errorf("BypassMethod(%q) = %t, want %t", tt.method, got, tt.want)
}
})
}
}

func TestBypass(t *testing.T) {
tests := []struct {
name string
data string
want bool
wantErr bool
}{{
name: "server/discover request",
data: `{"jsonrpc":"2.0","id":1,"method":"server/discover"}`,
want: true,
}, {
name: "initialize request",
data: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`,
want: true,
}, {
name: "tools/call request",
data: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"twprojects-get_task"}}`,
want: false,
wantErr: true,
}, {
name: "malformed payload",
data: `{not json`,
want: false,
wantErr: true,
}}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Bypass([]byte(tt.data))
if (err != nil) != tt.wantErr {
t.Errorf("Bypass() error = %v, wantErr %t", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("Bypass() = %t, want %t", got, tt.want)
}
})
}
}
Loading