Skip to content

Commit 3d9d5ef

Browse files
authored
Add basic team resource (#2)
* introduce initial "Team" resource support * Add resources tests
1 parent b2aee01 commit 3d9d5ef

19 files changed

+792
-73
lines changed

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ go 1.23.6
44

55
require (
66
github.com/google/go-cmp v0.7.0
7-
github.com/mark3labs/mcp-go v0.8.5
7+
github.com/google/uuid v1.6.0
8+
github.com/mark3labs/mcp-go v0.18.0
9+
github.com/spf13/cobra v1.9.1
810
gopkg.in/dnaeon/go-vcr.v4 v4.0.2
11+
gopkg.in/yaml.v3 v3.0.1
912
)
1013

1114
require (
12-
github.com/google/uuid v1.6.0 // indirect
1315
github.com/inconshreveable/mousetrap v1.1.0 // indirect
14-
github.com/spf13/cobra v1.9.1 // indirect
1516
github.com/spf13/pflag v1.0.6 // indirect
16-
gopkg.in/yaml.v3 v3.0.1 // indirect
17+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
1718
)

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
77
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
88
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
99
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
10-
github.com/mark3labs/mcp-go v0.8.5 h1:s5oRwQfs83Jim3ZAcQMyUQNHzCEVIuGD12GV8vhJqqc=
11-
github.com/mark3labs/mcp-go v0.8.5/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE=
10+
github.com/mark3labs/mcp-go v0.18.0 h1:YuhgIVjNlTG2ZOwmrkORWyPTp0dz1opPEqvsPtySXao=
11+
github.com/mark3labs/mcp-go v0.18.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
1212
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1313
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1414
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -18,6 +18,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
1818
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
1919
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
2020
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
21+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
22+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
2123
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
2224
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2325
gopkg.in/dnaeon/go-vcr.v4 v4.0.2 h1:7T5VYf2ifyK01ETHbJPl5A6XTpUljD4Trw3GEDcdedk=

pkg/server/resources.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/geropl/linear-mcp-go/pkg/linear"
10+
"github.com/mark3labs/mcp-go/mcp"
11+
mcpserver "github.com/mark3labs/mcp-go/server"
12+
)
13+
14+
// TeamsResource is the resource definition for Linear teams
15+
var TeamsResource = mcp.NewResource(
16+
"linear://teams",
17+
"Linear Teams",
18+
mcp.WithResourceDescription("List of teams in Linear"),
19+
mcp.WithMIMEType("application/json"),
20+
)
21+
22+
// TeamResource is the resource definition for a specific Linear team
23+
var TeamResource = mcp.NewResource(
24+
"linear://team/{id}",
25+
"Linear Team",
26+
mcp.WithResourceDescription("Details of a specific team in Linear"),
27+
mcp.WithMIMEType("application/json"),
28+
)
29+
30+
// RegisterResources registers all Linear resources with the MCP server
31+
func RegisterResources(s *mcpserver.MCPServer, linearClient *linear.LinearClient) {
32+
// Register Teams resource
33+
s.AddResource(TeamsResource, TeamsResourceHandler(linearClient))
34+
35+
// Register Team resource
36+
s.AddResource(TeamResource, TeamResourceHandler(linearClient))
37+
}
38+
39+
// TeamsResourceHandler handles the linear://teams resource
40+
func TeamsResourceHandler(linearClient *linear.LinearClient) mcpserver.ResourceHandlerFunc {
41+
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
42+
// Get teams from Linear
43+
teams, err := linearClient.GetTeams("")
44+
if err != nil {
45+
return nil, fmt.Errorf("failed to get teams: %v", err)
46+
}
47+
48+
// Create resource content
49+
results := []mcp.ResourceContents{}
50+
for _, t := range teams {
51+
teamJSON, err := json.Marshal(t)
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to marshal team: %v", err)
54+
}
55+
56+
results = append(results, mcp.TextResourceContents{
57+
URI: fmt.Sprintf("linear://team/%s", t.ID),
58+
MIMEType: "application/json",
59+
Text: string(teamJSON),
60+
})
61+
}
62+
63+
return results, nil
64+
}
65+
}
66+
67+
// TeamResourceHandler handles the linear://team/{id} resource
68+
func TeamResourceHandler(linearClient *linear.LinearClient) mcpserver.ResourceHandlerFunc {
69+
return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
70+
// Extract team ID from URI
71+
uri := request.Params.URI
72+
if !strings.HasPrefix(uri, "linear://team/") {
73+
return nil, fmt.Errorf("invalid team URI: %s", uri)
74+
}
75+
76+
teamID := uri[len("linear://team/"):]
77+
if teamID == "" {
78+
return nil, fmt.Errorf("team ID is required")
79+
}
80+
81+
// Resolve team ID (could be UUID, name, or key)
82+
resolvedTeamID, err := resolveTeamIdentifier(linearClient, teamID)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to resolve team identifier: %v", err)
85+
}
86+
87+
// Get all teams and find the matching one
88+
teams, err := linearClient.GetTeams("")
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to get teams: %v", err)
91+
}
92+
93+
var team *linear.Team
94+
for i, t := range teams {
95+
if t.ID == resolvedTeamID {
96+
team = &teams[i]
97+
break
98+
}
99+
}
100+
101+
if team == nil {
102+
return nil, fmt.Errorf("team not found: %s", teamID)
103+
}
104+
105+
// Format team as JSON
106+
teamJSON, err := json.Marshal(team)
107+
if err != nil {
108+
return nil, fmt.Errorf("failed to marshal team: %v", err)
109+
}
110+
111+
// Create resource content
112+
return []mcp.ResourceContents{
113+
mcp.TextResourceContents{
114+
URI: fmt.Sprintf("linear://team/%s", team.ID),
115+
MIMEType: "application/json",
116+
Text: string(teamJSON),
117+
},
118+
}, nil
119+
}
120+
}
121+
122+
// resolveTeamIdentifier resolves a team identifier (UUID, name, or key) to a team ID
123+
func resolveTeamIdentifier(linearClient *linear.LinearClient, identifier string) (string, error) {
124+
// If it's a valid UUID, use it directly
125+
if isValidUUID(identifier) {
126+
return identifier, nil
127+
}
128+
129+
// Otherwise, try to find a team by name or key
130+
teams, err := linearClient.GetTeams("")
131+
if err != nil {
132+
return "", fmt.Errorf("failed to get teams: %v", err)
133+
}
134+
135+
// First try exact match on name or key
136+
for _, team := range teams {
137+
if team.Name == identifier || team.Key == identifier {
138+
return team.ID, nil
139+
}
140+
}
141+
142+
// If no exact match, try case-insensitive match
143+
identifierLower := strings.ToLower(identifier)
144+
for _, team := range teams {
145+
if strings.ToLower(team.Name) == identifierLower || strings.ToLower(team.Key) == identifierLower {
146+
return team.ID, nil
147+
}
148+
}
149+
150+
return "", fmt.Errorf("no team found with identifier '%s'", identifier)
151+
}
152+
153+
// isValidUUID checks if a string is a valid UUID
154+
func isValidUUID(uuidStr string) bool {
155+
// Simple UUID validation - check if it has the correct format
156+
// This is a simplified version and doesn't validate the UUID fully
157+
return len(uuidStr) == 36 && strings.Count(uuidStr, "-") == 4
158+
}

pkg/server/resources_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/geropl/linear-mcp-go/pkg/linear"
10+
"github.com/google/go-cmp/cmp"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
mcpserver "github.com/mark3labs/mcp-go/server"
13+
)
14+
15+
func TestResourceHandlers(t *testing.T) {
16+
// Define test cases
17+
tests := []struct {
18+
handlerName string
19+
name string
20+
uri string
21+
handlerFunc func(*linear.LinearClient) mcpserver.ResourceHandlerFunc
22+
}{
23+
// TeamsResourceHandler test cases
24+
{
25+
handlerName: "TeamsResourceHandler",
26+
name: "List All",
27+
uri: "linear://teams",
28+
handlerFunc: TeamsResourceHandler,
29+
},
30+
// TeamResourceHandler test cases
31+
{
32+
handlerName: "TeamResourceHandler",
33+
name: "Fetch By ID",
34+
uri: "linear://team/" + TEAM_ID,
35+
handlerFunc: TeamResourceHandler,
36+
},
37+
{
38+
handlerName: "TeamResourceHandler",
39+
name: "Fetch By Name",
40+
uri: "linear://team/" + TEAM_NAME,
41+
handlerFunc: TeamResourceHandler,
42+
},
43+
{
44+
handlerName: "TeamResourceHandler",
45+
name: "Fetch By Key",
46+
uri: "linear://team/" + TEAM_KEY,
47+
handlerFunc: TeamResourceHandler,
48+
},
49+
{
50+
handlerName: "TeamResourceHandler",
51+
name: "Invalid ID",
52+
uri: "linear://team/invalid-identifier-does-not-exist", // Use a clearly invalid identifier
53+
handlerFunc: TeamResourceHandler,
54+
},
55+
{
56+
handlerName: "TeamResourceHandler",
57+
name: "Missing ID",
58+
uri: "linear://team/", // Test case where ID is missing from URI path
59+
handlerFunc: TeamResourceHandler,
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.handlerName+"_"+tt.name, func(t *testing.T) {
65+
// Generate fixture and golden file paths
66+
fixtureName := "resource_" + tt.handlerName + "_" + tt.name
67+
goldenPath := filepath.Join("../../testdata/golden", fixtureName+".golden")
68+
69+
// Create test client with VCR
70+
// Use distinct flags for resource tests to avoid conflicts
71+
client, cleanup := linear.NewTestClient(t, fixtureName, *record || *recordWrites)
72+
defer cleanup()
73+
74+
// Get the handler function
75+
handler := tt.handlerFunc(client)
76+
77+
// Create the request
78+
request := mcp.ReadResourceRequest{}
79+
request.Params.URI = tt.uri
80+
81+
// Call the handler
82+
contents, err := handler(context.Background(), request)
83+
84+
// Extract the actual output and error
85+
var actualOutput, actualErr string
86+
if err != nil {
87+
actualErr = err.Error()
88+
} else {
89+
// Marshal the contents to JSON for comparison
90+
jsonBytes, jsonErr := json.MarshalIndent(contents, "", " ") // Use indent for readability
91+
if jsonErr != nil {
92+
t.Fatalf("Failed to marshal resource contents to JSON: %v", jsonErr)
93+
}
94+
actualOutput = string(jsonBytes)
95+
}
96+
97+
// If goldenResource flag is set, update the golden file
98+
if *golden {
99+
writeGoldenFile(t, goldenPath, expectation{
100+
Err: actualErr,
101+
Output: actualOutput,
102+
})
103+
// Also update the VCR recording implicitly by running the test
104+
t.Logf("Updated golden file: %s", goldenPath)
105+
// We might need to re-run the test without the golden flag
106+
// after recording to ensure the comparison passes.
107+
// However, for now, just writing the golden file is the goal.
108+
return // Skip comparison when updating golden files
109+
}
110+
111+
// Otherwise, read the golden file and compare
112+
expected := readGoldenFile(t, goldenPath)
113+
114+
// Compare error
115+
if diff := cmp.Diff(expected.Err, actualErr); diff != "" {
116+
t.Errorf("Error mismatch (-want +got):\n%s", diff)
117+
}
118+
119+
// Compare output (only if no error is expected)
120+
if expected.Err == "" && actualErr == "" {
121+
// Compare JSON strings directly
122+
if diff := cmp.Diff(expected.Output, actualOutput); diff != "" {
123+
// To make diffs easier to read, unmarshal and compare structures
124+
var expectedContents []mcp.ResourceContents
125+
var actualContents []mcp.ResourceContents
126+
json.Unmarshal([]byte(expected.Output), &expectedContents) // Ignore error for diffing
127+
json.Unmarshal([]byte(actualOutput), &actualContents) // Ignore error for diffing
128+
t.Errorf("Output mismatch (-want +got):\n%s", cmp.Diff(expectedContents, actualContents))
129+
t.Logf("Expected JSON:\n%s", expected.Output)
130+
t.Logf("Actual JSON:\n%s", actualOutput)
131+
}
132+
} else if expected.Err == "" && actualErr != "" {
133+
t.Errorf("Expected no error, but got: %s", actualErr)
134+
} else if expected.Err != "" && actualErr == "" {
135+
t.Errorf("Expected error '%s', but got none", expected.Err)
136+
}
137+
})
138+
}
139+
}
140+
141+
// readGoldenFile and writeGoldenFile are defined in test_helpers.go

pkg/server/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ func NewLinearMCPServer(writeAccess bool) (*LinearMCPServer, error) {
4444

4545
// Register tools
4646
RegisterTools(mcpServer, linearClient, writeAccess)
47+
48+
// Register resources
49+
RegisterResources(mcpServer, linearClient)
4750

4851
return server, nil
4952
}

0 commit comments

Comments
 (0)