Skip to content

Commit bc88d59

Browse files
madhav165jonpspri
andauthored
fix: clean up server_tool_association before tool deletion (#4263)
* fix: clean up server_tool_association before tool deletion delete_tool() uses a Core SQL DELETE which bypasses ORM cascades. The server_tool_association FK to tools.id has no ondelete cascade, so deleting a tool that is associated with a virtual server (e.g. an A2A agent tool) raises sqlite3.IntegrityError. Explicitly delete server_tool_association rows for the tool before the tool row itself, matching the pattern already used in gateway_service. Closes #4261 Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * fix(tests): update tool delete test to expect association cleanup execute call delete_tool now calls db.execute twice: once for server_tool_association cleanup and once for the tool DELETE. The test assertion was stale after commit 5be84b4 added the association cleanup step. Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * docs(test): correct stale 'DELETE ... RETURNING' docstring in concurrency test test_concurrent_tool_delete_operations described an 'atomic DELETE ... RETURNING' implementation that no longer reflects the production code. delete_tool currently uses get_for_update(nowait=True) to acquire a row-level lock, then a Core-SQL DELETE with a rowcount check. Update the docstring so it accurately describes the two paths a losing concurrent caller can take (lock-conflict 409 or not-found 404). Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
1 parent 83d2142 commit bc88d59

6 files changed

Lines changed: 100 additions & 32 deletions

File tree

.secrets.baseline

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-26T18:36:21Z",
6+
"generated_at": "2026-04-26T22:08:03Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -8584,63 +8584,63 @@
85848584
"hashed_secret": "7b1552c7c7ffb8bd70b5666e5997c8e017630aab",
85858585
"is_secret": false,
85868586
"is_verified": false,
8587-
"line_number": 1981,
8587+
"line_number": 1984,
85888588
"type": "Base64 High Entropy String",
85898589
"verified_result": null
85908590
},
85918591
{
85928592
"hashed_secret": "9fb7fe1217aed442b04c0f5e43b5d5a7d3287097",
85938593
"is_secret": false,
85948594
"is_verified": false,
8595-
"line_number": 3318,
8595+
"line_number": 3321,
85968596
"type": "Secret Keyword",
85978597
"verified_result": null
85988598
},
85998599
{
86008600
"hashed_secret": "72cb70dbbafe97e5ea13ad88acd65d08389439b0",
86018601
"is_secret": false,
86028602
"is_verified": false,
8603-
"line_number": 3951,
8603+
"line_number": 3954,
86048604
"type": "Secret Keyword",
86058605
"verified_result": null
86068606
},
86078607
{
86088608
"hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc",
86098609
"is_secret": false,
86108610
"is_verified": false,
8611-
"line_number": 7307,
8611+
"line_number": 7310,
86128612
"type": "Secret Keyword",
86138613
"verified_result": null
86148614
},
86158615
{
86168616
"hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750",
86178617
"is_secret": false,
86188618
"is_verified": false,
8619-
"line_number": 7799,
8619+
"line_number": 7802,
86208620
"type": "Secret Keyword",
86218621
"verified_result": null
86228622
},
86238623
{
86248624
"hashed_secret": "4a249743d4d2241bd2ae085b4fe654d089488295",
86258625
"is_secret": false,
86268626
"is_verified": false,
8627-
"line_number": 9146,
8627+
"line_number": 9149,
86288628
"type": "Secret Keyword",
86298629
"verified_result": null
86308630
},
86318631
{
86328632
"hashed_secret": "0c8d051d3c7eada5d31b53d9936fce6bcc232ae2",
86338633
"is_secret": false,
86348634
"is_verified": false,
8635-
"line_number": 9288,
8635+
"line_number": 9291,
86368636
"type": "Secret Keyword",
86378637
"verified_result": null
86388638
},
86398639
{
86408640
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
86418641
"is_secret": false,
86428642
"is_verified": false,
8643-
"line_number": 9770,
8643+
"line_number": 9773,
86448644
"type": "Secret Keyword",
86458645
"verified_result": null
86468646
}
@@ -8674,71 +8674,71 @@
86748674
"hashed_secret": "0857abc2d90c5d9821229fc0a880f1c38ffb0e04",
86758675
"is_secret": false,
86768676
"is_verified": false,
8677-
"line_number": 4066,
8677+
"line_number": 4125,
86788678
"type": "Hex High Entropy String",
86798679
"verified_result": null
86808680
},
86818681
{
86828682
"hashed_secret": "99834bc4eff3f1e1c1e4692d2476b593b501d045",
86838683
"is_secret": false,
86848684
"is_verified": false,
8685-
"line_number": 6993,
8685+
"line_number": 7052,
86868686
"type": "Secret Keyword",
86878687
"verified_result": null
86888688
},
86898689
{
86908690
"hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511",
86918691
"is_secret": false,
86928692
"is_verified": false,
8693-
"line_number": 7011,
8693+
"line_number": 7070,
86948694
"type": "Secret Keyword",
86958695
"verified_result": null
86968696
},
86978697
{
86988698
"hashed_secret": "b8cec023ad982a1355abb9e7c7700e42d7e6fac3",
86998699
"is_secret": false,
87008700
"is_verified": false,
8701-
"line_number": 7035,
8701+
"line_number": 7094,
87028702
"type": "Secret Keyword",
87038703
"verified_result": null
87048704
},
87058705
{
87068706
"hashed_secret": "0614eb27c6e0d2f4ed9a1cce2a5bcbbbc17aa556",
87078707
"is_secret": false,
87088708
"is_verified": false,
8709-
"line_number": 7099,
8709+
"line_number": 7158,
87108710
"type": "Secret Keyword",
87118711
"verified_result": null
87128712
},
87138713
{
87148714
"hashed_secret": "a38fea79a043f0c9a62f851659d459dc3b716ea9",
87158715
"is_secret": false,
87168716
"is_verified": false,
8717-
"line_number": 7110,
8717+
"line_number": 7169,
87188718
"type": "Secret Keyword",
87198719
"verified_result": null
87208720
},
87218721
{
87228722
"hashed_secret": "a50da1fb101595ac5158f2c9394a65a84061b56c",
87238723
"is_secret": false,
87248724
"is_verified": false,
8725-
"line_number": 7151,
8725+
"line_number": 7210,
87268726
"type": "Secret Keyword",
87278727
"verified_result": null
87288728
},
87298729
{
87308730
"hashed_secret": "ed3e0017cb8e4b06a59af1a441f62cbe58d2ef59",
87318731
"is_secret": false,
87328732
"is_verified": false,
8733-
"line_number": 7157,
8733+
"line_number": 7216,
87348734
"type": "Secret Keyword",
87358735
"verified_result": null
87368736
},
87378737
{
87388738
"hashed_secret": "9bdc408babb4c5740aa9ee42e65b9308bd01f5f9",
87398739
"is_secret": false,
87408740
"is_verified": false,
8741-
"line_number": 8351,
8741+
"line_number": 8410,
87428742
"type": "Secret Keyword",
87438743
"verified_result": null
87448744
}

mcpgateway/services/tool_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3249,6 +3249,11 @@ async def delete_tool(self, db: Session, tool_id: str, user_email: Optional[str]
32493249
delete_metrics_in_batches(db, ToolMetric, ToolMetric.tool_id, tool_id)
32503250
delete_metrics_in_batches(db, ToolMetricsHourly, ToolMetricsHourly.tool_id, tool_id)
32513251

3252+
# Clean up server_tool_association rows referencing this tool.
3253+
# The association table FK has no ondelete cascade, so rows must
3254+
# be removed explicitly before the tool row can be deleted.
3255+
db.execute(delete(server_tool_association).where(server_tool_association.c.tool_id == tool_id))
3256+
32523257
# Use DELETE with rowcount check for database-agnostic atomic delete
32533258
stmt = delete(DbTool).where(DbTool.id == tool_id)
32543259
result = db.execute(stmt)

tests/integration/test_concurrency_row_locking.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -524,11 +524,12 @@ async def update_description():
524524
@pytest.mark.asyncio
525525
@SKIP_IF_NOT_POSTGRES
526526
async def test_concurrent_tool_delete_operations(client: AsyncClient):
527-
"""Test concurrent delete operations with atomic DELETE ... RETURNING.
527+
"""Test that concurrent deletes of the same tool don't corrupt state.
528528
529-
With the atomic DELETE ... RETURNING implementation, exactly one delete should
530-
succeed. All other concurrent deletes will find no row to delete and should
531-
return an error in the redirect URL.
529+
delete_tool uses a Core-SQL DELETE with a rowcount check: exactly one
530+
caller sees rowcount == 1 (success) while the others see rowcount == 0
531+
and raise ToolNotFoundError, which the admin endpoint surfaces as an
532+
error in the redirect URL.
532533
533534
Note: The admin endpoint always returns 303 redirects, so we check the
534535
redirect URL for error messages to distinguish success from failure.

tests/unit/mcpgateway/services/test_resource_ownership.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,8 @@ async def test_delete_tool_owner_success(self, tool_service, mock_db_session):
285285

286286
await tool_service.delete_tool(mock_db_session, "tool-1", user_email="owner@example.com")
287287

288-
# Verify execute was called for DELETE ... RETURNING
289-
mock_db_session.execute.assert_called_once()
288+
# Verify execute was called for association cleanup + tool DELETE
289+
assert mock_db_session.execute.call_count == 2
290290
mock_db_session.commit.assert_called_once()
291291

292292
@pytest.mark.asyncio

tests/unit/mcpgateway/services/test_tool_service.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,8 +1530,8 @@ async def test_delete_tool(self, tool_service, mock_tool, test_db):
15301530

15311531
# Verify DB operations
15321532
test_db.get.assert_called_once_with(DbTool, 1)
1533-
# Verify execute was called for DELETE ... RETURNING
1534-
test_db.execute.assert_called_once()
1533+
# Verify execute was called for server_tool_association cleanup + DELETE
1534+
assert test_db.execute.call_count == 2
15351535
test_db.commit.assert_called_once()
15361536

15371537
# Verify notification
@@ -1544,19 +1544,22 @@ async def test_delete_tool_purge_metrics(self, tool_service, mock_tool, test_db)
15441544
test_db.commit = Mock()
15451545
test_db.rollback = Mock()
15461546

1547-
# Mock execute results: batch deletes return rowcount=0 to stop loop, final DELETE returns rowcount=1
1547+
# Mock execute results: batch deletes return rowcount=0 to stop loop,
1548+
# association cleanup returns a result, final DELETE returns rowcount=1
15481549
batch_result = Mock()
15491550
batch_result.rowcount = 0 # No rows to delete (stops the batch loop)
1551+
assoc_result = Mock()
1552+
assoc_result.rowcount = 0 # No server_tool_association rows
15501553
delete_result = Mock()
15511554
delete_result.rowcount = 1 # Final DELETE succeeded
1552-
test_db.execute = Mock(side_effect=[batch_result, batch_result, delete_result])
1555+
test_db.execute = Mock(side_effect=[batch_result, batch_result, assoc_result, delete_result])
15531556

15541557
tool_service._notify_tool_deleted = AsyncMock()
15551558

15561559
await tool_service.delete_tool(test_db, 1, purge_metrics=True)
15571560

1558-
# Verify execute was called: 1 for ToolMetric + 1 for ToolMetricsHourly + 1 for DELETE = 3
1559-
assert test_db.execute.call_count == 3
1561+
# Verify execute was called: 1 for ToolMetric + 1 for ToolMetricsHourly + 1 for association cleanup + 1 for DELETE = 4
1562+
assert test_db.execute.call_count == 4
15601563
test_db.commit.assert_called_once()
15611564

15621565
@pytest.mark.asyncio

tests/unit/mcpgateway/services/test_tool_service_coverage.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3979,6 +3979,65 @@ async def test_delete_with_purge_metrics(self, tool_service):
39793979
assert mock_delete.call_count == 2 # ToolMetric + ToolMetricsHourly
39803980

39813981

3982+
class TestDeleteToolServerAssociationCleanup:
3983+
"""Tests for delete_tool cleaning up server_tool_association rows (issue #4261)."""
3984+
3985+
@pytest.fixture
3986+
def tool_service(self):
3987+
service = ToolService()
3988+
service._http_client = AsyncMock()
3989+
service._event_service = AsyncMock()
3990+
return service
3991+
3992+
@pytest.mark.asyncio
3993+
async def test_delete_tool_cleans_server_tool_association(self, tool_service):
3994+
"""delete_tool must remove server_tool_association rows before deleting the tool."""
3995+
db = MagicMock()
3996+
mock_tool = MagicMock(spec=DbTool)
3997+
mock_tool.id = "tool-1"
3998+
mock_tool.name = "a2a_test_agent"
3999+
mock_tool.url = "http://example.com"
4000+
mock_tool.tags = ["a2a", "agent"]
4001+
mock_tool.team_id = None
4002+
mock_tool.gateway_id = None
4003+
4004+
db.get.return_value = mock_tool
4005+
4006+
# Track execute calls to verify ordering
4007+
execute_calls = []
4008+
assoc_result = MagicMock()
4009+
assoc_result.rowcount = 1 # One association row removed
4010+
delete_result = MagicMock()
4011+
delete_result.rowcount = 1
4012+
4013+
def track_execute(stmt):
4014+
execute_calls.append(str(stmt))
4015+
# First call is association cleanup, second is tool DELETE
4016+
if len(execute_calls) == 1:
4017+
return assoc_result
4018+
return delete_result
4019+
4020+
db.execute.side_effect = track_execute
4021+
4022+
mock_admin_cache = MagicMock()
4023+
mock_admin_cache.invalidate_tags = AsyncMock()
4024+
mock_metrics_cache = MagicMock()
4025+
4026+
with (
4027+
patch("mcpgateway.services.tool_service._get_registry_cache") as mock_rc,
4028+
patch("mcpgateway.services.tool_service._get_tool_lookup_cache") as mock_tlc,
4029+
patch("mcpgateway.cache.admin_stats_cache.admin_stats_cache", mock_admin_cache),
4030+
patch("mcpgateway.cache.metrics_cache.metrics_cache", mock_metrics_cache),
4031+
):
4032+
mock_rc.return_value = AsyncMock()
4033+
mock_tlc.return_value = AsyncMock()
4034+
await tool_service.delete_tool(db, "tool-1")
4035+
4036+
# Must have 2 execute calls: association cleanup then tool DELETE
4037+
assert db.execute.call_count == 2
4038+
db.commit.assert_called()
4039+
4040+
39824041
# ============================================================================
39834042
# convert_tool_to_read with metrics
39844043
# ============================================================================
@@ -6576,7 +6635,7 @@ async def fake_get(*a, **kw):
65766635
# Positional fallback: the `success` argument is the 3rd positional
65776636
# after (tool_id, start_time, success) in the recorder signature.
65786637
recorded_success = metrics_record.call_args.args[2]
6579-
assert recorded_success is False, f"Expected metrics success=False for REST isError=true response, got {recorded_success}. " "This regression would silently inflate tool success rates."
6638+
assert recorded_success is False, f"Expected metrics success=False for REST isError=true response, got {recorded_success}. This regression would silently inflate tool success rates."
65806639

65816640
@pytest.mark.asyncio
65826641
async def test_mcp_non_direct_proxy_iserror_records_success_false_in_metrics(self, tool_service):
@@ -6688,7 +6747,7 @@ async def __aexit__(self, *exc):
66886747
assert metrics_record.called, "record_tool_metric was not invoked"
66896748
recorded_success = metrics_record.call_args.kwargs.get("success")
66906749
assert recorded_success is False, (
6691-
f"Expected metrics success=False for MCP non-direct-proxy isError=true response, got {recorded_success}. " "This would silently inflate federated-tool success rates."
6750+
f"Expected metrics success=False for MCP non-direct-proxy isError=true response, got {recorded_success}. This would silently inflate federated-tool success rates."
66926751
)
66936752

66946753

0 commit comments

Comments
 (0)