pluginmcp

package
v2.5.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 29, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

pluginmcp

Expose MCP (Model Context Protocol) tools from a Mattermost plugin to the Agents plugin. pluginmcp wraps the go-sdk MCP server with the namespacing, inter-plugin auth, user-ID propagation, and async registration that Agents-plugin tool calls require.

Requires Mattermost 11.3 or newer (uses Plugin.PluginHTTPStream).

Install

Until the Agents plugin cuts a release tag with external/pluginmcp/ exported, point your go.mod at a local checkout:

replace github.com/mattermost/mattermost-plugin-agents/v2 => ../mattermost-plugin-agents

Quickstart

A complete plugin that exposes one tool. The same shape works for any number of tools, call AddTool once per tool from OnActivate.

// plugin/plugin.go
package main

import (
	"context"
	"fmt"
	"net/http"
	"strings"

	"github.com/mattermost/mattermost-plugin-agents/v2/external/pluginmcp"
	"github.com/mattermost/mattermost/server/public/plugin"
	"github.com/mattermost/mattermost/server/public/pluginapi"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

const (
	pluginID    = "com.example.plugin-mcp-demo"
	mcpBasePath = "/mcp"
)

type Plugin struct {
	plugin.MattermostPlugin
	client    *pluginapi.Client
	mcpServer *pluginmcp.Server
}

type WhoAmIArgs struct{}
type WhoAmIOutput struct {
	Username string `json:"username" jsonschema:"Username of the caller"`
}

func (p *Plugin) OnActivate() error {
	p.client = pluginapi.NewClient(p.API, p.Driver)

	p.mcpServer = pluginmcp.NewServer(p.API, pluginmcp.Config{
		PluginID: pluginID,
		Name:     "MCP Demo",
		Path:     mcpBasePath,
		Version:  "0.0.1",
	})

	pluginmcp.AddTool(p.mcpServer, &mcp.Tool{
		Name:        "whoami",
		Description: "Return the calling user's username.",
	}, p.whoami)

	return p.mcpServer.Register()
}

func (p *Plugin) OnDeactivate() error {
	if p.mcpServer == nil {
		return nil
	}
	return p.mcpServer.Unregister()
}

func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
	if strings.HasPrefix(r.URL.Path, mcpBasePath) {
		p.mcpServer.ServeHTTP(w, r)
		return
	}
	http.NotFound(w, r)
}

func (p *Plugin) whoami(ctx context.Context, _ *mcp.CallToolRequest, _ WhoAmIArgs) (*mcp.CallToolResult, WhoAmIOutput, error) {
	userID := pluginmcp.GetUserID(ctx)
	if userID == "" {
		return nil, WhoAmIOutput{}, fmt.Errorf("no Mattermost user ID in context")
	}
	user, err := p.client.User.Get(userID)
	if err != nil {
		return nil, WhoAmIOutput{}, fmt.Errorf("get user %s: %w", userID, err)
	}
	return nil, WhoAmIOutput{Username: user.Username}, nil
}

func main() {
	plugin.ClientMain(&Plugin{})
}

The tool appears in the Agents admin "Tools" tab as com.example.plugin-mcp-demo__whoami.

API reference

type Config struct {
	PluginID string // required; must equal plugin.json "id"
	Name     string // human-readable; shown in admin UI
	Path     string // your plugin's MCP endpoint, e.g. "/mcp"
	// ExposeExternal: if true, your tools may be included on the Agents plugin's
	// external MCP aggregate when the server is also Enabled in admin (see below).
	ExposeExternal bool
	Version        string // optional; defaults to "0.0.1"
}

type PluginAPI interface {
	PluginHTTP(*http.Request) *http.Response
}
  • NewServer(api PluginAPI, cfg Config) *Server: construct a server. p.API satisfies PluginAPI.
  • AddTool[In, Out any](s *Server, tool *mcp.Tool, handler ...): register a typed tool. In/Out are introspected by the go-sdk to build the tool schema. Free function (not a method) because Go doesn't allow type parameters on methods.
  • (*Server).ServeHTTP(w, r): http.Handler for the MCP endpoint. Wire it from your plugin's ServeHTTP for requests under cfg.Path.
  • (*Server).Register() error: start async registration with the Agents plugin. Returns nil immediately; retries happen in a goroutine.
  • (*Server).Unregister() error: synchronously cancel pending retries and POST one unregister request. Call from OnDeactivate.
  • GetUserID(ctx context.Context) string: read the user ID extracted by ServeHTTP from X-Mattermost-UserID. Returns "" if missing.

Handler signature for AddTool is the go-sdk's mcp.ToolHandlerFor[In, Out]:

func(context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error)

Return (nil, out, nil) and the helper packs out into a CallToolResult. Return a non-nil *mcp.CallToolResult to control the response yourself (multi-content replies, IsError, etc.).

Per-field schema annotations come from json: and jsonschema: tags:

type EchoArgs struct {
	Message string `json:"message" jsonschema:"The string to echo back,minLength=1"`
}

How it works

Register POSTs cfg to the Agents plugin's /bridge/v1/mcp/register with backoff (1s -> 2s -> 4s -> 8s, max 15 attempts) until the Agents plugin responds 200. The Agents plugin then issues MCP tools/list and tools/call requests back through PluginHTTP to your plugin's ServeHTTP, which delegates to the go-sdk streamable HTTP handler in stateless / JSON-response mode (PluginHTTP buffers the full response, so streaming SSE is not used).

