From e6a94326efd0911798271afe2b9646467aaaefa1 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Thu, 26 Mar 2026 15:56:01 -0600 Subject: [PATCH 1/6] add sample for action buttons --- server/command_hooks.go | 50 +++++++++++++++++++++++++++ server/http_hooks.go | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/server/command_hooks.go b/server/command_hooks.go index 6d0d125..2f77650 100644 --- a/server/command_hooks.go +++ b/server/command_hooks.go @@ -22,6 +22,7 @@ const ( commandTriggerListFiles = "list_files" commandTriggerAutocompleteTest = "autocomplete_test" commandTriggerToast = "toast" + commandTriggerInlineAction = "inline_action" dialogElementNameNumber = "somenumber" dialogElementNameEmail = "someemail" @@ -136,6 +137,15 @@ func (p *Plugin) registerCommands() error { return errors.Wrapf(err, "failed to register %s command", commandTriggerDialog) } + if err := p.API.RegisterCommand(&model.Command{ + Trigger: commandTriggerInlineAction, + AutoComplete: true, + AutoCompleteHint: "", + AutoCompleteDesc: "Demonstrates inline action buttons in markdown tables.", + }); err != nil { + return errors.Wrapf(err, "failed to register %s command", commandTriggerInlineAction) + } + if err := p.API.RegisterCommand(&model.Command{ Trigger: commandTriggerToast, AutoComplete: true, @@ -303,6 +313,8 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo return p.executeCommandMentions(args), nil case commandTriggerAutocompleteTest: return p.executeAutocompleteTest(args), nil + case commandTriggerInlineAction: + return p.executeCommandInlineAction(args), nil case commandTriggerToast: return p.executeCommandToast(c, args), nil @@ -575,6 +587,44 @@ func (p *Plugin) executeCommandInteractive(args *model.CommandArgs) *model.Comma return &model.CommandResponse{} } +func (p *Plugin) executeCommandInlineAction(args *model.CommandArgs) *model.CommandResponse { + message := "### Issue Tracker [Demo Project]\n\n" + + "| ID | Title | Priority | Assignee | Action |\n" + + "|----|-------|----------|----------|--------|\n" + + "| ISS-101 | Login page returns 500 on Safari | High | @alice | [Triage](mmaction://triage?id=ISS-101&title=Login+page+returns+500+on+Safari&priority=high&assignee=alice) |\n" + + "| ISS-102 | Dark mode toggle doesn't persist | Medium | @bob | [Triage](mmaction://triage?id=ISS-102&title=Dark+mode+toggle+doesn't+persist&priority=medium&assignee=bob) |\n" + + "| ISS-103 | CSV export missing header row | Low | _unassigned_ | [Triage](mmaction://triage?id=ISS-103&title=CSV+export+missing+header+row&priority=low&assignee=) |\n" + + "| ISS-104 | API rate limiter blocks batch jobs | Critical | @charlie | [Triage](mmaction://triage?id=ISS-104&title=API+rate+limiter+blocks+batch+jobs&priority=critical&assignee=charlie) |\n" + + "| ISS-105 | Notification badge count off by one | Low | @alice | [Triage](mmaction://triage?id=ISS-105&title=Notification+badge+count+off+by+one&priority=low&assignee=alice) |\n" + + post := &model.Post{ + ChannelId: args.ChannelId, + RootId: args.RootId, + UserId: p.botID, + Message: message, + Props: model.StringInterface{ + "inline_actions": map[string]any{ + "triage": map[string]any{ + "url": fmt.Sprintf("/plugins/%s/inline_action/triage", manifest.Id), + "context": map[string]any{"project": "Demo Project"}, + }, + }, + }, + } + + _, err := p.API.CreatePost(post) + if err != nil { + const errorMessage = "Failed to create inline action post" + p.API.LogError(errorMessage, "err", err.Error()) + return &model.CommandResponse{ + ResponseType: model.CommandResponseTypeEphemeral, + Text: errorMessage, + } + } + + return &model.CommandResponse{} +} + func (p *Plugin) crash() { <-time.NewTimer(time.Second).C y := 0 diff --git a/server/http_hooks.go b/server/http_hooks.go index 37347fc..c6877e9 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -42,6 +42,9 @@ func (p *Plugin) initializeAPI() { interativeRouter.Use(p.withDelay) interativeRouter.HandleFunc("/button/1", p.handleInteractiveAction) + inlineActionRouter := router.PathPrefix("/inline_action").Subrouter() + inlineActionRouter.HandleFunc("/triage", p.handleInlineActionTriage) + dialogRouter := router.PathPrefix("/dialog").Subrouter() dialogRouter.Use(p.withDelay) dialogRouter.HandleFunc("/1", p.handleDialog1) @@ -492,6 +495,79 @@ func (p *Plugin) handleInteractiveAction(w http.ResponseWriter, r *http.Request) p.writeJSON(w, resp) } +func (p *Plugin) handleInlineActionTriage(w http.ResponseWriter, r *http.Request) { + var request model.PostActionIntegrationRequest + err := json.NewDecoder(r.Body).Decode(&request) + if err != nil { + p.API.LogError("Failed to decode PostActionIntegrationRequest", "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Extract inline params (per-row) and static context + inlineParams, _ := request.Context["inline_params"].(map[string]any) + issueID, _ := inlineParams["id"].(string) + project, _ := request.Context["project"].(string) + + // Build introduction text showing key identifiers passed via mmaction:// + intro := fmt.Sprintf("**Issue:** %s | **Project:** %s", issueID, project) + + serverConfig := p.API.GetConfig() + dialogRequest := model.OpenDialogRequest{ + TriggerId: request.TriggerId, + URL: fmt.Sprintf("%s/plugins/%s/dialog/3", *serverConfig.ServiceSettings.SiteURL, manifest.Id), + Dialog: model.Dialog{ + CallbackId: "triage_" + issueID, + Title: "Triage " + issueID, + IntroductionText: intro, + SubmitLabel: "Submit Triage", + Elements: []model.DialogElement{ + { + DisplayName: "QA Resource", + Name: "qa_resource", + Type: "select", + Placeholder: "Assign a QA resource...", + HelpText: "Select a team member to verify the fix.", + DataSource: "users", + }, + { + DisplayName: "Due Date", + Name: "due_date", + Type: "date", + HelpText: "Target date for resolution.", + }, + { + DisplayName: "Triage Notes", + Name: "notes", + Type: "textarea", + Optional: true, + Placeholder: "Root cause, reproduction steps, next actions...", + HelpText: "These notes will be posted to the channel.", + MaxLength: 500, + }, + }, + }, + } + + if appErr := p.API.OpenInteractiveDialog(dialogRequest); appErr != nil { + p.API.LogError("Failed to open triage dialog", "err", appErr.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + resp := &model.PostActionIntegrationResponse{} + p.writeJSON(w, resp) +} + +func formatJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("%+v", v) + } + return string(b) +} + func (p *Plugin) writeJSON(w http.ResponseWriter, response any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) From 47b0c4f6fc835302726fa9034b89bd5fc22249e0 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Tue, 19 May 2026 13:16:09 -0600 Subject: [PATCH 2/6] update action buttons sample for code review changes --- server/command_hooks.go | 6 +++++- server/http_hooks.go | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/server/command_hooks.go b/server/command_hooks.go index 2f77650..f6daf9e 100644 --- a/server/command_hooks.go +++ b/server/command_hooks.go @@ -603,8 +603,12 @@ func (p *Plugin) executeCommandInlineAction(args *model.CommandArgs) *model.Comm UserId: p.botID, Message: message, Props: model.StringInterface{ - "inline_actions": map[string]any{ + // Renamed from "inline_actions" → "mm_blocks_actions" on + // feature/action_buttons. Each entry now also requires + // an explicit "type" field (validated server-side). + "mm_blocks_actions": map[string]any{ "triage": map[string]any{ + "type": "external", "url": fmt.Sprintf("/plugins/%s/inline_action/triage", manifest.Id), "context": map[string]any{"project": "Demo Project"}, }, diff --git a/server/http_hooks.go b/server/http_hooks.go index c6877e9..f6ff393 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -505,13 +505,25 @@ func (p *Plugin) handleInlineActionTriage(w http.ResponseWriter, r *http.Request } defer r.Body.Close() - // Extract inline params (per-row) and static context - inlineParams, _ := request.Context["inline_params"].(map[string]any) - issueID, _ := inlineParams["id"].(string) + // Per-click params arrive as URL query string on feature/action_buttons — + // the server merges spec.Query and request.Query into the upstream URL + // via MergeQueryIntoURL before forwarding. Static context fields (like + // "project") still come through request.Context as before. + q := r.URL.Query() + issueID := q.Get("id") + title := q.Get("title") + priority := q.Get("priority") + assignee := q.Get("assignee") + if assignee == "" { + assignee = "unassigned" + } project, _ := request.Context["project"].(string) // Build introduction text showing key identifiers passed via mmaction:// - intro := fmt.Sprintf("**Issue:** %s | **Project:** %s", issueID, project) + intro := fmt.Sprintf( + "**Issue:** %s | **Project:** %s\n**Title:** %s | **Priority:** %s | **Assignee:** @%s", + issueID, project, title, priority, assignee, + ) serverConfig := p.API.GetConfig() dialogRequest := model.OpenDialogRequest{ From d658322b0de25c78b796d04d1747c7b2c76ac9d5 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Tue, 19 May 2026 14:09:30 -0600 Subject: [PATCH 3/6] Update server/http_hooks.go Per coderabbit review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- server/http_hooks.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/http_hooks.go b/server/http_hooks.go index f6ff393..3353865 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -514,15 +514,18 @@ func (p *Plugin) handleInlineActionTriage(w http.ResponseWriter, r *http.Request title := q.Get("title") priority := q.Get("priority") assignee := q.Get("assignee") - if assignee == "" { - assignee = "unassigned" + assignee := q.Get("assignee") + assigneeDisplay := "unassigned" + if assignee != "" { + assigneeDisplay = "@" + assignee } project, _ := request.Context["project"].(string) // Build introduction text showing key identifiers passed via mmaction:// intro := fmt.Sprintf( - "**Issue:** %s | **Project:** %s\n**Title:** %s | **Priority:** %s | **Assignee:** @%s", - issueID, project, title, priority, assignee, + "**Issue:** %s | **Project:** %s\n**Title:** %s | **Priority:** %s | **Assignee:** %s", + issueID, project, title, priority, assigneeDisplay, + ) ) serverConfig := p.API.GetConfig() From b770db102a1869cb7531cd56e658d14a8c314cf9 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Tue, 19 May 2026 14:14:47 -0600 Subject: [PATCH 4/6] fix build issues from coderabbit auto-fix --- server/http_hooks.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/http_hooks.go b/server/http_hooks.go index 3353865..e822853 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -514,7 +514,6 @@ func (p *Plugin) handleInlineActionTriage(w http.ResponseWriter, r *http.Request title := q.Get("title") priority := q.Get("priority") assignee := q.Get("assignee") - assignee := q.Get("assignee") assigneeDisplay := "unassigned" if assignee != "" { assigneeDisplay = "@" + assignee @@ -526,7 +525,6 @@ func (p *Plugin) handleInlineActionTriage(w http.ResponseWriter, r *http.Request "**Issue:** %s | **Project:** %s\n**Title:** %s | **Priority:** %s | **Assignee:** %s", issueID, project, title, priority, assigneeDisplay, ) - ) serverConfig := p.API.GetConfig() dialogRequest := model.OpenDialogRequest{ From 08517cb26049796240a200761c1f6a0a72dc1cf0 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 3 Jun 2026 09:41:11 -0400 Subject: [PATCH 5/6] Add MCP demo tools (#210) * Add MCP demo tools Expose demo MCP tools through the Agents plugin helper so cross-plugin MCP registration can be exercised from this plugin. Co-authored-by: Cursor * Update server/mcp_tools_test.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update MCP demo for pluginmcp helper Co-authored-by: Cursor * Synchronize MCP server initialization Co-authored-by: Cursor * Address MCP review feedback Co-authored-by: Cursor * Remove MCP lifecycle test seams Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- go.mod | 26 +++-- go.sum | 75 ++++++++------ server/activate_hooks.go | 20 +++- server/http_hooks.go | 3 + server/http_hooks_test.go | 59 +++++++++++ server/mcp.go | 84 ++++++++++++++++ server/mcp_tools.go | 78 +++++++++++++++ server/mcp_tools_test.go | 205 ++++++++++++++++++++++++++++++++++++++ server/plugin.go | 4 + 9 files changed, 515 insertions(+), 39 deletions(-) create mode 100644 server/mcp.go create mode 100644 server/mcp_tools.go create mode 100644 server/mcp_tools_test.go diff --git a/go.mod b/go.mod index 0af4fe9..ce1ce1e 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/mattermost/mattermost-plugin-demo -go 1.25.8 +go 1.26.2 require ( github.com/gorilla/mux v1.8.1 - github.com/mattermost/mattermost/server/public v0.3.0 + github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260519141957-379b06b188d9 + github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 ) @@ -19,6 +21,7 @@ require ( github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -34,26 +37,31 @@ require ( github.com/mattermost/logr/v2 v2.0.22 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/oklog/run v1.2.0 // indirect github.com/pborman/uuid v1.2.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect - golang.org/x/crypto v0.49.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect + google.golang.org/grpc v1.81.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index b9772fd..4c14b86 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -64,6 +66,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -120,8 +124,10 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI= github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4= github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s= -github.com/mattermost/mattermost/server/public v0.3.0 h1:AtzCjypbLcvSVQZMg0vKWL57vVfLSCC46j1nsOof2Ko= -github.com/mattermost/mattermost/server/public v0.3.0/go.mod h1:QnF/1Evlh7e3G8ifwut7Q5Joy/t4oHYNcDoyBTYuXho= +github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260519141957-379b06b188d9 h1:ovbzO370Fw8rzDRaQhZdY2HMan8QG4l0Gd1HaNEBKkY= +github.com/mattermost/mattermost-plugin-agents v1.14.1-0.20260519141957-379b06b188d9/go.mod h1:Ca1M+q6C0EwPEbDBZyPyqKlRdwv3NXbo+vd19B6MOgU= +github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f h1:FXDfzbDTk86bKEgBATCTAb3AWsQVzJMn9ruLY72nmQk= +github.com/mattermost/mattermost/server/public v0.3.1-0.20260402155910-d9d71af83e3f/go.mod h1:QnF/1Evlh7e3G8ifwut7Q5Joy/t4oHYNcDoyBTYuXho= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -130,10 +136,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= @@ -167,6 +175,10 @@ github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWy github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= @@ -215,26 +227,28 @@ github.com/wiggin77/merror v1.0.5 h1:P+lzicsn4vPMycAf2mFf7Zk6G9eco5N+jB1qJ2XW3ME github.com/wiggin77/merror v1.0.5/go.mod h1:H2ETSu7/bPE0Ymf4bEwdUoo73OOEkdClnoRisfw0Nm0= github.com/wiggin77/srslog v1.0.1 h1:gA2XjSMy3DrRdX9UqLuDtuVAAshb8bE1NhX1YK0Qe+8= github.com/wiggin77/srslog v1.0.1/go.mod h1:fehkyYDq1QfuYn60TDPu9YdY2bB85VUW2mvN1WynEls= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -250,12 +264,14 @@ golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -273,15 +289,14 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -289,8 +304,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= @@ -303,14 +320,14 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/activate_hooks.go b/server/activate_hooks.go index 35f3ec6..b19ab8e 100644 --- a/server/activate_hooks.go +++ b/server/activate_hooks.go @@ -15,6 +15,19 @@ import ( // This demo implementation logs a message to the demo channel whenever the plugin is activated. // It also creates a demo bot account func (p *Plugin) OnActivate() error { + if err := p.onActivateCore(); err != nil { + return err + } + + if err := p.ensureMCPServer(); err != nil { + return errors.Wrap(err, "failed to initialize MCP server") + } + p.registerMCPServerBestEffort() + + return nil +} + +func (p *Plugin) onActivateCore() error { if p.client == nil { p.client = pluginapi.NewClient(p.API, p.Driver) } @@ -73,8 +86,13 @@ func (p *Plugin) OnActivate() error { // // This demo implementation logs a message to the demo channel whenever the plugin is deactivated. func (p *Plugin) OnDeactivate() error { - configuration := p.getConfiguration() + err := p.onDeactivateCore() + p.unregisterMCPServerBestEffort() + return err +} +func (p *Plugin) onDeactivateCore() error { + configuration := p.getConfiguration() if p.backgroundJob != nil { if err := p.backgroundJob.Close(); err != nil { p.API.LogError("Failed to close background job", "err", err) diff --git a/server/http_hooks.go b/server/http_hooks.go index e822853..0bc8d22 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -29,6 +29,9 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req func (p *Plugin) initializeAPI() { router := mux.NewRouter() + router.HandleFunc(mcpBasePath, p.serveMCP) + router.PathPrefix(mcpBasePath + "/").HandlerFunc(p.serveMCP) + router.HandleFunc("/status", p.handleStatus) router.HandleFunc("/hello", p.handleHello) router.HandleFunc("/dynamic_arg_test_url", p.handleDynamicArgTest) diff --git a/server/http_hooks_test.go b/server/http_hooks_test.go index 0436218..b4821c7 100644 --- a/server/http_hooks_test.go +++ b/server/http_hooks_test.go @@ -60,3 +60,62 @@ func TestServeHTTP(t *testing.T) { }) } } + +func TestServeHTTPRoutesMCPBasePathToServer(t *testing.T) { + plugin := &Plugin{} + plugin.initializeAPI() + require.NoError(t, plugin.ensureMCPServer()) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/mcp", nil) + plugin.ServeHTTP(nil, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + + bodyBytes, err := io.ReadAll(result.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, result.StatusCode) + assert.Equal(t, "forbidden: plugin-ID header missing or mismatched\n", string(bodyBytes)) +} + +func TestServeHTTPRoutesMCPSubpathToServer(t *testing.T) { + plugin := &Plugin{} + plugin.initializeAPI() + require.NoError(t, plugin.ensureMCPServer()) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/mcp/tools/call", nil) + plugin.ServeHTTP(nil, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + + bodyBytes, err := io.ReadAll(result.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusForbidden, result.StatusCode) + assert.Equal(t, "forbidden: plugin-ID header missing or mismatched\n", string(bodyBytes)) +} + +func TestServeHTTPReturnsNotFoundForMCPWithoutServer(t *testing.T) { + plugin := &Plugin{} + plugin.initializeAPI() + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/mcp", nil) + plugin.ServeHTTP(nil, w, r) + + result := w.Result() + require.NotNil(t, result) + defer result.Body.Close() + + bodyBytes, err := io.ReadAll(result.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusNotFound, result.StatusCode) + assert.Equal(t, "404 page not found\n", string(bodyBytes)) +} diff --git a/server/mcp.go b/server/mcp.go new file mode 100644 index 0000000..733bf01 --- /dev/null +++ b/server/mcp.go @@ -0,0 +1,84 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "github.com/mattermost/mattermost-plugin-agents/external/pluginmcp" +) + +const mcpBasePath = "/mcp" + +func (p *Plugin) ensureMCPServer() error { + p.mcpServerLock.Lock() + defer p.mcpServerLock.Unlock() + + if p.mcpServer != nil { + return nil + } + + if manifest.Id == "" { + return errors.New("plugin manifest id is required for MCP server") + } + if manifest.Version == "" { + return errors.New("plugin manifest version is required for MCP server") + } + + serverName := strings.TrimSpace(manifest.Name) + if serverName == "" { + return errors.New("plugin manifest name is required for MCP server") + } + + server := pluginmcp.NewServer(p.API, pluginmcp.Config{ + PluginID: manifest.Id, + Name: serverName + " MCP", + Path: mcpBasePath, + ExposeExternal: true, + Version: manifest.Version, + }) + + p.registerMCPTools(server) + p.mcpServer = server + return nil +} + +func (p *Plugin) registerMCPServerBestEffort() { + server := p.currentMCPServer() + if server == nil { + p.API.LogWarn("MCP registration unavailable; continuing plugin activation", "reason", "server not initialized") + return + } + + if err := server.Register(); err != nil { + p.API.LogWarn("MCP registration unavailable; continuing plugin activation", "err", err.Error()) + } +} + +func (p *Plugin) unregisterMCPServerBestEffort() { + server := p.currentMCPServer() + if server == nil { + return + } + + if err := server.Unregister(); err != nil { + p.API.LogWarn("MCP unregister failed; continuing plugin shutdown", "err", err.Error()) + } +} + +func (p *Plugin) serveMCP(w http.ResponseWriter, r *http.Request) { + server := p.currentMCPServer() + if server == nil { + http.NotFound(w, r) + return + } + + server.ServeHTTP(w, r) +} + +func (p *Plugin) currentMCPServer() *pluginmcp.Server { + p.mcpServerLock.RLock() + defer p.mcpServerLock.RUnlock() + + return p.mcpServer +} diff --git a/server/mcp_tools.go b/server/mcp_tools.go new file mode 100644 index 0000000..37363ee --- /dev/null +++ b/server/mcp_tools.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + + "github.com/mattermost/mattermost-plugin-agents/external/pluginmcp" + "github.com/mattermost/mattermost/server/public/model" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type EchoArgs struct { + Message string `json:"message" jsonschema:"The string to echo back,minLength=1"` +} + +type EchoOutput struct { + Echoed string `json:"echoed" jsonschema:"The same string that was passed in"` +} + +type AddTwoNumbersArgs struct { + A int `json:"a" jsonschema:"First addend"` + B int `json:"b" jsonschema:"Second addend"` +} + +type AddTwoNumbersOutput struct { + Sum int `json:"sum" jsonschema:"Sum of a and b"` +} + +type GetUserDisplayNameArgs struct{} + +type GetUserDisplayNameOutput struct { + UserID string `json:"user_id" jsonschema:"Mattermost user ID of the caller"` + Username string `json:"username" jsonschema:"Username of the caller"` + DisplayName string `json:"display_name" jsonschema:"Full-name display name of the caller, falling back to username"` +} + +func (p *Plugin) registerMCPTools(server *pluginmcp.Server) { + pluginmcp.AddTool(server, &mcp.Tool{ + Name: "echo", + Description: "Echo a string back to the caller. Useful for verifying the MCP round-trip.", + }, p.echoHandler) + + pluginmcp.AddTool(server, &mcp.Tool{ + Name: "add_two_numbers", + Description: "Return the sum of two integers. Exercises typed JSON-schema generation.", + }, p.addTwoNumbersHandler) + + pluginmcp.AddTool(server, &mcp.Tool{ + Name: "get_user_display_name", + Description: "Look up the calling user's display name. Exercises the X-Mattermost-UserID context propagation chain: server -> agents plugin -> PluginHTTP -> pluginmcp.ServeHTTP -> tool handler.", + }, p.getUserDisplayNameHandler) +} + +func (p *Plugin) echoHandler(_ context.Context, _ *mcp.CallToolRequest, in EchoArgs) (*mcp.CallToolResult, EchoOutput, error) { + return nil, EchoOutput{Echoed: in.Message}, nil +} + +func (p *Plugin) addTwoNumbersHandler(_ context.Context, _ *mcp.CallToolRequest, in AddTwoNumbersArgs) (*mcp.CallToolResult, AddTwoNumbersOutput, error) { + return nil, AddTwoNumbersOutput{Sum: in.A + in.B}, nil +} + +func (p *Plugin) getUserDisplayNameHandler(ctx context.Context, _ *mcp.CallToolRequest, _ GetUserDisplayNameArgs) (*mcp.CallToolResult, GetUserDisplayNameOutput, error) { + userID := pluginmcp.GetUserID(ctx) + if userID == "" { + return nil, GetUserDisplayNameOutput{}, fmt.Errorf("no Mattermost user ID in tool context (did the request arrive via pluginmcp.ServeHTTP?)") + } + + user, err := p.client.User.Get(userID) + if err != nil { + return nil, GetUserDisplayNameOutput{}, fmt.Errorf("failed to fetch user %s: %w", userID, err) + } + + return nil, GetUserDisplayNameOutput{ + UserID: user.Id, + Username: user.Username, + DisplayName: user.GetDisplayName(model.ShowFullName), + }, nil +} diff --git a/server/mcp_tools_test.go b/server/mcp_tools_test.go new file mode 100644 index 0000000..7f37e33 --- /dev/null +++ b/server/mcp_tools_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const agentsPluginID = "mattermost-ai" + +func TestEchoHandler(t *testing.T) { + plugin := &Plugin{} + + callResult, out, err := plugin.echoHandler(context.Background(), nil, EchoArgs{Message: "hello, MCP demo!"}) + require.NoError(t, err) + assert.Nil(t, callResult) + assert.Equal(t, "hello, MCP demo!", out.Echoed) + + callResult, out, err = plugin.echoHandler(context.Background(), nil, EchoArgs{Message: " keep spacing "}) + require.NoError(t, err) + assert.Nil(t, callResult) + assert.Equal(t, " keep spacing ", out.Echoed) +} + +func TestAddTwoNumbersHandler(t *testing.T) { + plugin := &Plugin{} + + callResult, out, err := plugin.addTwoNumbersHandler(context.Background(), nil, AddTwoNumbersArgs{A: 2, B: 3}) + require.NoError(t, err) + assert.Nil(t, callResult) + assert.Equal(t, 5, out.Sum) + + callResult, out, err = plugin.addTwoNumbersHandler(context.Background(), nil, AddTwoNumbersArgs{A: -4, B: 7}) + require.NoError(t, err) + assert.Nil(t, callResult) + assert.Equal(t, 3, out.Sum) +} + +func TestGetUserDisplayNameHandlerMissingUserContext(t *testing.T) { + plugin := &Plugin{} + + callResult, out, err := plugin.getUserDisplayNameHandler(context.Background(), nil, GetUserDisplayNameArgs{}) + require.Error(t, err) + assert.Nil(t, callResult) + assert.Contains(t, err.Error(), "no Mattermost user ID in tool context") + assert.Equal(t, GetUserDisplayNameOutput{}, out) +} + +func TestGetUserDisplayNameHandlerUserLookupFailure(t *testing.T) { + api := &plugintest.API{} + userID := "user-id-123" + apiErr := model.NewAppError("TestGetUserDisplayNameHandlerUserLookupFailure", "app.user.get", nil, "lookup failed", http.StatusInternalServerError) + api.On("GetUser", userID).Return((*model.User)(nil), apiErr).Once() + + plugin := newToolTestPlugin(t, api) + session := newToolTestSession(t, plugin, headerWithUserID(userID)) + toolName := lookupToolName(t, session, "__get_user_display_name") + + out, err := callGetUserDisplayNameTool(t, session, toolName) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch user "+userID) + assert.Equal(t, GetUserDisplayNameOutput{}, out) + api.AssertExpectations(t) +} + +func TestGetUserDisplayNameHandlerSuccess(t *testing.T) { + api := &plugintest.API{} + userID := "user-id-123" + user := &model.User{ + Id: userID, + Username: "demo_user", + FirstName: "Demo", + LastName: "User", + } + api.On("GetUser", userID).Return(user, (*model.AppError)(nil)).Once() + + plugin := newToolTestPlugin(t, api) + session := newToolTestSession(t, plugin, headerWithUserID(userID)) + toolName := lookupToolName(t, session, "__get_user_display_name") + + out, err := callGetUserDisplayNameTool(t, session, toolName) + require.NoError(t, err) + assert.Equal(t, user.Id, out.UserID) + assert.Equal(t, user.Username, out.Username) + assert.Equal(t, user.GetDisplayName(model.ShowFullName), out.DisplayName) + api.AssertExpectations(t) +} + +func TestGetUserDisplayNameHandlerFallsBackToUsername(t *testing.T) { + api := &plugintest.API{} + userID := "user-id-456" + user := &model.User{ + Id: userID, + Username: "demo_user", + } + api.On("GetUser", userID).Return(user, (*model.AppError)(nil)).Once() + + plugin := newToolTestPlugin(t, api) + session := newToolTestSession(t, plugin, headerWithUserID(userID)) + toolName := lookupToolName(t, session, "__get_user_display_name") + + out, err := callGetUserDisplayNameTool(t, session, toolName) + require.NoError(t, err) + assert.Equal(t, user.GetDisplayName(model.ShowFullName), out.DisplayName) + api.AssertExpectations(t) +} + +func newToolTestPlugin(t *testing.T, api *plugintest.API) *Plugin { + t.Helper() + + plugin := &Plugin{} + plugin.API = api + plugin.client = pluginapi.NewClient(api, nil) + plugin.initializeAPI() + require.NoError(t, plugin.ensureMCPServer()) + return plugin +} + +func newToolTestSession(t *testing.T, plugin *Plugin, extraHeaders http.Header) *mcp.ClientSession { + t.Helper() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("Mattermost-Plugin-ID", agentsPluginID) + for key, values := range extraHeaders { + for _, value := range values { + r.Header.Add(key, value) + } + } + plugin.ServeHTTP(nil, w, r) + })) + t.Cleanup(ts.Close) + + client := mcp.NewClient(&mcp.Implementation{Name: "demo-plugin-test-client", Version: "0.0.1"}, nil) + session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: ts.URL + mcpBasePath}, nil) + require.NoError(t, err) + t.Cleanup(func() { + _ = session.Close() + }) + return session +} + +func lookupToolName(t *testing.T, session *mcp.ClientSession, suffix string) string { + t.Helper() + + tools, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) + require.NoError(t, err) + + for _, tool := range tools.Tools { + if strings.HasSuffix(tool.Name, suffix) { + return tool.Name + } + } + + t.Fatalf("tool ending with %q not found", suffix) + return "" +} + +func callGetUserDisplayNameTool(t *testing.T, session *mcp.ClientSession, toolName string) (GetUserDisplayNameOutput, error) { + t.Helper() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: toolName, + Arguments: map[string]any{}, + }) + if err != nil { + return GetUserDisplayNameOutput{}, err + } + + if result.IsError { + if len(result.Content) == 0 { + return GetUserDisplayNameOutput{}, errors.New("tool returned MCP error with empty content") + } + text, ok := result.Content[0].(*mcp.TextContent) + if ok { + return GetUserDisplayNameOutput{}, errors.New(text.Text) + } + return GetUserDisplayNameOutput{}, errors.New("tool returned MCP error content") + } + + var out GetUserDisplayNameOutput + payload, err := json.Marshal(result.StructuredContent) + if err != nil { + return GetUserDisplayNameOutput{}, err + } + + err = json.Unmarshal(payload, &out) + return out, err +} + +func headerWithUserID(userID string) http.Header { + headers := http.Header{} + headers.Set("X-Mattermost-UserID", userID) + return headers +} diff --git a/server/plugin.go b/server/plugin.go index e3547fb..a36f21f 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -4,6 +4,7 @@ import ( "sync" "github.com/gorilla/mux" + "github.com/mattermost/mattermost-plugin-agents/external/pluginmcp" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" @@ -36,6 +37,9 @@ type Plugin struct { // backgroundJob is a job that executes periodically on only one plugin instance at a time backgroundJob *cluster.Job + mcpServerLock sync.RWMutex + mcpServer *pluginmcp.Server + // Session tracking sessionToConn map[string]string sessionToConnMu sync.RWMutex From 412b4a0ed5f547320cbca00e5d4d3f78097febce Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Wed, 12 Aug 2026 16:20:48 -0600 Subject: [PATCH 6/6] small formatting change --- server/http_hooks.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/http_hooks.go b/server/http_hooks.go index 0bc8d22..b54cfd3 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -525,7 +525,7 @@ func (p *Plugin) handleInlineActionTriage(w http.ResponseWriter, r *http.Request // Build introduction text showing key identifiers passed via mmaction:// intro := fmt.Sprintf( - "**Issue:** %s | **Project:** %s\n**Title:** %s | **Priority:** %s | **Assignee:** %s", + "**Issue:** %s | **Project:** %s | **Title:** %s | **Priority:** %s | **Assignee:** %s", issueID, project, title, priority, assigneeDisplay, )