Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mirror-hosted-mcp-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"server": minor
---

Mirror hosted MCP configuration between toolsets and mcp_servers: toolset writes project onto the toolset's wrapper and primary endpoint, wrapper and endpoint writes project back onto the toolset columns, and a toolset can have at most one live wrapper.
2 changes: 1 addition & 1 deletion seed/demo/PAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Status: `[x]` seeded + verified · `[~]` seeded, not yet verified · `[ ]` not s
| Killswitch detail and version history | PG `killswitch_prescription_versions` ×8 + complete resource snapshots; changed A/B/C → A, lifted successor, expiry marker, notes, and matching Audit events | `[~]` |
| Access challenges | CH `authz_challenges` (member user_ids pass the suppression filter) | `[~]` |
| Budgets / spend controls | PG `spend_rules` ×2 + `spend_rule_events` ×4, calibrated to CH usage (breach+warning per rule); usage MV already fed by existing rows | `[~]` |
| Toolsets / MCP / Sources / Deployments / Playground | PG deployment stack: asset + completed deployment + 8 `http_tool_definitions` (urns match telemetry, doc slug `acme`) + 3 toolsets (+versions) | `[~]` |
| Toolsets / MCP / Sources / Deployments / Playground | PG deployment stack: asset + completed deployment + 8 `http_tool_definitions` (urns match telemetry, doc slug `acme`) + 3 toolsets (+versions, each mirrored by an `mcp_servers` wrapper and a platform `mcp_endpoints` row carrying its `mcp_slug`) | `[~]` |
| Prompts | PG `prompt_templates` ×2 | `[~]` |
| Skills | PG `skills` ×3 + `skill_versions` + 1 open edit suggestion with diff | `[~]` |
| Shadow MCP | CH `shadow_mcp_inventory_urls` ×15 + `hooks:` telemetry rows | `[~]` |
Expand Down
3 changes: 3 additions & 0 deletions server/internal/assistants/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ func (s *Service) CreateAssistant(ctx context.Context, payload *gen.CreateAssist
authCtx.ActiveOrganizationID,
*authCtx.ProjectID,
authCtx.UserID,
authCtx.Email,
payload.Name,
payload.Model,
payload.Instructions,
Expand Down Expand Up @@ -191,6 +192,8 @@ func (s *Service) UpdateAssistant(ctx context.Context, payload *gen.UpdateAssist
ctx,
*authCtx.ProjectID,
assistantID,
authCtx.UserID,
authCtx.Email,
payload.Name,
payload.Model,
payload.Instructions,
Expand Down
117 changes: 117 additions & 0 deletions server/internal/assistants/impl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import (
"github.com/speakeasy-api/gram/server/internal/authz"
"github.com/speakeasy-api/gram/server/internal/authztest"
"github.com/speakeasy-api/gram/server/internal/contextvalues"
"github.com/speakeasy-api/gram/server/internal/conv"
mcpendpointsRepo "github.com/speakeasy-api/gram/server/internal/mcpendpoints/repo"
mcpserversRepo "github.com/speakeasy-api/gram/server/internal/mcpservers/repo"
"github.com/speakeasy-api/gram/server/internal/oops"
projectsRepo "github.com/speakeasy-api/gram/server/internal/projects/repo"
remotemcpRepo "github.com/speakeasy-api/gram/server/internal/remotemcp/repo"
remotesessionsRepo "github.com/speakeasy-api/gram/server/internal/remotesessions/repo"
"github.com/speakeasy-api/gram/server/internal/telemetry"
"github.com/speakeasy-api/gram/server/internal/testenv"
"github.com/speakeasy-api/gram/server/internal/testenv/testrepo"
"github.com/speakeasy-api/gram/server/internal/thirdparty/workos"
toolsetsRepo "github.com/speakeasy-api/gram/server/internal/toolsets/repo"
usersessionsRepo "github.com/speakeasy-api/gram/server/internal/usersessions/repo"
Expand Down Expand Up @@ -519,3 +522,117 @@ func requireOopsCode(t *testing.T, err error, code oops.Code) {
require.ErrorAs(t, err, &oopsErr)
require.Equal(t, code, oopsErr.Code)
}

// Attaching a disabled toolset lifts its wrapper out of disabled too, so the
// endpoint the assistant runtime addresses is served rather than a terminal 404.
func TestServiceCreateAssistantLiftsDisabledWrapper(t *testing.T) {
t.Parallel()

svc, ctx, projectID, conn := newRBACServiceWithConn(t, "assistants_mcp_wrapper")
ctx = authztest.WithExactGrants(t, ctx, authz.Grant{
Scope: authz.ScopeProjectWrite,
Selector: authz.NewSelector(authz.ScopeProjectWrite, projectID.String()),
})

ts, err := toolsetsRepo.New(conn).CreateToolset(t.Context(), toolsetsRepo.CreateToolsetParams{
OrganizationID: "org-test",
ProjectID: projectID,
Name: "Slack",
Slug: "slack",
McpSlug: pgtype.Text{String: "org-test-slack-wrapped", Valid: true},
McpEnabled: false,
})
require.NoError(t, err)
issuer, err := usersessionsRepo.New(conn).CreateUserSessionIssuer(t.Context(), usersessionsRepo.CreateUserSessionIssuerParams{
ProjectID: projectID,
OrganizationID: pgtype.Text{String: "", Valid: false},
Slug: "usi-" + uuid.NewString()[:8],
AuthnChallengeMode: "interactive",
SessionDuration: pgtype.Interval{Microseconds: time.Hour.Microseconds(), Days: 0, Months: 0, Valid: true},
})
require.NoError(t, err)
_, err = toolsetsRepo.New(conn).UpdateToolsetUserSessionIssuer(t.Context(), toolsetsRepo.UpdateToolsetUserSessionIssuerParams{
UserSessionIssuerID: uuid.NullUUID{UUID: issuer.ID, Valid: true},
Slug: ts.Slug,
ProjectID: projectID,
})
require.NoError(t, err)
// remote_session_issuers FK to organization_metadata; the fixture org is a bare id.
now := time.Now()
require.NoError(t, testrepo.New(conn).CreateOrganizationMetadataFixture(t.Context(), testrepo.CreateOrganizationMetadataFixtureParams{
ID: "org-test", Name: "org-test", Slug: "org-test", GramAccountType: "enterprise",
FreeTrialStartedAt: conv.ToPGTimestamptz(now), FreeTrialEndsAt: conv.ToPGTimestamptz(now.Add(14 * 24 * time.Hour)),
}))
remoteIssuerID := seedBoundRemoteSessionIssuer(t, conn, "org-test", projectID, issuer.ID)
serverID, err := uuid.NewV7()
require.NoError(t, err)
wrapper, err := mcpserversRepo.New(conn).CreateMCPServer(t.Context(), mcpserversRepo.CreateMCPServerParams{
ID: serverID,
ProjectID: projectID,
Name: pgtype.Text{String: "Slack", Valid: true},
Slug: pgtype.Text{String: "slack-" + serverID.String()[:8], Valid: true},
ToolsetID: uuid.NullUUID{UUID: ts.ID, Valid: true},
Visibility: "disabled",
})
require.NoError(t, err)
_, err = mcpendpointsRepo.New(conn).CreateMCPEndpoint(t.Context(), mcpendpointsRepo.CreateMCPEndpointParams{
ProjectID: projectID,
McpServerID: uuid.NullUUID{UUID: wrapper.ID, Valid: true},
Slug: ts.McpSlug.String,
})
require.NoError(t, err)

_, err = svc.CreateAssistant(ctx, &gen.CreateAssistantPayload{
Name: "Assistant",
Model: "openai/gpt-4o-mini",
Instructions: "",
Toolsets: []*types.AssistantToolsetRef{{ToolsetSlug: ts.Slug, EnvironmentSlug: nil}},
})
require.NoError(t, err)

lifted, err := mcpserversRepo.New(conn).GetMCPServerByToolsetID(t.Context(), mcpserversRepo.GetMCPServerByToolsetIDParams{ToolsetID: ts.ID, ProjectID: projectID})
require.NoError(t, err)
require.Equal(t, "private", lifted.Visibility)
// The stale wrapper issuer is repaired and its derived remote issuer resynced.
require.Equal(t, uuid.NullUUID{UUID: issuer.ID, Valid: true}, lifted.UserSessionIssuerID)
require.Equal(t, uuid.NullUUID{UUID: remoteIssuerID, Valid: true}, lifted.RemoteSessionIssuerID)
endpoints, err := mcpendpointsRepo.New(conn).ListMCPEndpointsByMCPServerID(t.Context(), mcpendpointsRepo.ListMCPEndpointsByMCPServerIDParams{ProjectID: projectID, McpServerID: wrapper.ID})
require.NoError(t, err)
require.Len(t, endpoints, 1)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
require.Equal(t, ts.McpSlug.String, endpoints[0].Slug)
}

// seedBoundRemoteSessionIssuer creates a remote session issuer with one client
// bound to the given user issuer, the shape the issuer resync derives from.
func seedBoundRemoteSessionIssuer(t *testing.T, conn *pgxpool.Pool, organizationID string, projectID, userIssuerID uuid.UUID) uuid.UUID {
t.Helper()

q := remotesessionsRepo.New(conn)
suffix := uuid.NewString()[:8]
issuer, err := q.CreateRemoteSessionIssuer(t.Context(), remotesessionsRepo.CreateRemoteSessionIssuerParams{
ProjectID: conv.ToNullUUID(projectID),
OrganizationID: conv.ToPGText(organizationID),
Slug: "rsi-" + suffix,
Issuer: "https://issuer-" + suffix + ".example.com",
AuthorizationEndpoint: conv.ToPGText("https://issuer-" + suffix + ".example.com/authorize"),
TokenEndpoint: conv.ToPGText("https://issuer-" + suffix + ".example.com/token"),
ScopesSupported: []string{"openid"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
ResponseTypesSupported: []string{"code"},
TokenEndpointAuthMethodsSupported: []string{"none"},
})
require.NoError(t, err)
client, err := q.CreateRemoteSessionClient(t.Context(), remotesessionsRepo.CreateRemoteSessionClientParams{
ProjectID: conv.ToNullUUID(projectID),
OrganizationID: conv.ToPGTextEmpty(organizationID),
RemoteSessionIssuerID: issuer.ID,
ClientID: "client-" + suffix,
ClientIDIssuedAt: conv.ToPGTimestamptz(time.Now()),
})
require.NoError(t, err)
require.NoError(t, q.AttachRemoteSessionClientToUserSessionIssuer(t.Context(), remotesessionsRepo.AttachRemoteSessionClientToUserSessionIssuerParams{
RemoteSessionClientID: client.ID,
UserSessionIssuerID: userIssuerID,
}))
return issuer.ID
}
2 changes: 1 addition & 1 deletion server/internal/assistants/provisioning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func TestEnableManagedAssistantFailsWhenNameTaken(t *testing.T) {
projectID := newProvisioningProject(t, conn, "managed-taken")

// A user creates an assistant that happens to occupy the managed name.
_, err = core.CreateAssistant(ctx, "org-test", projectID, "user-1",
_, err = core.CreateAssistant(ctx, "org-test", projectID, "user-1", nil,
managedAssistantName("managed-taken"), managedAssistantModel, "hi", nil, nil,
int(managedAssistantWarmTTLSeconds), int(managedAssistantMaxConcurrency), StatusActive)
require.NoError(t, err)
Expand Down
15 changes: 0 additions & 15 deletions server/internal/assistants/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -316,21 +316,6 @@ INSERT INTO assistant_toolsets (
@project_id
);

-- name: EnableMCPForToolsets :exec
-- Flips mcp_enabled to TRUE for the listed toolsets in a project. Every
-- toolset attached to an assistant must be MCP-reachable for the runtime's
-- startup config to build; we enable on attach so users don't have to do it
-- separately. mcp_slug is required for an MCP-reachable toolset, so we skip
-- rows that lack one.
UPDATE toolsets
SET mcp_enabled = TRUE,
updated_at = clock_timestamp()
WHERE id = ANY(@toolset_ids::UUID[])
AND project_id = @project_id
AND mcp_enabled IS FALSE
AND mcp_slug IS NOT NULL
AND deleted IS FALSE;

-- name: LoadAssistantMcpServers :many
-- Hydrates assistant_mcp_servers with the fronting mcp_servers row, its
-- Gram-hosted endpoint slug (custom_domain_id IS NULL), and the bound
Expand Down
26 changes: 0 additions & 26 deletions server/internal/assistants/repo/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 23 additions & 11 deletions server/internal/assistants/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/speakeasy-api/gram/server/internal/encryption"
"github.com/speakeasy-api/gram/server/internal/feature"
"github.com/speakeasy-api/gram/server/internal/guardian"
"github.com/speakeasy-api/gram/server/internal/hostedmcp"
"github.com/speakeasy-api/gram/server/internal/mcpservers/visibility"
"github.com/speakeasy-api/gram/server/internal/metering"
"github.com/speakeasy-api/gram/server/internal/oops"
Expand Down Expand Up @@ -1072,6 +1073,7 @@ func (s *ServiceCore) hydrateAssistantSkills(ctx context.Context, projectID uuid
func writeAssistantToolsets(
ctx context.Context,
tx pgx.Tx,
mirror hostedmcp.Mirror,
assistantID, projectID uuid.UUID,
resolved []resolvedToolsetInsert,
) error {
Expand Down Expand Up @@ -1099,15 +1101,20 @@ func writeAssistantToolsets(
if _, err := queries.AddAssistantToolsets(ctx, rows); err != nil {
return fmt.Errorf("insert assistant toolsets: %w", err)
}
// The runtime startup config requires every assistant-attached toolset
// to be MCP-reachable; assistants address tools via the MCP server.
// Auto-enable on attach so the user doesn't have to toggle it
// separately on each toolset.
if err := queries.EnableMCPForToolsets(ctx, assistantrepo.EnableMCPForToolsetsParams{
ToolsetIds: toolsetIDs,
ProjectID: projectID,
}); err != nil {
return fmt.Errorf("enable mcp for assistant toolsets: %w", err)
// The runtime reaches attached toolsets through their MCP server, so attach
// enables it. All toolset locks are taken in id order before any wrapper
// write so this cannot deadlock with another mirror operation.
lockIDs := make([]uuid.NullUUID, 0, len(toolsetIDs))
for _, id := range toolsetIDs {
lockIDs = append(lockIDs, uuid.NullUUID{UUID: id, Valid: true})
}
if err := hostedmcp.LockToolsets(ctx, tx, projectID, lockIDs...); err != nil {
return fmt.Errorf("lock assistant toolsets: %w", err)
}
for _, toolsetID := range toolsetIDs {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if err := mirror.EnableToolsetMCP(ctx, tx, projectID, toolsetID); err != nil {
Comment thread
daviddanialy marked this conversation as resolved.
return fmt.Errorf("enable mcp for assistant toolset: %w", err)
}
}
return nil
}
Expand Down Expand Up @@ -1211,6 +1218,7 @@ func (s *ServiceCore) CreateAssistant(
organizationID string,
projectID uuid.UUID,
createdByUserID string,
createdByEmail *string,
name string,
model string,
instructions string,
Expand Down Expand Up @@ -1256,7 +1264,8 @@ func (s *ServiceCore) CreateAssistant(
}
record := assistantRecordFromCreateRow(created)

if err := writeAssistantToolsets(ctx, tx, record.ID, projectID, resolved); err != nil {
mirror := hostedmcp.Mirror{Audit: s.audit, ActorUserID: createdByUserID, ActorEmail: createdByEmail}
if err := writeAssistantToolsets(ctx, tx, mirror, record.ID, projectID, resolved); err != nil {
return assistantRecord{}, err
}
if err := writeAssistantMcpServers(ctx, tx, record.ID, projectID, resolvedMcpServers); err != nil {
Expand Down Expand Up @@ -1341,6 +1350,8 @@ func (s *ServiceCore) UpdateAssistant(
ctx context.Context,
projectID uuid.UUID,
assistantID uuid.UUID,
actorUserID string,
actorEmail *string,
name *string,
model *string,
instructions *string,
Expand Down Expand Up @@ -1390,7 +1401,8 @@ func (s *ServiceCore) UpdateAssistant(
record := assistantRecordFromUpdateRow(updated)

if toolsets != nil {
if err := writeAssistantToolsets(ctx, tx, record.ID, projectID, resolved); err != nil {
mirror := hostedmcp.Mirror{Audit: s.audit, ActorUserID: actorUserID, ActorEmail: actorEmail}
if err := writeAssistantToolsets(ctx, tx, mirror, record.ID, projectID, resolved); err != nil {
return assistantRecord{}, err
}
}
Expand Down
Loading
Loading