For registration and unregister auth, the Agents plugin treats the trusted Mattermost-Plugin-ID header added by Mattermost inter-plugin RPC as the canonical plugin identity. The JSON plugin_id field is still sent for compatibility, but it is not trusted for identity.

User-ID flow per call: browser -> Mattermost server (sets Mattermost-User-Id) -> Agents plugin -> PluginHTTP (Mattermost adds Mattermost-Plugin-ID: mattermost-ai, Agents adds X-Mattermost-UserID) -> your ServeHTTP -> pluginmcp checks the plugin-ID header and stashes the user ID in the request context -> your handler reads it via GetUserID.

ExposeExternal vs admin Enabled / tool policy. Each registration POST sends expose_external from your Config, and that plugin-provided value controls whether the server is eligible for the external MCP server. Admins still control the server's Enabled state in the Agents system console, and per-tool policy still applies there as well. Enabled and per-tool settings are preserved across your plugin's re-registration, while ExposeExternal continues to come from the plugin registration payload.

Constraints and gotchas

Tool-name sanitization (Bifrost / Anthropic). AddTool prepends {pluginID}__ to tool.Name, replacing any character outside [A-Za-z0-9_-] with _ so the final name matches Bifrost's ^[a-zA-Z0-9_-]{1,128}$. Sanitization applies only to the LLM-facing tool-name prefix; routing, registry keys, and the wire PluginID are kept verbatim. If tool.Name already starts with the prefix it is not duplicated.

PluginID must not contain __. The double-underscore is the namespace separator. pluginmcp does not reject it, but a plugin ID containing __ parses ambiguously on the Agents side. Use a normal reverse-DNS ID like com.example.plugin-foo.

GetUserID is trustworthy only inside a ServeHTTP request. ServeHTTP rejects requests without Mattermost-Plugin-ID: mattermost-ai (Mattermost strips that header on external requests, so only inter-plugin RPC sees it). The user ID is then read from X-Mattermost-UserID and stored under an unexported context key. 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 with Agents plugin failed permanently and stop.

Tool-count budget. Each tool costs ~20-200 schema tokens in every LLM request. Aim for ~10 tools per plugin, with union-typed args, over many narrow tools.

Per-tool admin policy. Admins configure per-tool policy (enabled, auto-run-in-DM, require-confirmation) for plugin-registered tools from the system console Tools tab, alongside the server-level enable/disable toggle. Policy applies in both the user-facing tool flow and (when ExposeExternal is true) the external aggregated MCP endpoint.

Troubleshooting

  • Tool doesn't appear in the admin Tools tab. Look for Connected to plugin MCP server <pluginID> in the Agents plugin log; absence indicates Register() was never called or kept failing. The retry loop logs registration with Agents plugin gave up after N attempts on terminal failure and failed permanently on a 4xx.
  • GetUserID returns "". Either the request didn't go through pluginmcp.Server.ServeHTTP (typical in unit tests, inject a context yourself) or your outer ServeHTTP isn't routing to it.
  • Registration keeps retrying. Agents plugin disabled, in a crash loop, or cfg.PluginID doesn't match plugin.json's id (the Agents plugin returns 403 in that case, which is non-retriable).

Documentation

Overview

Package pluginmcp helps Mattermost plugins expose MCP tools to the Agents plugin. It handles tool-name namespacing, inter-plugin request checks, and async registration retries.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AddTool

func AddTool[In, Out any](s *Server, tool *mcp.Tool, handler mcp.ToolHandlerFor[In, Out])

AddTool registers a typed tool, prepending the sanitized plugin-ID namespace to tool.Name when not already present.

func GetUserID

func GetUserID(ctx context.Context) string

GetUserID returns the Mattermost user ID extracted from X-Mattermost-UserID by ServeHTTP, or "" if none was set. Tool handlers should call this instead of reading headers directly.

Types

type Config

type Config struct {
	PluginID string `json:"plugin_id"`
	Name     string `json:"name"`
	Path     string `json:"path"`
	// ExposeExternal, when true, allows the server's tools to appear on the
	// Agents plugin's external MCP aggregate (subject to Enabled and admin tool policy).
	ExposeExternal bool   `json:"expose_external"`
	Version        string `json:"version,omitempty"`
}

Config is the wire descriptor sent to the Agents plugin on register. Version defaults to "0.0.1" when empty.

type PluginAPI

type PluginAPI interface {
	PluginHTTP(*http.Request) *http.Response
}

PluginAPI is the minimal Mattermost plugin API subset pluginmcp needs.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a cross-plugin MCP server owned by a source plugin. Safe for concurrent use after construction.

func NewServer

func NewServer(pluginAPI PluginAPI, config Config) *Server

NewServer constructs a cross-plugin MCP server. The config must have non-empty PluginID, Name, and Path.

func (*Server) Register

func (s *Server) Register() error

Register asynchronously registers this server with the Agents plugin and returns immediately. The background goroutine is tracked via s.regWG so Unregister() can wait for it to drain before posting /unregister.

func (*Server) ServeHTTP

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP gates the MCP endpoint on the Mattermost-Plugin-ID header so only the Agents plugin can call it; X-Mattermost-UserID is trusted only after that check.

func (*Server) Unregister

func (s *Server) Unregister() error

Unregister synchronously unregisters this server with the Agents plugin. Cancels any pending Register() retries, waits (bounded) for an in-flight register attempt to drain so a late /register cannot land after our /unregister, then fires one POST. Intended for OnDeactivate: bounded wait, single attempt.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL