Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b8fb8b0
feat(mcp): support protocol version 2026-07-28
Laisky Aug 30, 2026
094c8d3
refactor(mcp): model modern and legacy protocol profiles
Laisky Aug 31, 2026
ba4b62b
feat(mcp): preserve complete tool and result wire contracts
Laisky Aug 31, 2026
6a9bd0b
feat(mcp): add bounded correlated response transport helpers
Laisky Aug 31, 2026
2b72d31
fix(mcp): harden modern client transport and fallback behavior
Laisky Aug 31, 2026
981f07f
fix(mcp): align legacy client negotiation and transport safety
Laisky Aug 31, 2026
7ffdf30
fix(mcp): enforce exact header encoding and numeric validation
Laisky Aug 31, 2026
bbd9616
feat(mcp): persist complete case-sensitive tool descriptors
Laisky Aug 31, 2026
c31bfc3
fix(mcp): replace synchronized tool catalogs atomically
Laisky Aug 31, 2026
12255c0
feat(mcp): synchronize lossless tool descriptors end to end
Laisky Aug 31, 2026
bdcae77
refactor(mcp): separate modern protocol ingress and legacy delegation
Laisky Aug 31, 2026
dd1daf3
feat(mcp): add lossless aggregate catalog and opaque pagination
Laisky Aug 31, 2026
5a76fde
feat(mcp): add exact routing and final-only MRTR billing
Laisky Aug 31, 2026
5c33471
feat(mcp): upgrade legacy server compatibility and modern-first proxying
Laisky Aug 31, 2026
bba1fa0
fix(mcp): restore schema header validation
Laisky Aug 31, 2026
23f712d
fix(mcp): restore legacy proxy integration contracts
Laisky Aug 31, 2026
a6f6e7a
fix(mcp): classify modern call errors and share billing
Laisky Aug 31, 2026
07dfd18
ci: export PR 387 source snapshot for validation
Laisky Aug 31, 2026
3cd90d2
ci: export Go 1.26.3 toolchain for PR validation
Laisky Aug 31, 2026
60fbf05
ci: export Go module cache for deterministic PR validation
Laisky Aug 31, 2026
3be4398
ci: stage deterministic PR 387 completion repair
Laisky Aug 31, 2026
07c1297
ci: run gated completion for PR 387
Laisky Aug 31, 2026
c27c7ec
ci: correct gated PR 387 repair execution
Laisky Aug 31, 2026
0b001d7
ci: rerun PR 387 completion with diagnostic capture
Laisky Aug 31, 2026
6afab76
ci: finalize and independently verify PR 387
Laisky Aug 31, 2026
f33fb88
ci: complete final PR 387 verification closure
Laisky Aug 31, 2026
d225f40
ci: add two-phase final verification for PR 387
Laisky Aug 31, 2026
0b35b66
chore: add temporary PR 387 verification oracle
Laisky Aug 31, 2026
6b2b936
chore: remove temporary PR 387 verification oracle
Laisky Aug 31, 2026
a4bec7c
ci: serialize final PR 387 completion and verification
Laisky Aug 31, 2026
22e394c
ci(mcp): finalize and verify PR 387 implementation
Laisky Aug 31, 2026
3684027
ci(mcp): enforce final client transport and negotiation checks
Laisky Aug 31, 2026
eddce84
ci(mcp): add one-time final acceptance gate
Laisky Aug 31, 2026
3947029
ci(mcp): finalize production transport call sites
Laisky Aug 31, 2026
f20cae3
ci: clean accidental probe issues and request final review
Laisky Aug 31, 2026
8272b2f
chore: remove one-time PR acceptance workflows
Laisky Aug 31, 2026
d9de33c
ci: export PR 387 source for review validation
Laisky Aug 31, 2026
05cba08
ci: validate PR 387 review fixes before publishing
Laisky Aug 31, 2026
1291bde
ci: repair verified PR 387 review payload
Laisky Aug 31, 2026
ab4befa
ci: push PR 387 reviewer fixes before full validation
Laisky Aug 31, 2026
53acb94
fix(mcp): address reviewer findings with behavior regressions
Laisky Aug 31, 2026
41a8070
fix(mcp): enforce credential transport policy
Laisky Sep 1, 2026
c4f6fa9
docs(mcp): clarify loopback credential boundary
Laisky Sep 1, 2026
e5bc263
fix(mcp): reuse prepared tool catalog for modern calls
Laisky Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions controller/mcp_call_latest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package controller

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

"github.com/Laisky/errors/v2"
gmw "github.com/Laisky/gin-middlewares/v7"
"github.com/gin-gonic/gin"

"github.com/Laisky/one-api/common/config"
"github.com/Laisky/one-api/model"
"github.com/Laisky/one-api/relay/mcp"
)

type modernMCPToolCallPlan struct {
userID int
serverByID map[int]*model.MCPServer
candidates []mcp.ToolCandidate
}

// executeModernMCPTool validates one modern call against its lossless descriptor and executes it.
//
// Parameters:
// - ctx: The request context controls database and upstream work.
// - c: The Gin context contains the authenticated user and request headers.
// - params: The call parameters contain the exact qualified tool name, arguments, signature, and optional MRTR state.
//
// Return values:
// - *mcp.CallToolResult: The normalized upstream result is returned on success.
// - error: A modern validation error or a wrapped routing, execution, or billing error is returned on failure.
func executeModernMCPTool(ctx context.Context, c *gin.Context, params modernMCPCallParams) (*mcp.CallToolResult, error) {
if params.Arguments == nil {
params.Arguments = map[string]any{}
}

plan, err := prepareModernMCPToolCall(c, params)
if err != nil {
return nil, &modernMCPValidationError{
Status: http.StatusOK,
Code: mcpErrInvalidParams,
Err: errors.Wrap(err, "resolve modern mcp tool descriptor"),
}
}
descriptor, err := resolveModernMCPToolDescriptor(ctx, c, params.Name, plan)
if err != nil {
return nil, &modernMCPValidationError{
Status: http.StatusOK,
Code: mcpErrInvalidParams,
Err: errors.Wrap(err, "resolve modern mcp tool descriptor"),
}
}
if err := mcp.ValidateToolArgumentHeaders(c.Request.Header, descriptor.InputSchema, params.Arguments); err != nil {
return nil, &modernMCPValidationError{
Status: http.StatusBadRequest,
Code: mcp.ErrorCodeHeaderMismatch,
Err: err,
}
}

result, err := executeModernMCPToolCall(ctx, c, params, plan)
if err != nil {
return nil, err
}
return mcp.NormalizeCallToolResult(result), nil
}

// prepareModernMCPToolCall loads and resolves one immutable candidate snapshot for validation and execution.
//
// Parameters:
// - c: The Gin context contains the authenticated user.
// - params: The call parameters contain the exact qualified tool name and optional signature.
//
// Return values:
// - *modernMCPToolCallPlan: The prepared user, server, and candidate snapshot.
// - error: A wrapped authentication, catalog, policy, or candidate error.
func prepareModernMCPToolCall(c *gin.Context, params modernMCPCallParams) (*modernMCPToolCallPlan, error) {
user, err := getUserFromContext(c)
if err != nil {
return nil, errors.Wrap(err, "get user from context")
}

serverLabel, toolName := splitToolName(params.Name)
if toolName == "" {
toolName = strings.TrimSpace(params.Name)
}
if toolName == "" {
return nil, errors.WithStack(errors.New("tool name is required"))
}

servers, serverByID, err := loadMCPCallServers(serverLabel)
if err != nil {
return nil, err
}
toolsByServer, err := loadMCPToolsByServer(servers)
if err != nil {
return nil, err
}

candidates, err := mcp.BuildToolCandidates(
servers,
toolsByServer,
nil,
user.MCPToolBlacklist,
[]string{toolName},
toolName,
params.Signature,
)
if err != nil {
return nil, errors.Wrapf(err, "build mcp tool candidates for %q", toolName)
}
candidates = filterExactMCPToolCandidates(candidates, toolName)
if len(candidates) == 0 {
return nil, errors.Errorf("no eligible MCP tool found for exact name %q", toolName)
}

return &modernMCPToolCallPlan{
userID: user.Id,
serverByID: serverByID,
candidates: candidates,
}, nil
}

// resolveModernMCPToolDescriptor restores the descriptor from the prepared candidate snapshot.
//
// Parameters:
// - ctx: The request context is used only by the compatibility fallback.
// - c: The Gin context is used only by the compatibility fallback.
// - name: The exact qualified wire name returned by tools/list.
// - plan: The prepared candidate snapshot; nil retains the catalog lookup fallback for isolated callers.
//
// Return values:
// - mcp.ToolDescriptor: The exact policy-filtered descriptor used by execution.
// - error: A wrapped descriptor or unknown-tool error.
func resolveModernMCPToolDescriptor(
ctx context.Context,
c *gin.Context,
name string,
plan *modernMCPToolCallPlan,
) (mcp.ToolDescriptor, error) {
if plan == nil {
return findModernMCPToolDescriptor(ctx, c, name)
}
for _, candidate := range plan.candidates {
if candidate.Tool == nil {
continue
}
qualifiedName := candidate.ServerLabel + "." + strings.TrimSpace(candidate.Tool.Name)
if qualifiedName != name {
continue
}
descriptor, err := descriptorForMCPTool(candidate.Tool)
if err != nil {
return mcp.ToolDescriptor{}, errors.Wrapf(
err,
"restore mcp descriptor for server %d tool %q",
candidate.ServerID,
candidate.Tool.Name,
)
}
descriptor.Name = qualifiedName
return descriptor, nil
}
return mcp.ToolDescriptor{}, errors.Errorf("no eligible MCP tool found for %q", name)
}

// callMCPToolForUserLatest routes one exact tool name across eligible servers and applies final-result billing.
//
// Parameters:
// - ctx: The request context controls database and upstream work.
// - c: The Gin context contains the authenticated user and request-scoped logger.
// - params: The call parameters contain the exact qualified tool name, arguments, signature, and optional MRTR state.
//
// Return values:
// - *mcp.CallToolResult: The normalized upstream result, including input_required intermediates, is returned on success.
// - error: A wrapped authentication, catalog, routing, execution, or billing error is returned on failure.
func callMCPToolForUserLatest(ctx context.Context, c *gin.Context, params modernMCPCallParams) (*mcp.CallToolResult, error) {
plan, err := prepareModernMCPToolCall(c, params)
if err != nil {
return nil, err
}
return executeModernMCPToolCall(ctx, c, params, plan)
}

