feat: support custom plugins for Claude via zip#2194
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 989e85b The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
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 |
🚀 Preview Environment (PR #2194)Preview URL: https://pr-2194.dev.getgram.ai
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
|
|
||||||||||||||||
|
|
||||||||||||||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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
| 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") | ||
| } |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const { data: plugin } = usePluginSuspense({ id: pluginId! }); | ||
|
|
||
| const { fetch: authFetch } = useFetcher(); | ||
| const { data: toolsetsData } = useListToolsetsForOrg(); |
There was a problem hiding this comment.
🔴 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).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Did we decide to do plugins at the org level? I can't recall
|
|
||
| func generateClaudePluginWithPrefix(files map[string][]byte, prefix string, p PluginInfo, cfg GenerateConfig) error { | ||
| userConfig := map[string]userConfigEntry{ | ||
| "GRAM_API_KEY": { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
How does this userConfig object work? Is the user prompted at some point to provide these vars?
There was a problem hiding this comment.
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{ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
| // 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") | ||
| } |
There was a problem hiding this comment.
🚩 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const { data: toolsetsData } = useListToolsetsForOrg(); | ||
| const toolsets = toolsetsData?.toolsets ?? []; |
There was a problem hiding this comment.
🚩 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.
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>
| 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)) | ||
| } |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
plugin.jsonwithuserConfigfor API key prompting, and.mcp.jsonwith HTTP MCP server declarationsWhat's included
plugins,plugin_servers,plugin_assignments) with project-scoped indexesWhat's deferred
bradcypert/plugins-github-integrationbranchTest plan