-
Notifications
You must be signed in to change notification settings - Fork 95
MCP Apps (1/3): backend plumbing — tool _meta.ui, resources/read, app-resource endpoint
#911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
+2,857
−112
Draft
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
a6fa070
Bump modelcontextprotocol/go-sdk to v1.6.1
cursoragent 2ca5ead
mcp: declare MCP Apps ui extension and preserve tool _meta.ui
cursoragent b0ec9e8
Plumb tool UI metadata through tool calls, blocks, and broadcasts
cursoragent cebb02b
mcp: add ReadAppResource with per-user session reuse and reconnect
cursoragent 1308846
api: add GET /mcp/app-resource endpoint enforcing app authorization
cursoragent c3cb57c
review: serialize Client session access and single-flight reconnect
cursoragent 8f30ee2
review: lazy origin-targeted reads with resource hardening
cursoragent 901bf28
review: add FindToolCallBlocks with post-anchored lookup
cursoragent 243fcd1
review: reshape app-resource contract and authorization gates
cursoragent 99040b1
review: close raced client and drop dead getClientForUser
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| // Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. | ||
| // See LICENSE.txt for license information. | ||
|
|
||
| package api | ||
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| "github.com/mattermost/mattermost-plugin-agents/v2/conversation" | ||
| "github.com/mattermost/mattermost-plugin-agents/v2/mcp" | ||
| "github.com/mattermost/mattermost/server/public/model" | ||
| ) | ||
|
|
||
| const ( | ||
| appResourceErrInvalidRequest = "invalid_request" | ||
| appResourceErrNotFound = "not_found" | ||
| appResourceErrForbidden = "forbidden" | ||
| appResourceErrAuthRequired = "mcp_auth_required" | ||
| appResourceErrUpstreamUnreachable = "upstream_unreachable" | ||
| appResourceErrInvalidResourceMime = "invalid_resource_mime" | ||
| ) | ||
|
|
||
| // AppResourceContents is one contents[] entry in the MCP ReadResourceResult | ||
| // wire shape returned by GET /mcp/app-resource on success. | ||
| type AppResourceContents struct { | ||
| URI string `json:"uri"` | ||
| MIMEType string `json:"mimeType"` | ||
| Text string `json:"text"` | ||
| Meta *AppResourceMeta `json:"_meta,omitempty"` | ||
| } | ||
|
|
||
| // AppResourceMeta is the MCP `_meta` object on a resource contents item. | ||
| type AppResourceMeta struct { | ||
| UI *mcp.AppResourceUIMeta `json:"ui,omitempty"` | ||
| } | ||
|
|
||
| // AppResourceResponse is the success JSON shape for GET /mcp/app-resource. | ||
| // It mirrors MCP ReadResourceResult so Phase 1c's onReadResource callback can | ||
| // return response.json() directly to @mcp-ui/client. | ||
| type AppResourceResponse struct { | ||
| Contents []AppResourceContents `json:"contents"` | ||
| } | ||
|
|
||
| // AppResourceErrorResponse is the JSON error shape for GET /mcp/app-resource. | ||
| // ErrorCode drives webapp behavior: mcp_auth_required renders "Connect to | ||
| // view" with AuthURL; forbidden renders the no-access popover. | ||
| // | ||
| // D5 state 3 ("can never gain access") is approximated client-side: after a | ||
| // shared-result onlooker receives repeated mcp_auth_required responses and | ||
| // OAuth fails/denies, the webapp renders the state-3 popover. The server has | ||
| // no distinct definitive-rejection code beyond that flow. | ||
| type AppResourceErrorResponse struct { | ||
| ErrorCode string `json:"error_code"` | ||
| Message string `json:"message"` | ||
| AuthURL string `json:"auth_url,omitempty"` | ||
| } | ||
|
|
||
| func writeAppResourceError(c *gin.Context, status int, code, message, authURL string) { | ||
| c.JSON(status, AppResourceErrorResponse{ | ||
| ErrorCode: code, | ||
| Message: message, | ||
| AuthURL: authURL, | ||
| }) | ||
| } | ||
|
|
||
| func (a *API) handleGetMCPAppResource(c *gin.Context) { | ||
| // Explicit non-checks: no license gate (viewing an already-executed tool's | ||
| // UI is a read, consistent with un-gated GET /conversations/:id; execution | ||
| // was already license-gated at handleToolCall), no EnableChannelMentionToolCalling | ||
| // gate (same reason), no tool-status gate (D3's Success/AutoApproved gating | ||
| // is a render-time webapp concern; the resource template itself is not | ||
| // result data). | ||
| // | ||
| // D6(b) uses the tool_use block's Shared flag — the same flag | ||
| // FilterForNonRequester uses when exposing ui_meta — so the invariant is | ||
| // exact: if GET /conversations/:id shows ui_meta to the caller, this | ||
| // fetch will not 403 for the share check. | ||
| userID := c.GetHeader("Mattermost-User-Id") | ||
| postID := c.Query("post_id") | ||
| toolCallID := c.Query("tool_call_id") | ||
| if postID == "" || toolCallID == "" || !model.IsValidId(postID) { | ||
| writeAppResourceError(c, http.StatusBadRequest, appResourceErrInvalidRequest, "post_id and tool_call_id are required", "") | ||
| return | ||
| } | ||
|
|
||
| post, err := a.pluginAPI.Post.GetPost(postID) | ||
| if err != nil { | ||
| writeAppResourceError(c, http.StatusNotFound, appResourceErrNotFound, "post not found", "") | ||
| return | ||
| } | ||
|
|
||
| if !a.pluginAPI.User.HasPermissionToChannel(userID, post.ChannelId, model.PermissionReadChannel) { | ||
| writeAppResourceError(c, http.StatusForbidden, appResourceErrForbidden, "permission denied", "") | ||
| return | ||
| } | ||
|
|
||
| turn, err := a.conversationStore.GetTurnByPostID(postID) | ||
| if err != nil || turn == nil { | ||
| writeAppResourceError(c, http.StatusNotFound, appResourceErrNotFound, "turn not found", "") | ||
| return | ||
| } | ||
|
|
||
| conv, err := a.conversationStore.GetConversation(turn.ConversationID) | ||
| if err != nil { | ||
| c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get conversation: %w", err)) | ||
| return | ||
| } | ||
| requesterID := conv.UserID | ||
|
|
||
| turns, err := a.conversationStore.GetTurnsForConversation(conv.ID) | ||
| if err != nil { | ||
| c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get turns: %w", err)) | ||
| return | ||
| } | ||
|
|
||
| toolUse, _, findErr := conversation.FindToolCallBlocks(turns, postID, toolCallID) | ||
| if findErr != nil { | ||
| if errors.Is(findErr, conversation.ErrAmbiguousToolCallID) { | ||
| writeAppResourceError(c, http.StatusNotFound, appResourceErrNotFound, "ambiguous tool call", "") | ||
| return | ||
| } | ||
| writeAppResourceError(c, http.StatusNotFound, appResourceErrNotFound, "tool call not found", "") | ||
| return | ||
| } | ||
| if toolUse.UIMeta == nil || toolUse.UIMeta.ResourceURI == "" { | ||
| writeAppResourceError(c, http.StatusNotFound, appResourceErrNotFound, "tool call has no app resource", "") | ||
| return | ||
| } | ||
|
|
||
| if userID != requesterID { | ||
| if toolUse.Shared == nil || !*toolUse.Shared { | ||
| writeAppResourceError(c, http.StatusForbidden, appResourceErrForbidden, "tool result is not shared", "") | ||
| return | ||
| } | ||
| } | ||
|
|
||
| res, err := a.mcpClientManager.ReadUserAppResource(c.Request.Context(), userID, toolUse.ServerOrigin, toolUse.UIMeta.ResourceURI) | ||
| if err != nil { | ||
| var oauthErr *mcp.OAuthNeededError | ||
| if errors.As(err, &oauthErr) { | ||
| writeAppResourceError(c, http.StatusUnauthorized, appResourceErrAuthRequired, "MCP authentication required", oauthErr.AuthURL()) | ||
| return | ||
| } | ||
| if errors.Is(err, mcp.ErrServerNotConfigured) { | ||
| writeAppResourceError(c, http.StatusNotFound, appResourceErrNotFound, "server no longer configured", "") | ||
| return | ||
| } | ||
| var invalidErr *mcp.InvalidAppResourceError | ||
| if errors.As(err, &invalidErr) { | ||
| a.pluginAPI.Log.Error("Invalid MCP app resource", | ||
| "userID", userID, "serverOrigin", toolUse.ServerOrigin, "uri", toolUse.UIMeta.ResourceURI, "error", invalidErr) | ||
| writeAppResourceError(c, http.StatusBadGateway, appResourceErrInvalidResourceMime, "invalid app resource", "") | ||
| return | ||
| } | ||
| a.pluginAPI.Log.Error("MCP app resource upstream failure", | ||
| "userID", userID, "serverOrigin", toolUse.ServerOrigin, "uri", toolUse.UIMeta.ResourceURI, "error", err) | ||
| writeAppResourceError(c, http.StatusBadGateway, appResourceErrUpstreamUnreachable, "MCP server unreachable", "") | ||
| return | ||
| } | ||
|
|
||
| var meta *AppResourceMeta | ||
| if res.UIMeta != nil { | ||
| meta = &AppResourceMeta{UI: res.UIMeta} | ||
| } | ||
| c.JSON(http.StatusOK, AppResourceResponse{ | ||
| Contents: []AppResourceContents{{ | ||
| URI: res.URI, | ||
| MIMEType: res.MIMEType, | ||
| Text: res.HTML, | ||
| Meta: meta, | ||
| }}, | ||
| }) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: mattermost/mattermost-plugin-agents
Length of output: 173
🏁 Script executed:
Repository: mattermost/mattermost-plugin-agents
Length of output: 18718
🏁 Script executed:
Repository: mattermost/mattermost-plugin-agents
Length of output: 17777
🏁 Script executed:
Repository: mattermost/mattermost-plugin-agents
Length of output: 15630
Add a bounded timeout to the remote MCP app-resource path
ReadUserAppResourcepassescacheableContext(ctx)intoConnectToRemoteServer(...), which drops request cancellation. If the MCP server hangs during connect orresources/read, this request can block indefinitely; use a per-call timeout here instead of relying on the client connection.🤖 Prompt for AI Agents