Skip to content

fix: never create the first marketplace repo from a change signal - #6013

Open
adaam2 wants to merge 3 commits into
mainfrom
fix/plugin-publish-no-first-repo
Open

fix: never create the first marketplace repo from a change signal#6013
adaam2 wants to merge 3 commits into
mainfrom
fix/plugin-publish-no-first-repo

Conversation

@adaam2

@adaam2 adaam2 commented Sep 3, 2026

Copy link
Copy Markdown
Member

Follow-up to #6009, which was already in the merge queue when this came out of review.

Summary

Adds PublishProjectInput.SkipIfUnpublished: the publish short-circuits when the project has no plugin_github_connections row yet. Every change-driven signal sets it, so a signal can update a marketplace but never bring one into existence.

Creating the repo stays with the paths that mean it — project creation and a first Default-plugin attach (both forced), the dashboard Publish button, and the rollout sweep, whose candidate list is already scoped to projects with a Default plugin or a previous publish.

Motivation

publishProject treats a project with no connection as firstPublish, which ignores SkipIfUnchanged and creates the repository. #6009 gates each mutation path on whether the server or toolset could contribute generated output, but "could contribute" is not the same as "is a plugin member": an enabled MCP server with no endpoint is never attached, so renaming one in a never-published project would create an empty marketplace repo and mint an API key for it. An MCP-enabled toolset that predates attach-on-enable has the same shape.

Gating at the publish boundary fixes the whole class in one place, rather than adding a membership lookup to each mutation path and leaving the next new callsite to rediscover the trap.

https://claude.ai/code/session_011G5NTsW2Qbm31ET9oRSMS9


Summary by cubic

Fixes change-driven plugin publishes so they can update an existing marketplace repo but never create the first one. A project with no plugin_github_connections row previously took the first-publish path on signals like renaming an enabled MCP server with no endpoint, creating an empty repo and minting an API key; those publishes now short-circuit.

Behavior

  • Adds PublishProjectInput.AllowFirstPublish, opt-in and false by default, so a change signal updates an existing marketplace but never creates one.
  • TriggerPluginPublish takes allowFirstPublish separately from force, so a first attach can create the repo without a fingerprint-blind republish.
  • triggerPluginPublish now takes publish and attached separately: only an actual Default-plugin attach may create the repo, so editing an existing member never does.
  • First-repo creation stays with project creation, first Default-plugin attach, the dashboard Publish button, the rollout sweep, and local dev restore.
  • The connection lookup now runs before plugin/config generation, so a skipped signal costs one query; tests cover a skipped signal without minting keys and a forced publish still creating the repo.

Written for commit 46fe5a4. Summary will update on new commits.

Review in cubic

…ignal

Addresses review on #6009. An enabled MCP server with no endpoint is not
a Default-plugin member, so renaming one in a project that has never
published took the first-publish path and created an empty marketplace
repository (plus an API key for it). A toolset that was MCP-enabled
before the attach-on-enable behaviour existed had the same problem.

Rather than a membership lookup on each mutation path, the guard goes at
the publish boundary: PublishProjectInput.SkipIfUnpublished short-
circuits when the project has no github connection yet, and every
change-driven signal sets it. A signal can now update a marketplace but
never bring one into existence.

Creating the repo stays with the paths that mean it — project creation
and a first Default-plugin attach (both forced), and the dashboard
Publish button — plus the rollout sweep, whose candidates are already
scoped to projects with a Default plugin or a previous publish.

Claude-Session: https://claude.ai/code/session_011G5NTsW2Qbm31ET9oRSMS9
@adaam2
adaam2 requested a review from a team as a code owner September 3, 2026 12:01
@adaam2 adaam2 added the go Pull requests that update go code label Sep 3, 2026
@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 46fe5a4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Running ultrareview automatically — This changes the cross-cutting marketplace publish boundary and workflow flags that govern repository creation and API-key issuance across signals, forced paths, and rollout recovery, so a subtle gating or debouncing bug could suppress required publishes or create unintended repositories.. I'll post findings when complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ultrareview completed in 10m 9s

All reported issues were addressed across 5 files

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread server/internal/plugins/impl.go Outdated
Comment thread server/internal/plugins/impl.go Outdated
Comment thread server/internal/plugins/publish_signal_test.go
Comment thread server/internal/background/plugin_publish.go Outdated
Comment thread server/internal/plugins/impl.go Outdated
Addresses review on #6013.

The guard flag is inverted: SkipIfUnpublished becomes AllowFirstPublish,
so false is the safe value. A workflow params payload encoded before the
field existed decodes as false, and so does a call site that forgets it
— both now defer the repo to the rollout sweep instead of creating one a
change signal never intended. That also covers the two Platform MCP
mutation callbacks, which omitted the flag entirely; they are explicit
now.

TriggerPluginPublish takes allowFirstPublish separately from force. A
legacy project whose Default plugin was lazily provisioned by a
dashboard read reaches its first attach with pluginCreated false, and
would otherwise have waited out the sweep for its marketplace: an attach
may now create the repo without also forcing a fingerprint-blind
republish.

The connection lookup moves above resolvePluginInfos and generateConfig,
so a signal for an unpublished project costs one query rather than a
full in-memory generate it throws away. The skip test also asserts no API
key is minted, and the workflow tests assert both flags reach the
activity unchanged.

Six existing PublishProject tests publish a project for the first time
through the automated publisher and now opt in explicitly, which is the
inversion working as intended.

Claude-Session: https://claude.ai/code/session_011G5NTsW2Qbm31ET9oRSMS9

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 12 files (changes from recent commits).

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread server/internal/mcpendpoints/impl.go Outdated
Comment thread server/internal/toolsets/impl.go Outdated
Addresses review on #6013. The service helper passed allowFirstPublish
as a hardcoded true, so a mutation that only edits an existing member —
renaming an MCP-enabled toolset that has no membership, or a server
already attached to Default — could still hand an unpublished project
its first marketplace repo and an API key for it.

triggerPluginPublish now takes publish and attached separately: publish
says the generated output may have moved, attached says this request
actually ran the attach, and only the latter licenses creating the
marketplace. On the toolsets update path only the disabled-to-enabled
transition attaches, so only it passes attached.

Claude-Session: https://claude.ai/code/session_011G5NTsW2Qbm31ET9oRSMS9

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 3 files (changes from recent commits).

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="server/internal/mcpendpoints/impl.go">

<violation number="1" location="server/internal/mcpendpoints/impl.go:229">
P1: When the server is already attached and the project has no GitHub connection, this line passes `attached=true` as `AllowFirstPublish` even though no membership was added, allowing a second endpoint to create the first marketplace repo. Return and propagate the actual attach result from `attachToDefaultPlugin` instead of unconditionally returning `true`.

(Based on your team's feedback about first-repo gating on real attaches.)</violation>
</file>

<file name="server/internal/toolsets/impl.go">

<violation number="1" location="server/internal/toolsets/impl.go:630">
P2: When an already-attached toolset is disabled and then re-enabled in an unpublished project, this line authorizes first-repo creation even though the attach helper no-ops on the existing membership. Return and pass the actual attachment result instead of the enabled-state transition.</violation>
</file>

<file name="server/internal/mcpservers/impl.go">

<violation number="1" location="server/internal/mcpservers/impl.go:758">
P2: When a disabled MCP server is already attached to the Default plugin, this line passes `allowFirstPublish=true` even though the attach operation no-ops. A project without a GitHub connection can therefore get its first marketplace repo from re-enabling that server; pass the actual insertion result from `attachToDefaultPlugin` (and the attach API) instead of the unconditional endpoint-path flag.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

}

s.triggerPluginPublish(ctx, authCtx, attached, pluginCreated)
s.triggerPluginPublish(ctx, authCtx, attached, attached, pluginCreated)

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When the server is already attached and the project has no GitHub connection, this line passes attached=true as AllowFirstPublish even though no membership was added, allowing a second endpoint to create the first marketplace repo. Return and propagate the actual attach result from attachToDefaultPlugin instead of unconditionally returning true.

