You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The tools table on main has accumulated 45 columns (per mcpgateway/db.py:3166+), three distinct families of provider-specific config baked in (REST/MCP/A2A), one gateway_id foreign key for federated MCP, and is about to gain grpc_service_id via #3202. Several other columns are stored but never queried.
Before kicking off the broader MCP services refactor, we should agree on a single typing strategy for tool variants โ applied consistently in both the Python ORM and the Rust runtime โ so the next round of work doesn't bake in another N provider-specific FKs and dispatch branches.
This is a design/scoping issue, not an implementation issue. Goal: choose one of the patterns below, document it, then file follow-up issues for the migration.
request_type=POST, annotations.a2a_agent_id, annotations.a2a_agent_type, auth_type, auth_value (the agent reference is currently smuggled through the annotations JSON column)
Provider-specific columns leak in via JSON keys because authors don't want to add yet another column. annotations.a2a_agent_id and input_schema.x-grpc-input-type are both load-bearing fields hidden inside JSON blobs the schema doesn't declare.
Many columns are stored but never queried in mcpgateway/services/: created_by, created_from_ip, created_user_agent, import_batch_id, federation_source, _computed_name (per Tool.<col> / DbTool.<col> audit). Strong candidates to collapse into a JSON config blob.
No constraint that REST-only fields stay null on non-REST rows.expose_passthrough, allowlist, plugin_chain_pre, plugin_chain_post, jsonpath_filter, base_url, path_template, query_mapping, header_mapping, timeout_ms are all nullable on non-REST rows by accident, not by design.
integration_type is a string column with no enum. Dispatch is if tool_integration_type == "REST" style across multiple services โ easy to typo, easy to miss a branch when adding a provider.
Add-a-provider currently means: new column on tools, new mapped_column on Tool, new *_id FK to a new provider table, new elif in invoke_tool, new write path in the provider service, new fields on ToolCreate / ToolUpdate / ToolRead, new test fixtures, new mock attribute (tool.grpc_service_id=None) on every SimpleNamespace/MagicMock(spec=DbTool) in the test suite. PR feat(transport): add gRPC methods as MCP toolsย #3202 hit every one of these.
Cross-language: crates/mcp_runtime/src/lib.rs currently models tools as flat rows (McpToolDefinition:655-666, ResolvedMcpToolCallPlan:423-462, raw SQL at :5081-5142) over tokio-postgres โ no discriminated union, no trait hierarchy, no serde tag. So whatever Python picks, Rust either has to mirror it or the two implementations diverge structurally.
Why now: the tracking issue mentions "major refactoring of the MCP services" is imminent. Locking in a typing decision now prevents a third round of provider-specific FKs.
๐งญ Design options
Each pattern has a working code skeleton in SQLAlchemy 2.0 syntax, plus Mapped[] subtype fields where relevant.
Pros: 1 table, simple polymorphic queries, zero migration disruption (current schema already is the STI base table โ we'd add polymorphic_on to the existing column).
Cons: still wide & null-heavy; weak DB-level integrity for subtype fields; subtype-specific indexes are messy.
Pros: real DB integrity (NOT NULL where it matters); narrow tools core; new providers add a new tools_<kind> table without touching the others; polymorphic_load="selectin" prevents N+1.
Cons: every read joins; more migration work; crates/mcp_runtime raw SQL has to learn LEFT JOINs per kind.
Authoritative ref: SQLAlchemy selectin_polymorphic at lib/sqlalchemy/orm/mapper.py:212-220; in-the-wild example: rivenmedia/riven/src/program/media/item.py:761-770.
Cons: polymorphic queries become UNION-heavy; relationships (servers, metrics) are awkward; current foreign keys (server_tool_association.tool_id) need rework.
Authoritative ref: examples/inheritance/concrete.py:37-79 in SQLAlchemy.
Option D โ Flat core + provider_config JSONB blob (hybrid)
Pros: fastest schema evolution; new providers add zero DDL; matches what most adjacent OSS projects already do; eliminates the annotations.a2a_agent_id / input_schema.x-grpc-* smuggling.
Cons: weaker DB constraints; can't easily index inside JSONB without GIN; FK relationships (gateway_id, grpc_service_id) become "soft" string IDs unless we keep them as proper columns.
Adjacent OSS evidence:
arc53/DocsGPT/application/storage/db/models.py:70-80 โ tool config / requirements / actions all in JSONB
Haohao-end/openagent/api/internal/model/app.py:195-206 โ tools, graph, retrieval_config, long_term_memory all JSONB
langgenius/dify/api/core/provider_manager.py:59-69 โ typed ProviderConfigurations registry, no ORM polymorphism
BerriAI/litellm/litellm/utils.py:8015-8022 โ ProviderConfigManager keyed by LlmProviders enum
Option E โ Full normalization
classTool(Base): # generic onlyid, integration_type, name, ...
classRestToolDetails(Base):
tool_id: FK โ tools.id (PK)
base_url, request_type, ...
classMcpToolDetails(Base):
tool_id: FK โ tools.id (PK)
gateway_id: FK โ gateways.idclassGrpcToolDetails(Base):
tool_id: FK โ tools.id (PK)
grpc_service_id: FK โ grpc_services.id
Pros: strongest integrity, best for large/complex provider payloads, no NULL columns anywhere.
Cons: most joins, most tables, biggest migration; we'd be adding ~3-4 new tables on day one.
crates/mcp_runtime/src/lib.rs:5081-5142 โ raw SELECT โฆ FROM tools with no kind discrimination
No #[serde(tag = "...")] on tool types; the only tagged enum in the workspace is unrelated cache invalidation (crates/a2a_runtime/src/cache.rs:302-312)
If Python picks Option A or D, Rust can keep its flat row model with minor changes (add integration_type discriminator field, optional provider_config: serde_json::Value).
If Python picks Option B/C/E, Rust needs explicit per-kind structs and either UNION queries or JOINs.
The decision must be made jointly โ the contract surface is the wire/DB schema both runtimes hit.
โ Recommended path (for discussion)
Option D (Hybrid JSONB) for provider-specific config + Option B (JTI) only for the foreign-keyed providers.
Rationale:
Most provider-specific fields are stored, never queried โ perfect JSONB candidates (audit shows headers, auth_value, base_url, path_template, query_mapping, header_mapping, timeout_ms, expose_passthrough, allowlist, plugin_chain_*, jsonpath_filter are never used in WHERE/JOIN clauses in services/).
Real foreign keys (gateway_id, grpc_service_id, future A2A agent ref) belong as proper columns / JTI side tables for ON DELETE CASCADE and indexable joins.
Pydantic discriminated unions provide compile-time-ish safety on the API surface.
Rust mirrors cleanly: enum ProviderConfig { Rest{...}, Mcp{...}, ... } with #[serde(tag = "kind")] over a provider_config: serde_json::Value column.
Migration is incremental: ship the new provider_config JSONB column, double-write for one release, switch readers, drop legacy columns in a follow-up.
Open for debate. The point of this issue is to pick one before #3202 lands a fourth provider FK.
๐ Migration recipe (for the chosen option)
Add the discriminator/JSONB column nullable, ship it.
Backfill via Alembic data migration โ read from existing columns, write into the new shape, leave old columns in place.
Switch all readers to the new shape under a feature flag.
Once stable, drop the old columns.
Add a CI test that parses every integration_type enum case (catches missing dispatch branches).
use_existing_column=True is the SQLAlchemy escape hatch when multiple STI subclasses want the same column during the transition: lib/sqlalchemy/orm/mapper.py (test_inheritance.py:825-838 in SQLAlchemy repo).
๐ Related / prior art
This RFC overlaps with active work and should be coordinated, not duplicated:
This is intentionally a "design meeting in an issue" rather than a PR proposal. No code yet.
Audit data and adjacent-project survey are reproducible; happy to drop the exact gh search / grep invocations into a comment if reviewers want to verify.
๐ง Chore Summary
The
toolstable onmainhas accumulated 45 columns (permcpgateway/db.py:3166+), three distinct families of provider-specific config baked in (REST/MCP/A2A), onegateway_idforeign key for federated MCP, and is about to gaingrpc_service_idvia #3202. Several other columns are stored but never queried.Before kicking off the broader MCP services refactor, we should agree on a single typing strategy for tool variants โ applied consistently in both the Python ORM and the Rust runtime โ so the next round of work doesn't bake in another N provider-specific FKs and dispatch branches.
This is a design/scoping issue, not an implementation issue. Goal: choose one of the patterns below, document it, then file follow-up issues for the migration.
๐งฑ Area Affected
mcpgateway/db.py, services)crates/mcp_runtime/) โ cross-language paritymcpgateway/schemas.py)โ๏ธ Context / Why now
ContextForge has been growing one provider at a time and the
toolstable has paid the price each round:De-facto subtyping that already exists in code (per audit on
origin/main):integration_typetool_service.register_tool()(tool_service.py:1796,DbTool(...)at:1919)RESTurl,request_type,headers,auth_type,auth_value,base_url,path_template,query_mapping,header_mapping,timeout_ms,expose_passthrough,allowlist,plugin_chain_pre,plugin_chain_post,jsonpath_filtergateway_service._create_db_tool()(gateway_service.py:4562)MCPgateway_id(FK), gateway-derivedauth_type,auth_value,headers,request_type โ {SSE, STDIO, STREAMABLEHTTP}tool_service.create_tool_from_a2a_agent()(tool_service.py:6683)A2Arequest_type=POST,annotations.a2a_agent_id,annotations.a2a_agent_type,auth_type,auth_value(the agent reference is currently smuggled through theannotationsJSON column)grpc_service._sync_tools_from_reflection()gRPCgrpc_service_idFK,input_schema.x-grpc-input-type,input_schema.x-grpc-output-type,input_schema.x-grpc-client-streaming,input_schema.x-grpc-server-streaming(currently smuggled throughinput_schema)RESTinvoke_tool()dispatch tree (tool_service.py:4296+) already branches into::4804) โ readsurl,request_type,headers,auth_*,query_mapping,header_mapping,output_schema,jsonpath_filter:5077) โ readsrequest_typeas transport, gateway-derived auth/cert/passthrough:5708) โ readsa2a_agent_*fromtool_annotations:5811, after feat(transport): add gRPC methods as MCP toolsย #3202) โ readsgrpc_service_idSymptoms of the smell:
annotations.a2a_agent_idandinput_schema.x-grpc-input-typeare both load-bearing fields hidden inside JSON blobs the schema doesn't declare.mcpgateway/services/:created_by,created_from_ip,created_user_agent,import_batch_id,federation_source,_computed_name(perTool.<col>/DbTool.<col>audit). Strong candidates to collapse into a JSON config blob.expose_passthrough,allowlist,plugin_chain_pre,plugin_chain_post,jsonpath_filter,base_url,path_template,query_mapping,header_mapping,timeout_msare all nullable on non-REST rows by accident, not by design.integration_typeis a string column with no enum. Dispatch isif tool_integration_type == "REST"style across multiple services โ easy to typo, easy to miss a branch when adding a provider.tools, newmapped_columnonTool, new*_idFK to a new provider table, newelifininvoke_tool, new write path in the provider service, new fields onToolCreate/ToolUpdate/ToolRead, new test fixtures, new mock attribute (tool.grpc_service_id=None) on everySimpleNamespace/MagicMock(spec=DbTool)in the test suite. PR feat(transport): add gRPC methods as MCP toolsย #3202 hit every one of these.Cross-language:
crates/mcp_runtime/src/lib.rscurrently models tools as flat rows (McpToolDefinition:655-666,ResolvedMcpToolCallPlan:423-462, raw SQL at:5081-5142) overtokio-postgresโ no discriminated union, no trait hierarchy, no serde tag. So whatever Python picks, Rust either has to mirror it or the two implementations diverge structurally.Why now: the tracking issue mentions "major refactoring of the MCP services" is imminent. Locking in a typing decision now prevents a third round of provider-specific FKs.
๐งญ Design options
Each pattern has a working code skeleton in SQLAlchemy 2.0 syntax, plus
Mapped[]subtype fields where relevant.Option A โ Single Table Inheritance (STI)
polymorphic_onto the existing column).Authoritative ref:
lib/sqlalchemy/orm/mapper.py:212-220, official docs https://docs.sqlalchemy.org/en/20/orm/inheritance.htmlOption B โ Joined Table Inheritance (JTI)
toolscore; new providers add a newtools_<kind>table without touching the others;polymorphic_load="selectin"prevents N+1.crates/mcp_runtimeraw SQL has to learn LEFT JOINs per kind.Authoritative ref: SQLAlchemy
selectin_polymorphicatlib/sqlalchemy/orm/mapper.py:212-220; in-the-wild example:rivenmedia/riven/src/program/media/item.py:761-770.Option C โ Concrete Table Inheritance
servers,metrics) are awkward; current foreign keys (server_tool_association.tool_id) need rework.Authoritative ref:
examples/inheritance/concrete.py:37-79in SQLAlchemy.Option D โ Flat core +
provider_configJSONB blob (hybrid)with Pydantic discriminated unions on the Python edge:
annotations.a2a_agent_id/input_schema.x-grpc-*smuggling.gateway_id,grpc_service_id) become "soft" string IDs unless we keep them as proper columns.Adjacent OSS evidence:
arc53/DocsGPT/application/storage/db/models.py:70-80โ tool config / requirements / actions all in JSONBHaohao-end/openagent/api/internal/model/app.py:195-206โtools,graph,retrieval_config,long_term_memoryall JSONBlanggenius/dify/api/core/provider_manager.py:59-69โ typedProviderConfigurationsregistry, no ORM polymorphismBerriAI/litellm/litellm/utils.py:8015-8022โProviderConfigManagerkeyed byLlmProvidersenumOption E โ Full normalization
๐ Tradeoffs matrix
serde(tag)cleanly)๐ฆ Cross-language parity contract
The Rust runtime today:
crates/mcp_runtime/src/lib.rs:655-666โMcpToolDefinitionflat structcrates/mcp_runtime/src/lib.rs:5081-5142โ rawSELECT โฆ FROM toolswith no kind discrimination#[serde(tag = "...")]on tool types; the only tagged enum in the workspace is unrelated cache invalidation (crates/a2a_runtime/src/cache.rs:302-312)If Python picks Option A or D, Rust can keep its flat row model with minor changes (add
integration_typediscriminator field, optionalprovider_config: serde_json::Value).If Python picks Option B/C/E, Rust needs explicit per-kind structs and either UNION queries or JOINs.
The decision must be made jointly โ the contract surface is the wire/DB schema both runtimes hit.
โ Recommended path (for discussion)
Option D (Hybrid JSONB) for provider-specific config + Option B (JTI) only for the foreign-keyed providers.
Rationale:
headers,auth_value,base_url,path_template,query_mapping,header_mapping,timeout_ms,expose_passthrough,allowlist,plugin_chain_*,jsonpath_filterare never used in WHERE/JOIN clauses inservices/).gateway_id,grpc_service_id, future A2A agent ref) belong as proper columns / JTI side tables for ON DELETE CASCADE and indexable joins.enum ProviderConfig { Rest{...}, Mcp{...}, ... }with#[serde(tag = "kind")]over aprovider_config: serde_json::Valuecolumn.provider_configJSONB column, double-write for one release, switch readers, drop legacy columns in a follow-up.Open for debate. The point of this issue is to pick one before #3202 lands a fourth provider FK.
๐ Migration recipe (for the chosen option)
integration_typeenum case (catches missing dispatch branches).use_existing_column=Trueis the SQLAlchemy escape hatch when multiple STI subclasses want the same column during the transition:lib/sqlalchemy/orm/mapper.py(test_inheritance.py:825-838 in SQLAlchemy repo).๐ Related / prior art
This RFC overlaps with active work and should be coordinated, not duplicated:
[CHORE]: Unify tool-invocation pipeline around canonical ToolResult (Option B refactor)โ sameinvoke_tooldispatch tree this RFC reshapes[BUG][API]: gRPC service registration unusableโ driver of the newgrpc_service_idFK*_service_idcolumn unless this RFC lands first[PROPOSAL]: Plugin Framework Evolutionโ proposes normalized message/content abstractions[EPIC][PLUGIN]: Conditional Request Routing Pluginโ declarative dispatch over tools[EPIC][UI]: Import tools from OpenAPI/Swaggerโ explicitly maps specs toToolCreateandintegration_type="REST"[EPIC][PROTOCOL]: GraphQL-to-MCP translationโ yet another tool-generation pipeline[FEATURE]: Tool versioning with history and rollbackโ evidence the tool model is already on the roadmap๐ Acceptance criteria
docs/docs/architecture/doc is added recording the decision and rationalemcpgateway/db.pymodel changes, (c)crates/mcp_runtimemirror changes, (d) Pydantic schema changes, (e) test-fixture refactor (kills themock.grpc_service_id=Nonepaper-cut), (f) deprecation policy for legacy columns๐ Notes
gh search/grepinvocations into a comment if reviewers want to verify.