Skip to content

Add Google Stitch integration#105

Draft
pzipoy wants to merge 1 commit into
mainfrom
pzipoy/google-stitch-mcp
Draft

Add Google Stitch integration#105
pzipoy wants to merge 1 commit into
mainfrom
pzipoy/google-stitch-mcp

Conversation

@pzipoy
Copy link
Copy Markdown
Collaborator

@pzipoy pzipoy commented Apr 3, 2026

Summary

  • Adds a new Google Stitch integration adapter with 12 tools covering projects (list/create/get), screens (list/get/generate/edit/variants), and design systems (list/create/update/apply)
  • Includes field compaction specs for list operations, full dispatch map and compaction parity tests, and HTTP roundtrip handler tests
  • Registers the integration in cmd/server/main.go and config/config.go

Test plan

  • make ci passes
  • Verify tools are discoverable via search and executable via execute with a valid STITCH_ACCESS_TOKEN

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@acmacalister acmacalister left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid integration — clean architecture, good test coverage, nice use of field compaction and RetryableError. A few things worth fixing before merge, mostly around URL path escaping and some empty-string serialization that could corrupt API calls.

if name == "" {
return &mcp.ToolResult{Data: "name parameter is required", IsError: true}, nil
}
data, err := s.get(ctx, "/%s", name)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name here is user-supplied (e.g. projects/4044680601076201931) and goes straight into the URL path without escaping. A value containing ?, #, or % would corrupt the request URL — or worse, inject query parameters into the upstream call. The other handlers in this file call url.PathEscape(projectID) correctly; this one should too.

data, err := s.get(ctx, "/%s", url.PathEscape(name))

Same issue in screens.go:36 (getScreen) and design_systems.go:144 (updateDesignSystem).

if name == "" {
return &mcp.ToolResult{Data: "name parameter is required", IsError: true}, nil
}
data, err := s.get(ctx, "/%s", name)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same unescaped name issue as getProject in projects.go:49 — should use url.PathEscape(name) before path interpolation.

},
}

path := fmt.Sprintf("/%s", name)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same unescaped name issue — name like assets/15996705518239280238 is safe today, but url.PathEscape is still the right call here for consistency and to guard against unexpected inputs.

path := fmt.Sprintf("/%s", url.PathEscape(name))


body := map[string]any{
"name": name,
"projectId": projectID,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

projectId is always set in the PATCH body, even as "" when the caller doesn't supply it. The sibling function createDesignSystem guards this correctly:

// createDesignSystem (line 82)
if projectID != "" {
    body["projectId"] = projectID
}

updateDesignSystem drops that guard, so every PATCH call sends "projectId": "" to the API when the field is absent — which could disassociate the design system from its project.

return &mcp.ToolResult{Data: "display_name parameter is required", IsError: true}, nil
}

theme := map[string]any{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five theme fields are unconditionally included in the map even when empty, so a call that omits color_mode will send "colorMode": "" to the API. On a PATCH endpoint (updateDesignSystem has the same shape at line 111), that would overwrite existing values with empty strings.

Compare: colorVariant and designMd are correctly guarded with if != "" before being set. The five required-looking fields should either be validated as non-empty upfront, or guarded the same way:

theme := map[string]any{}
if colorMode != "" { theme["colorMode"] = colorMode }
if headlineFont != "" { theme["headlineFont"] = headlineFont }
// etc.

return &mcp.ToolResult{Data: "name parameter is required", IsError: true}, nil
}

theme := map[string]any{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same empty-string serialization issue as createDesignSystem:47 — the theme map in updateDesignSystem will send "colorMode": "", "headlineFont": "", etc. for any fields the caller omits. On a PATCH this will clobber the existing design system's theme fields with empty strings.


func New() mcp.Integration {
return &stitch{
client: &http.Client{Timeout: 120 * time.Second},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool descriptions for stitch_generate_screen_from_text, stitch_edit_screens, and stitch_generate_variants all say "Takes a few minutes to complete" — but the shared client timeout is 120s (2 min). These AI generation calls will likely hit the deadline before completing. Worth bumping to at least 300 * time.Second or using a per-request context with a longer deadline for the generation tools.

return json.RawMessage(data), nil
}

func (s *stitch) get(ctx context.Context, pathFmt string, args ...any) (json.RawMessage, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get() helper accepts a format string, but post() and patch() take a plain path string. The format-string API creates a footgun: if a future caller passes user input as the format string itself (rather than as an argument), it opens up format string misinterpretation. Since all existing callers either pass a fixed literal format string or build the path externally, we could simplify this to match post/patch:

func (s *stitch) get(ctx context.Context, path string) (json.RawMessage, error) {
    return s.doRequest(ctx, "GET", path, nil)
}

Callers that currently use the format form would just build the path string before calling — which they mostly do anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants