Skip to content

P1.3 Domain-typed ElicitationRequester (bounded rewrite) #5436

Description

@tgrunnagle

Description

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.Content any → 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

  1. 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).
  2. 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 Content any → 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.
  3. 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 ElicitationRequestmcp.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.
  4. 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:80SDKElicitationRequester interface (leaks mcp.ElicitationRequest/mcp.ElicitationResult) being replaced by the domain type.
    • :111NewDefaultElicitationHandler / 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.
    • :204result.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 sdkElicitationAdapter struct (: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:435ElicitationProtocolHandler interface and :457 ElicitationResponse (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.goElicitationParams (: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.
type ElicitationRequester interface {
    RequestElicitation(ctx context.Context, req ElicitationRequest) (*ElicitationResult, error)
}

// ElicitationRequest is the domain-typed elicitation request (form-mode only;
// mirrors the mcp-go fields the composer constructs today).
type ElicitationRequest struct {
    Message         string         // -> mcp.ElicitationParams.Message
    RequestedSchema any            // -> mcp.ElicitationParams.RequestedSchema (JSON Schema; pass-through)
    Meta            map[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.
type ElicitationResult struct {
    Action  string // <- string(mcp.ElicitationResponse.Action): "accept"|"decline"|"cancel"
    Content any    // <- 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.

Integration / Behavioral Parity Tests (dedicated R4 parity suite — required merge gate)

  • 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.
    • timeoutcontext.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.
  • Introducing New(cfg) -> VMCP or wiring the core to the domain ElicitationRequester — that is P1.4 New(cfg) -> VMCP core constructor #5437.
  • Any change to composer.ElicitationProtocolHandler / composer.ElicitationResponse / composer.ElicitationConfig public shapes.
  • Any change to server.New's signature or the SDK-backed elicitation flow embedders use via (*Server).MCPServer().

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorvmcpVirtual MCP Server related issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions