-
Notifications
You must be signed in to change notification settings - Fork 92
Enforce MCP tool name uniqueness per capset #513
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| package store | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "octobus/internal/domain" | ||
| ) | ||
|
|
||
| func TestMigrateReportsExistingMCPToolConflicts(t *testing.T) { | ||
| st, err := Open(":memory:") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| defer st.Close() | ||
| ctx := context.Background() | ||
| if err := st.UpsertService(ctx, domain.Service{ID: "echo", Name: "Echo"}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.UpsertInstance(ctx, domain.Instance{ID: "echo-instance", ServiceID: "echo", Name: "Echo", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.CreateCapset(ctx, domain.Capset{ID: "dev", Name: "Dev", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.AddCapsetInstance(ctx, domain.CapsetInstance{ID: "dev:echo-instance", CapsetID: "dev", ServiceID: "echo", InstanceID: "echo-instance", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.AddCapsetMethod(ctx, domain.CapsetMethod{CapsetInstanceID: "dev:echo-instance", MethodFullName: "echo.Echo/Call", MCPToolName: "shared_tool", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if _, err := st.DB().ExecContext(ctx, `DROP INDEX uq_capset_methods_mcp_tool_key`); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if _, err := st.DB().ExecContext(ctx, `INSERT INTO capset_methods (id, capset_instance_id, method_full_name, mcp_tool_name, mcp_tool_key, created_at, updated_at) VALUES (?, ?, ?, ?, '', ?, ?)`, "duplicate", "dev:echo-instance", "echo.Echo/Other", "shared_tool", "", ""); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.Migrate(ctx); err == nil || !strings.Contains(err.Error(), "conflicting methods") { | ||
| t.Fatalf("migration conflict error = %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestAddCapsetMethodEnforcesToolNameUniquenessWithinCapset(t *testing.T) { | ||
| st, err := Open(":memory:") | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| defer st.Close() | ||
| ctx := context.Background() | ||
|
|
||
| if err := st.UpsertService(ctx, domain.Service{ID: "echo", Name: "Echo"}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.UpsertInstance(ctx, domain.Instance{ID: "echo-instance", ServiceID: "echo", Name: "Echo", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| for _, capsetID := range []string{"dev", "qa"} { | ||
| if err := st.CreateCapset(ctx, domain.Capset{ID: capsetID, Name: capsetID, Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.AddCapsetInstance(ctx, domain.CapsetInstance{ID: capsetID + ":echo-instance", CapsetID: capsetID, ServiceID: "echo", InstanceID: "echo-instance", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| } | ||
| if err := st.UpsertInstance(ctx, domain.Instance{ID: "echo-copy", ServiceID: "echo", Name: "Echo Copy", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.AddCapsetInstance(ctx, domain.CapsetInstance{ID: "dev:echo-copy", CapsetID: "dev", ServiceID: "echo", InstanceID: "echo-copy", Enabled: true}); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| first := domain.CapsetMethod{CapsetInstanceID: "dev:echo-instance", MethodFullName: "echo.Echo/Call", MCPToolName: "shared_tool", Enabled: true} | ||
| if err := st.AddCapsetMethod(ctx, first); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if err := st.AddCapsetMethod(ctx, domain.CapsetMethod{CapsetInstanceID: "dev:echo-instance", MethodFullName: "echo.Echo/Other", MCPToolName: "shared_tool", Enabled: true}); !errors.Is(err, ErrMCPToolNameConflict) { | ||
| t.Fatalf("duplicate tool error = %v", err) | ||
| } | ||
| if err := st.AddCapsetMethod(ctx, domain.CapsetMethod{CapsetInstanceID: "dev:echo-copy", MethodFullName: "echo.Echo/Call", MCPToolName: "shared_tool", Enabled: true}); !errors.Is(err, ErrMCPToolNameConflict) { | ||
| t.Fatalf("cross-instance duplicate tool error = %v", err) | ||
| } | ||
| if err := st.AddCapsetMethod(ctx, domain.CapsetMethod{CapsetInstanceID: "qa:echo-instance", MethodFullName: "echo.Echo/Call", MCPToolName: "shared_tool", Enabled: true}); err != nil { | ||
| t.Fatalf("same tool in another capset error = %v", err) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,10 @@ type Store struct { | |
| db *sql.DB | ||
| } | ||
|
|
||
| var ErrMCPToolNameConflict = errors.New("MCP tool name conflict") | ||
|
|
||
| const mcpToolKeySeparator = "\x1f" | ||
|
|
||
| type ServiceInUseError struct { | ||
| ServiceID string | ||
| InstanceID string | ||
|
|
@@ -109,6 +113,27 @@ func (s *Store) Migrate(ctx context.Context) error { | |
| if err := addColumnIfMissing(ctx, s.db, "services", "service_root", "TEXT NOT NULL DEFAULT '.'"); err != nil { | ||
| return err | ||
| } | ||
| if err := addColumnIfMissing(ctx, s.db, "capset_methods", "mcp_tool_key", "TEXT NOT NULL DEFAULT ''"); err != nil { | ||
| return err | ||
| } | ||
| if _, err := s.db.ExecContext(ctx, `UPDATE capset_methods SET mcp_tool_key = ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 迁移在“上一版本已建实例级唯一索引且存在跨实例同名工具”的库上会先在回填 UPDATE 处失败,新增友好冲突检测不可达本变更的目的之一是在迁移阶段对同 capset 内跨实例重复 mcp_tool_name 的存量数据给出可操作的“remove or rename duplicates”指引(新增 127-133 行重复检测 + 新测试)。但上一版本(base)的迁移已创建实例级唯一索引 uq_capset_methods_mcp_tool_key,且其 key 前缀为 capset_instance_id,因此 base 代码允许同 capset 的不同实例写入同名 MCP 工具,即这类问题库必然同时带有该索引。升级时,新 Migrate 的回填 UPDATE(119-124 行)把两条记录都改写为 capset 级 key(capset_id + \x1f + name),在既有唯一索引仍在的情况下,第二条记录被更新成相同 key 时 UPDATE 语句即报 UNIQUE constraint failed 并整体回滚,返回原始 sqlite 错误,执行不到 127-133 行的友好重复检测。证据:新增测试 TestMigrateReportsExistingMCPToolConflicts 必须先执行 DROP INDEX uq_capset_methods_mcp_tool_key 才能让 Migrate 走到 "conflicting methods" 分支,恰好证明在真实升级路径(索引存在)下该分支不可达。因此历史问题“升级遇重复数据时服务无法启动且仅报原始 UNIQUE 错误、无清理指引”在主要升级场景下仍然残留,且新增的错误消息本身展示的是含 \x1f 控制符的内部 mcp_tool_key(如 %q 输出 capset_id\x1ftool_name),可操作性有限。 Problem code: Recommendation: Suggested diff: if err := addColumnIfMissing(ctx, s.db, "capset_methods", "mcp_tool_key", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
+ if _, err := s.db.ExecContext(ctx, `DROP INDEX IF EXISTS uq_capset_methods_mcp_tool_key`); err != nil {
+ return err
+ }
if _, err := s.db.ExecContext(ctx, `UPDATE capset_methods SET mcp_tool_key = (
SELECT ci.capset_id || char(31) || capset_methods.mcp_tool_name
FROM capset_instances ci
WHERE ci.id = capset_methods.capset_instance_id
)
WHERE mcp_tool_name <> ''`); err != nil {
return err
} |
||
| SELECT ci.capset_id || char(31) || capset_methods.mcp_tool_name | ||
| FROM capset_instances ci | ||
| WHERE ci.id = capset_methods.capset_instance_id | ||
| ) | ||
| WHERE mcp_tool_name <> ''`); err != nil { | ||
| return err | ||
| } | ||
| var duplicateKey string | ||
| var duplicateCount int | ||
| if err := s.db.QueryRowContext(ctx, `SELECT mcp_tool_key, COUNT(*) FROM capset_methods WHERE mcp_tool_key <> '' GROUP BY mcp_tool_key HAVING COUNT(*) > 1 LIMIT 1`).Scan(&duplicateKey, &duplicateCount); err != nil && !errors.Is(err, sql.ErrNoRows) { | ||
| return err | ||
| } else if err == nil { | ||
| return fmt.Errorf("MCP tool name %q has %d conflicting methods; remove or rename duplicates before restarting", duplicateKey, duplicateCount) | ||
| } | ||
| if _, err := s.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS uq_capset_methods_mcp_tool_key ON capset_methods(mcp_tool_key) WHERE mcp_tool_key <> ''`); err != nil { | ||
| return err | ||
|
monkeyscan[bot] marked this conversation as resolved.
|
||
| } | ||
| _, err := s.db.ExecContext(ctx, `UPDATE services SET runtime_mode = 'long-running' WHERE runtime_mode = ''`) | ||
| return err | ||
| } | ||
|
|
@@ -782,8 +807,36 @@ func (s *Store) AddCapsetMethod(ctx context.Context, method domain.CapsetMethod) | |
| now := time.Now().UTC() | ||
| method.CreatedAt = now | ||
| method.UpdatedAt = now | ||
| _, err := s.db.ExecContext(ctx, `INSERT INTO capset_methods (id, capset_instance_id, method_full_name, rest_alias, mcp_tool_name, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, method.ID, method.CapsetInstanceID, method.MethodFullName, method.RestAlias, method.MCPToolName, boolInt(method.Enabled), formatTime(method.CreatedAt), formatTime(method.UpdatedAt)) | ||
| return err | ||
|
|
||
| tx, err := s.db.BeginTx(ctx, nil) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer tx.Rollback() | ||
|
|
||
| toolKey := "" | ||
| if method.MCPToolName != "" { | ||
| var capsetID string | ||
| if err := tx.QueryRowContext(ctx, `SELECT capset_id FROM capset_instances WHERE id = ?`, method.CapsetInstanceID).Scan(&capsetID); err != nil { | ||
| return err | ||
| } | ||
| toolKey = capsetID + mcpToolKeySeparator + method.MCPToolName | ||
| } | ||
| _, err = tx.ExecContext(ctx, `INSERT INTO capset_methods (id, capset_instance_id, method_full_name, rest_alias, mcp_tool_name, mcp_tool_key, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, method.ID, method.CapsetInstanceID, method.MethodFullName, method.RestAlias, method.MCPToolName, toolKey, boolInt(method.Enabled), formatTime(method.CreatedAt), formatTime(method.UpdatedAt)) | ||
| if err != nil { | ||
| var existingID string | ||
| if idErr := tx.QueryRowContext(ctx, `SELECT id FROM capset_methods WHERE id = ?`, method.ID).Scan(&existingID); idErr == nil { | ||
| return err | ||
| } | ||
| if toolKey != "" { | ||
| var existingToolID string | ||
| if keyErr := tx.QueryRowContext(ctx, `SELECT id FROM capset_methods WHERE mcp_tool_key = ?`, toolKey).Scan(&existingToolID); keyErr == nil { | ||
| return ErrMCPToolNameConflict | ||
| } | ||
| } | ||
|
monkeyscan[bot] marked this conversation as resolved.
|
||
| return err | ||
| } | ||
| return tx.Commit() | ||
| } | ||
|
|
||
| func (s *Store) GetCapsetMethod(ctx context.Context, capsetInstanceID, methodFullName string) (domain.CapsetMethod, error) { | ||
|
|
@@ -1257,6 +1310,7 @@ CREATE TABLE IF NOT EXISTS capset_methods ( | |
| method_full_name TEXT NOT NULL, | ||
| rest_alias TEXT NOT NULL DEFAULT '', | ||
| mcp_tool_name TEXT NOT NULL DEFAULT '', | ||
| mcp_tool_key TEXT NOT NULL DEFAULT '', | ||
| enabled INTEGER NOT NULL DEFAULT 1, | ||
| created_at TEXT NOT NULL, | ||
| updated_at TEXT NOT NULL, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.