(Based on your team's feedback about first-repo gating on real attaches.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/internal/mcpendpoints/impl.go, line 229:

<comment>When the server is already attached and the project has no GitHub connection, this line passes `attached=true` as `AllowFirstPublish` even though no membership was added, allowing a second endpoint to create the first marketplace repo. Return and propagate the actual attach result from `attachToDefaultPlugin` instead of unconditionally returning `true`.

(Based on your team's feedback about first-repo gating on real attaches.) </comment>

<file context>
@@ -226,7 +226,7 @@ func (s *Service) CreateMcpEndpoint(ctx context.Context, payload *gen.CreateMcpE
 	}
 
-	s.triggerPluginPublish(ctx, authCtx, attached, pluginCreated)
+	s.triggerPluginPublish(ctx, authCtx, attached, attached, pluginCreated)
 
 	return mv.BuildMcpEndpointView(created), nil
</file context>
Fix with cubic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not making this change — the insert/no-op distinction does not change the outcome here.

attachToPlugin no-ops precisely when GetPluginServerByBackend finds a live plugin_servers row (deleted IS FALSE). So "the attach no-opped" means the server or toolset is already a live member of the Default plugin: the project has plugin content, and the repo this creates is a populated marketplace, not the empty one this PR is about. That project is also already in the rollout sweep's candidate population (projects with a Default plugin), so the same repo appears within the hour regardless — creating it on the attach path just makes it prompt.

The hole that mattered was a mutation where no attach runs at all (a rename or a disable), because nothing there proves any membership exists; 46fe5a4 closes that. Threading an inserted-vs-no-op boolean back through AttachToDefaultPluginAudited and the three services would add plumbing without changing behaviour.

// so only it may create the project's first marketplace.
s.triggerPluginPublish(ctx, authCtx,
existingToolset.McpEnabled || updatedToolset.McpEnabled,
!existingToolset.McpEnabled && updatedToolset.McpEnabled,

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When an already-attached toolset is disabled and then re-enabled in an unpublished project, this line authorizes first-repo creation even though the attach helper no-ops on the existing membership. Return and pass the actual attachment result instead of the enabled-state transition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/internal/toolsets/impl.go, line 630:

<comment>When an already-attached toolset is disabled and then re-enabled in an unpublished project, this line authorizes first-repo creation even though the attach helper no-ops on the existing membership. Return and pass the actual attachment result instead of the enabled-state transition.</comment>

<file context>
@@ -620,8 +623,12 @@ func (s *Service) UpdateToolset(ctx context.Context, payload *gen.UpdateToolsetP
+	// so only it may create the project's first marketplace.
+	s.triggerPluginPublish(ctx, authCtx,
+		existingToolset.McpEnabled || updatedToolset.McpEnabled,
+		!existingToolset.McpEnabled && updatedToolset.McpEnabled,
+		pluginCreated)
 
</file context>
Fix with cubic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not making this change — the insert/no-op distinction does not change the outcome here.

attachToPlugin no-ops precisely when GetPluginServerByBackend finds a live plugin_servers row (deleted IS FALSE). So "the attach no-opped" means the server or toolset is already a live member of the Default plugin: the project has plugin content, and the repo this creates is a populated marketplace, not the empty one this PR is about. That project is also already in the rollout sweep's candidate population (projects with a Default plugin), so the same repo appears within the hour regardless — creating it on the attach path just makes it prompt.

The hole that mattered was a mutation where no attach runs at all (a rename or a disable), because nothing there proves any membership exists; 46fe5a4 closes that. Threading an inserted-vs-no-op boolean back through AttachToDefaultPluginAudited and the three services would add plumbing without changing behaviour.

// enable transition this block attaches. A server disabled before and
// after contributes nothing either way and stays silent.
s.triggerPluginPublish(ctx, authCtx, attached || existing.Visibility != VisibilityDisabled, pluginCreated)
s.triggerPluginPublish(ctx, authCtx, attached || existing.Visibility != VisibilityDisabled, attached, pluginCreated)

@cubic-dev-ai cubic-dev-ai Bot Sep 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When a disabled MCP server is already attached to the Default plugin, this line passes allowFirstPublish=true even though the attach operation no-ops. A project without a GitHub connection can therefore get its first marketplace repo from re-enabling that server; pass the actual insertion result from attachToDefaultPlugin (and the attach API) instead of the unconditional endpoint-path flag.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/internal/mcpservers/impl.go, line 758:

<comment>When a disabled MCP server is already attached to the Default plugin, this line passes `allowFirstPublish=true` even though the attach operation no-ops. A project without a GitHub connection can therefore get its first marketplace repo from re-enabling that server; pass the actual insertion result from `attachToDefaultPlugin` (and the attach API) instead of the unconditional endpoint-path flag.</comment>

<file context>
@@ -755,7 +755,7 @@ func (s *Service) UpdateMcpServer(ctx context.Context, payload *gen.UpdateMcpSer
 	// enable transition this block attaches. A server disabled before and
 	// after contributes nothing either way and stays silent.
-	s.triggerPluginPublish(ctx, authCtx, attached || existing.Visibility != VisibilityDisabled, pluginCreated)
+	s.triggerPluginPublish(ctx, authCtx, attached || existing.Visibility != VisibilityDisabled, attached, pluginCreated)
 	if err := s.reconcileMcpServerCustomDomains(ctx, clearedRootDomainIDs); err != nil {
 		return nil, err
</file context>
Fix with cubic

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not making this change — the insert/no-op distinction does not change the outcome here.

attachToPlugin no-ops precisely when GetPluginServerByBackend finds a live plugin_servers row (deleted IS FALSE). So "the attach no-opped" means the server or toolset is already a live member of the Default plugin: the project has plugin content, and the repo this creates is a populated marketplace, not the empty one this PR is about. That project is also already in the rollout sweep's candidate population (projects with a Default plugin), so the same repo appears within the hour regardless — creating it on the attach path just makes it prompt.

The hole that mattered was a mutation where no attach runs at all (a rename or a disable), because nothing there proves any membership exists; 46fe5a4 closes that. Threading an inserted-vs-no-op boolean back through AttachToDefaultPluginAudited and the three services would add plumbing without changing behaviour.

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

Labels

go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant