Skip to content

Commit 891fc7f

Browse files
MimoJanraclaude
andcommitted
feat: streamable HTTP transport, launch dashboard widget, tool annotations, LLM docs
- MCP Streamable HTTP transport (spec 2025-03-26): POST /mcp endpoint with Mcp-Session-Id session management; legacy /sse + /messages kept for backward compatibility - Launch dashboard MCP app widget (get_launch_dashboard): renders live status, progress bar, pass/fail stats inline in Claude Desktop/claude.ai; ext-apps bundle fetched lazily from unpkg with fallback stub - MCP resources support: resources/list + resources/read dispatch, Resource registry with RWMutex, ui:// URI scheme - Tool annotations: readOnlyHint / destructiveHint auto-classified for all 102 tools by naming convention (get_/list_/search_ → readonly, delete_/bulk_delete_/detach_ → destructive, rest → write) - Tool _meta field: ui.resourceUri wiring for widget tools - llms.txt + llms-full.txt: LLM-friendly project docs with full tool signatures for AI-assisted discovery - Protocol: resources capability in InitializeResponse, Resource types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bf9e5e0 commit 891fc7f

18 files changed

Lines changed: 1882 additions & 87 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Add this to the `mcpServers` section:
4646
{
4747
"mcpServers": {
4848
"testops": {
49-
"url": "http://your-server.com:3000/sse",
49+
"url": "http://your-server.com:3000/mcp",
5050
"headers": {
5151
"Authorization": "Bearer your-mcp-auth-token", // optional
5252
"X-Allure-Token": "your-personal-allure-token"

cmd/server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func runHTTP(mcpServer *mcp.Server, cfg *config.Config, logger *core.Logger) {
6161
}
6262

6363
mux := http.NewServeMux()
64+
// Streamable HTTP transport (MCP spec 2025-03-26) — recommended
65+
mux.HandleFunc("/mcp", mcpServer.HandleMCP)
66+
// Legacy HTTP+SSE transport (MCP spec 2024-11-05) — kept for backward compat
6467
mux.HandleFunc("/sse", mcpServer.HandleSSE)
6568
mux.HandleFunc("/messages", mcpServer.HandleMessages)
6669

internal/adapters/allure/client.go

Lines changed: 53 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,14 @@ func (c *Client) SetSessionTokenFunc(fn func(ctx context.Context) string) {
5050

5151
// resolveAPIToken returns the API token to use for this request: the per-session
5252
// token (if set by the user via configure_allure_token) or the server-wide token.
53+
// The function-pointer field is read under the mutex to avoid a data race with
54+
// SetSessionTokenFunc, which writes the field under the same mutex.
5355
func (c *Client) resolveAPIToken(ctx context.Context) string {
54-
if c.getSessionToken != nil {
55-
if t := c.getSessionToken(ctx); t != "" {
56+
c.mu.Lock()
57+
fn := c.getSessionToken
58+
c.mu.Unlock()
59+
if fn != nil {
60+
if t := fn(ctx); t != "" {
5661
return t
5762
}
5863
}
@@ -65,14 +70,19 @@ func (c *Client) getJWTToken(ctx context.Context) (string, error) {
6570
return "", fmt.Errorf("no token configured - set ALLURE_TOKEN env var or use configure_allure_token tool")
6671
}
6772

68-
// Check cache without holding the lock during the HTTP call.
73+
// Fast path: check cache under the lock.
6974
c.mu.Lock()
7075
if entry, ok := c.jwtCache[apiToken]; ok && time.Now().Before(entry.expiresAt) {
7176
c.mu.Unlock()
7277
return entry.jwt, nil
7378
}
7479
c.mu.Unlock()
7580

81+
// Cache miss – fetch a new JWT outside the lock so we don't block other
82+
// goroutines. Two concurrent goroutines may both reach here for the same
83+
// apiToken; that results in a harmless double-fetch. After the fetch we
84+
// re-check the cache before writing so a race winner's entry is not
85+
// needlessly overwritten with a stale result.
7686
jwt, expiresIn, err := c.fetchJWT(ctx, apiToken)
7787
if err != nil {
7888
return "", err
@@ -84,6 +94,11 @@ func (c *Client) getJWTToken(ctx context.Context) (string, error) {
8494
}
8595

8696
c.mu.Lock()
97+
// Re-check: another goroutine may have already populated the cache.
98+
if existing, ok := c.jwtCache[apiToken]; ok && time.Now().Before(existing.expiresAt) {
99+
c.mu.Unlock()
100+
return existing.jwt, nil
101+
}
87102
c.jwtCache[apiToken] = jwtEntry{jwt: jwt, expiresAt: expiresAt}
88103
c.mu.Unlock()
89104

@@ -2662,8 +2677,11 @@ func (c *Client) RemoveTestCaseMembers(ctx context.Context, testCaseID int64, me
26622677
return nil
26632678
}
26642679

2680+
// GetTestCaseExternalLinks returns the external URL links attached to a test case.
2681+
// The spec exposes these only via GET /api/testcase/{id}/overview (field "links").
2682+
// The /relation endpoint returns test-case-to-test-case relations (different schema).
26652683
func (c *Client) GetTestCaseExternalLinks(ctx context.Context, testCaseID int64) ([]ExternalLinkDto, error) {
2666-
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url(fmt.Sprintf("/api/testcase/%d/relation", testCaseID)), nil)
2684+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url(fmt.Sprintf("/api/testcase/%d/overview", testCaseID)), nil)
26672685
if err != nil {
26682686
return nil, fmt.Errorf("create request: %w", err)
26692687
}
@@ -2682,62 +2700,53 @@ func (c *Client) GetTestCaseExternalLinks(ctx context.Context, testCaseID int64)
26822700
return nil, errFromResponse(resp)
26832701
}
26842702

2685-
var result []ExternalLinkDto
2686-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
2703+
// Decode only the "links" field to avoid pulling the full overview into memory.
2704+
var overview struct {
2705+
Links []ExternalLinkDto `json:"links"`
2706+
}
2707+
if err := json.NewDecoder(resp.Body).Decode(&overview); err != nil {
26872708
return nil, fmt.Errorf("decode response: %w", err)
26882709
}
26892710

2690-
return result, nil
2711+
return overview.Links, nil
26912712
}
26922713

2714+
// AddTestCaseExternalLink appends an external URL link to a test case.
2715+
// The spec provides no per-item POST endpoint; the only way to mutate links is
2716+
// PATCH /api/testcase/{id} with the complete desired "links" array (replace-all).
2717+
// We therefore fetch the current links first and patch with the appended list.
26932718
func (c *Client) AddTestCaseExternalLink(ctx context.Context, testCaseID int64, link ExternalLinkDto) error {
2694-
body, err := json.Marshal(link)
2695-
if err != nil {
2696-
return fmt.Errorf("marshal request: %w", err)
2697-
}
2698-
2699-
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url(fmt.Sprintf("/api/testcase/%d/relation", testCaseID)), bytes.NewBuffer(body))
2700-
if err != nil {
2701-
return fmt.Errorf("create request: %w", err)
2702-
}
2703-
if err := c.setAuthHeader(ctx, httpReq); err != nil {
2704-
return fmt.Errorf("set auth: %w", err)
2705-
}
2706-
httpReq.Header.Set("Content-Type", "application/json")
2707-
2708-
resp, err := c.httpClient.Do(httpReq)
2719+
current, err := c.GetTestCaseExternalLinks(ctx, testCaseID)
27092720
if err != nil {
2710-
return fmt.Errorf("http request: %w", err)
2721+
return fmt.Errorf("get current links: %w", err)
27112722
}
2712-
defer resp.Body.Close()
2713-
2714-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
2715-
return errFromResponse(resp)
2716-
}
2717-
2718-
return nil
2723+
updated := append(current, link)
2724+
return c.UpdateTestCase(ctx, testCaseID, UpdateTestCaseRequest{Links: updated})
27192725
}
27202726

2721-
func (c *Client) DeleteTestCaseExternalLink(ctx context.Context, testCaseID int64, relationID int64) error {
2722-
httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.url(fmt.Sprintf("/api/testcase/%d/relation/%d", testCaseID, relationID)), nil)
2727+
// DeleteTestCaseExternalLink removes the external link with the given URL from a test case.
2728+
// The spec has no DELETE endpoint for external links; removal is achieved by fetching
2729+
// the current list and PATCHing with the matching entry omitted.
2730+
func (c *Client) DeleteTestCaseExternalLink(ctx context.Context, testCaseID int64, linkURL string) error {
2731+
current, err := c.GetTestCaseExternalLinks(ctx, testCaseID)
27232732
if err != nil {
2724-
return fmt.Errorf("create request: %w", err)
2725-
}
2726-
if err := c.setAuthHeader(ctx, httpReq); err != nil {
2727-
return fmt.Errorf("set auth: %w", err)
2733+
return fmt.Errorf("get current links: %w", err)
27282734
}
27292735

2730-
resp, err := c.httpClient.Do(httpReq)
2731-
if err != nil {
2732-
return fmt.Errorf("http request: %w", err)
2736+
remaining := make([]ExternalLinkDto, 0, len(current))
2737+
found := false
2738+
for _, l := range current {
2739+
if l.URL == linkURL {
2740+
found = true
2741+
continue
2742+
}
2743+
remaining = append(remaining, l)
27332744
}
2734-
defer resp.Body.Close()
2735-
2736-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
2737-
return errFromResponse(resp)
2745+
if !found {
2746+
return fmt.Errorf("external link not found: %s", linkURL)
27382747
}
27392748

2740-
return nil
2749+
return c.UpdateTestCase(ctx, testCaseID, UpdateTestCaseRequest{Links: remaining})
27412750
}
27422751

27432752
func (c *Client) RestoreTestCase(ctx context.Context, testCaseID int64) error {

internal/core/logger.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ func (l *Logger) Error(message string, err error, data any) {
9090
}
9191

9292
func (l *Logger) log(level Level, message string, data any) {
93+
// Read l.level under the mutex to avoid a data race if SetLevel is ever
94+
// called concurrently (and to keep the level check + write atomic).
95+
l.mu.Lock()
96+
defer l.mu.Unlock()
9397
if level < l.level {
9498
return
9599
}
@@ -99,10 +103,7 @@ func (l *Logger) log(level Level, message string, data any) {
99103
Message: message,
100104
Data: data,
101105
}
102-
103106
bytes, err := json.Marshal(entry)
104-
l.mu.Lock()
105-
defer l.mu.Unlock()
106107
if err != nil {
107108
fmt.Fprintf(os.Stderr, `{"timestamp":%q,"level":%q,"message":%q,"error":"log marshal failed: %s"}`+"\n",
108109
entry.Timestamp, entry.Level, entry.Message, err.Error())

internal/mcp/protocol.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ type InitializeRequest struct {
4646
type InitializeResponse struct {
4747
ProtocolVersion string `json:"protocolVersion"`
4848
Capabilities struct {
49-
Tools struct{} `json:"tools"`
49+
Tools struct{} `json:"tools"`
50+
Resources struct{} `json:"resources"`
5051
} `json:"capabilities"`
5152
ServerInfo struct {
5253
Name string `json:"name"`
@@ -55,15 +56,42 @@ type InitializeResponse struct {
5556
}
5657

5758
type Tool struct {
58-
Name string `json:"name"`
59-
Description string `json:"description"`
60-
InputSchema any `json:"inputSchema"`
59+
Name string `json:"name"`
60+
Description string `json:"description"`
61+
InputSchema any `json:"inputSchema"`
62+
Annotations map[string]any `json:"annotations,omitempty"`
63+
Meta map[string]any `json:"_meta,omitempty"`
6164
}
6265

6366
type ToolsListResponse struct {
6467
Tools []Tool `json:"tools"`
6568
}
6669

70+
// Resource represents an MCP resource entry (e.g. a widget HTML page).
71+
type MCPResource struct {
72+
URI string `json:"uri"`
73+
Name string `json:"name"`
74+
MimeType string `json:"mimeType,omitempty"`
75+
}
76+
77+
type ResourcesListResponse struct {
78+
Resources []MCPResource `json:"resources"`
79+
}
80+
81+
type ResourcesReadRequest struct {
82+
URI string `json:"uri"`
83+
}
84+
85+
type ResourceContent struct {
86+
URI string `json:"uri"`
87+
MimeType string `json:"mimeType,omitempty"`
88+
Text string `json:"text"`
89+
}
90+
91+
type ResourcesReadResponse struct {
92+
Contents []ResourceContent `json:"contents"`
93+
}
94+
6795
type ToolCallRequest struct {
6896
Name string `json:"name"`
6997
Arguments json.RawMessage `json:"arguments"`

0 commit comments

Comments
 (0)