Skip to content

feat: support custom plugins for Claude via zip#2194

Merged
bradcypert merged 48 commits intomainfrom
bradcypert/plugins
Apr 16, 2026
Merged

feat: support custom plugins for Claude via zip#2194
bradcypert merged 48 commits intomainfrom
bradcypert/plugins

Conversation

@bradcypert
Copy link
Copy Markdown
Contributor

@bradcypert bradcypert commented Apr 13, 2026

Summary

  • Introduces Plugins — project-scoped bundles of MCP servers that admins can package and distribute to their team's AI coding assistants (Claude Code, Cowork)
  • Admins create a plugin, add MCP servers (from existing toolsets or external URLs), then download platform-specific plugin packages as ZIP files
  • Generated Claude Code plugins include plugin.json with userConfig for API key prompting, and .mcp.json with HTTP MCP server declarations

What's included

  • Database: 3 new tables (plugins, plugin_servers, plugin_assignments) with project-scoped indexes
  • API: 10 endpoints — plugin CRUD, server management, assignment management, per-plugin/per-platform ZIP download
  • Plugin generation: Produces correctly structured plugin packages matching Claude Code and Cowork, including marketplace manifest
  • Dashboard UI: Plugins page in project sidebar with list view, detail page, toolset picker for adding servers, and download buttons

What's deferred

  • GitHub publishing (auto-push to a repo for marketplace distribution) — progress preserved on bradcypert/plugins-github-integration branch
  • Role-based assignments — tables exist but no UI surface yet

Test plan

  • Create a plugin, add a toolset MCP server
  • Download Claude Code ZIP — verify structure matches plugin spec
  • Install downloaded plugin via team interface in Claude / Cowork
  • Verify plugin list, edit, delete flows
  • Verify project-scoping (plugins only visible within their project)

bradcypert and others added 5 commits April 9, 2026 17:31
Preserving progress on GitHub publishing before descoping for initial PR.
This includes:
- GitHubConfig with Gram-owned org model
- publishPlugins + getPublishStatus endpoints
- GitHub client (create repo, push files via Trees API)
- Frontend publish/download UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Apr 16, 2026 1:33pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 13, 2026

🦋 Changeset detected

Latest commit: 989e85b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
dashboard Minor
@gram/client Minor
server Minor

Not sure what this means? Click here to learn what changesets are.

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

@github-actions github-actions bot added the preview Spawn a preview environment label Apr 13, 2026
@speakeasybot
Copy link
Copy Markdown
Collaborator

speakeasybot commented Apr 13, 2026

