Skip to content

Commit 418338a

Browse files
fix(mcp): preserve produced types in text projection and decouple poset resolver from storage
Include produced_artifact_types in both the discovery-match and descriptor allowlists of _find_text_projection so agents consuming the agent-facing content projection (rather than structured_content) can connect poset.finite.materialize to poset.width.compute. Covered by test_mcp_text_projection_preserves_produced_artifact_types. Replace MaterializedOperation.input_resolver with artifact_converter, artifact_payload_model, and artifact_uri_field. The installer now owns artifact retrieval, schema/semantics validation, and payload deserialization; the domain module provides only a pure typed conversion from FinitePosetMaterializationResult to PosetRequest. This removes the ArtifactRepository import from posets/operations.py, keeping the mathematical domain module decoupled from storage mechanics as the operation-library contract requires. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 3fadd80 commit 418338a

5 files changed

Lines changed: 73 additions & 34 deletions

File tree

src/jacobian/adapters/mcp/tools.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def _find_text_projection(response: dict[str, Any]) -> dict[str, Any]:
9090
"modes",
9191
"accepted_input_kinds",
9292
"accepted_artifact_types",
93+
"produced_artifact_types",
9394
"input_schema_summary",
9495
"output_schema_summary",
9596
"scope",
@@ -122,6 +123,7 @@ def _find_text_projection(response: dict[str, Any]) -> dict[str, Any]:
122123
"modes",
123124
"accepted_input_kinds",
124125
"accepted_artifact_types",
126+
"produced_artifact_types",
125127
"input_schema_summary",
126128
"output_schema_summary",
127129
)

src/jacobian/domains/posets/operations.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
)
3333
from jacobian.domains._examples import example
3434
from jacobian.operations import ComputedSuccess, MaterializedOperation
35-
from jacobian.storage.repository import ArtifactRepository
3635

3736

3837
def _presentation_graph(request: FinitePosetRequest) -> nx.DiGraph[str]:
@@ -172,24 +171,20 @@ def _width(request: PosetRequest) -> ComputedSuccess[PosetWidthResult]:
172171
)
173172

174173

175-
def _resolve_poset_request(
174+
def _convert_materialized_poset(
176175
request: PosetRequest,
177-
store: ArtifactRepository,
178-
accepted_artifact_types: tuple[str, ...],
179-
semantics_uri: str,
176+
payload: FinitePosetMaterializationResult,
180177
) -> tuple[PosetRequest, tuple[str, ...]]:
181-
if request.poset is not None:
182-
return request, ()
183-
if request.poset_artifact_uri is None:
184-
raise ValueError("poset artifact URI is required")
185-
artifact = store.get(request.poset_artifact_uri)
186-
if (
187-
artifact.manifest.schema_uri not in accepted_artifact_types
188-
or artifact.manifest.semantics_uri != semantics_uri
189-
):
190-
raise ValueError("artifact is not a compatible finite poset")
191-
materialized = FinitePosetMaterializationResult.model_validate(artifact.payload)
192-
return PosetRequest(poset=materialized.poset), (request.poset_artifact_uri,)
178+
"""Convert a materialized poset artifact into a domain request.
179+
180+
This is a pure typed conversion with no storage dependency; the installer
181+
handles artifact retrieval, schema/semantics validation, and payload
182+
deserialization before calling this converter.
183+
"""
184+
185+
return PosetRequest(poset=payload.poset), (
186+
request.poset_artifact_uri,
187+
) if request.poset_artifact_uri else ()
193188

194189

195190
def _linear_extensions(
@@ -378,7 +373,9 @@ def _mobius(
378373
implementation=_width,
379374
relation_id="poset.width.dilworth.relation",
380375
accepted_result_capability_ids=("poset.finite.materialize",),
381-
input_resolver=_resolve_poset_request,
376+
artifact_converter=_convert_materialized_poset,
377+
artifact_payload_model=FinitePosetMaterializationResult,
378+
artifact_uri_field="poset_artifact_uri",
382379
tags=(
383380
"poset",
384381
"width",

src/jacobian/operation_installation.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,24 @@ def invoke(self, request: CapabilityRequest) -> CapabilityResult:
332332
request.input
333333
)
334334
source_artifact_uris: tuple[str, ...] = ()
335-
if self.operation.input_resolver is not None:
336-
validated_request, source_artifact_uris = self.operation.input_resolver(
337-
validated_request,
338-
self.resources.artifacts.store,
339-
self._descriptor.accepted_artifact_types,
340-
self.resources.semantics_uri,
341-
)
335+
if self.operation.artifact_converter is not None:
336+
uri_field = self.operation.artifact_uri_field
337+
payload_model = self.operation.artifact_payload_model
338+
assert uri_field is not None and payload_model is not None
339+
artifact_uri = getattr(validated_request, uri_field)
340+
if artifact_uri is not None:
341+
artifact = self.resources.artifacts.store.get(artifact_uri)
342+
if (
343+
artifact.manifest.schema_uri
344+
not in self._descriptor.accepted_artifact_types
345+
or artifact.manifest.semantics_uri
346+
!= self.resources.semantics_uri
347+
):
348+
raise ValueError("artifact is not a compatible producer result")
349+
payload = payload_model.model_validate(artifact.payload)
350+
validated_request, source_artifact_uris = (
351+
self.operation.artifact_converter(validated_request, payload)
352+
)
342353
except (StorageError, ValidationError, ValueError) as exc:
343354
raise CapabilityInvocationError(
344355
self.operation.invalid_request

src/jacobian/operations.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from jacobian.checker_operations import ExactReplayCheckerDeclaration
1111
from jacobian.installation.context import InstallationContext
1212
from jacobian.operation_installation import InstalledDomainBundle
13-
from jacobian.storage.repository import ArtifactRepository
1413

1514
from jacobian.contracts.capabilities import (
1615
CapabilityDiagnostic,
@@ -97,23 +96,28 @@ class MaterializedOperation[
9796
preview: Callable[[ArtifactT], PreviewT] | None = None
9897
preview_complete: bool = False
9998
accepted_result_capability_ids: tuple[str, ...] = ()
100-
input_resolver: (
101-
Callable[
102-
[RequestT, ArtifactRepository, tuple[str, ...], str],
103-
tuple[RequestT, tuple[str, ...]],
104-
]
105-
| None
99+
artifact_converter: (
100+
Callable[[RequestT, ArtifactT], tuple[RequestT, tuple[str, ...]]] | None
106101
) = None
102+
artifact_payload_model: type[ContractModel] | None = None
103+
artifact_uri_field: str | None = None
107104
version: str = "2"
108105

109106
def __post_init__(self) -> None:
110107
if self.preview_complete and self.preview is None:
111108
raise ValueError("a complete materialized preview requires a preview")
112-
if bool(self.accepted_result_capability_ids) != bool(self.input_resolver):
109+
has_converter = self.artifact_converter is not None
110+
if bool(self.accepted_result_capability_ids) != has_converter:
113111
raise ValueError(
114-
"accepted result capabilities and an input resolver must be "
112+
"accepted result capabilities and an artifact converter must be "
115113
"declared together"
116114
)
115+
if has_converter and (
116+
self.artifact_payload_model is None or self.artifact_uri_field is None
117+
):
118+
raise ValueError(
119+
"an artifact converter requires a payload model and URI field"
120+
)
117121

118122

119123
@dataclass(frozen=True, slots=True)

tests/boundary/mcp/test_mcp_catalog_and_policy.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,28 @@ async def scenario() -> None:
176176
assert invalid["error"]["code"] == "INVALID_CURSOR"
177177

178178
asyncio.run(scenario())
179+
180+
181+
def test_mcp_text_projection_preserves_produced_artifact_types(
182+
tmp_path: Path,
183+
) -> None:
184+
"""The agent-facing text projection must include produced_artifact_types."""
185+
186+
async def scenario() -> None:
187+
from mcp import Client
188+
189+
async with Client(create_server(tmp_path), raise_exceptions=True) as client:
190+
listed = await client.call_tool(
191+
"math.find",
192+
{"query": "poset", "limit": 20},
193+
)
194+
text = json.loads(listed.content[0].text)
195+
assert text["kind"] == "discovery"
196+
producing = [m for m in text["matches"] if "produced_artifact_types" in m]
197+
assert producing, (
198+
"text projection must preserve produced_artifact_types for at "
199+
"least one poset capability so agents can connect producers to "
200+
"consumers without structured_content"
201+
)
202+
203+
asyncio.run(scenario())

0 commit comments

Comments
 (0)