// executeModernMCPToolCall executes one prepared modern tool call with fallback and final-result billing.
//
// Parameters:
// - ctx: The request context controls upstream work.
// - c: The Gin context contains the request-scoped logger.
// - params: The call parameters contain arguments and optional MRTR state.
// - plan: The immutable user, server, and candidate snapshot prepared for this request.
//
// Return values:
// - *mcp.CallToolResult: The normalized upstream result, including input_required intermediates, is returned on success.
// - error: A wrapped routing, execution, or billing error is returned on failure.
func executeModernMCPToolCall(
ctx context.Context,
c *gin.Context,
params modernMCPCallParams,
plan *modernMCPToolCallPlan,
) (*mcp.CallToolResult, error) {
if plan == nil {
return nil, errors.WithStack(errors.New("modern mcp tool call plan is nil"))
}

logger := gmw.GetLogger(c)
startedAt := time.Now() // Preserve the monotonic component for elapsed-time measurement.
selected, result, err := mcp.CallWithFallback(ctx, plan.candidates, func(ctx context.Context, candidate mcp.ToolCandidate) (*mcp.CallToolResult, error) {
server := plan.serverByID[candidate.ServerID]
if server == nil {
return nil, errors.WithStack(errors.New("mcp server not loaded"))
}
descriptor, err := descriptorForMCPTool(candidate.Tool)
if err != nil {
return nil, errors.Wrapf(err, "build descriptor for tool %q", candidate.Tool.Name)
}
client := mcp.NewStreamableHTTPClientWithLogger(
server,
nil,
time.Duration(config.MCPToolCallTimeoutSec)*time.Second,
logger,
)
callResult, err := client.CallToolLatestWithOptions(ctx, descriptor, params.Arguments, mcp.CallToolRequestOptions{
InputResponses: params.InputResponses,
RequestState: params.RequestState,
})
if err != nil {
return nil, errors.Wrapf(err, "call mcp tool %q on server %d", candidate.Tool.Name, candidate.ServerID)
}
return callResult, nil
})
if err != nil {
return nil, errors.Wrap(err, "call mcp tool with fallback")
}

result = mcp.NormalizeCallToolResult(result)
if !shouldBillMCPToolResult(result) {
return result, nil
}
if err := chargeAndRecordMCPToolCall(ctx, c, plan.userID, plan.serverByID, selected, startedAt); err != nil {
return nil, err
}
return result, nil
}

// filterExactMCPToolCandidates removes candidates that only matched case-insensitive policy normalization.
//
// Parameters:
// - candidates: The policy-filtered candidates come from the shared registry.
// - exactName: The exactName value is the case-sensitive upstream wire name requested by the client.
//
// Return values:
// - []mcp.ToolCandidate: The returned candidates have stored wire names that exactly equal exactName.
func filterExactMCPToolCandidates(candidates []mcp.ToolCandidate, exactName string) []mcp.ToolCandidate {
filtered := make([]mcp.ToolCandidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Tool == nil || strings.TrimSpace(candidate.Tool.Name) != exactName {
continue
}
filtered = append(filtered, candidate)
}
return filtered
}

// shouldBillMCPToolResult reports whether one result represents a successful completed logical call.
//
// Parameters:
// - result: The normalized upstream result is inspected for final completion.
//
// Return values:
// - bool: True is returned only for non-error results whose resultType is complete.
func shouldBillMCPToolResult(result *mcp.CallToolResult) bool {
return result != nil && !result.IsError && result.ResultType == mcp.ResultTypeComplete
}
38 changes: 38 additions & 0 deletions controller/mcp_call_latest_catalog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package controller

import (
"context"
"testing"

"github.com/stretchr/testify/require"
"gorm.io/gorm"

"github.com/Laisky/one-api/model"
)

func TestExecuteModernMCPToolReusesPreparedCatalog(t *testing.T) {
cleanup, fixture := setupMCPProxyTest(t)
defer cleanup()

toolQueries := 0
const callbackName = "test:modern-mcp-tool-call-reuses-prepared-catalog"
err := model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx != nil && tx.Statement != nil && tx.Statement.Table == "mcp_tools" {
toolQueries++
}
})
require.NoError(t, err)
defer func() {
require.NoError(t, model.DB.Callback().Query().Remove(callbackName))
}()

c, _ := newMCPCallContext(t, fixture.user.Id, "modern-catalog-reuse")
result, err := executeModernMCPTool(context.Background(), c, modernMCPCallParams{
Name: "fake-mcp.echo",
Arguments: map[string]any{"message": "hello"},
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, fixture.upstreamHits)
require.Equal(t, 1, toolQueries, "modern tools/call must reuse one prepared tool-catalog snapshot")
}
Loading
Loading