🚀 Preview Environment (PR #2194)

Preview URL: https://pr-2194.dev.getgram.ai

Component Status Details Updated (UTC)
✅ Database Ready Existing database reused 2026-04-16 13:51:22.
✅ Images Available Container images ready 2026-04-16 13:50:39.

Gram Preview Bot

# Conflicts:
#	client/sdk/.speakeasy/gen.lock
#	client/sdk/.speakeasy/gen.yaml
#	client/sdk/README.md
#	client/sdk/jsr.json
#	client/sdk/package.json
#	client/sdk/src/lib/config.ts
#	client/sdk/src/react-query/index.ts
#	server/cmd/gram/start.go
#	server/database/schema.sql
#	server/gen/http/openapi3.json
#	server/migrations/atlas.sum
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 13, 2026

atlas migrate lint on server/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

@github-actions
Copy link
Copy Markdown
Contributor

atlas migrate lint on server/clickhouse/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bradcypert and others added 4 commits April 13, 2026 17:03
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	client/sdk/.speakeasy/gen.lock
#	client/sdk/.speakeasy/gen.yaml
#	client/sdk/README.md
#	client/sdk/jsr.json
#	client/sdk/package.json
#	client/sdk/src/lib/config.ts
#	client/sdk/src/models/operations/index.ts
#	client/sdk/src/react-query/index.ts
#	server/cmd/gram/start.go
#	server/gen/http/openapi3.json
#	server/migrations/atlas.sum
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents cross-project toolset references by looking up the toolset
and verifying its project_id matches the caller's active project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	client/sdk/.speakeasy/gen.lock
#	client/sdk/.speakeasy/gen.yaml
#	client/sdk/jsr.json
#	client/sdk/package.json
#	client/sdk/src/lib/config.ts
#	client/sdk/src/models/operations/index.ts
#	server/gen/http/openapi3.json
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 29 additional findings in Devin Review.

Open in Devin Review

Comment on lines +358 to +360
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
return nil, oops.E(oops.CodeConflict, nil, "a server with this display name already exists in the plugin")
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 15, 2026

Choose a reason for hiding this comment

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

🟡 AddPluginServer reports misleading error when duplicate toolset triggers unique violation

The AddPluginServer handler catches all UniqueViolation PostgreSQL errors and always reports "a server with this display name already exists in the plugin" (server/internal/plugins/impl.go:359). However, the plugin_servers table has two unique indexes: one on (plugin_id, display_name) and one on (plugin_id, toolset_id) (server/database/schema.sql:1742-1748). When a user tries to add the same toolset twice to a plugin with a different display name, the toolset_id unique constraint fires, but the error message incorrectly blames a display name collision. This will confuse users trying to understand why their operation failed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const { data: plugin } = usePluginSuspense({ id: pluginId! });

const { fetch: authFetch } = useFetcher();
const { data: toolsetsData } = useListToolsetsForOrg();
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 15, 2026

Choose a reason for hiding this comment

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

🔴 PluginDetail uses org-wide toolset listing, causing add-server failures for cross-project toolsets

The PluginDetail.tsx component imports useListToolsetsForOrg (client/dashboard/src/pages/org/PluginDetail.tsx:14) which returns toolsets from ALL projects in the organization. However, the backend AddPluginServer handler at server/internal/plugins/impl.go:353 rejects toolsets that don't belong to the current project (toolset.ProjectID != *ac.ProjectID). This means the "Add Server" dropdown displays toolsets the user cannot actually add, and selecting one from a different project results in a confusing "toolset belongs to a different project" error. The project-scoped useListToolsets hook exists and is used elsewhere in the dashboard (e.g., client/dashboard/src/pages/toolsets/Toolsets.tsx:2).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did we decide to do plugins at the org level? I can't recall

Comment thread server/database/schema.sql
Comment thread server/design/plugins/design.go
Comment thread server/internal/plugins/generate.go
Comment thread server/internal/plugins/generate.go Outdated
Comment thread server/internal/plugins/generate.go Outdated
Comment thread server/internal/plugins/generate.go Outdated

func generateClaudePluginWithPrefix(files map[string][]byte, prefix string, p PluginInfo, cfg GenerateConfig) error {
userConfig := map[string]userConfigEntry{
"GRAM_API_KEY": {
Copy link
Copy Markdown
Member

@chase-crumbaugh chase-crumbaugh Apr 15, 2026

Choose a reason for hiding this comment

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

Are we always going to have/need this? Isn't this only necessary if there are private MCPs contained in the plugin? Only private servers require a Gram api key for auth

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My understanding is that since we simplified to toolset-only, all servers go through Gram's proxy and require auth. But you're right that public MCP servers shouldn't need the API key though.

Version: "0.1.0",
Author: pluginAuthor{Name: cfg.OrgName, URL: "https://getgram.ai"},
Homepage: "https://getgram.ai",
UserConfig: userConfig,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How does this userConfig object work? Is the user prompted at some point to provide these vars?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude Code plugins support a userConfig section in plugin.json where you declare variables the user needs to provide. When someone installs the plugin, Claude Code prompts them for these values. The ${user_config.GRAM_API_KEY} syntax in the MCP server headers references this config. https://code.claude.com/docs/en/plugins-reference#user-configuration.

Are you thinking we should handle this differently?

mcpServers[s.DisplayName] = claudeMCPServer{
Type: "http",
URL: s.MCPURL,
Headers: map[string]string{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think we need to block this PR on this issue, but a follow up item is probably going to involve making this logic somewhat more complicated. If you look at the serveInstallPage implementation (here), you can see we have this concept of "environmentConfigs" which is how the user-facing required headers/env is defined for a given server. So as previously mentioned a GRAM_API_KEY is only needed for private servers--for public servers, they required configs are defined by environmentConfigs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Noted, ill track this as a follow up

- Remove GitHub repo reference from schema comment (not in this PR)
- Add optional slug field to CreatePluginForm
- Rename prefix -> subdir in generate.go for clarity
- Use path.Join for zip entry paths instead of string concatenation
- Generate .cursor-plugin/marketplace.json alongside Claude's manifest
  so the same repo works for both platform marketplaces

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 29 additional findings in Devin Review.

Open in Devin Review

Comment on lines +345 to +355
// Verify the toolset exists and belongs to the same project.
toolset, err := toolsetsrepo.New(s.db).GetToolsetByID(ctx, toolsetID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, oops.E(oops.CodeBadRequest, nil, "toolset not found")
}
return nil, oops.E(oops.CodeUnexpected, err, "verify toolset").Log(ctx, s.logger)
}
if toolset.ProjectID != *ac.ProjectID {
return nil, oops.E(oops.CodeBadRequest, nil, "toolset belongs to a different project")
}
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.

🚩 AddPluginServer does not validate that the toolset has MCP enabled

In server/internal/plugins/impl.go:345-355, AddPluginServer validates that the toolset exists and belongs to the same project, but does not check whether the toolset has a non-null mcp_slug or mcp_enabled = true. Since the ListPluginsWithServersForProject query (server/internal/plugins/queries.sql:110-129) joins on toolsets and the resolvePluginInfos function at impl.go:633 skips servers where ToolsetMcpSlug is NULL, a user could add a toolset without MCP configured to a plugin and it would be silently omitted from generated packages. This is not a crash or data corruption issue, but could confuse users who add a toolset expecting it to appear in the download.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +31 to +32
const { data: toolsetsData } = useListToolsetsForOrg();
const toolsets = toolsetsData?.toolsets ?? [];
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.

🚩 ListToolsetsForOrg shows cross-project toolsets in the Add Server dialog

In PluginDetail.tsx:31, useListToolsetsForOrg() fetches all toolsets across the organization, but the server-side AddPluginServer at impl.go:353 validates toolset.ProjectID != *ac.ProjectID and rejects toolsets from other projects. This means the UI dropdown may show toolsets from sibling projects that will fail on submission. Consider filtering client-side by the current project or using a project-scoped toolset listing endpoint instead.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

# Conflicts:
#	client/sdk/.speakeasy/gen.lock
#	client/sdk/.speakeasy/gen.yaml
#	client/sdk/jsr.json
#	client/sdk/package.json
#	client/sdk/src/lib/config.ts
#	client/sdk/src/models/operations/index.ts
#	server/database/schema.sql
#	server/gen/http/openapi3.json
#	server/internal/database/models.go
These entries are added by go mod tidy on darwin but not on linux,
causing CI porcelain check to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 30 additional findings in Devin Review.

Open in Devin Review

Comment on lines +498 to +509
assignments := make([]*gen.PluginAssignment, 0, len(payload.PrincipalUrns))
for _, urn := range payload.PrincipalUrns {
row, err := txRepo.AddPluginAssignment(ctx, repo.AddPluginAssignmentParams{
PluginID: pluginID,
OrganizationID: ac.ActiveOrganizationID,
PrincipalUrn: urn,
})
if err != nil {
return nil, oops.E(oops.CodeUnexpected, err, "add plugin assignment").Log(ctx, s.logger)
}
assignments = append(assignments, pluginAssignmentToGen(row))
}
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.

🟡 SetPluginAssignments returns duplicate entries when caller passes duplicate principal URNs

In SetPluginAssignments (server/internal/plugins/impl.go:498-509), the code iterates over payload.PrincipalUrns without deduplication. If the same URN appears multiple times (e.g., ["role:eng", "role:eng"]), the DB upsert (ON CONFLICT ... DO UPDATE) correctly stores only one row, but the loop appends each returned row to the response. This produces an API response with duplicate assignment entries that misrepresents the actual DB state.

Prompt for agents
In SetPluginAssignments (server/internal/plugins/impl.go), the payload.PrincipalUrns slice is iterated directly without deduplication. Since the AddPluginAssignment SQL query uses ON CONFLICT DO UPDATE, duplicate URNs produce duplicate entries in the API response while the DB only stores one row.

To fix: deduplicate payload.PrincipalUrns before the insert loop. A simple approach is to build a seen set before iterating:

  seen := make(map[string]struct{})
  for _, urn := range payload.PrincipalUrns {
      if _, ok := seen[urn]; ok { continue }
      seen[urn] = struct{}{}
      // ... existing insert logic
  }

Alternatively, deduplicate at validation time (near line 480) before the transaction begins.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@bradcypert bradcypert merged commit f749a53 into main Apr 16, 2026
27 checks passed
@bradcypert bradcypert deleted the bradcypert/plugins branch April 16, 2026 13:58
@github-actions github-actions bot locked and limited conversation to collaborators Apr 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants