feat: add MCP Apps (SEP-1865) support to platform and functions SDK#2061
feat: add MCP Apps (SEP-1865) support to platform and functions SDK#2061
Conversation
Add resource declaration capabilities to the Gram Functions SDK, enabling function authors to declare UI resources and link tools to them following the MCP Apps specification. - Add resource(), uiResource(), and handleResourceRead() to Gram class - Add meta field to ToolDefinition for tool-to-UI linking - Update manifest() to include resources and tool meta - Update extend() to merge resources across Gram instances - Add resources capability to fromGram() MCP server conversion - Align ManifestToolV0.Meta type with ManifestResourceV0.Meta (map[string]any) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Without this, tools/list from fromGram()-created MCP servers would not include _meta (e.g. ui/resourceUri), making it impossible for MCP Apps hosts to discover tool-to-UI resource links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ata() helper
uiResource() now provides genuine convenience beyond setting the MIME type:
- Auto-generates `ui://{name}` URI when `uri` is omitted
- Accepts `body` + `styles` to wrap in HTML scaffold with Gram.onData() helper
- Falls back to raw `content` mode for full control
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
🦋 Changeset detectedLatest commit: 97af11b The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| const manifest = g.manifest(); | ||
| const hasResources = | ||
| manifest.resources != null && manifest.resources.length > 0; |
There was a problem hiding this comment.
🚩 fromGram() captures manifest snapshot once, won't reflect later tool/resource additions
In ts-framework/functions/src/mcp.ts:202, const manifest = g.manifest() is called once when fromGram() executes. The ListToolsRequestSchema and ListResourcesRequestSchema handlers at lines 209 and 302 use this captured snapshot. If tools or resources are added to the Gram instance after fromGram() is called, they won't be reflected. This appears intentional — fromGram() creates a server from a point-in-time snapshot — but it's a subtle contract worth documenting.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const shouldSeedFunctions = functionsProvider === "local"; | ||
| if (!shouldSeedFunctions) { | ||
| log.info( | ||
| `Skipping seeded MCP app function assets because GRAM_FUNCTIONS_PROVIDER is '${functionsProvider}', not 'local'.`, |
There was a problem hiding this comment.
TODO: make this work with flyio
| --- | ||
| "dashboard": patch | ||
| "server": patch | ||
| "@gram-ai/functions": patch | ||
| --- | ||
|
|
There was a problem hiding this comment.
| --- | |
| "dashboard": patch | |
| "server": patch | |
| "@gram-ai/functions": patch | |
| --- | |
| --- | |
| "dashboard": minor | |
| "server": patch | |
| "@gram-ai/functions": minor | |
| --- | |
There was a problem hiding this comment.
Can you test this out on dev before going to to prod? Specifically interested in CSP abuses.
| func invCheckLocalDeploy(req RunnerDeployRequest) error { | ||
| switch { | ||
| case req.ProjectID == uuid.Nil: | ||
| return errors.New("project id cannot be nil") | ||
| case req.DeploymentID == uuid.Nil: | ||
| return errors.New("deployment id cannot be nil") | ||
| case req.FunctionID == uuid.Nil: | ||
| return errors.New("function id cannot be nil") | ||
| case req.AccessID == uuid.Nil: | ||
| return errors.New("access id cannot be nil") | ||
| case !IsSupportedRuntime(string(req.Runtime)): | ||
| return fmt.Errorf("unsupported runtime: %s", req.Runtime) | ||
| case len(req.Assets) == 0: | ||
| return errors.New("deployment assets cannot be empty") | ||
| case req.Assets[0].AssetURL == nil: | ||
| return errors.New("deployment asset url cannot be nil") | ||
| case req.BearerSecret == "": | ||
| return errors.New("bearer secret cannot be empty") | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func invCheckLocalToolCall(req RunnerToolCallRequest) error { | ||
| switch { | ||
| case req.OrganizationID == "": | ||
| return errors.New("organization id cannot be empty") | ||
| case req.OrganizationSlug == "": | ||
| return errors.New("organization slug cannot be empty") | ||
| case req.ProjectID == uuid.Nil: | ||
| return errors.New("project id cannot be nil") | ||
| case req.DeploymentID == uuid.Nil: | ||
| return errors.New("deployment id cannot be nil") | ||
| case req.FunctionsID == uuid.Nil: | ||
| return errors.New("functions id cannot be nil") | ||
| case req.ToolURN.IsZero(): | ||
| return errors.New("tool urn cannot be empty") | ||
| case req.ToolName == "": | ||
| return errors.New("tool name cannot be empty") | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| func invCheckLocalReadResource(req RunnerResourceReadRequest) error { | ||
| switch { | ||
| case req.OrganizationID == "": | ||
| return errors.New("organization id cannot be empty") | ||
| case req.OrganizationSlug == "": | ||
| return errors.New("organization slug cannot be empty") | ||
| case req.ProjectID == uuid.Nil: | ||
| return errors.New("project id cannot be nil") | ||
| case req.DeploymentID == uuid.Nil: | ||
| return errors.New("deployment id cannot be nil") | ||
| case req.FunctionsID == uuid.Nil: | ||
| return errors.New("functions id cannot be nil") | ||
| case req.ResourceURN.IsZero(): | ||
| return errors.New("resource urn cannot be empty") | ||
| case req.ResourceURI == "": | ||
| return errors.New("resource uri cannot be empty") | ||
| default: | ||
| return nil | ||
| } | ||
| } |
There was a problem hiding this comment.
use inv.Check for these
🚀 Preview Environment (PR #2061)Preview URL: https://pr-2061.dev.getgram.ai
Gram Preview Bot |
|
|
||
| l.binaryPath = filepath.Join(binDir, localRunnerBinaryName) | ||
| //nolint:gosec // builds the checked-out local runner from a fixed repo-relative package path. | ||
| cmd := exec.CommandContext(ctx, "go", "build", "-o", l.binaryPath, "./functions/cmd/runner") |
There was a problem hiding this comment.
🔴 sync.Once with request-scoped context permanently poisons runner binary build on cancellation
In ensureRunnerBinary, the ctx parameter is the caller's request context (from invokeLocalRunner → handleLocalProxyRequest → r.Context()). The go build command uses exec.CommandContext(ctx, ...), so if the first HTTP request that triggers the build is canceled (e.g., client disconnects) while the build is in progress, the build fails with context cancellation. Because sync.Once guarantees the function runs only once, l.binaryErr is permanently set to the cancellation error. All subsequent ToolCall and ReadResource calls will fail forever, even though the context cancellation was transient and unrelated to the build itself. The process must be restarted to recover.
| cmd := exec.CommandContext(ctx, "go", "build", "-o", l.binaryPath, "./functions/cmd/runner") | |
| cmd := exec.CommandContext(context.Background(), "go", "build", "-o", l.binaryPath, "./functions/cmd/runner") |
Was this helpful? React with 👍 or 👎 to provide feedback.
| env := append(os.Environ(), | ||
| "GRAM_SERVER_URL="+l.serverURL.String(), | ||
| functionAuthSecretVar+"="+deployment.BearerSecret, | ||
| "GRAM_PROJECT_ID="+deployment.ProjectID, | ||
| "GRAM_DEPLOYMENT_ID="+deployment.DeploymentID, | ||
| "GRAM_FUNCTION_ID="+deployment.FunctionID, | ||
| ) |
There was a problem hiding this comment.
🚩 Parent process environment leaked to local runner subprocess
At deploy_local.go:383-389, os.Environ() passes the entire parent process environment to the runner subprocess, including potentially sensitive variables (database URLs, API keys, etc.). While this is a dev-only local runner where the parent and child share the same trust domain, the runner's user code (functions.js) executes in the same process and could read these environment variables. The Fly runner uses isolated VMs with explicit secrets management.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Screen.Recording.2026-04-01.at.12.16.03.mov
Runner-side changes
:8888port: the runner now binds a listener, writes the chosen address to an addr file, and the local orchestrator proxies to that live processserver/cmd/gram/deps.gonow instantiates the newLocalRunnerorchestrator, which launches runner processes and proxies tool/resource traffic through themmain()when launched from copied script pathsContext