From 9a3db439ece07f59e9e939af192f2b879e940683 Mon Sep 17 00:00:00 2001 From: core-devops Date: Mon, 18 May 2026 15:36:55 -0700 Subject: [PATCH] fix(canvas): make "Add to Claude Code" snippet use unique server name per workspace (multi-workspace) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Universal MCP install snippet hardcoded `claude mcp add molecule -s user` — `claude mcp add` keys entries by name, so installing for workspace B silently overwrote workspace A in the user's ~/.claude.json. A single external Claude Code session ended up able to talk to only ONE molecule workspace at a time — the CTO-observed "this is per-session" UX (2026-05-18 22:28Z). MCP itself supports many servers per session; the install snippet was the only thing standing in the way. Fix: derive a unique server name per workspace at payload-build time — `molecule-` where slug = lowercased/hyphen-collapsed workspace name (max 24 chars), falling back to the first 8 chars of the workspace ID when the name is empty or slugifies to nothing. The result is alphanumeric + hyphens only (URL-safe + Claude-Code-name-safe). Plumbed through all 3 callers of BuildExternalConnectionPayload: - Create (workspace.go) passes payload.Name directly. - Rotate / GetExternalConnection (external_rotate.go) extend the existing runtime lookup to also SELECT name in the same round-trip (lookupWorkspaceRuntimeAndName replaces lookupWorkspaceRuntime — one query, no extra DB load). Snippet header now documents the multi-workspace contract: re-running the snippet from another workspace's modal ADDS another entry; same- name workspaces collide by design, rename one to disambiguate. Surgical: only externalUniversalMcpTemplate gained a {{MCP_SERVER_NAME}} placeholder. Other tabs (Python SDK / curl / Hermes / codex / openclaw / kimi) already use distinct config keys per provider and aren't affected. Tests: TestBuildExternalConnectionPayload_McpServerNameUniquePerWorkspace pins 4 cases (plain name, name w/ spaces+caps, name w/ symbols, empty name fallback to UUID prefix) — would have caught the original "claude mcp add molecule" regression. Existing rotate/get tests updated for the 2-column SELECT. Related: task #229 (molecule-mcp-claude-channel install-doc blockers). This is the canvas-side counterpart — that PR fixed the plugin docs, this PR fixes the modal-generator snippet operators actually copy. Sample generated lines (was → now): was: claude mcp add molecule -s user -- env WORKSPACE_ID=... molecule-mcp now: claude mcp add molecule-my-bot -s user -- env WORKSPACE_ID=... molecule-mcp (where "my-bot" is the workspace name; "molecule-12345678" if unnamed) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handlers/external_connection.go | 118 ++++++++++++++++-- .../internal/handlers/external_rotate.go | 28 +++-- .../internal/handlers/external_rotate_test.go | 82 +++++++++--- .../internal/handlers/workspace.go | 2 +- 4 files changed, 194 insertions(+), 36 deletions(-) diff --git a/workspace-server/internal/handlers/external_connection.go b/workspace-server/internal/handlers/external_connection.go index 598a312ff..589aa9894 100644 --- a/workspace-server/internal/handlers/external_connection.go +++ b/workspace-server/internal/handlers/external_connection.go @@ -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 -- ...` 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-" 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="" \ @@ -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 diff --git a/workspace-server/internal/handlers/external_rotate.go b/workspace-server/internal/handlers/external_rotate.go index 5973d362f..4e08a8203 100644 --- a/workspace-server/internal/handlers/external_rotate.go +++ b/workspace-server/internal/handlers/external_rotate.go @@ -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 } diff --git a/workspace-server/internal/handlers/external_rotate_test.go b/workspace-server/internal/handlers/external_rotate_test.go index df31b224b..429712d87 100644 --- a/workspace-server/internal/handlers/external_rotate_test.go +++ b/workspace-server/internal/handlers/external_rotate_test.go @@ -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) + } + }) + } +} diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 746705391..56edb3875 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -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) -- 2.52.0