Skip to content
Merged
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/assistant-trigger-self-update-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"server": patch
---

Assistants can now update their own triggers. Previously, calling `configure_trigger` on an existing trigger returned a generic internal error every time, even though the assistant could read its triggers fine — its scoped tool was being silently swapped for a stricter variant that demanded fields the assistant isn't allowed to send. As a side effect, an assistant's trigger list no longer leaks sibling assistants' triggers in the same project.
14 changes: 14 additions & 0 deletions server/internal/gateway/models.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
package gateway

import (
"context"
"errors"
"io"

"github.com/speakeasy-api/gram/server/internal/billing"
"github.com/speakeasy-api/gram/server/internal/externalmcp"
"github.com/speakeasy-api/gram/server/internal/functions"
"github.com/speakeasy-api/gram/server/internal/toolconfig"
"github.com/speakeasy-api/gram/server/internal/urn"
)

// PlatformDirectExecutor lets a caller pin a specific executor to a plan so
// the runtime skips URN resolution. Used where the caller's matching context
// (e.g. a platform toolset slice) is more authoritative than the URN-keyed
// registry — multiple scoped variants of one tool can share a URN.
type PlatformDirectExecutor interface {
Call(ctx context.Context, env toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error
}

type FilterType string

const (
Expand Down Expand Up @@ -135,6 +146,9 @@ type PlatformToolCallPlan struct {
OwnerKind string `json:"owner_kind" yaml:"owner_kind"`
OwnerID string `json:"owner_id" yaml:"owner_id"`
InputSchema []byte `json:"input_schema" yaml:"input_schema"`
// Executor, when set, bypasses URN resolution in the platform runtime.
// In-process only; never serialized.
Executor PlatformDirectExecutor `json:"-" yaml:"-"`
}

// ExternalMCPToolCallPlan is an alias for externalmcp.ToolCallPlan.
Expand Down
3 changes: 3 additions & 0 deletions server/internal/mcp/serve_platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ func (s *Service) callPlatformToolsetTool(
OwnerKind: conv.PtrValOrEmpty(desc.OwnerKind, ""),
OwnerID: conv.PtrValOrEmpty(desc.OwnerID, ""),
InputSchema: desc.InputSchema,
// The toolset slice is authoritative; the runtime's URN registry can
// hold a differently scoped variant of the same tool.
Executor: matched.Executor,
})

ctx, logger := o11y.EnrichToolCallContext(ctx, s.logger, descriptor.OrganizationSlug, descriptor.ProjectSlug)
Expand Down
17 changes: 16 additions & 1 deletion server/internal/platformtools/runtime/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func TriggerExternalTools(db *pgxpool.Pool, app *bgtriggers.App, auditLogger *au
}

func (s *Service) ExecuteTool(ctx context.Context, plan *gateway.ToolCallPlan, env toolconfig.ToolCallEnv, requestBody io.Reader) (*gateway.PlatformResult, error) {
if plan == nil || plan.Kind != gateway.ToolKindPlatform || plan.Descriptor == nil {
if plan == nil || plan.Kind != gateway.ToolKindPlatform || plan.Descriptor == nil || plan.Platform == nil {
return nil, fmt.Errorf("invalid platform tool plan")
}
authCtx, ok := contextvalues.GetAuthContext(ctx)
Expand All @@ -141,6 +141,21 @@ func (s *Service) ExecuteTool(ctx context.Context, plan *gateway.ToolCallPlan, e
}
}

// A pinned executor wins over the URN registry: scoped variants of a
// platform tool share a URN, so the caller's match is more specific than
// what the registry would resolve.
if plan.Platform.Executor != nil {
var out bytes.Buffer
if err := plan.Platform.Executor.Call(ctx, env, requestBody, &out); err != nil {
return nil, fmt.Errorf("execute platform tool %s: %w", plan.Descriptor.URN, err)
}
return &gateway.PlatformResult{
StatusCode: http.StatusOK,
ContentType: "application/json",
Body: out.Bytes(),
}, nil
}

executor, ok := s.executors[urnStr]
if !ok {
return nil, oops.E(oops.CodeNotFound, nil, "platform tool not found").Log(ctx, s.logger)
Expand Down
88 changes: 88 additions & 0 deletions server/internal/platformtools/runtime/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package runtime

import (
"context"
"errors"
"fmt"
"io"
"strings"
"testing"

"github.com/google/uuid"
Expand All @@ -15,6 +19,31 @@ import (
"github.com/speakeasy-api/gram/server/internal/urn"
)

type stubDirectExecutor struct {
response string
err error
called bool
gotBody string
}

func (s *stubDirectExecutor) Call(_ context.Context, _ toolconfig.ToolCallEnv, payload io.Reader, wr io.Writer) error {
s.called = true
if payload != nil {
body, err := io.ReadAll(payload)
if err != nil {
return fmt.Errorf("read payload: %w", err)
}
s.gotBody = string(body)
}
if s.err != nil {
return s.err
}
if _, err := io.WriteString(wr, s.response); err != nil {
return fmt.Errorf("write response: %w", err)
}
return nil
}

func TestService_ExecuteTool_RequiresProjectAuthContext(t *testing.T) {
t.Parallel()

Expand All @@ -27,6 +56,7 @@ func TestService_ExecuteTool_RequiresProjectAuthContext(t *testing.T) {
ProjectID: projectID.String(),
URN: urn.NewTool(urn.ToolKindPlatform, "logs", "search_logs"),
},
Platform: &gateway.PlatformToolCallPlan{},
}, toolconfig.ToolCallEnv{
UserConfig: toolconfig.NewCaseInsensitiveEnv(),
SystemEnv: toolconfig.NewCaseInsensitiveEnv(),
Expand All @@ -53,6 +83,7 @@ func TestService_ExecuteTool_RejectsMismatchedProjectAuthContext(t *testing.T) {
ProjectID: descriptorProjectID.String(),
URN: urn.NewTool(urn.ToolKindPlatform, "logs", "search_logs"),
},
Platform: &gateway.PlatformToolCallPlan{},
}, toolconfig.ToolCallEnv{
UserConfig: toolconfig.NewCaseInsensitiveEnv(),
SystemEnv: toolconfig.NewCaseInsensitiveEnv(),
Expand All @@ -62,3 +93,60 @@ func TestService_ExecuteTool_RejectsMismatchedProjectAuthContext(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "does not match project")
}

// overridePlan builds a plan whose URN is unregistered, so any test that
// reaches the URN registry instead of the pinned executor would 404.
func overridePlan(projectID uuid.UUID, exec gateway.PlatformDirectExecutor) *gateway.ToolCallPlan {
return &gateway.ToolCallPlan{
Kind: gateway.ToolKindPlatform,
Descriptor: &gateway.ToolDescriptor{
ProjectID: projectID.String(),
URN: urn.NewTool(urn.ToolKindPlatform, "unregistered", "tool"),
},
Platform: &gateway.PlatformToolCallPlan{Executor: exec},
}
}

func TestService_ExecuteTool_UsesPlanExecutorOverride(t *testing.T) {
t.Parallel()

svc := NewService(testenv.NewLogger(t), nil, nil, audit.NewLogger())
projectID := uuid.New()
ctx := contextvalues.SetAuthContext(context.Background(), &contextvalues.AuthContext{
ActiveOrganizationID: "org-1",
ProjectID: &projectID,
})

exec := &stubDirectExecutor{response: `{"ok":true}`}

result, err := svc.ExecuteTool(ctx, overridePlan(projectID, exec), toolconfig.ToolCallEnv{
UserConfig: toolconfig.NewCaseInsensitiveEnv(),
SystemEnv: toolconfig.NewCaseInsensitiveEnv(),
}, strings.NewReader(`{"hello":"world"}`))
require.NoError(t, err)
require.True(t, exec.called)
require.JSONEq(t, `{"hello":"world"}`, exec.gotBody)
require.NotNil(t, result)
require.JSONEq(t, `{"ok":true}`, string(result.Body))
}

func TestService_ExecuteTool_PlanExecutorOverrideSurfacesError(t *testing.T) {
t.Parallel()

svc := NewService(testenv.NewLogger(t), nil, nil, audit.NewLogger())
projectID := uuid.New()
ctx := contextvalues.SetAuthContext(context.Background(), &contextvalues.AuthContext{
ActiveOrganizationID: "org-1",
ProjectID: &projectID,
})

exec := &stubDirectExecutor{err: errors.New("boom")}

_, err := svc.ExecuteTool(ctx, overridePlan(projectID, exec), toolconfig.ToolCallEnv{
UserConfig: toolconfig.NewCaseInsensitiveEnv(),
SystemEnv: toolconfig.NewCaseInsensitiveEnv(),
}, nil)
require.Error(t, err)
require.ErrorContains(t, err, "boom")
require.True(t, exec.called)
}
1 change: 1 addition & 0 deletions server/internal/toolsets/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func (t *Toolsets) extractPlatformToolCallPlan(ctx context.Context, projectID uu
OwnerKind: conv.PtrValOrEmpty(tool.OwnerKind, ""),
OwnerID: conv.PtrValOrEmpty(tool.OwnerID, ""),
InputSchema: tool.InputSchema,
Executor: nil,
}

return gateway.NewPlatformToolCallPlan(descriptor, plan), nil
Expand Down
Loading