Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type MCPClientManager interface {
GetConfig() mcp.Config

RegisterPluginServer(cfg mcp.PluginServerConfig)
UpdatePluginServer(cfg mcp.PluginServerConfig)
UnregisterPluginServer(pluginID string)
ListPluginServers() []mcp.PluginServerConfig
GetPluginServer(pluginID string) (mcp.PluginServerConfig, bool)
Expand Down
2 changes: 1 addition & 1 deletion api/api_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ func (a *API) handleUpdatePluginServer(c *gin.Context) {
return
}

a.mcpClientManager.RegisterPluginServer(updated)
a.mcpClientManager.UpdatePluginServer(updated)
a.configUpdater.Update(cfg)

// Rebuild when either old or new state was external so removed tools
Expand Down
68 changes: 44 additions & 24 deletions api/api_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -547,11 +547,12 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body string
hasAdminPerm bool
expectStatus int
expectRegisterCalls int
expectRegistryCalls int
expectEnabledAfter bool
expectExposeAfter bool
expectToolConfigsAfter []mcp.ToolConfig
expectRebuildCalls int
orphanPluginIDs map[string]bool
}{
{
name: "happy path: flips Enabled true->false",
Expand All @@ -562,7 +563,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"enabled": false}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: false,
expectExposeAfter: false,
expectRebuildCalls: 0,
Expand All @@ -577,7 +578,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"enabled": false}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: false,
expectExposeAfter: true,
expectRebuildCalls: 1,
Expand All @@ -592,7 +593,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"expose_external": true}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: true,
expectExposeAfter: false,
expectRebuildCalls: 0,
Expand All @@ -607,11 +608,24 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: true,
expectExposeAfter: true,
expectRebuildCalls: 1,
},
{
name: "admin update keeps config-only orphan unregistered",
pluginID: "com.mattermost.demo",
preRegistered: []mcp.PluginServerConfig{{
PluginID: "com.mattermost.demo", Name: "Demo", Path: "/mcp", Enabled: true,
}},
orphanPluginIDs: map[string]bool{"com.mattermost.demo": true},
body: `{"enabled": false}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegistryCalls: 1,
expectEnabledAfter: false,
},
{
name: "404 when pluginID not registered",
pluginID: "com.missing",
Expand All @@ -638,7 +652,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"enabled": false}`,
hasAdminPerm: false,
expectStatus: http.StatusForbidden,
expectRegisterCalls: 0,
expectRegistryCalls: 0,
},
{
name: "tool_configs partial PUT sets policy, preserves enabled",
Expand All @@ -650,7 +664,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"tool_configs": [{"name": "echo", "policy": "ask", "enabled": false}]}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: true,
expectExposeAfter: false,
expectToolConfigsAfter: []mcp.ToolConfig{
Expand All @@ -670,7 +684,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"tool_configs": []}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: true,
expectExposeAfter: false,
expectToolConfigsAfter: []mcp.ToolConfig{},
Expand All @@ -689,7 +703,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {
body: `{"enabled": false}`,
hasAdminPerm: true,
expectStatus: http.StatusOK,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectEnabledAfter: false,
expectExposeAfter: false,
expectToolConfigsAfter: []mcp.ToolConfig{
Expand All @@ -709,6 +723,7 @@ func TestHandleUpdatePluginServer(t *testing.T) {

mgr := api.mcpClientManager.(*mockMCPClientManager)
mgr.pluginServers = tt.preRegistered
mgr.orphanPluginIDs = tt.orphanPluginIDs

// Seed a baseline persisted config so the handler can clone it
// instead of treating the store's nil as a 500.
Expand All @@ -727,15 +742,19 @@ func TestHandleUpdatePluginServer(t *testing.T) {
resp := recorder.Result()
require.Equal(t, tt.expectStatus, resp.StatusCode)

require.Len(t, mgr.registerCalls, tt.expectRegisterCalls)
require.Empty(t, mgr.registerCalls)
require.Len(t, mgr.updateCalls, tt.expectRegistryCalls)
if tt.expectStatus == http.StatusOK {
require.Equal(t, tt.expectEnabledAfter, mgr.registerCalls[0].Enabled)
require.Equal(t, tt.expectExposeAfter, mgr.registerCalls[0].ExposeExternal)
require.Equal(t, "Demo", mgr.registerCalls[0].Name)
require.Equal(t, "/mcp", mgr.registerCalls[0].Path)
require.Equal(t, "com.mattermost.demo", mgr.registerCalls[0].PluginID)
require.Equal(t, tt.expectEnabledAfter, mgr.updateCalls[0].Enabled)
require.Equal(t, tt.expectExposeAfter, mgr.updateCalls[0].ExposeExternal)
require.Equal(t, "Demo", mgr.updateCalls[0].Name)
require.Equal(t, "/mcp", mgr.updateCalls[0].Path)
require.Equal(t, "com.mattermost.demo", mgr.updateCalls[0].PluginID)
if tt.expectToolConfigsAfter != nil {
require.Equal(t, tt.expectToolConfigsAfter, mgr.registerCalls[0].ToolConfigs, "ToolConfigs assertion")
require.Equal(t, tt.expectToolConfigsAfter, mgr.updateCalls[0].ToolConfigs, "ToolConfigs assertion")
}
if tt.orphanPluginIDs[tt.pluginID] {
require.False(t, mgr.IsPluginRegistered(tt.pluginID))
}
}
require.Equal(t, tt.expectRebuildCalls, spy.callCount)
Expand All @@ -757,7 +776,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls int
expectUpdateCalls int
expectPublishCalls int
expectRegisterCalls int
expectRegistryCalls int
expectUnregisterCalls int
assertPersistedState func(t *testing.T, savedCfg *config.Config)
}{
Expand All @@ -774,7 +793,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls: 1,
expectUpdateCalls: 1,
expectPublishCalls: 1,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectUnregisterCalls: 0,
assertPersistedState: func(t *testing.T, savedCfg *config.Config) {
require.Len(t, savedCfg.MCP.PluginServers, 1)
Expand Down Expand Up @@ -819,7 +838,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls: 1,
expectUpdateCalls: 1,
expectPublishCalls: 1,
expectRegisterCalls: 1,
expectRegistryCalls: 1,
expectUnregisterCalls: 0,
assertPersistedState: func(t *testing.T, savedCfg *config.Config) {
require.Len(t, savedCfg.MCP.PluginServers, 2,
Expand Down Expand Up @@ -855,7 +874,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls: 0,
expectUpdateCalls: 0,
expectPublishCalls: 0,
expectRegisterCalls: 0,
expectRegistryCalls: 0,
expectUnregisterCalls: 0,
},
{
Expand All @@ -869,7 +888,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls: 1,
expectUpdateCalls: 0,
expectPublishCalls: 0,
expectRegisterCalls: 0,
expectRegistryCalls: 0,
expectUnregisterCalls: 0,
},
{
Expand All @@ -883,7 +902,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls: 1,
expectUpdateCalls: 0,
expectPublishCalls: 1,
expectRegisterCalls: 0,
expectRegistryCalls: 0,
expectUnregisterCalls: 0,
},
{
Expand All @@ -900,7 +919,7 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
expectSaveCalls: 0,
expectUpdateCalls: 0,
expectPublishCalls: 0,
expectRegisterCalls: 0,
expectRegistryCalls: 0,
expectUnregisterCalls: 0,
},
}
Expand Down Expand Up @@ -954,7 +973,8 @@ func TestHandleUpdatePluginServer_PersistsToConfig(t *testing.T) {
require.Equal(t, tt.expectUpdateCalls, stores.configUpdater.callCount)
require.Equal(t, tt.expectPublishCalls, stores.clusterNotifier.callCount)

require.Len(t, mgr.registerCalls, tt.expectRegisterCalls, "live plugin registry must not be mutated on failure paths")
require.Empty(t, mgr.registerCalls)
require.Len(t, mgr.updateCalls, tt.expectRegistryCalls, "live plugin registry must not be mutated on failure paths")
require.Len(t, mgr.unregisterCalls, tt.expectUnregisterCalls, "live plugin registry must not be mutated on failure paths")

if tt.assertPersistedState != nil {
Expand Down
12 changes: 11 additions & 1 deletion api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type mockMCPClientManager struct {
ensureSessionErr error

registerCalls []mcp.PluginServerConfig
updateCalls []mcp.PluginServerConfig
unregisterCalls []string
pluginServers []mcp.PluginServerConfig
// orphanPluginIDs simulates entries present in pluginServers but with
Expand Down Expand Up @@ -206,7 +207,16 @@ func (m *mockMCPClientManager) GetConfig() mcp.Config {

func (m *mockMCPClientManager) RegisterPluginServer(cfg mcp.PluginServerConfig) {
m.registerCalls = append(m.registerCalls, cfg)
// Mirror real ClientManager: same PluginID replaces existing entry.
delete(m.orphanPluginIDs, cfg.PluginID)
m.storePluginServer(cfg)
}

func (m *mockMCPClientManager) UpdatePluginServer(cfg mcp.PluginServerConfig) {
m.updateCalls = append(m.updateCalls, cfg)
m.storePluginServer(cfg)
}

func (m *mockMCPClientManager) storePluginServer(cfg mcp.PluginServerConfig) {
for i, existing := range m.pluginServers {
if existing.PluginID == cfg.PluginID {
m.pluginServers[i] = cfg
Expand Down
11 changes: 5 additions & 6 deletions external/pluginmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,11 @@ External callers can't inject one. Don't add a second auth gate in
your outer `ServeHTTP`, and don't read `X-Mattermost-UserID` directly
from headers in handlers; always go through `GetUserID`.

**Registration is one-shot per `OnActivate`.** The retry goroutine
exits on success or after 15 attempts. If the Agents plugin restarts
later, the in-memory registration is lost. The Agents plugin restores
admin-persisted entries on its own restart, but a never-saved
registration only comes back when your plugin is re-activated. Permanent
non-retriable errors (4xx other than 404/429) log
**Registration retries are bounded.** The retry goroutine exits on
success or after 15 attempts. After a successful registration, the
Agents plugin persists it and restores it across its own restarts while
your plugin remains enabled. Permanent non-retriable errors (4xx other
than 404/429) log
`registration with Agents plugin failed permanently` and stop.

**Tool-count budget.** Each tool costs ~20-200 schema tokens in every
Expand Down
Loading
Loading