Skip to content

Conversation

@qstearns
Copy link
Contributor

Implements runtime tool listing and calling for external MCP servers. Proxy tools (tools:externalmcp:<slug>:proxy) are "unfolded" at runtime to expose the actual tools from the external MCP server.

Key changes:

  • unfoldExternalMCPTools() in MCP RPC handlers - expands proxy tools to actual tools via ListToolsFromProxy()
  • handleExternalMCPToolCall() - routes tool calls to external MCP servers via session API
  • doExternalMCP() in gateway proxy - handles tool execution with OAuth token passthrough
  • GetToolCallPlanByURN() updated to handle ToolKindExternalMCP
  • conv.ToBaseTool() returns error for proxy tools (must be unfolded first)
  • RAG search and agents updated to unfold proxy tools before processing

Tool naming convention: External MCP tools are named as <slug>:<toolname> (e.g., notion:search, github:list_repos).

Summary

  • external-mcp/5/list-and-call: URN kind and design types - Add ToolKindExternalMCP
  • external-mcp/5/list-and-call: implementation using generated types - Core unfolding and calling logic
  • external-mcp/5/list-and-call: instances, toolsets, and tests - Integration with instances API and test updates

🤖 Generated with Claude Code

@qstearns qstearns requested a review from a team as a code owner December 12, 2025 00:53
@changeset-bot
Copy link

changeset-bot bot commented Dec 12, 2025

⚠️ No Changeset found

Latest commit: 4bd3ffd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Dec 12, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
gram Ready Ready Preview, Comment Dec 17, 2025 7:40pm
gram-docs-redirect Ready Ready Preview, Comment Dec 17, 2025 7:40pm
gram-landing-redirect Ready Ready Preview, Comment Dec 17, 2025 7:40pm

tools = buildToolListEntries(toolset.Tools)

// Unfold proxy tools from external MCP servers
unfoldedTools, err := unfoldExternalMCPTools(ctx, logger, toolset.Tools, payload.oauthTokenInputs)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So checking my understanding here. It seems like we actually only unfold an external tool in mcp context. So right now these tools wouldn't be visible/useable in the playground. Correct?

@qstearns qstearns force-pushed the external-mcp/4/oauth branch from 60aa2a0 to af7c803 Compare December 15, 2025 22:55
@qstearns qstearns changed the title feat: List and call tools from external MCPs feat: list and call tools from external MCPs Dec 17, 2025
func ToToolListEntry(tool *types.Tool) (name, description string, inputSchema json.RawMessage, meta map[string]any, err error) {
if tool == nil {
return "", "", nil, nil
return "", "", nil, nil, nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I draw the line at five return params lol
Wrap that baby in a struct

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that baby belongs in a struct. ✅

}

// NewExternalMCPToolCallPlan creates a new Tool wrapping an ExternalMCPTool.
func NewExternalMCPToolCallPlan(tool *ToolDescriptor, plan *ExternalMCPToolCallPlan) *ToolCallPlan {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im surprised the Plan for this isn't the same as HTTP

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that there are some differences for sure:

for instance, the way you want to map input configuration to source configuration is way less complex for external MCP in that you don't have to worry about all the Header vs Body vs Param configuration options of open API + formatting options Open API supports. Also no notion of something like request method

}

// Pre-resolve external MCP OAuth config for use in switch
fullToolset, descErr := mv.DescribeToolset(ctx, s.logger, s.db, mv.ProjectID(toolset.ProjectID), mv.ToolsetSlug(toolset.Slug), &s.toolsetCache)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the FULL toolset? DescribeToolset is somewhat expensive

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does wellKnown get spammed or am i misremembering

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// TODO: Consider caching hasExternalMCPOAuth on the toolset record to avoid loading
// the full toolset on every request just to check OAuth requirements.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes lets definitely do that. Alternatively you can add a query that just retrieves the information needed for oauth instead of loading the entire toolet

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a ticket draft for this which I can tag you on for discussion. I don't think it's a matter of just storing it on the toolset. It interfaces with other OAuth concepts in pretty complicated ways, so I was hoping to peel off the actual abstraction work into a separately reviewed PR

Token: externalSecret.Token,
})
}
case toolset.McpIsPublic && hasExternalMCPOAuth:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already thought all this oauth stuff needed to be factored out of this method even before this addition. Let's figure out a way to encapsulate the oauth logic elsewhere. At the very least make a ticket with a link to this method saying to refactor

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a ticket drafted for this. I need to wrap my head around the implications of Walker's recent changes if I'm going to have any hope of making a refactor here a net improvement

metrics.RecordMCPToolCall(ctx, toolset.OrganizationID, mcpURL, params.Name)
}

// Check if this is an external MCP tool call (format: "slug:toolname")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that refactor coming in a later pr

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version implemented here uses the tool name format. I'll follow up with another PR alongside support for instances.

Copy link
Contributor Author

@qstearns qstearns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few responses. Addressed some of the feedback here but also would prefer to keep some of this work scoped to linear tickets. Stuff like refactoring our oauth logic I think is just gonna require a slightly more holistic view of the system than what I want to bite off as the scope of this PR

}

// Pre-resolve external MCP OAuth config for use in switch
fullToolset, descErr := mv.DescribeToolset(ctx, s.logger, s.db, mv.ProjectID(toolset.ProjectID), mv.ToolsetSlug(toolset.Slug), &s.toolsetCache)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// TODO: Consider caching hasExternalMCPOAuth on the toolset record to avoid loading
// the full toolset on every request just to check OAuth requirements.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a ticket draft for this which I can tag you on for discussion. I don't think it's a matter of just storing it on the toolset. It interfaces with other OAuth concepts in pretty complicated ways, so I was hoping to peel off the actual abstraction work into a separately reviewed PR

Token: externalSecret.Token,
})
}
case toolset.McpIsPublic && hasExternalMCPOAuth:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a ticket drafted for this. I need to wrap my head around the implications of Walker's recent changes if I'm going to have any hope of making a refactor here a net improvement

metrics.RecordMCPToolCall(ctx, toolset.OrganizationID, mcpURL, params.Name)
}

// Check if this is an external MCP tool call (format: "slug:toolname")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version implemented here uses the tool name format. I'll follow up with another PR alongside support for instances.

}

// NewExternalMCPToolCallPlan creates a new Tool wrapping an ExternalMCPTool.
func NewExternalMCPToolCallPlan(tool *ToolDescriptor, plan *ExternalMCPToolCallPlan) *ToolCallPlan {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that there are some differences for sure:

for instance, the way you want to map input configuration to source configuration is way less complex for external MCP in that you don't have to worry about all the Header vs Body vs Param configuration options of open API + formatting options Open API supports. Also no notion of something like request method

@qstearns qstearns merged commit eadbcc2 into main Dec 18, 2025
24 checks passed
@qstearns qstearns deleted the external-mcp/5/list-and-call-tools branch December 18, 2025 00:35
@github-actions github-actions bot locked and limited conversation to collaborators Dec 18, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants