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
Replace composer.SDKElicitationRequester — which leaks mcp-go's mcp.ElicitationRequest/mcp.ElicitationResult across what is about to become the domain boundary — with a domain-typed ElicitationRequester interface plus ElicitationRequest/ElicitationResult value types that carry no mcp-go types. This is bounded rewrite R4: the core (added in #5437) drives composite-tool elicitation steps through this domain seam, and all mcp-go translation is confined to the adapter in pkg/vmcp/server. Without this, anti-pattern #5 (SDK coupling leaking through abstractions) would cross the new VMCP boundary the moment New(cfg) -> VMCP is introduced.
Context
The composer.DefaultElicitationHandler already isolates elicitation behind an injected SDKElicitationRequester, but that interface's method signature uses mcp-go types directly (elicitation_handler.go:80-84). The architecture phase identified this as risk R4: it must become a domain-typed interface, translated to/from mcp-go only inside the existing sdkElicitationAdapter (server/sdk_elicitation_adapter.go:46). This is one of the two bounded rewrites in Phase 1 (the other is the admission seam, R1/#5438).
This is a one-to-one field mirror of the mcp-go types the composer already uses — not a redesign of elicitation semantics. URL-mode fields (ElicitationID/URL/Mode) are deliberately omitted because the composer does not construct them today (elicitation_handler.go:168-173 builds form-mode requests only). The composer's DefaultElicitationHandler (elicitation_handler.go:111) keeps all of its security validation (timeout cap, schema size/depth, content size — elicitation_handler.go:142-197) unchanged; only the underlying requester type changes from mcp-go to the domain type.
See architecture.md, section "ElicitationRequester boundary (R4 — domain-typed, bounded rewrite)" (lines ~200-241), including the one-to-one mcp-go field-mapping table.
Parent Story: #<#5430> Dependencies: None (root task; can start immediately) Blocks: #5437 (New(cfg) -> VMCP core constructor — the core depends on the domain ElicitationRequester)
Acceptance Criteria
A domain-typed ElicitationRequester interface plus ElicitationRequest/ElicitationResult value types exist (in pkg/vmcp), exposing no mcp-go types across the boundary.
composer.SDKElicitationRequester references are replaced by the domain ElicitationRequester; DefaultElicitationHandler constructs the domain ElicitationRequest (no mcp.ElicitationRequest) and reads the domain ElicitationResult (no mcp.ElicitationResult).
All mcp-go ⇄ domain translation is confined to server/sdk_elicitation_adapter.go; package composer no longer imports github.com/mark3labs/mcp-go/mcp for the elicitation path.
Field mapping is one-to-one with mcp-go v0.54.0 per the table below; no URL-mode fields (ElicitationID/URL/Mode) are added to the domain types.
The composer's security validation (timeout cap, schema size/depth, content size) and the result.Contentany → map[string]any assertion (elicitation_handler.go:204) behave exactly as before.
R4 elicitation parity tests pass (see Testing Strategy): accept (content map round-trips), decline, cancel, timeout — byte-for-byte equivalent through the new domain requester + translator vs. the prior mcp-go path; security validation still fires.
PR is ≤ 400 LOC and ≤ 10 files changed (excluding tests/docs/generated).
server.New signature and observable behavior unchanged.
All tests pass (task test); lint clean (task lint-fix); mocks regenerated (task gen) for the renamed/retyped interface.
Code reviewed and approved.
Technical Approach
Recommended Implementation
Define the domain types in pkg/vmcp (e.g. pkg/vmcp/elicitation.go): the ElicitationRequester interface and the ElicitationRequest/ElicitationResult value structs, with no mcp-go imports. Mirror the mcp-go fields the composer actually uses, one-to-one (see Component Interfaces).
Retype the composer seam: change DefaultElicitationHandler.sdkRequester and NewDefaultElicitationHandler's parameter from composer.SDKElicitationRequester to the domain vmcp.ElicitationRequester. Inside DefaultElicitationHandler.RequestElicitation, build a domain ElicitationRequest{Message, RequestedSchema} (currently mcp.ElicitationRequest{Params: mcp.ElicitationParams{Message, RequestedSchema}} at elicitation_handler.go:168-173) and read the domain ElicitationResult{Action, Content} (currently *mcp.ElicitationResult at elicitation_handler.go:179). All validation between (142-197) and the Contentany → map[string]any assertion (204) stay byte-for-byte. Remove the now-unused github.com/mark3labs/mcp-go/mcp import from this file. Delete or relocate the old SDKElicitationRequester interface.
Make the adapter the sole translator: change sdkElicitationAdapter / NewSDKElicitationAdapter (server/sdk_elicitation_adapter.go:46) to satisfy the domain vmcp.ElicitationRequester. Its RequestElicitation maps the domain ElicitationRequest → mcp.ElicitationRequest{Params: mcp.ElicitationParams{...}}, calls a.mcpServer.RequestElicitation(ctx, mcpReq), then maps the returned *mcp.ElicitationResult → domain *ElicitationResult (string(resp.Action), resp.Content pass-through as any). This is the only place mcp-go elicitation types appear.
Regenerate mocks: the //go:generate mockgen ... SDKElicitationRequester directive (elicitation_handler.go:7) must be updated to target the domain interface (and moved to wherever the interface now lives). Run task gen.
Keep the rewrite minimal — relocate the type boundary, do not change request/response semantics, ordering, defaults, or error transforms.
Patterns & Frameworks
mcp-go SDK boundary: mcp-go (github.com/mark3labs/mcp-go/mcp) elicitation types must appear only in server/sdk_elicitation_adapter.go — the sanctioned adapter layer. Eases vmcp anti-pattern Implement secret injection #5 (SDK coupling leaking through abstractions) on this path.
Avoid parallel types that drift (.claude/rules/go-style.md, Interface Design): the domain types are an intentional one-to-one mirror of the mcp-go shapes the composer uses; keep them minimal so the single translation point in the adapter stays trivial. Do not add fields the composer does not construct.
Copy before mutating caller input (.claude/rules/go-style.md): RequestedSchema/Meta/Content are reference types passed across the seam; the translator must not mutate caller-provided maps in place.
Conventions: .claude/rules/go-style.md (SPDX headers, error wrapping with %w, no SDK leak), .claude/rules/vmcp-anti-patterns.md (Implement secret injection #5, Start simple hardcoded registry #8 — do not add interface methods beyond RequestElicitation).
Code Pointers
pkg/vmcp/elicitation.go(new — or co-located in pkg/vmcp) — domain ElicitationRequester interface + ElicitationRequest/ElicitationResult value types (architecture.md lines 200-241). No mcp-go imports.
pkg/vmcp/composer/elicitation_handler.go:80 — SDKElicitationRequester interface (leaks mcp.ElicitationRequest/mcp.ElicitationResult) being replaced by the domain type.
:111 — NewDefaultElicitationHandler / DefaultElicitationHandler.sdkRequester field: retype parameter to the domain ElicitationRequester.
:142-197 — security validation (timeout cap to maxElicitationTimeout, validateSchemaSize, validateContentSize, validateContentDepth): unchanged.
:168-173 — currently constructs mcp.ElicitationRequest{Params: mcp.ElicitationParams{Message, RequestedSchema}} (form-mode only; URL-mode fields not constructed) → becomes a domain ElicitationRequest.
:204 — result.Content.(map[string]any) assertion (the any → map conversion of the response): unchanged; the domain ElicitationResult.Content is still any to preserve this exact behavior.
:7 — //go:generate mockgen ... SDKElicitationRequester directive: retarget to the domain interface.
pkg/vmcp/server/sdk_elicitation_adapter.go — the sdkElicitationAdapterstruct (:29) and its constructor NewSDKElicitationAdapter (:46) become the sole domain ⇄ mcp-go translator (currently :69-84 calls a.mcpServer.RequestElicitation(ctx, request)).
pkg/vmcp/composer/composer.go:435 — ElicitationProtocolHandler interface and :457ElicitationResponse (the composer's existing public domain return type): not changed by this task; they already use plain Go types. (Confirms the domain-typing approach is consistent with existing composer API.)
mcp-go v0.54.0 mcp/types.go — ElicitationParams (:1006), ElicitationResponse (:1058): the field source of truth for the mapping table.
Component Interfaces
Domain types live in pkg/vmcp and import no mcp-go types:
// In pkg/vmcp (e.g. elicitation.go). No mcp-go imports.// ElicitationRequester sends an elicitation request to the client and blocks// for the response. Implemented by the transport adapter (sdkElicitationAdapter),// which is the sole point that translates to/from mcp-go.typeElicitationRequesterinterface {
RequestElicitation(ctx context.Context, reqElicitationRequest) (*ElicitationResult, error)
}
// ElicitationRequest is the domain-typed elicitation request (form-mode only;// mirrors the mcp-go fields the composer constructs today).typeElicitationRequeststruct {
Messagestring// -> mcp.ElicitationParams.MessageRequestedSchemaany// -> mcp.ElicitationParams.RequestedSchema (JSON Schema; pass-through)Metamap[string]any// -> mcp.ElicitationParams.Meta (optional; nil today)
}
// ElicitationResult is the domain-typed elicitation response.// Content stays `any` so the composer's existing map assertion// (elicitation_handler.go:204) and security validation remain byte-for-byte.typeElicitationResultstruct {
Actionstring// <- string(mcp.ElicitationResponse.Action): "accept"|"decline"|"cancel"Contentany// <- mcp.ElicitationResponse.Content (any; asserted to map[string]any by the composer)
}
Field mapping (translation lives ONLY in server/sdk_elicitation_adapter.go):
Domain field
mcp-go equivalent (v0.54.0, mcp/types.go)
Notes
ElicitationRequest.Message
ElicitationParams.Message (:1011)
direct
ElicitationRequest.RequestedSchema
ElicitationParams.RequestedSchema (:1016)
any JSON Schema, pass-through
ElicitationRequest.Meta
ElicitationParams.Meta (*Meta, :1007)
optional; composer sets none today
ElicitationResult.Action
ElicitationResponse.Action (:1061)
string(...); "accept"/"decline"/"cancel"
ElicitationResult.Content
ElicitationResponse.Content (:1064)
any → composer asserts to map[string]any (elicitation_handler.go:204)
URL-mode fields ElicitationParams.Mode (:1009), ElicitationID (:1021), URL (:1023) are deliberately omitted from the domain types — the composer never constructs them (elicitation_handler.go:168-173). Adding them would exceed the bounded-rewrite scope.
Testing Strategy
Unit Tests
Adapter translation round-trip: domain ElicitationRequest{Message, RequestedSchema} maps to the equivalent mcp.ElicitationParams; mcp.ElicitationResult{Action, Content} maps back to the domain ElicitationResult with Action string-converted and Content preserved.
DefaultElicitationHandler now consumes the domain ElicitationRequester (via regenerated mock) and produces an unchanged composer.ElicitationResponse.
No mcp-go import remains in package composer for the elicitation path (grep/task lint guard); mcp-go elicitation types appear only in server/sdk_elicitation_adapter.go.
Composite-tool workflow with an elicitation step runs through the new domain ElicitationRequester + adapter translator and is byte-for-byte equivalent to the prior SDKElicitationRequester path for each action:
accept — response Content map (map[string]any) round-trips identically into the workflow (the :204 assertion still yields the same map); workflow continues with the same templated values.
decline — yields the same ErrElicitationDeclined/decline handling as before.
cancel — yields the same ErrElicitationCancelled/cancel handling as before.
timeout — context.DeadlineExceeded from the adapter still surfaces as ErrElicitationTimeout (elicitation_handler.go:182-185).
Security validation still fires through the domain seam (unchanged behavior):
schema size over maxSchemaSize (100KB) → ErrSchemaTooLarge.
schema depth over maxSchemaDepth (10) → ErrSchemaTooDeep.
response content size over maxResponseContentSize (1MB) → ErrContentTooLarge.
timeout cap: a configured timeout over maxElicitationTimeout (10min) is capped (warn-logged), not honored verbatim.
Edge Cases
result.Content that is not a map[string]any (non-map any) is handled exactly as today — warn-logged, treated as nil content (elicitation_handler.go:205-211).
Meta is nil on the domain request (composer sets none today) and the adapter produces a request with no _meta — no behavior change.
Accept action with nil Content passes content validation (no-op) as before.
Out of Scope
Adding URL-mode elicitation (ElicitationID/URL/Mode) or any new elicitation semantics — explicitly excluded; one-to-one mirror only.
Changing the security validation logic, defaults, timeouts, or error types in DefaultElicitationHandler.
Description
Replace
composer.SDKElicitationRequester— which leaks mcp-go'smcp.ElicitationRequest/mcp.ElicitationResultacross what is about to become the domain boundary — with a domain-typedElicitationRequesterinterface plusElicitationRequest/ElicitationResultvalue types that carry no mcp-go types. This is bounded rewrite R4: the core (added in #5437) drives composite-tool elicitation steps through this domain seam, and all mcp-go translation is confined to the adapter inpkg/vmcp/server. Without this, anti-pattern #5 (SDK coupling leaking through abstractions) would cross the newVMCPboundary the momentNew(cfg) -> VMCPis introduced.Context
The
composer.DefaultElicitationHandleralready isolates elicitation behind an injectedSDKElicitationRequester, but that interface's method signature uses mcp-go types directly (elicitation_handler.go:80-84). The architecture phase identified this as risk R4: it must become a domain-typed interface, translated to/from mcp-go only inside the existingsdkElicitationAdapter(server/sdk_elicitation_adapter.go:46). This is one of the two bounded rewrites in Phase 1 (the other is the admission seam, R1/#5438).This is a one-to-one field mirror of the mcp-go types the composer already uses — not a redesign of elicitation semantics. URL-mode fields (
ElicitationID/URL/Mode) are deliberately omitted because the composer does not construct them today (elicitation_handler.go:168-173builds form-mode requests only). The composer'sDefaultElicitationHandler(elicitation_handler.go:111) keeps all of its security validation (timeout cap, schema size/depth, content size —elicitation_handler.go:142-197) unchanged; only the underlying requester type changes from mcp-go to the domain type.See architecture.md, section "
ElicitationRequesterboundary (R4 — domain-typed, bounded rewrite)" (lines ~200-241), including the one-to-one mcp-go field-mapping table.Parent Story: #<#5430>
Dependencies: None (root task; can start immediately)
Blocks: #5437 (
New(cfg) -> VMCPcore constructor — the core depends on the domainElicitationRequester)Acceptance Criteria
ElicitationRequesterinterface plusElicitationRequest/ElicitationResultvalue types exist (inpkg/vmcp), exposing no mcp-go types across the boundary.composer.SDKElicitationRequesterreferences are replaced by the domainElicitationRequester;DefaultElicitationHandlerconstructs the domainElicitationRequest(nomcp.ElicitationRequest) and reads the domainElicitationResult(nomcp.ElicitationResult).server/sdk_elicitation_adapter.go;package composerno longer importsgithub.com/mark3labs/mcp-go/mcpfor the elicitation path.ElicitationID/URL/Mode) are added to the domain types.result.Contentany → map[string]anyassertion (elicitation_handler.go:204) behave exactly as before.server.Newsignature and observable behavior unchanged.task test); lint clean (task lint-fix); mocks regenerated (task gen) for the renamed/retyped interface.Technical Approach
Recommended Implementation
pkg/vmcp(e.g.pkg/vmcp/elicitation.go): theElicitationRequesterinterface and theElicitationRequest/ElicitationResultvalue structs, with no mcp-go imports. Mirror the mcp-go fields the composer actually uses, one-to-one (see Component Interfaces).DefaultElicitationHandler.sdkRequesterandNewDefaultElicitationHandler's parameter fromcomposer.SDKElicitationRequesterto the domainvmcp.ElicitationRequester. InsideDefaultElicitationHandler.RequestElicitation, build a domainElicitationRequest{Message, RequestedSchema}(currentlymcp.ElicitationRequest{Params: mcp.ElicitationParams{Message, RequestedSchema}}atelicitation_handler.go:168-173) and read the domainElicitationResult{Action, Content}(currently*mcp.ElicitationResultatelicitation_handler.go:179). All validation between (142-197) and theContentany → map[string]anyassertion (204) stay byte-for-byte. Remove the now-unusedgithub.com/mark3labs/mcp-go/mcpimport from this file. Delete or relocate the oldSDKElicitationRequesterinterface.sdkElicitationAdapter/NewSDKElicitationAdapter(server/sdk_elicitation_adapter.go:46) to satisfy the domainvmcp.ElicitationRequester. ItsRequestElicitationmaps the domainElicitationRequest→mcp.ElicitationRequest{Params: mcp.ElicitationParams{...}}, callsa.mcpServer.RequestElicitation(ctx, mcpReq), then maps the returned*mcp.ElicitationResult→ domain*ElicitationResult(string(resp.Action),resp.Contentpass-through asany). This is the only place mcp-go elicitation types appear.//go:generate mockgen ... SDKElicitationRequesterdirective (elicitation_handler.go:7) must be updated to target the domain interface (and moved to wherever the interface now lives). Runtask gen.Keep the rewrite minimal — relocate the type boundary, do not change request/response semantics, ordering, defaults, or error transforms.
Patterns & Frameworks
github.com/mark3labs/mcp-go/mcp) elicitation types must appear only inserver/sdk_elicitation_adapter.go— the sanctioned adapter layer. Eases vmcp anti-pattern Implement secret injection #5 (SDK coupling leaking through abstractions) on this path..claude/rules/go-style.md, Interface Design): the domain types are an intentional one-to-one mirror of the mcp-go shapes the composer uses; keep them minimal so the single translation point in the adapter stays trivial. Do not add fields the composer does not construct..claude/rules/go-style.md):RequestedSchema/Meta/Contentare reference types passed across the seam; the translator must not mutate caller-provided maps in place..claude/rules/go-style.md(SPDX headers, error wrapping with%w, no SDK leak),.claude/rules/vmcp-anti-patterns.md(Implement secret injection #5, Start simple hardcoded registry #8 — do not add interface methods beyondRequestElicitation).Code Pointers
pkg/vmcp/elicitation.go(new — or co-located inpkg/vmcp) — domainElicitationRequesterinterface +ElicitationRequest/ElicitationResultvalue types (architecture.md lines 200-241). No mcp-go imports.pkg/vmcp/composer/elicitation_handler.go:80—SDKElicitationRequesterinterface (leaksmcp.ElicitationRequest/mcp.ElicitationResult) being replaced by the domain type.:111—NewDefaultElicitationHandler/DefaultElicitationHandler.sdkRequesterfield: retype parameter to the domainElicitationRequester.:142-197— security validation (timeout cap tomaxElicitationTimeout,validateSchemaSize,validateContentSize,validateContentDepth): unchanged.:168-173— currently constructsmcp.ElicitationRequest{Params: mcp.ElicitationParams{Message, RequestedSchema}}(form-mode only; URL-mode fields not constructed) → becomes a domainElicitationRequest.:204—result.Content.(map[string]any)assertion (theany → mapconversion of the response): unchanged; the domainElicitationResult.Contentis stillanyto preserve this exact behavior.:7—//go:generate mockgen ... SDKElicitationRequesterdirective: retarget to the domain interface.pkg/vmcp/server/sdk_elicitation_adapter.go— thesdkElicitationAdapterstruct (:29) and its constructorNewSDKElicitationAdapter(:46) become the sole domain ⇄ mcp-go translator (currently:69-84callsa.mcpServer.RequestElicitation(ctx, request)).pkg/vmcp/composer/composer.go:435—ElicitationProtocolHandlerinterface and:457ElicitationResponse(the composer's existing public domain return type): not changed by this task; they already use plain Go types. (Confirms the domain-typing approach is consistent with existing composer API.)mcp/types.go—ElicitationParams(:1006),ElicitationResponse(:1058): the field source of truth for the mapping table.Component Interfaces
Domain types live in
pkg/vmcpand import no mcp-go types:Field mapping (translation lives ONLY in
server/sdk_elicitation_adapter.go):mcp/types.go)ElicitationRequest.MessageElicitationParams.Message(:1011)ElicitationRequest.RequestedSchemaElicitationParams.RequestedSchema(:1016)anyJSON Schema, pass-throughElicitationRequest.MetaElicitationParams.Meta(*Meta,:1007)ElicitationResult.ActionElicitationResponse.Action(:1061)string(...); "accept"/"decline"/"cancel"ElicitationResult.ContentElicitationResponse.Content(:1064)any→ composer asserts tomap[string]any(elicitation_handler.go:204)Testing Strategy
Unit Tests
ElicitationRequest{Message, RequestedSchema}maps to the equivalentmcp.ElicitationParams;mcp.ElicitationResult{Action, Content}maps back to the domainElicitationResultwithActionstring-converted andContentpreserved.DefaultElicitationHandlernow consumes the domainElicitationRequester(via regenerated mock) and produces an unchangedcomposer.ElicitationResponse.package composerfor the elicitation path (grep/task lintguard); mcp-go elicitation types appear only inserver/sdk_elicitation_adapter.go.Integration / Behavioral Parity Tests (dedicated R4 parity suite — required merge gate)
ElicitationRequester+ adapter translator and is byte-for-byte equivalent to the priorSDKElicitationRequesterpath for each action:Contentmap (map[string]any) round-trips identically into the workflow (the:204assertion still yields the same map); workflow continues with the same templated values.ErrElicitationDeclined/decline handling as before.ErrElicitationCancelled/cancel handling as before.context.DeadlineExceededfrom the adapter still surfaces asErrElicitationTimeout(elicitation_handler.go:182-185).maxSchemaSize(100KB) →ErrSchemaTooLarge.maxSchemaDepth(10) →ErrSchemaTooDeep.maxResponseContentSize(1MB) →ErrContentTooLarge.maxElicitationTimeout(10min) is capped (warn-logged), not honored verbatim.Edge Cases
result.Contentthat is not amap[string]any(non-mapany) is handled exactly as today — warn-logged, treated as nil content (elicitation_handler.go:205-211).Metais nil on the domain request (composer sets none today) and the adapter produces a request with no_meta— no behavior change.Contentpasses content validation (no-op) as before.Out of Scope
ElicitationID/URL/Mode) or any new elicitation semantics — explicitly excluded; one-to-one mirror only.DefaultElicitationHandler.New(cfg) -> VMCPor wiring the core to the domainElicitationRequester— that is P1.4 New(cfg) -> VMCP core constructor #5437.composer.ElicitationProtocolHandler/composer.ElicitationResponse/composer.ElicitationConfigpublic shapes.server.New's signature or the SDK-backed elicitation flow embedders use via(*Server).MCPServer().References
/Users/trey/Documents/GitHub/stacklok/toolhive-rfcs/rfcs/THV-0076-vmcp-core-interface.mdarchitecture.md— section "ElicitationRequesterboundary (R4 — domain-typed, bounded rewrite)" (lines ~200-241)