fix(canvas): make "Add to Claude Code" snippet use unique server name per workspace (multi-workspace) #1535
@@ -24,17 +24,30 @@ import (
|
||||
|
||||
// BuildExternalConnectionPayload assembles the gin.H payload that the
|
||||
// canvas's ExternalConnectModal consumes. Pure data — caller owns DB
|
||||
// reads (workspace_id) and token minting (auth_token).
|
||||
// reads (workspace_id, workspace_name) and token minting (auth_token).
|
||||
//
|
||||
// authToken may be empty for the read-only "show instructions again"
|
||||
// path; the modal masks the field in that case rather than displaying
|
||||
// an empty string.
|
||||
func BuildExternalConnectionPayload(platformURL, workspaceID, authToken string) gin.H {
|
||||
//
|
||||
// workspaceName feeds the per-workspace MCP server-name in the snippets
|
||||
// that wire molecule-mcp into an external Claude Code (or other
|
||||
// MCP-stdio) client. Without a unique server name a second
|
||||
// `claude mcp add molecule` call REPLACES the first entry, collapsing
|
||||
// multi-workspace use into a single per-session slot — see
|
||||
// mcpServerNameForWorkspace below. May be empty (re-show / rotate paths
|
||||
// that don't plumb the name); the helper falls back to the workspace
|
||||
// ID's short prefix so the snippet is always unique.
|
||||
func BuildExternalConnectionPayload(platformURL, workspaceID, workspaceName, authToken string) gin.H {
|
||||
pURL := strings.TrimSuffix(platformURL, "/")
|
||||
mcpName := mcpServerNameForWorkspace(workspaceID, workspaceName)
|
||||
stamp := func(tmpl string) string {
|
||||
return strings.ReplaceAll(
|
||||
strings.ReplaceAll(tmpl, "{{PLATFORM_URL}}", pURL),
|
||||
"{{WORKSPACE_ID}}", workspaceID,
|
||||
strings.ReplaceAll(
|
||||
strings.ReplaceAll(tmpl, "{{PLATFORM_URL}}", pURL),
|
||||
"{{WORKSPACE_ID}}", workspaceID,
|
||||
),
|
||||
"{{MCP_SERVER_NAME}}", mcpName,
|
||||
)
|
||||
}
|
||||
return gin.H{
|
||||
@@ -77,6 +90,81 @@ func externalPlatformURL(c *gin.Context) string {
|
||||
return scheme + "://" + host
|
||||
}
|
||||
|
||||
// mcpServerNameForWorkspace derives the unique MCP server name used in
|
||||
// the Universal MCP snippet's `claude mcp add <name> -- ...` line.
|
||||
//
|
||||
// Why per-workspace, not a fixed "molecule": `claude mcp add` keys
|
||||
// entries by name in ~/.claude.json, so re-running with the same name
|
||||
// silently REPLACES the previous entry. A single external Claude Code
|
||||
// session that connects to N molecule workspaces must therefore use N
|
||||
// distinct server names — otherwise the second install collapses the
|
||||
// first, and the user experiences "MCP is per-session". MCP itself
|
||||
// supports many servers per session; the install-snippet name was the
|
||||
// only thing standing in the way.
|
||||
//
|
||||
// Pattern: "molecule-<slug>" where slug comes from the workspace name
|
||||
// (lowercased, non-alphanumeric → hyphen, collapsed, trimmed, <=24
|
||||
// chars). Falls back to the workspace ID's first 8 chars when the name
|
||||
// is empty or slugifies to nothing — both produce a deterministic,
|
||||
// Claude-Code-name-safe (alphanumeric + hyphens, no spaces / dots /
|
||||
// slashes) identifier that disambiguates per-workspace.
|
||||
//
|
||||
// Two workspaces with identical names still produce identical slugs by
|
||||
// design — the user picked them to look the same. The
|
||||
// `claude mcp add` step will overwrite the older one in that case;
|
||||
// the workaround is to rename one, then re-run. Documented in the
|
||||
// snippet header so users aren't surprised.
|
||||
func mcpServerNameForWorkspace(workspaceID, workspaceName string) string {
|
||||
const fallbackIDPrefixLen = 8
|
||||
const maxSlugLen = 24
|
||||
slug := slugifyForMcpName(workspaceName, maxSlugLen)
|
||||
if slug == "" {
|
||||
id := strings.ReplaceAll(workspaceID, "-", "")
|
||||
if len(id) > fallbackIDPrefixLen {
|
||||
id = id[:fallbackIDPrefixLen]
|
||||
}
|
||||
slug = id
|
||||
}
|
||||
if slug == "" {
|
||||
// Defensive: empty workspaceID at this layer means the caller
|
||||
// is misusing the API; we still return a usable (non-colliding
|
||||
// in the common case) constant rather than producing "molecule-"
|
||||
// which Claude Code would reject.
|
||||
return "molecule"
|
||||
}
|
||||
return "molecule-" + slug
|
||||
}
|
||||
|
||||
// slugifyForMcpName lowercases, replaces non-[a-z0-9] runs with a single
|
||||
// '-', trims leading/trailing '-', and truncates to maxLen. Returns ""
|
||||
// if nothing usable remains. Pure helper; no allocations beyond the
|
||||
// builder.
|
||||
func slugifyForMcpName(s string, maxLen int) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
lastHyphen := true // suppress leading hyphens
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r + ('a' - 'A'))
|
||||
lastHyphen = false
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
lastHyphen = false
|
||||
default:
|
||||
if !lastHyphen {
|
||||
b.WriteByte('-')
|
||||
lastHyphen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := strings.TrimRight(b.String(), "-")
|
||||
if len(out) > maxLen {
|
||||
out = strings.TrimRight(out[:maxLen], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// externalCurlTemplate — zero-dependency register snippet. Placeholders:
|
||||
// - {{PLATFORM_URL}}, {{WORKSPACE_ID}} — filled server-side
|
||||
// - $WORKSPACE_AUTH_TOKEN — env var, operator sets
|
||||
@@ -216,6 +304,14 @@ const externalUniversalMcpTemplate = `# Universal MCP — standalone register +
|
||||
# for any MCP-aware runtime (Claude Code, hermes, codex, etc.).
|
||||
# Pair with the Claude Code or Python SDK tab if your runtime needs
|
||||
# inbound A2A delivery (canvas messages → agent conversation turns).
|
||||
#
|
||||
# Multi-workspace: MCP supports many servers per Claude Code session.
|
||||
# This snippet uses a workspace-specific server name ({{MCP_SERVER_NAME}})
|
||||
# so installing for a second workspace ADDS another entry instead of
|
||||
# overwriting the first — run the snippet from each workspace's modal
|
||||
# in turn and ` + "`claude mcp list`" + ` will show all of them. If two
|
||||
# workspaces have the same name, slugs collide and the second install
|
||||
# overwrites the first; rename one workspace to disambiguate.
|
||||
|
||||
# Requires Python >= 3.11. On 3.10 or older pip says
|
||||
# "Could not find a version that satisfies the requirement
|
||||
@@ -224,11 +320,14 @@ const externalUniversalMcpTemplate = `# Universal MCP — standalone register +
|
||||
# Upgrade the interpreter (brew install python@3.12 / apt install
|
||||
# python3.12 / etc.) or use a 3.11+ venv.
|
||||
|
||||
# 1. Install the workspace runtime wheel:
|
||||
# 1. Install the workspace runtime wheel (once per machine — safe to
|
||||
# re-run; subsequent workspaces share the same wheel):
|
||||
pip install molecule-ai-workspace-runtime
|
||||
|
||||
# 2. Wire molecule-mcp into your agent's MCP config. Claude Code:
|
||||
claude mcp add molecule -s user -- env \
|
||||
# NOTE the server name is workspace-specific ("{{MCP_SERVER_NAME}}") so
|
||||
# multiple molecule workspaces co-exist in one Claude Code session.
|
||||
claude mcp add {{MCP_SERVER_NAME}} -s user -- env \
|
||||
WORKSPACE_ID={{WORKSPACE_ID}} \
|
||||
PLATFORM_URL={{PLATFORM_URL}} \
|
||||
MOLECULE_WORKSPACE_TOKEN="<paste from create response>" \
|
||||
@@ -249,8 +348,11 @@ claude mcp add molecule -s user -- env \
|
||||
# Documentation: https://doc.moleculesai.app/docs/guides/mcp-server-setup
|
||||
# Common errors:
|
||||
# • "Tools not appearing in your agent" — run ` + "`claude mcp list`" + ` (or
|
||||
# your runtime's equivalent) and confirm the molecule entry. If
|
||||
# missing, re-run the ` + "`claude mcp add`" + ` line above.
|
||||
# your runtime's equivalent) and confirm the {{MCP_SERVER_NAME}} entry.
|
||||
# If missing, re-run the ` + "`claude mcp add`" + ` line above.
|
||||
# • "Connecting a second workspace overwrote the first" — re-check that
|
||||
# the server name in the line above is {{MCP_SERVER_NAME}} (not a bare
|
||||
# "molecule"); each workspace's modal generates a distinct name.
|
||||
# • "ConnectionRefused / DNS error on first call" — PLATFORM_URL must
|
||||
# include the scheme (https://) and have NO trailing slash. Verify
|
||||
# with: curl ${PLATFORM_URL}/healthz
|
||||
|
||||
@@ -52,7 +52,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
|
||||
runtime, name, err := lookupWorkspaceRuntimeAndName(ctx, db.DB, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
@@ -108,7 +108,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
|
||||
|
||||
platformURL := externalPlatformURL(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connection": BuildExternalConnectionPayload(platformURL, id, tok),
|
||||
"connection": BuildExternalConnectionPayload(platformURL, id, name, tok),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
|
||||
runtime, name, err := lookupWorkspaceRuntimeAndName(ctx, db.DB, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
@@ -149,16 +149,20 @@ func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
|
||||
|
||||
platformURL := externalPlatformURL(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connection": BuildExternalConnectionPayload(platformURL, id, ""),
|
||||
"connection": BuildExternalConnectionPayload(platformURL, id, name, ""),
|
||||
})
|
||||
}
|
||||
|
||||
// lookupWorkspaceRuntime returns the workspace's runtime field. Wrapped
|
||||
// for readability + so tests can mock the single SELECT.
|
||||
func lookupWorkspaceRuntime(ctx context.Context, handle *sql.DB, id string) (string, error) {
|
||||
var runtime string
|
||||
err := handle.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(runtime, '') FROM workspaces WHERE id = $1
|
||||
`, id).Scan(&runtime)
|
||||
return runtime, err
|
||||
// lookupWorkspaceRuntimeAndName returns runtime + name in one round-trip.
|
||||
// Wrapped for readability + so tests can mock the single SELECT.
|
||||
// Used by rotate / re-show paths: runtime gates the external-only check;
|
||||
// name feeds the per-workspace MCP server slug in BuildExternalConnectionPayload
|
||||
// (so the Universal MCP snippet uses a stable per-workspace name instead
|
||||
// of overwriting prior `claude mcp add molecule` entries).
|
||||
// Returns sql.ErrNoRows when the workspace doesn't exist.
|
||||
func lookupWorkspaceRuntimeAndName(ctx context.Context, handle *sql.DB, id string) (runtime, name string, err error) {
|
||||
err = handle.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(runtime, ''), COALESCE(name, '') FROM workspaces WHERE id = $1
|
||||
`, id).Scan(&runtime, &name)
|
||||
return runtime, name, err
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ func TestRotateExternalCredentials_HappyPath(t *testing.T) {
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// 1. Runtime lookup
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("external", "test-ws"))
|
||||
|
||||
// 2. Revoke all live tokens
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens`).
|
||||
@@ -98,9 +98,9 @@ func TestRotateExternalCredentials_RejectsNonExternal(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-hermes").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("hermes", "test-ws"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -129,9 +129,9 @@ func TestRotateExternalCredentials_NotFound(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"})) // no rows
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"})) // no rows
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -172,9 +172,9 @@ func TestGetExternalConnection_HappyPathReturnsBlankToken(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("external", "test-ws"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -211,9 +211,9 @@ func TestGetExternalConnection_RejectsNonExternal(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-claude").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}).AddRow("claude-code", "test-ws"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -233,9 +233,9 @@ func TestGetExternalConnection_NotFound(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\), COALESCE\(name, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime", "name"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -253,7 +253,7 @@ func TestGetExternalConnection_NotFound(t *testing.T) {
|
||||
// ---------- BuildExternalConnectionPayload (pure helper) ----------
|
||||
|
||||
func TestBuildExternalConnectionPayload_StampsPlaceholders(t *testing.T) {
|
||||
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "tok-abc")
|
||||
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "my-bot", "tok-abc")
|
||||
|
||||
if got["workspace_id"] != "ws-7" {
|
||||
t.Errorf("workspace_id: %v", got["workspace_id"])
|
||||
@@ -267,6 +267,18 @@ func TestBuildExternalConnectionPayload_StampsPlaceholders(t *testing.T) {
|
||||
if got["registry_endpoint"] != "https://platform.test/registry/register" {
|
||||
t.Errorf("registry_endpoint: %v", got["registry_endpoint"])
|
||||
}
|
||||
// Universal MCP snippet must contain a workspace-specific server
|
||||
// name derived from the workspace name. Without this each new
|
||||
// `claude mcp add` would overwrite the previous entry in the user's
|
||||
// ~/.claude.json (servers are keyed by name) — collapsing
|
||||
// multi-workspace use into one slot. See mcpServerNameForWorkspace.
|
||||
mcp, _ := got["universal_mcp_snippet"].(string)
|
||||
if !strings.Contains(mcp, "claude mcp add molecule-my-bot ") {
|
||||
t.Errorf("universal_mcp_snippet missing per-workspace server name 'molecule-my-bot':\n%s", mcp)
|
||||
}
|
||||
if strings.Contains(mcp, "{{MCP_SERVER_NAME}}") {
|
||||
t.Errorf("universal_mcp_snippet still contains literal {{MCP_SERVER_NAME}}")
|
||||
}
|
||||
// {{PLATFORM_URL}} + {{WORKSPACE_ID}} placeholders must be substituted
|
||||
// out of every snippet — if any snippet still contains a literal
|
||||
// "{{PLATFORM_URL}}" or "{{WORKSPACE_ID}}", a future template author
|
||||
@@ -292,7 +304,7 @@ func TestBuildExternalConnectionPayload_TrimsTrailingSlash(t *testing.T) {
|
||||
// being concatenated into endpoint paths — otherwise the operator
|
||||
// gets `https://platform.test//registry/register` (double slash) which
|
||||
// some servers reject as a redirect target.
|
||||
got := BuildExternalConnectionPayload("https://platform.test/", "ws-7", "")
|
||||
got := BuildExternalConnectionPayload("https://platform.test/", "ws-7", "", "")
|
||||
if got["platform_url"] != "https://platform.test" {
|
||||
t.Errorf("platform_url: trailing slash not trimmed; got %v", got["platform_url"])
|
||||
}
|
||||
@@ -304,8 +316,48 @@ func TestBuildExternalConnectionPayload_TrimsTrailingSlash(t *testing.T) {
|
||||
func TestBuildExternalConnectionPayload_BlankAuthTokenIsAllowed(t *testing.T) {
|
||||
// Re-show path: auth_token="" is the contract; the modal masks the
|
||||
// field and labels it "rotate to reveal a new token".
|
||||
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "")
|
||||
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "", "")
|
||||
if got["auth_token"] != "" {
|
||||
t.Errorf("blank token must propagate as \"\"; got %v", got["auth_token"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildExternalConnectionPayload_McpServerNameUniquePerWorkspace
|
||||
// pins the multi-workspace install contract: two distinct workspaces
|
||||
// must produce two distinct `claude mcp add` server-name lines, or
|
||||
// installing the second one will overwrite the first entry in the
|
||||
// user's ~/.claude.json (servers are keyed by name) — collapsing
|
||||
// multi-workspace use into a single per-session slot, which is the
|
||||
// "this is per-session" UX the CTO observed 2026-05-18.
|
||||
func TestBuildExternalConnectionPayload_McpServerNameUniquePerWorkspace(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
workspaceID string
|
||||
wsName string
|
||||
wantAddLine string // must appear in universal_mcp_snippet
|
||||
}{
|
||||
{"plain name", "id-a", "my-bot", "claude mcp add molecule-my-bot "},
|
||||
{"name with spaces + caps", "id-b", "My Bot 1", "claude mcp add molecule-my-bot-1 "},
|
||||
// Symbol/punctuation collapses to single hyphens and trims.
|
||||
{"name with symbols", "id-c", "--Foo!!Bar--", "claude mcp add molecule-foo-bar "},
|
||||
// Empty name falls back to the first 8 chars of the (de-hyphenated)
|
||||
// workspace UUID — keeps the snippet unique per workspace even
|
||||
// when callers (rotate/re-show pre-name-lookup) pass "".
|
||||
{"empty name, uuid id", "12345678-aaaa-bbbb-cccc-deadbeef0000", "", "claude mcp add molecule-12345678 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := BuildExternalConnectionPayload("https://p.test", tc.workspaceID, tc.wsName, "tok")
|
||||
mcp, _ := got["universal_mcp_snippet"].(string)
|
||||
if !strings.Contains(mcp, tc.wantAddLine) {
|
||||
t.Errorf("missing %q in universal_mcp_snippet:\n%s", tc.wantAddLine, mcp)
|
||||
}
|
||||
// Belt + suspenders: never the bare fixed `molecule` name —
|
||||
// that was the bug. (Match with trailing space so the
|
||||
// "molecule-…" form passes.)
|
||||
if strings.Contains(mcp, "claude mcp add molecule ") {
|
||||
t.Errorf("snippet regressed to fixed `claude mcp add molecule `; got:\n%s", mcp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,7 +546,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// shape. Adding a new snippet means adding it once there;
|
||||
// all three callers pick it up automatically.
|
||||
resp["connection"] = BuildExternalConnectionPayload(
|
||||
externalPlatformURL(c), id, connectionToken,
|
||||
externalPlatformURL(c), id, payload.Name, connectionToken,
|
||||
)
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
|
||||
Reference in New Issue
Block a user