use anyhow::Result; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use prost::Message; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, ServerConfig}, tool, tool_handler, tool_router, ServerHandler, }; use trogon_atlas_client::{ apply::ApplyRejected, branch::Resolution, client::{client_for_transport, Client, ConnectOptions, Transport}, compat::{self, MutationIntent}, precondition::{BranchEntryState, StalePlan}, protection, tenant_types::{self, TypeScope}, }; use trogon_atlas_core::transcode::TranscodePool; use trogon_atlas_proto as pb; use crate::{ caller::{Caller, CallerToken}, error::{attach_operation_id, tool_err, McpError}, profile::ToolProfile, tools::{ AnalysisScopeParams, ApplyManifestsParams, BatchGetParams, BatchMutateJsonParams, BatchMutateParams, CatalogQueryParams, CompileTypeLibraryParams, CreateBranchParams, DeleteBranchParams, DeleteByQueryParams, DeleteEntityParams, DiffBranchParams, DiffEntitiesParams, DiffManifestsParams, EmptyParams, EntitySchemaParams, EntitySpec, EventModelProjectionParams, ExportAttestedModelParams, ExportNamespaceParams, ExportOpensloParams, ExtractSubgraphParams, GetChangesetParams, GetEntityHistoryParams, GetEntityProtectionParams, GetLatestVersionParams, GetOperationParams, GetSnapshotIdParams, ImpactParams, JsonOpts, ListChangesParams, ListChangesetsParams, ListEntitiesByDomainParams, ListEntitiesParams, ListEntityProtectionsParams, ListVersionsParams, MergeBranchParams, MoveNamespaceParams, ProjectionParams, ProtectEntityParams, PutEntityJsonParams, PutEntityParams, ReferencesParams, RegisterNamespaceParams, ResolveBranchEntryParams, ResolveTypeParams, ResponsibilityQueryParams, RetargetReferencesParams, RevertChangesetParams, SearchParams, SliceProjectionParams, SourceRequestParams, SupersessionChainParams, UnprotectEntityParams, UpdateBranchParams, ValidateEventModelParams, ValidateProjectParams, VerifyAttestedBundleParams, }, }; /// The `initialize` result `_meta` key naming the active tool profile. pub const PROFILE_META_KEY: &str = "trogon-atlas/tool-profile"; pub const MAX_JSON_BYTES: usize = 4 * 1024 * 1024; /// Must match `trogon-atlas-server` `search_entities` MAX_QUERY_BYTES (2048). pub const MAX_SEARCH_QUERY_BYTES: usize = 2048; /// ServerHandler instructions. Keep in sync with `get_info`. pub const SERVER_INSTRUCTIONS: &str = "trogon-atlas MCP: tools for the trogonatlas.api.eventmodel.v1alpha1 gRPC service. \ Read tools return a binpb_base64 envelope you can decode (or use *_json companions). Mutations \ have two surfaces: JSON (get_entity_json -> edit proto3 JSON -> put_entity_json / \ batch_mutate_json; no protobuf tooling required) and binpb (get_entity -> modify \ binpb_base64 -> put_entity / batch_mutate). Call who_am_i if you need to confirm \ which role, namespaces, or owner you are authenticated as; the tools listed here \ are already filtered to what that role can call.\n\ \n\ SPECIFICATION AUTHORING: use the native specification entity for retained contract artifacts, \ schemas, messages, operations, provider exposures and usages. API classification is derived \ from operations and exposures selecting them. Integration messages map only to ReadModels; \ request/response/notification roles do not create Commands or Events. Preserve authoritative \ member IDs, connection IDs, exact artifact text and SHA-256 digests on edits and manifest \ round trips. Add replacement artifact members to change bytes. Opaque selectors remain \ unverified; no format importer, remote fetch or semantic validation is implied.\n\ \n\ STORYBOARD AUTHORING RULES (validated; violations are rejected or flagged):\n\ 1. A storyboard is a TEMPORAL sequence: each slice is one moment. A read-model \ slice lists only the events that just happened at that moment, never the type's \ full source list (the ReadModel entity carries the type-level list).\n\ 2. Projection adjacency: every event's read-model projections come directly after \ the emitting slice, before the next command or automation slice acts. Updates \ from divergent branches (e.g. approve vs reject) are separate slices, never merged.\n\ 3. One processor issues exactly one command; branching outcomes are separate \ automation slices with separate processors. Record 'these ship as one worker' \ with a Component entity listing them as members, not by sharing entities.\n\ 4. Split read models by shape, not by consumer: add a read model only when a \ consumer needs different fields, key or source events. Consumers that read the \ same data share one read model; when several automations observe it, declare a \ SharedConsumersAnnotation naming them. A command's decision state comes from its \ stream (the given events) and is not a read model. Two read models with the same \ source events and fields are a duplicate. External integrations are declared on BOTH seams: the processor \ that performs the call lists the ExternalSystem in Processor.calls (outbound), \ and the results land in ExternalSource read models pointing at the same system \ (inbound); the round trip is structural, never prose-only.\n\ 5. Sub-workflows are SEPARATE storyboards chained implicitly: storyboard A's \ outcome events project into read models that storyboard B's entry observes. \ Never build one mega-storyboard; never invent nesting. The EventModel is the \ workflow umbrella, and its ordered members list sequences the storyboards.\n\ 6. Every storyboard declares an honest entry (the read model observed plus the \ human persona+ui or automation processor observing it) and an outcome (the \ terminal events). Clock/schedule triggers are read models observed by automations.\n\ 7. Single-use or entitlement constraints (e.g. review-once) get their own \ aggregate swimlane; that stream is the consistency boundary.\n\ 8. Swimlane stream_id placeholders must be followable names: each {placeholder} \ references a FieldSpec on that lane's events/commands, or the name of a \ Uuidv5IdentityAnnotation registered on the swimlane (composed identities). Never \ inline expressions like {uuidv5(...)}; register the identity once under a name \ and write e.g. 'review-eligibility-{eligibility_id}'.\n\ 9. Forks in the road live on the SWIMLANE: declare the stream's lifecycle as \ Swimlane.transitions (after , next = [ + guard doc, ...]). \ Exclusivity comes from the consistency boundary: competing commands append to \ the same stream and the aggregate accepts one successor, so alternatives \ without a shared stream are parallel fan-out, not a fork. Cycles are legal \ (lifecycles loop). Tools derive the storyboard fork graph from the table, and \ validation checks every GWT against it: a scenario may only emit a legal \ successor of the last given event on that stream.\n\ 10. TIME ONLY POINTS FORWARD. Every edge goes from an earlier moment to a later \ one; recurrence of a type (a screen shown again, a view updated again, a \ lifecycle re-entered) is a NEW occurrence, never an arrow into the past; a \ backward arrow destroys the ability to read time off the graph. Order slices \ so events precede their projections and projections precede their consumers; \ write scenario given histories as forward walks of each stream's transitions \ (validated both in given order and in emission).\n\ 11. Bounded contexts touch at exactly ONE seam: an upstream EVENT flowing into \ a downstream subscription READ MODEL's source_events (the translation pattern). \ A cross-model link is NOT a continuation: no shared stream means no fork, no \ guard, no single timeline; it is async pub/sub with fan-out. The DOWNSTREAM \ declares the dependency (its consumer-shaped view names the upstream event); \ the upstream never knows its consumers. Any other cross-namespace structural \ ref is rejected (CROSS_MODEL_BOUNDARY_VIOLATION): translate, don't reference. \ A namespace IS a bounded context; its root document is the BoundedContext \ entity (slug == namespace, validated) carrying the context charter, links, \ and metadata. Event models are free-form curations WITHIN a context (a \ feature, a flow, a release), and a namespace may hold many.\n\ 12. Project management layers ON TOP of the model, never inside it. Delivery \ status (todo/in_progress/review/blocked/done) lives in Tracker entities: \ denormalized lists of {subject EntityRef, TrackStatus, doc} pointing at any \ entity kind. Design entities carry NO status fields; flipping a status never \ mutates the model. Several trackers may overlay one model (per team, per \ milestone); within one tracker each subject appears once. Trackers are NOT \ event model members. 'What needs work?' = list_entities kind=tracker.\n\ 13. UI.transitions declares PURE navigation only: surface-to-surface links \ that carry no command and no event (tab bars, back affordances, menu links, \ deep links). Any hop a storyboard command path already explains is DERIVED \ from the slices; re-declaring it is managed duplication and warns \ (UI_TRANSITION_DERIVABLE). Cycles are legal in the data (a navigation map is \ a graph, like the swimlane lifecycle); renderers keep time forward by \ unrolling occurrences.\n\ 14. Model DECISIONS AND DATA, never implementation. In scope: \ strategic design (contexts, charters, seams, investment) and the behavioral \ contract (facts, order, fields). Out of scope, always: classes, services, \ repositories, frameworks, persistence, code structure. Litmus test: would it \ survive reimplementing the system in another language?\n\ 15. The problem space is a knowledge graph: Domain (the \ business, usually one) contains Subdomains (problems the business must be \ good at, classified CORE/SUPPORTING/GENERIC, a budget document). They live \ in a namespace named after the domain, which is NOT a bounded context. A \ Subdomain never contains contexts: BoundedContext.realizes maps solution to \ problem (cross-namespace, legal: knowledge, not coupling). One context \ realizing several subdomains is a monolith confessing; realizing subdomains \ of different classifications also warns (REALIZES_MIXED_CLASSIFICATIONS), \ because one boundary then carries two investment levels. A Domain declared in \ a context's namespace warns (DOMAIN_IN_CONTEXT_NAMESPACE): give it its own \ problem-space namespace. Classification goes on the SUBDOMAIN, never the \ context. Layout: the Project, the Domain and ALL Subdomains live in ONE \ problem-space namespace (Domain and Project slug == that namespace); each \ context namespace holds only its BoundedContext, which realizes ONE \ subdomain across namespaces. Never create a Project or Domain per context \ namespace to silence DOMAIN_MISSING_PROJECT; point the contexts at the one \ problem space instead. Declare a relationship intent (rule 16) for every \ cross-context seam.\n\ 16. Seams carry data; relationships carry intent. The DOWNSTREAM \ BoundedContext declares relationships: upstream context + intent \ (CUSTOMER_SUPPLIER / CONFORMIST / PARTNERSHIP / SEPARATE_WAYS) + doc. Intent \ and data must agree: integration intent without a seam warns, SEPARATE_WAYS \ with a seam errors, a seam with no declared intent nudges (Info).\n\ 17. Screen is composition, UI is the moment. Use UI for every \ slice and storyboard touchpoint. Use Screen only for URL-addressable \ surfaces that compose UI contributions through UI.slot. Put Screen \ entities in a non-context application namespace. Required Screen slots \ need at least one contributing UI. A Screen with UI contributions from \ multiple contexts is expected shared-surface composition, not a \ replacement for context seams.\n\ 18. Terms and Ambiguities are language data. A Term lives in the \ bounded context whose language it defines and points outward through \ embodied_by to the entities carrying that meaning. An Ambiguity lives \ in the problem-space namespace and records a real ruling across two \ or more Term refs: HOMONYM or SYNONYM, with DELIBERATE, RENAME, or \ MERGE. Do not store unresolved language work as an Ambiguity.\n\ 19. Disconnects are data when declared. Any model member that no \ slice produces, consumes, or feeds is DISCONNECTED. Disconnects are not always \ errors: a subscription view may wait for its consumer, an audit log may have no \ UI yet, a niche flow may be deferred. Declare the disconnect with OrphanAnnotation \ (a canonical Any in the entity's metadata) carrying a non-empty `doc` explaining \ WHY. Unflagged disconnects warn (ORPHAN_NOT_FLAGGED); flag-with-empty-doc errors \ (ORPHAN_DOC_EMPTY); flag-on-a-wired-entity nudges (ORPHAN_FLAG_OBSOLETE); remove \ the annotation once the entity is connected.\n\ 20. Open questions and assumptions are first-class, not comments. An \ OpenQuestionAnnotation names something the model does not yet decide: put it on \ the entity or scenario where the gap lives, with `question` and `author`. An \ AssumptionAnnotation names something the design already proceeds as if true but \ nobody confirmed, with fields `statement`, optional `basis`, and `author`. Both \ attach via put_entity_json as a canonical Any (\"@type\": \ \"type.googleapis.com/trogonatlas.annotation.v1alpha1.OpenQuestionAnnotation\" or \ \"...AssumptionAnnotation\") in the entity's metadata, or a scenario's own metadata. \ Both surface as Info findings (OPEN_QUESTION_PRESENT, ASSUMPTION_PRESENT), never an \ error or a warning, so recording one never blocks a mutation.\n\ 21. Reliability overlays point outward like Tracker, never as \ event model members: a ServiceLevelIndicator names a Connection \ (CommandHandling, Projection, Reaction, Display, Integration, or \ Journey) that must resolve to a real slice or edge; a \ ServiceLevelObjective points at one indicator, carries the \ threshold and target, and lists the AlertPolicy entities that \ watch it; an AlertPolicy points at AlertNotificationTarget \ entities. Validation checks the connection resolves, a Journey \ is structurally reachable, correlation fields are sourced on \ both ends, the signal matches the measure kind, and an \ objective with no alert policy is flagged (warning when \ externally committed, info otherwise).\n\ entity.system (uid, created_at) is SERVER-OWNED incarnation identity, Kubernetes \ metadata.uid style: assigned on create, preserved across updates, regenerated on \ delete+recreate. Anything you send there is ignored; never try to set it.\n\ Mutation responses (put_entity, put_entity_json, batch_mutate, batch_mutate_json, merge_branch) \ return non-blocking validation findings, strategic ones included, in an `issues` array \ beside binpb_base64: read it and fix them. Run validate_event_model after every mutation \ batch and fix all Errors and Warnings. After editing only problem-space entities \ (Project, Domain, Subdomain) or a BoundedContext there may be no EventModel to validate: \ run validate_project with the namespace or project scope instead. Mutations \ execute concurrently when pipelined: issue ordered workflows (write -> delete -> \ validate) as separate sequential requests."; /// The MCP crate's own ordering of the server's RBAC roles, so tool /// listing can compare a caller's role against a tool's minimum without /// depending on `trogon-atlas-server`, which depends on this crate (see /// `TOOL_POLICIES` below). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum ToolRole { Reader, Writer, Admin, } impl ToolRole { pub fn as_str(self) -> &'static str { match self { ToolRole::Reader => "reader", ToolRole::Writer => "writer", ToolRole::Admin => "admin", } } } /// The part of the service a tool works on. Tool profiles are derived from /// a tool's area and its effect, so this is what places a new tool in them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolArea { /// Server identity, catalogues and type resolution every task needs. Discovery, /// Entities, their references, projections, validation and history. Model, Branch, Source, /// Bulk or cross-namespace operations no task profile offers. Administration, } impl From for ToolRole { fn from(role: pb::Role) -> Self { match role { pb::Role::Unspecified | pb::Role::Reader => ToolRole::Reader, pb::Role::Writer => ToolRole::Writer, pb::Role::Admin => ToolRole::Admin, } } } /// One row of the tool policy table: the short gRPC method names (no /// leading service path) a tool invokes, directly or through /// `trogon_atlas_client` (`apply`, `export`, `openslo`, `branch`), and the /// minimum role a caller needs before this adapter offers the tool at all. /// /// `min_role` is hand-maintained here rather than derived at runtime from /// `trogon_atlas_server::auth::required_role`, because that crate serves /// these tools and so can only be a dev-dependency here. The `tests` /// module below, which already depends on `trogon-atlas-server`, asserts /// every row's `min_role` equals the max role `required_role` reports over /// `rpcs`, so the two can never drift silently. pub struct ToolPolicy { pub tool: &'static str, pub rpcs: &'static [&'static str], pub min_role: ToolRole, pub area: ToolArea, } pub const TOOL_POLICIES: &[ToolPolicy] = &[ ToolPolicy { tool: "list_source_run_operations", rpcs: &["ListSourceRunOperations"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "create_source_registration", rpcs: &["CreateSourceRegistration"], min_role: ToolRole::Admin, area: ToolArea::Source, }, ToolPolicy { tool: "update_source_registration", rpcs: &["UpdateSourceRegistration"], min_role: ToolRole::Admin, area: ToolArea::Source, }, ToolPolicy { tool: "get_source_registration", rpcs: &["GetSourceRegistration"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "list_source_registrations", rpcs: &["ListSourceRegistrations"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "delete_source_registration", rpcs: &["DeleteSourceRegistration"], min_role: ToolRole::Admin, area: ToolArea::Source, }, ToolPolicy { tool: "accept_source_snapshot", rpcs: &["AcceptSourceSnapshot"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "get_source_snapshot", rpcs: &["GetSourceSnapshot"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "list_source_snapshots", rpcs: &["ListSourceSnapshots"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "create_source_mapping", rpcs: &["CreateSourceMapping"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "update_source_mapping", rpcs: &["UpdateSourceMapping"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "get_source_mapping", rpcs: &["GetSourceMapping"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "list_source_mappings", rpcs: &["ListSourceMappings"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "delete_source_mapping", rpcs: &["DeleteSourceMapping"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "get_source_overlay", rpcs: &["GetSourceOverlay"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "preview_source_run", rpcs: &["PreviewSourceRun"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "apply_source_run", rpcs: &["ApplySourceRun"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "cancel_source_run", rpcs: &["CancelSourceRun"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "get_source_run", rpcs: &["GetSourceRun"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "list_source_runs", rpcs: &["ListSourceRuns"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "recover_source_run", rpcs: &["RecoverSourceRun"], min_role: ToolRole::Writer, area: ToolArea::Source, }, ToolPolicy { tool: "list_source_run_results", rpcs: &["ListSourceRunResults"], min_role: ToolRole::Reader, area: ToolArea::Source, }, ToolPolicy { tool: "list_decision_amendments", rpcs: &["ListDecisionAmendments"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_subject_decisions", rpcs: &["ListSubjectDecisions"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_subject_documents", rpcs: &["ListSubjectDocuments"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_decision_detail", rpcs: &["GetDecisionDetail"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_document_detail", rpcs: &["GetDocumentDetail"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_incoming_decision_declarations", rpcs: &["ListIncomingDecisionDeclarations"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_term_domain_declarations", rpcs: &["ListTermDomainDeclarations"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_incoming_document_declarations", rpcs: &["ListIncomingDocumentDeclarations"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "inspect_human_interaction", rpcs: &["InspectHumanInteraction"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_channel_messages", rpcs: &["ListChannelMessages"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_design_graph_detail", rpcs: &["GetDesignGraphDetail"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_subject_design_graphs", rpcs: &["ListSubjectDesignGraphs"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "traverse_design_graph_topology", rpcs: &["TraverseDesignGraphTopology"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_incoming_design_graph_subflows", rpcs: &["ListIncomingDesignGraphSubflows"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_subject_assignments", rpcs: &["ListSubjectAssignments"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_assignee_responsibilities", rpcs: &["ListAssigneeResponsibilities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_team_members", rpcs: &["ListTeamMembers"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_person_teams", rpcs: &["ListPersonTeams"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_team_contacts", rpcs: &["GetTeamContacts"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "inspect_data_product", rpcs: &[ "InspectDataProduct", "GetServerInfo", "GetTypeLibraryDescriptorSet", ], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_server_info", rpcs: &["GetServerInfo"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "who_am_i", rpcs: &["WhoAmI"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "compile_type_library", rpcs: &["CompileTypeLibrary"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "resolve_type", rpcs: &["ResolveType"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "list_namespaces", rpcs: &["ListNamespaces"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "register_namespace", rpcs: &["RegisterNamespace"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "move_namespace", rpcs: &["MoveNamespace"], min_role: ToolRole::Admin, area: ToolArea::Administration, }, ToolPolicy { tool: "protect_entity", rpcs: &["ProtectEntity"], min_role: ToolRole::Admin, area: ToolArea::Administration, }, ToolPolicy { tool: "unprotect_entity", rpcs: &["UnprotectEntity"], min_role: ToolRole::Admin, area: ToolArea::Administration, }, ToolPolicy { tool: "get_entity_protection", rpcs: &["GetEntityProtection"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_entity_protections", rpcs: &["ListEntityProtections"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_snapshot_id", rpcs: &["GetSnapshotId"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_entities", rpcs: &["ListEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_entity", rpcs: &["GetEntity"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "batch_get_entities", rpcs: &["BatchGetEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "search_entities", rpcs: &["SearchEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_versions", rpcs: &["ListVersions"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_latest_version", rpcs: &["GetLatestVersion"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_supersession_chain", rpcs: &["GetSupersessionChain"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_incoming_references", rpcs: &["GetIncomingReferences"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_outgoing_references", rpcs: &["GetOutgoingReferences"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_impact", rpcs: &["GetImpact"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "retarget_references", rpcs: &["RetargetReferences"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "get_slice_projection", rpcs: &["GetSliceProjection"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_storyboard_projection", rpcs: &["GetStoryboardProjection"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_event_model_projection", rpcs: &["GetEventModelProjection"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "extract_subgraph", rpcs: &["ExtractSubgraph"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "diff_entities", rpcs: &["DiffEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "put_entity", rpcs: &["PutEntity"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "get_entity_json", rpcs: &["GetEntity"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "put_entity_json", rpcs: &["PutEntity"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "batch_mutate_json", rpcs: &["BatchMutate"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "delete_entity", rpcs: &["DeleteEntity"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "batch_mutate", rpcs: &["BatchMutate"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "list_changes", rpcs: &["ListChanges"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_changesets", rpcs: &["ListChangesets"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_changeset", rpcs: &["GetChangeset"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "get_entity_history", rpcs: &["GetEntityHistory"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "revert_changeset", rpcs: &["RevertChangeset"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "validate_event_model", rpcs: &["ValidateEventModel"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "list_validation_rules", rpcs: &["ListValidationRules"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "validate_project", rpcs: &["ValidateProject"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "delete_by_query", rpcs: &["DeleteByQuery"], min_role: ToolRole::Admin, area: ToolArea::Administration, }, ToolPolicy { tool: "list_entity_kinds", rpcs: &["ListEntityKinds"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "list_entities_by_domain", rpcs: &["ListEntitiesByDomain"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "infer_data_flow", rpcs: &["InferDataFlow"], min_role: ToolRole::Writer, area: ToolArea::Model, }, ToolPolicy { tool: "check_information_completeness", rpcs: &["CheckInformationCompleteness"], min_role: ToolRole::Writer, area: ToolArea::Model, }, // apply_manifests plans with BatchGetEntities, then writes through // BatchMutate (trogon_atlas_client::apply::plan / apply). ToolPolicy { tool: "apply_manifests", rpcs: &["BatchGetEntities", "BatchMutate"], min_role: ToolRole::Writer, area: ToolArea::Model, }, // diff_manifests only plans (trogon_atlas_client::apply::plan); it // never calls apply, so it never reaches BatchMutate. ToolPolicy { tool: "diff_manifests", rpcs: &["BatchGetEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "export_namespace", rpcs: &["ListEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "export_attested_model", rpcs: &["ExportAttestedModel"], min_role: ToolRole::Reader, area: ToolArea::Model, }, // verify_attested_bundle checks a bundle the caller already holds and // never reaches the server, so any role may use it. ToolPolicy { tool: "verify_attested_bundle", rpcs: &[], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "export_openslo", rpcs: &["ListEntities"], min_role: ToolRole::Reader, area: ToolArea::Model, }, ToolPolicy { tool: "create_branch", rpcs: &["CreateBranch"], min_role: ToolRole::Writer, area: ToolArea::Branch, }, ToolPolicy { tool: "list_branches", rpcs: &["ListBranches"], min_role: ToolRole::Reader, area: ToolArea::Branch, }, ToolPolicy { tool: "delete_branch", rpcs: &["DeleteBranch"], min_role: ToolRole::Writer, area: ToolArea::Branch, }, ToolPolicy { tool: "diff_branch", rpcs: &["DiffBranch"], min_role: ToolRole::Reader, area: ToolArea::Branch, }, ToolPolicy { tool: "merge_branch", rpcs: &["MergeBranch"], min_role: ToolRole::Writer, area: ToolArea::Branch, }, ToolPolicy { tool: "update_branch", rpcs: &["UpdateBranch"], min_role: ToolRole::Writer, area: ToolArea::Branch, }, ToolPolicy { tool: "resolve_branch_entry", rpcs: &["ResolveBranchEntry"], min_role: ToolRole::Writer, area: ToolArea::Branch, }, ToolPolicy { tool: "get_entity_schema", rpcs: &["ListValidationRules"], min_role: ToolRole::Reader, area: ToolArea::Discovery, }, ToolPolicy { tool: "get_operation", rpcs: &["GetOperation"], min_role: ToolRole::Writer, area: ToolArea::Discovery, }, ]; pub(crate) fn policy_for(tool: &str) -> Option<&'static ToolPolicy> { TOOL_POLICIES.iter().find(|p| p.tool == tool) } /// Whether `role` may be offered `tool` at all. A tool with no policy row is /// treated as admin-only rather than silently listed, so a tool added /// without a matching row fails toward hiding it, not exposing it; the /// `every_tool_has_annotations_matching_the_role_map` test keeps this table /// complete in practice. fn tool_allowed_for(tool: &str, role: ToolRole) -> bool { let Some(policy) = policy_for(tool) else { tracing::error!( tool = tool, "list_tools: no ToolPolicy row for this tool; treating as admin-only" ); return role >= ToolRole::Admin; }; policy.min_role <= role } #[derive(Clone)] pub struct McpServer { /// Shared by every caller; each caller gets its own `Client` over it /// (see `for_caller`). transport: Transport, options: ConnectOptions, /// The client tool handlers call through. On the shared instance it /// carries no credential; `for_caller` swaps in one carrying the /// caller's own bearer token for the duration of a request. client: Client, /// Whether every request carries `x-trogon-atlas-branch`, which a server /// must honor before this adapter lets an agent mutate through it. branch_scoped: bool, /// The operator's profile on the shared instance; on a per-request copy, /// the profile that request runs under. profile: Option, // Required for `#[tool_router]` codegen; the macro reads this field // off `self` at dispatch time but the compiler can't prove the read // because the dispatch flows through trait-object indirection. The // attribute silences that diagnostic without papering over real // dead code elsewhere. #[allow(dead_code)] tool_router: ToolRouter, } impl McpServer { /// `options.branch` is the branch callers act on unless their request /// names its own. pub fn new(transport: Transport, options: ConnectOptions) -> Result { let client = client_for_transport(transport.clone(), None, &options)?; Ok(Self { transport, branch_scoped: options.branch.is_some(), options, client, profile: None, tool_router: Self::tool_router(), }) } /// Fix every session to `profile`. A request may still name a profile, /// but only this one: a request cannot widen or swap the operator's /// choice. #[must_use] pub fn with_profile(self, profile: Option) -> Self { Self { profile, ..self } } pub fn profile(&self) -> Option { self.profile } fn profile_for( &self, requested: Option, ) -> Result, rmcp::ErrorData> { match (self.profile, requested) { (Some(configured), Some(requested)) if configured != requested => { Err(rmcp::ErrorData::invalid_request( format!( "this server runs the `{configured}` tool profile; a request cannot select `{requested}`" ), None, )) } (configured, requested) => Ok(configured.or(requested)), } } fn offers(&self, tool: &rmcp::model::Tool) -> bool { self.profile.is_none_or(|profile| profile.admits(tool)) } /// Every tool this adapter registers, whatever any caller's role. pub fn registered_tools() -> Vec { Self::tool_router().list_all() } /// A copy of this server whose every gRPC call carries `caller`'s token /// and branch. fn for_caller(&self, caller: Caller) -> Result { let profile = self.profile_for(caller.profile)?; let options = ConnectOptions { branch: caller.branch.or_else(|| self.options.branch.clone()), ..self.options.clone() }; let client = client_for_transport( self.transport.clone(), caller.token.as_ref().map(CallerToken::as_str), &options, ) .map_err(|e| rmcp::ErrorData::invalid_request(e.to_string(), None))?; Ok(Self { client, branch_scoped: options.branch.is_some(), options, profile, ..self.clone() }) } fn for_request( &self, context: &rmcp::service::RequestContext, ) -> Result { self.for_caller(Caller::from_context(context)?) } /// Learn the caller's own role via `WhoAmI`, gated on the server /// advertising the `who_am_i` feature flag. A server that predates the /// flag yields `None` (list every tool unfiltered), the menu it offered /// before role filtering existed. A failed `GetServerInfo` or `WhoAmI` /// yields `Reader` instead, so a transient error never shows a reader /// the names and schemas of tools it cannot call. async fn learn_role(&self) -> Option { let mut client = self.client(); let info = match client.get_server_info(pb::GetServerInfoRequest {}).await { Ok(resp) => resp.into_inner(), Err(e) => { tracing::warn!(error = %e, "learn_role: GetServerInfo failed; listing reader tools"); return Some(ToolRole::Reader); } }; if !info.features.unwrap_or_default().who_am_i { tracing::warn!("learn_role: server does not advertise who_am_i; listing every tool"); return None; } match client.who_am_i(pb::WhoAmIRequest {}).await { Ok(resp) => { let role = resp.into_inner().role; Some(ToolRole::from( pb::Role::try_from(role).unwrap_or(pb::Role::Unspecified), )) } Err(e) => { tracing::warn!(error = %e, "learn_role: WhoAmI failed; listing reader tools"); Some(ToolRole::Reader) } } } /// Hand each tool invocation its own client clone so concurrent calls /// multiplex over the shared transport instead of serializing behind a /// single mutex. fn client(&self) -> Client { self.client.clone() } /// The scope this server's writes land in, named in every mutating /// response because a branch write leaves no durable receipt to check. fn scope_fields(&self) -> (&'static str, Option<&str>) { match self.options.branch.as_deref() { Some(branch) => ("branch", Some(branch)), None => ("baseline", None), } } fn scoped_envelope(&self, msg: &M) -> CallToolResult { let (scope, branch) = self.scope_fields(); let body = serde_json::json!({ "scope": scope, "branch": branch, "binpb_base64": encode_b64(msg), }) .to_string(); CallToolResult::success(vec![ContentBlock::text(body)]) } /// `scoped_envelope` plus the response's validation findings as a JSON /// `issues` array, so an agent can read non-blocking findings without /// decoding the binpb. Optional server `coverage` is passed through /// when present and never invented here. fn scoped_envelope_with_issues( &self, msg: &M, issues: &[serde_json::Value], coverage: Option<&pb::ValidationCoverage>, ) -> CallToolResult { let (scope, branch) = self.scope_fields(); let mut body = serde_json::json!({ "scope": scope, "branch": branch, "issues": issues, "binpb_base64": encode_b64(msg), }); trogon_atlas_client::apply::insert_coverage(&mut body, coverage); CallToolResult::success(vec![ContentBlock::text(body.to_string())]) } fn scoped_response( &self, message_name: &str, msg: &M, want_json: bool, ) -> CallToolResult { let mut result = ok_response(message_name, msg, want_json); let (scope, branch) = self.scope_fields(); let body = result .content .first() .and_then(|block| block.as_text()) .and_then(|text| serde_json::from_str::(&text.text).ok()); if let Some(serde_json::Value::Object(mut fields)) = body { fields.insert("scope".into(), scope.into()); fields.insert("branch".into(), branch.into()); result.content = vec![ContentBlock::text( serde_json::Value::Object(fields).to_string(), )]; } result } async fn ensure_can_mutate( &self, client: &mut Client, validate_only: bool, ) -> Result<(), rmcp::ErrorData> { self.ensure_intent( client, MutationIntent { validate_only, branch_scoped: self.branch_scoped, state_preconditions: false, }, ) .await } async fn ensure_can_apply_plan( &self, client: &mut Client, validate_only: bool, ) -> Result<(), rmcp::ErrorData> { self.ensure_intent( client, MutationIntent { validate_only, branch_scoped: self.branch_scoped, state_preconditions: true, }, ) .await } /// Protection is not branch-scoped and the server refuses a branch /// header on its writes, so refuse here before any request is sent. async fn protection_admin_client( &self, validate_only: bool, ) -> Result { if let Some(branch) = self.options.branch.as_deref() { return Err(mcp_error_data(trogon_atlas_client::failure::invalid_input( format!( "protection applies to an identity on every branch; call this without branch {branch}" ), ))); } let mut client = self.client(); compat::ensure_can_mutate_requiring( &mut client, MutationIntent { validate_only, ..Default::default() }, &[compat::Capability::EntityProtection], ) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; Ok(client) } async fn ensure_intent( &self, client: &mut Client, intent: MutationIntent, ) -> Result<(), rmcp::ErrorData> { compat::ensure_can_mutate(client, intent) .await .map_err(|e| McpError::from(e).into()) } } // JSON transcoding delegates to the ONE implementation in // trogon-atlas-core::transcode; these adapters only lift the error into // anyhow for the ~30 tool handlers below. fn message_from_json(message_name: &str, json: &str) -> Result { Ok(trogon_atlas_core::transcode::message_from_json( message_name, json, )?) } fn message_to_json(message_name: &str, msg: &M) -> Result { Ok(trogon_atlas_core::transcode::message_to_json( message_name, msg, )?) } async fn tenant_types_for( client: &mut Client, scope: &TypeScope, ) -> Result { tenant_types::transcode_pool(client, scope) .await .map_err(|e| McpError::from(e).into()) } fn json_type_scope(json: &str) -> TypeScope { serde_json::from_str(json) .map(|value| TypeScope::of_json(&value)) .unwrap_or_default() } fn principal_kind_name(kind: pb::PrincipalKind) -> &'static str { match kind { pb::PrincipalKind::Unspecified => "unspecified", pb::PrincipalKind::User => "user", pb::PrincipalKind::Agent => "agent", } } fn role_name(role: pb::Role) -> &'static str { match role { pb::Role::Unspecified => "unspecified", pb::Role::Reader => "reader", pb::Role::Writer => "writer", pb::Role::Admin => "admin", } } fn mcp_error_data(e: anyhow::Error) -> rmcp::ErrorData { McpError::from(e).into() } fn json_result(body: &serde_json::Value) -> CallToolResult { CallToolResult::success(vec![ContentBlock::text(body.to_string())]) } fn protected_identity( kind: &str, namespace: &str, slug: &str, ) -> Result { let kind = parse_kind(kind).map_err(rmcp::ErrorData::from)?; protection::ProtectedIdentity::new(kind, namespace, slug).map_err(mcp_error_data) } fn parse_kind(s: &str) -> Result { pb::canonical::parse_kind(s).ok_or_else(|| McpError::UnknownKind(s.to_string())) } fn kinds_to_i32(kinds: Option<&Vec>) -> Result, McpError> { match kinds { None => Ok(Vec::new()), Some(list) => list .iter() .map(|s| parse_kind(s).map(|k| k as i32)) .collect(), } } fn parse_delete_mode( s: Option<&String>, ) -> Result { let Some(raw) = s else { return Ok(pb::delete_entity_request::Mode::FailIfReferenced); }; match raw.trim().to_ascii_lowercase().as_str() { "" | "fail_if_referenced" | "failifreferenced" => { Ok(pb::delete_entity_request::Mode::FailIfReferenced) } "force" => Ok(pb::delete_entity_request::Mode::Force), "dry_run" | "dryrun" => Ok(pb::delete_entity_request::Mode::DryRun), other => Err(rmcp::ErrorData::invalid_params( format!("mode must be fail_if_referenced, force, or dry_run; got {other:?}"), None, )), } } /// Validate an `operation_id` param at the MCP boundary and resolve it to /// the value a request should actually carry. Dropped to empty whenever /// `dry_run_like` is set: the server rejects `operation_id` paired with /// `validate_only`/`dry_run`, since a dry run never produces a write for a /// later replay to recover (see `operation_receipts::reject_operation_id_with_dry_run`). fn resolve_operation_id( operation_id: Option, dry_run_like: bool, ) -> Result { let Some(id) = operation_id else { return Ok(String::new()); }; trogon_atlas_core::validate_operation_id(&id) .map_err(|e| rmcp::ErrorData::invalid_params(format!("operation_id {e}"), None))?; Ok(if dry_run_like { String::new() } else { id }) } /// Validate a namespace or slug at the MCP boundary before issuing any RPC. /// /// Replicates the charset rule from `trogon_atlas_core::validate_id_component` /// (see trogon-atlas-server/src/conv.rs): ASCII alphanumerics plus `-`, `_`, `.`; /// not empty, not `.` or `..`, not leading `.`, max 200 chars. /// Checked locally so callers get a structured `invalid_params` instead of a /// cryptic transport error from the server. fn validate_id_component(label: &str, value: &str) -> Result<(), rmcp::ErrorData> { if value.is_empty() { return Err(rmcp::ErrorData::invalid_params( format!("{label} must not be empty"), None, )); } if value.len() > 200 { return Err(rmcp::ErrorData::invalid_params( format!("{label} exceeds 200 chars"), None, )); } if value == "." || value == ".." { return Err(rmcp::ErrorData::invalid_params( format!("{label} must not be '.' or '..'"), None, )); } if value.starts_with('.') { return Err(rmcp::ErrorData::invalid_params( format!("{label} must not start with '.'"), None, )); } for ch in value.chars() { let safe = ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'); if !safe { return Err(rmcp::ErrorData::invalid_params( format!("{label} contains invalid character {ch:?}; allowed: [A-Za-z0-9_.-]"), None, )); } } Ok(()) } fn id_from(ns: &str, slug: &str, version: u64) -> pb::Id { pb::Id { namespace: ns.to_string(), slug: slug.to_string(), version, } } /// Validates namespace, slug, and version before building an Id, returning a /// structured `invalid_params` error instead of forwarding bad input to gRPC. fn validated_id_from(ns: &str, slug: &str, version: u64) -> Result { validate_id_component("namespace", ns)?; validate_id_component("slug", slug)?; if version == 0 { return Err(rmcp::ErrorData::invalid_params("version must be > 0", None)); } Ok(id_from(ns, slug, version)) } fn validate_id_component_mcp(field: &'static str, value: &str) -> Result<(), McpError> { validate_id_component(field, value).map_err(|e| McpError::InvalidIdComponent { field, detail: e.message.to_string(), }) } fn scope_from( kind: &str, ns: &str, slug: &str, version: u64, ) -> Result { let k = parse_kind(kind)?; validate_id_component_mcp("namespace", ns)?; validate_id_component_mcp("slug", slug)?; let entity_ref = pb::EntityRef { kind: k as i32, id: Some(id_from(ns, slug, version)), }; let scope = match k { pb::EntityKind::CommandSlice | pb::EntityKind::ReadModelSlice | pb::EntityKind::AutomationSlice | pb::EntityKind::UiSlice => pb::analysis_scope::Scope::Slice(entity_ref), pb::EntityKind::Storyboard => pb::analysis_scope::Scope::Storyboard(entity_ref), pb::EntityKind::EventModel => pb::analysis_scope::Scope::EventModel(entity_ref), other => return Err(McpError::InvalidScopeKind { kind: other }), }; Ok(pb::AnalysisScope { scope: Some(scope) }) } fn decode_entity_b64(b64: &str) -> Result { let bytes = B64 .decode(b64) .map_err(|e| McpError::InvalidBase64(e.to_string()))?; pb::Entity::decode(bytes.as_slice()).map_err(|source| McpError::InvalidBinpb { message: "Entity", source, }) } fn encode_b64(msg: &M) -> String { // `encode_to_vec` allocates a `Vec` sized to `encoded_len` and cannot // fail (writing to a Vec is infallible). Avoids the cosmetic `.expect` // a `grep -n expect` would otherwise surface. B64.encode(msg.encode_to_vec()) } /// Wire-stable envelope for proto responses returned through MCP. The body /// is a JSON object with a single `binpb_base64` field carrying the /// base64-encoded protobuf; callers decode it (or use the `*_json` /// companion tools when JSON is wanted directly). Rust `Debug` output is /// deliberately NOT included: it would couple consumers to a format that /// changes whenever prost regenerates field ordering or struct shape. fn ok_envelope(msg: &M) -> CallToolResult { let body = serde_json::json!({ "binpb_base64": encode_b64(msg) }).to_string(); CallToolResult::success(vec![ContentBlock::text(body)]) } /// JSON shape of one `ValidationIssue` in a mutation response: /// `{severity, code, subject, field, message}`. `severity` is /// `error`/`warning`/`info`; `subject` is the canonical entity id string or /// `null`; `field` is the structural path inside the subject or `null`. fn rule_json(rule: &pb::ValidationRule) -> serde_json::Value { let severity = match pb::validation_issue::Severity::try_from(rule.default_severity) { Ok(pb::validation_issue::Severity::Error) => "error", Ok(pb::validation_issue::Severity::Warning) => "warning", Ok(pb::validation_issue::Severity::Info) => "info", _ => "unspecified", }; serde_json::json!({ "code": rule.code, "default_severity": severity, "title": rule.title, "doc": rule.doc, "category": rule.category, "field": (!rule.subject_field.is_empty()).then_some(rule.subject_field.as_str()), }) } /// Hex SHA-256 of the schema's serialized form. `serde_json` keeps object /// keys sorted here, so the digest is stable for an unchanged schema. fn schema_sha256(schema: &serde_json::Value) -> String { use sha2::{Digest, Sha256}; Sha256::digest(schema.to_string().as_bytes()).iter().fold( String::with_capacity(64), |mut hex, byte| { let _ = std::fmt::Write::write_fmt(&mut hex, format_args!("{byte:02x}")); hex }, ) } fn issue_json(issue: &pb::ValidationIssue) -> serde_json::Value { let mut value = trogon_atlas_client::apply::issue_to_json(issue); if let serde_json::Value::Object(fields) = &mut value { fields.insert( "field".into(), (!issue.subject_field.is_empty()) .then_some(issue.subject_field.as_str()) .into(), ); } value } fn issues_json(issues: &[pb::ValidationIssue]) -> Vec { issues.iter().map(issue_json).collect() } /// Canonical id of the entity each batch op writes or deletes, in op order. /// Read from the request because a delete result names no entity. fn batch_op_entities(ops: &[pb::BatchMutateOp]) -> Vec> { ops.iter() .map(|op| match op.op.as_ref()? { pb::batch_mutate_op::Op::Put(put) => { let entity = put.entity.as_ref()?; let kind = trogon_atlas_core::entity_kind(entity)?; let id = trogon_atlas_core::entity_id(entity)?; Some(pb::canonical::id_string(kind, id)) } pb::batch_mutate_op::Op::Delete(delete) => { let kind = pb::EntityKind::try_from(delete.kind).ok()?; Some(pb::canonical::id_string(kind, delete.id.as_ref()?)) } }) .collect() } /// Findings of every put and delete op in a batch, in op order. Each carries /// the zero based `op_index` and the canonical `op_entity` id of the entity /// the op wrote or deleted, because a finding's own `subject` can be a /// different entity (a strategic rule attached to the BoundedContext, Domain /// or Subdomain left behind). fn batch_issues_json( op_entities: &[Option], resp: &pb::BatchMutateResponse, ) -> Vec { let mut out = Vec::new(); for (index, result) in resp.results.iter().enumerate() { let findings = match result.result.as_ref() { Some(pb::batch_mutate_op_result::Result::Put(put)) => &put.validation, Some(pb::batch_mutate_op_result::Result::Delete(delete)) => &delete.validation, None => continue, }; let op_entity = op_entities.get(index).and_then(Option::as_deref); out.extend(op_tagged_issues_json(index, op_entity, findings)); } out } /// `issues_json` with each finding tagged by the op that produced it. fn op_tagged_issues_json( op_index: usize, op_entity: Option<&str>, issues: &[pb::ValidationIssue], ) -> Vec { issues .iter() .map(|issue| { let mut value = issue_json(issue); if let serde_json::Value::Object(fields) = &mut value { fields.insert("op_index".into(), op_index.into()); fields.insert("op_entity".into(), op_entity.into()); } value }) .collect() } /// Findings of an `apply_manifests` run, in the same shape as batch findings. /// `op_index` counts the changed manifests actually sent, and `op_entity` is /// the manifest's canonical id. fn apply_issues_json(op_issues: &[trogon_atlas_client::apply::OpIssues]) -> Vec { op_issues .iter() .flat_map(|op| op_tagged_issues_json(op.op_index, Some(op.op_entity.as_str()), &op.issues)) .collect() } /// Add `issues` to an already-built JSON tool result body so a response that /// carries findings alongside other data exposes them without a decode step. fn with_coverage( mut result: CallToolResult, coverage: Option<&pb::ValidationCoverage>, ) -> CallToolResult { let body = result .content .first() .and_then(|block| block.as_text()) .and_then(|text| serde_json::from_str::(&text.text).ok()); if let (Some(mut body), Some(_)) = (body, coverage) { trogon_atlas_client::apply::insert_coverage(&mut body, coverage); result.content[0] = ContentBlock::text(body.to_string()); } result } fn with_issues(mut result: CallToolResult, issues: Vec) -> CallToolResult { let body = result .content .first() .and_then(|block| block.as_text()) .and_then(|text| serde_json::from_str::(&text.text).ok()); if let Some(serde_json::Value::Object(mut fields)) = body { fields.insert("issues".into(), issues.into()); result.content = vec![ContentBlock::text( serde_json::Value::Object(fields).to_string(), )]; } result } /// `BatchMutateResponse.status == STATUS_FAILED` is an RPC that returned /// `Ok` while carrying a domain failure in its body; `ok_envelope` alone /// would report it to the agent as a successful tool call. Classify it the /// same way `trogon-atlas-client::apply` classifies the identical response /// shape, so the agent gets an MCP tool error with the same category data /// any other failure carries instead of a binpb blob it has to decode to /// discover the batch did not apply. fn batch_mutate_failure(resp: &pb::BatchMutateResponse) -> anyhow::Error { if let Some(failure) = &resp.precondition_failure { return match StalePlan::from_wire(failure) { Ok(stale) => anyhow::Error::new(stale), Err(e) => e, }; } anyhow::Error::new(ApplyRejected { failed: format!("op[{}]", resp.failed_op_index), issues: resp.failure.clone(), coverage: resp.coverage.clone(), }) } /// Universal response shaper. When `want_json` is true and the descriptor pool /// knows the message, return the JSON-rendered proto in a /// `{ "format": "json", "json": "..." }` envelope. On any serialization /// failure (descriptor missing, transcoding fault) fall back to the /// `binpb_base64` envelope so the tool call never fails purely over the response /// shape, but log at warn and include `"format": "binpb_base64"` so callers /// can detect the fallback without inspecting field presence. fn ok_response(message_name: &str, msg: &M, want_json: bool) -> CallToolResult { if want_json { match message_to_json(message_name, msg) { Ok(s) => { let body = serde_json::json!({ "format": "json", "json": s }).to_string(); return CallToolResult::success(vec![ContentBlock::text(body)]); } Err(e) => { tracing::warn!( message_name = message_name, error = %e, "ok_response: JSON serialization failed; falling back to binpb_base64" ); } } } let body = serde_json::json!({ "format": "binpb_base64", "binpb_base64": encode_b64(msg) }) .to_string(); CallToolResult::success(vec![ContentBlock::text(body)]) } #[tool_router] impl McpServer { #[tool( description = "Server identity, schema version, feature flags, and limits.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_server_info( &self, Parameters(_p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .get_server_info(pb::GetServerInfoRequest {}) .await .map_err(tool_err("get_server_info"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Who you are authenticated as: name, kind, role, writable namespaces, \ owner, and whether you're the anonymous principal.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn who_am_i( &self, Parameters(_p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .who_am_i(pb::WhoAmIRequest {}) .await .map_err(tool_err("who_am_i"))? .into_inner(); let kind = pb::PrincipalKind::try_from(resp.kind).unwrap_or(pb::PrincipalKind::Unspecified); let role = pb::Role::try_from(resp.role).unwrap_or(pb::Role::Unspecified); let body = serde_json::json!({ "name": resp.name, "kind": principal_kind_name(kind), "role": role_name(role), "namespaces": resp.namespaces, "owner": resp.owner, "anonymous": resp.anonymous, }) .to_string(); Ok(CallToolResult::success(vec![ContentBlock::text(body)])) } #[tool( description = "List known namespaces with approximate entity counts.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_namespaces( &self, Parameters(_p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .list_namespaces(pb::ListNamespacesRequest {}) .await .map_err(tool_err("list_namespaces"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Claim a namespace name and get back its id. Always succeeds for a name you do not already hold, because the id is minted rather than taken from the name: this is the answer when a write is refused because the bare name is unavailable. Idempotent, so re-registering a name you hold returns the existing row with created=false.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn register_namespace( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); self.ensure_can_mutate(&mut client, false).await?; let resp = client .register_namespace(pb::RegisterNamespaceRequest { name: p.name, parent: p.parent.unwrap_or_default(), }) .await .map_err(tool_err("register_namespace"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Re-parent a namespace. Admin only: it is the one operation that crosses a tenancy boundary. Writes the registry row and nothing else, so no entity is re-keyed and no reference changes.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn move_namespace( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); self.ensure_can_mutate(&mut client, false).await?; let resp = client .move_namespace(pb::MoveNamespaceRequest { id: p.id, parent: p.parent, }) .await .map_err(tool_err("move_namespace"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Freeze every version of an identity (kind, namespace, slug), including versions not written yet, so every write route refuses it with ENTITY_PROTECTED until an admin lifts it. Admin only, baseline only, and the identity must already have a stored version. changed=false means it was already protected; protection then holds the existing row, whose reason may differ.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn protect_entity( &self, Parameters(p): Parameters, ) -> Result { let identity = protected_identity(&p.kind, &p.namespace, &p.slug)?; let reason = protection::ProtectionReason::new(&p.reason).map_err(mcp_error_data)?; let mut client = self.protection_admin_client(p.validate_only).await?; let outcome = protection::protect( &mut client, &identity, &reason, protection::Effect::from_validate_only(p.validate_only), ) .await .map_err(mcp_error_data)?; Ok(json_result(&outcome.to_json())) } #[tool( description = "Lift protection from an identity (kind, namespace, slug) so writes to it are accepted again. Admin only, baseline only. Pass if_match with the row revision get_entity_protection or list_entity_protections reported to lift only that row; that is also how an unreadable (unknown) row is removed. changed=false means it was not protected.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = false ) )] async fn unprotect_entity( &self, Parameters(p): Parameters, ) -> Result { let identity = protected_identity(&p.kind, &p.namespace, &p.slug)?; let if_match = match p.if_match { None => None, Some(revision) => Some(protection::RowRevision::new(revision).ok_or_else(|| { mcp_error_data(trogon_atlas_client::failure::invalid_input( "if_match must be a positive row revision; omit it to lift whatever is current", )) })?), }; let mut client = self.protection_admin_client(p.validate_only).await?; let outcome = protection::unprotect( &mut client, &identity, if_match, protection::Effect::from_validate_only(p.validate_only), ) .await .map_err(mcp_error_data)?; Ok(json_result(&outcome.to_json())) } #[tool( description = "Whether an identity (kind, namespace, slug) is protected, unprotected, or unknown. state=unknown means the protection row could not be read: writes to the identity are refused, and an admin can lift the row with unprotect_entity if_match=unknown.revision when unknown.liftable is true.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_entity_protection( &self, Parameters(p): Parameters, ) -> Result { let identity = protected_identity(&p.kind, &p.namespace, &p.slug)?; let mut client = self.client(); let lookup = protection::get_protection(&mut client, &identity) .await .map_err(mcp_error_data)?; Ok(json_result(&lookup.to_json())) } #[tool( description = "List protected identities and unreadable protection rows. verdict=partial (complete=false, or any entry in unknown) means the listing is not the whole registry: an identity missing from it may still be protected, so do not treat absence as permission to write.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_entity_protections( &self, Parameters(p): Parameters, ) -> Result { let namespace = p .namespace .as_deref() .map(protection::NamespaceFilter::new) .transpose() .map_err(mcp_error_data)?; let mut client = self.client(); let listing = protection::list_protections(&mut client, namespace.as_ref()) .await .map_err(mcp_error_data)?; Ok(json_result(&listing.to_json())) } #[tool( description = "Compile a type library's proto source without writing it. Returns the compiler diagnostics (path, line, column, message) and any wire-compatibility violations against the library's live version; both empty means put_entity_json would accept it.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn compile_type_library( &self, Parameters(p): Parameters, ) -> Result { if p.library_json.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "library_json exceeds the 4 MiB limit ({} bytes)", p.library_json.len() ), None, )); } let library: pb::TypeLibrary = message_from_json( "trogonatlas.eventmodel.v1alpha1.TypeLibrary", &p.library_json, ) .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; let mut client = self.client(); let resp = client .compile_type_library(pb::CompileTypeLibraryRequest { library: Some(library), }) .await .map_err(tool_err("compile_type_library"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Resolve a message type against a namespace's live type libraries. Returns the library that declares it (absent for built-in types) and a FileDescriptorSet holding its file and every import, enough to decode payloads of that type.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn resolve_type( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .resolve_type(pb::ResolveTypeRequest { namespace: p.namespace, type_url: p.type_url, }) .await .map_err(tool_err("resolve_type"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Content-addressable id naming the current model state. Take one before an analysis and one after to tell whether anything changed, without walking the change feed; cite one to pin the state a report described. Ids are comparable only across the same namespace scope, and only when truncated is false.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_snapshot_id( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .get_snapshot_id(pb::GetSnapshotIdRequest { namespaces: p.namespaces.unwrap_or_default(), include_entries: p.include_entries, }) .await .map_err(tool_err("get_snapshot_id"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "List entities filtered by optional kind/namespace/lifecycle status with paging. Use lifecycle_status_not_in with [\"draft\", \"proposed\"] to hide drafts, or lifecycle_status_in with [\"draft\"] to show only drafts.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_entities( &self, Parameters(p): Parameters, ) -> Result { let kinds = match p.kind.as_deref() { Some(k) if !k.is_empty() => { vec![parse_kind(k).map_err(rmcp::ErrorData::from)? as i32] } _ => Vec::new(), }; let namespaces = p.namespace.into_iter().collect::>(); let page_size = i32::try_from(p.page_size.clamp(1, 500)) .map_err(|_| rmcp::ErrorData::invalid_params("page_size overflows i32", None))?; let req = pb::ListEntitiesRequest { kinds, namespaces, latest_versions_only: false, page_size, page_token: p.page_token.unwrap_or_default(), lifecycle_status_in: p.lifecycle_status_in.unwrap_or_default(), lifecycle_status_not_in: p.lifecycle_status_not_in.unwrap_or_default(), }; let mut client = self.client(); let resp = client .list_entities(req) .await .map_err(tool_err("list_entities"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "CreateSourceRegistration: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn create_source_registration( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "CreateSourceRegistrationRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::create_source_registration( &mut client, request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "UpdateSourceRegistration: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn update_source_registration( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "UpdateSourceRegistrationRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::update_source_registration( &mut client, request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetSourceRegistration: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_source_registration( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "GetSourceRegistrationRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::get_source_registration( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSourceRegistrations: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_source_registrations( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "ListSourceRegistrationsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::list_source_registrations( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "DeleteSourceRegistration: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn delete_source_registration( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "DeleteSourceRegistrationRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::delete_source_registration( &mut client, request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "AcceptSourceSnapshot: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn accept_source_snapshot( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "AcceptSourceSnapshotRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::accept_source_snapshot(&mut client, request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetSourceSnapshot: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_source_snapshot( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "GetSourceSnapshotRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::get_source_snapshot( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSourceSnapshots: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_source_snapshots( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "ListSourceSnapshotsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::list_source_snapshots( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "CreateSourceMapping: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn create_source_mapping( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "CreateSourceMappingRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = Box::pin( trogon_atlas_client::source_integration::create_source_mapping(&mut client, request), ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "UpdateSourceMapping: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn update_source_mapping( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "UpdateSourceMappingRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = Box::pin( trogon_atlas_client::source_integration::update_source_mapping(&mut client, request), ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetSourceMapping: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_source_mapping( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "GetSourceMappingRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::get_source_mapping( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSourceMappings: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_source_mappings( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "ListSourceMappingsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::list_source_mappings( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "DeleteSourceMapping: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn delete_source_mapping( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "DeleteSourceMappingRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::delete_source_mapping(&mut client, request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetSourceOverlay: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_source_overlay( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "GetSourceOverlayRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::get_source_overlay( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "PreviewSourceRun: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn preview_source_run( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let document = trogon_atlas_client::source_integration::prepare_preview_request(&json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let request = document .load(&mut client) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::preview_source_run(&mut client, request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ApplySourceRun: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn apply_source_run( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request("ApplySourceRunRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::apply_source_run(&mut client, request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "CancelSourceRun: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn cancel_source_run( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request("CancelSourceRunRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::cancel_source_run(&mut client, request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetSourceRun: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_source_run( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request("GetSourceRunRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::get_source_run(&mut self.client(), request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSourceRuns: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_source_runs( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request("ListSourceRunsRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::list_source_runs(&mut self.client(), request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "RecoverSourceRun: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn recover_source_run( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "RecoverSourceRunRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; let result = trogon_atlas_client::source_integration::recover_source_run(&mut client, request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSourceRunResults: connected backend source control. Explicit selectors and ownership only. Returned scope, fidelity, incomplete and recovery state remain authoritative; preview is not an apply receipt.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_source_run_results( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > 48 * 1024 * 1024 { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "ListSourceRunResultsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::list_source_run_results( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSourceRunOperations: inspect bounded source plan operations and guards. Bind each page to returned run revision and preview digest; incomplete pages never establish complete safety.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_source_run_operations( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "source request exceeds transport limit", None, )); } let json = serde_json::from_str(&text) .map_err(|_| rmcp::ErrorData::invalid_params("invalid source request JSON", None))?; let request = trogon_atlas_client::source_integration::parse_request( "ListSourceRunOperationsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::source_integration::list_source_run_operations( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetDesignGraphDetail: inspect native design topology with exact hydrated targets, hidden and missing endpoints sharing one generic state, and explicit incomplete boundaries. Incomplete or unavailable results do not prove absence.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_design_graph_detail( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::design_topology::parse_request( "GetDesignGraphDetailRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::design_topology::get_design_graph_detail( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSubjectDesignGraphs: inspect native design topology with exact hydrated targets, hidden and missing endpoints sharing one generic state, and explicit incomplete boundaries. Incomplete or unavailable results do not prove absence.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_subject_design_graphs( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::design_topology::parse_request( "ListSubjectDesignGraphsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::design_topology::list_subject_design_graphs( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "TraverseDesignGraphTopology: inspect native design topology with exact hydrated targets, hidden and missing endpoints sharing one generic state, and explicit incomplete boundaries. Incomplete or unavailable results do not prove absence.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn traverse_design_graph_topology( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::design_topology::parse_request( "TraverseDesignGraphTopologyRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::design_topology::traverse_design_graph_topology( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListIncomingDesignGraphSubflows: inspect native design topology with exact hydrated targets, hidden and missing endpoints sharing one generic state, and explicit incomplete boundaries. Incomplete or unavailable results do not prove absence.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_incoming_design_graph_subflows( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::design_topology::parse_request( "ListIncomingDesignGraphSubflowsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::design_topology::list_incoming_design_graph_subflows( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListDecisionAmendments: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_decision_amendments( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "ListDecisionAmendmentsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::list_decision_amendments( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSubjectDecisions: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_subject_decisions( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "ListSubjectDecisionsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::list_subject_decisions( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSubjectDocuments: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_subject_documents( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "ListSubjectDocumentsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::list_subject_documents( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetDecisionDetail: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_decision_detail( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "GetDecisionDetailRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::get_decision_detail( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetDocumentDetail: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_document_detail( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "GetDocumentDetailRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::get_document_detail( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListIncomingDecisionDeclarations: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_incoming_decision_declarations( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "ListIncomingDecisionDeclarationsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::list_incoming_decision_declarations( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListTermDomainDeclarations: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_term_domain_declarations( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "ListTermDomainDeclarationsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::term_domains::list_term_domain_declarations( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListIncomingDocumentDeclarations: inspect native catalog declarations with exact selectors and backend checked scope. Incomplete or unavailable results do not prove absence; no approval or implementation evidence is inferred.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_incoming_document_declarations( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::decision_document::parse_request( "ListIncomingDocumentDeclarationsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::decision_document::list_incoming_document_declarations( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "InspectHumanInteraction: check one exact hosting occurrence and a complete authored human issuance or display tuple. Return unchanged selectors, endpoint receipts, checked scope and per-owner read-set consistency. Owners may be read at different instants; refresh stale receipts. Unavailable selections do not prove absence or runtime permission.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn inspect_human_interaction( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::human_interaction::parse_request( "InspectHumanInteractionRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::human_interaction::inspect_human_interaction( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListChannelMessages: list messages declared on an exact channel version. Preserve returned authority, owner UID, occurrence selectors, parameter values, delivery mode, and doc presence. A page token continues the same caller and scope. Incomplete, hidden, or unavailable results do not prove absence.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_channel_messages( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::channel_messages::parse_request( "ListChannelMessagesRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::channel_messages::list_channel_messages( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListSubjectAssignments: return backend checked exact responsibility declarations and provenance. Incomplete or inaccessible results never prove absence. Team contact member expansion requires explicit includeMembers; no inheritance or authorization grants.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_subject_assignments( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::responsibility::parse_request( "ListSubjectAssignmentsRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::responsibility::list_subject_assignments( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListAssigneeResponsibilities: return backend checked exact responsibility declarations and provenance. Incomplete or inaccessible results never prove absence. Team contact member expansion requires explicit includeMembers; no inheritance or authorization grants.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_assignee_responsibilities( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::responsibility::parse_request( "ListAssigneeResponsibilitiesRequest", &json, ) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::responsibility::list_assignee_responsibilities( &mut self.client(), request, ) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListTeamMembers: return backend checked exact responsibility declarations and provenance. Incomplete or inaccessible results never prove absence. Team contact member expansion requires explicit includeMembers; no inheritance or authorization grants.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_team_members( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::responsibility::parse_request("ListTeamMembersRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::responsibility::list_team_members(&mut self.client(), request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "ListPersonTeams: return backend checked exact responsibility declarations and provenance. Incomplete or inaccessible results never prove absence. Team contact member expansion requires explicit includeMembers; no inheritance or authorization grants.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_person_teams( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::responsibility::parse_request("ListPersonTeamsRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::responsibility::list_person_teams(&mut self.client(), request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "GetTeamContacts: return backend checked exact responsibility declarations and provenance. Incomplete or inaccessible results never prove absence. Team contact member expansion requires explicit includeMembers; no inheritance or authorization grants.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_team_contacts( &self, Parameters(p): Parameters, ) -> Result { let text = p.request_json.to_json_text(); if text.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( "request_json exceeds the 4 MiB limit", None, )); } let json = serde_json::from_str(&text).map_err(|error| { rmcp::ErrorData::invalid_params(format!("invalid request JSON: {error}"), None) })?; let request = trogon_atlas_client::responsibility::parse_request("GetTeamContactsRequest", &json) .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; let result = trogon_atlas_client::responsibility::get_team_contacts(&mut self.client(), request) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "Inspect an exact DataProduct using backend checked selection states, Event emission witnesses and producing model paths. Incomplete evidence is not runtime proof.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn inspect_data_product( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; if kind != pb::EntityKind::DataProduct { return Err(rmcp::ErrorData::invalid_params( "expected data_product kind", None, )); } let id = validated_id_from(&p.namespace, &p.slug, p.version)?; let result = trogon_atlas_client::data_product::inspect(&mut self.client(), id) .await .map_err(|error| rmcp::ErrorData::from(McpError::from(error)))?; Ok(CallToolResult::success(vec![ContentBlock::text( result.to_string(), )])) } #[tool( description = "Fetch a single entity by (kind, namespace, slug, version). Returns an opaque etag for use with put_entity / delete_entity if_match.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_entity( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let req = pb::GetEntityRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }; let mut client = self.client(); let resp = client .get_entity(req) .await .map_err(tool_err("get_entity"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Batch fetch entities by (kind, namespace, slug, version) tuples. Returns the entities in arbitrary order (missing entries omitted).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn batch_get_entities( &self, Parameters(p): Parameters, ) -> Result { const MAX_REFS: usize = 100; if p.refs.len() > MAX_REFS { return Err(rmcp::ErrorData::invalid_params( format!( "batch_get_entities accepts at most {} refs, got {}", MAX_REFS, p.refs.len() ), None, )); } let mut keys = Vec::with_capacity(p.refs.len()); for r in p.refs { let kind = parse_kind(&r.kind).map_err(rmcp::ErrorData::from)?; keys.push(pb::EntityRef { kind: kind as i32, id: Some(validated_id_from(&r.namespace, &r.slug, r.version)?), }); } let req = pb::BatchGetEntitiesRequest { keys, dense: false }; let mut client = self.client(); let resp = client .batch_get_entities(req) .await .map_err(tool_err("batch_get_entities"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Full-text search across entity title/doc. Returns results with relevance scores and optional excerpts.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn search_entities( &self, Parameters(p): Parameters, ) -> Result { let kinds = kinds_to_i32(p.kinds.as_ref()).map_err(rmcp::ErrorData::from)?; if p.q.len() > MAX_SEARCH_QUERY_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "q exceeds the {MAX_SEARCH_QUERY_BYTES}-byte limit ({} bytes)", p.q.len() ), None, )); } let limit = i32::try_from(p.page_size.clamp(1, 500)) .map_err(|_| rmcp::ErrorData::invalid_params("page_size overflows i32", None))?; let req = pb::SearchEntitiesRequest { query: p.q, kinds, namespaces: Vec::new(), limit, }; let mut client = self.client(); let resp = client .search_entities(req) .await .map_err(tool_err("search_entities"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "List every stored version of (kind, namespace, slug), ordered ascending.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_versions( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; validate_id_component("namespace", &p.namespace)?; validate_id_component("slug", &p.slug)?; let req = pb::ListVersionsRequest { kind: kind as i32, namespace: p.namespace, slug: p.slug, page_size: 0, page_token: String::new(), }; let mut client = self.client(); let resp = client .list_versions(req) .await .map_err(tool_err("list_versions"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Return the highest-version entity for (kind, namespace, slug).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_latest_version( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; validate_id_component("namespace", &p.namespace)?; validate_id_component("slug", &p.slug)?; let req = pb::GetLatestVersionRequest { kind: kind as i32, namespace: p.namespace, slug: p.slug, }; let mut client = self.client(); let resp = client .get_latest_version(req) .await .map_err(tool_err("get_latest_version"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Walk the supersedes/superseded-by chain. include_descendants=true returns both ancestors and descendants; otherwise only ancestors.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_supersession_chain( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let direction = if p.include_descendants { pb::get_supersession_chain_request::Direction::Both } else { pb::get_supersession_chain_request::Direction::Ancestors }; let req = pb::GetSupersessionChainRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), direction: direction as i32, page_size: 0, page_token: String::new(), }; let mut client = self.client(); let resp = client .get_supersession_chain(req) .await .map_err(tool_err("get_supersession_chain"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Find entities that reference this one (incoming pointer hits).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_incoming_references( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let filter_kinds = kinds_to_i32(p.filter_kinds.as_ref()).map_err(rmcp::ErrorData::from)?; let req = pb::GetReferencesRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), filter_kinds, page_size: 0, page_token: String::new(), }; let mut client = self.client(); let resp = client .get_incoming_references(req) .await .map_err(tool_err("get_incoming_references"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Find entities this one references (outgoing pointer hits).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_outgoing_references( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let filter_kinds = kinds_to_i32(p.filter_kinds.as_ref()).map_err(rmcp::ErrorData::from)?; let req = pb::GetReferencesRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), filter_kinds, page_size: 0, page_token: String::new(), }; let mut client = self.client(); let resp = client .get_outgoing_references(req) .await .map_err(tool_err("get_outgoing_references"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Transitive impact analysis: every entity reachable from root, with depth and the path of references that led there.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_impact( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let filter_kinds = kinds_to_i32(p.filter_kinds.as_ref()).map_err(rmcp::ErrorData::from)?; let req = pb::GetImpactRequest { root: Some(pb::EntityRef { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }), max_depth: p.max_depth, filter_kinds, page_size: 0, page_token: String::new(), }; let mut client = self.client(); let resp = client .get_impact(req) .await .map_err(tool_err("get_impact"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Rewrite every stored reference to `from` so it points at `to`. \ The common case is a version promotion: create v+1 with supersedes, \ call retarget_references (dry_run=true first to preview), \ then drain residual SLICE_STALE_REF findings. \ For partial migrations use get_incoming_references + batch_mutate instead.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn retarget_references( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.from_kind).map_err(rmcp::ErrorData::from)?; let to_ns = p.to_namespace.as_deref().unwrap_or(&p.from_namespace); let to_slug = p.to_slug.as_deref().unwrap_or(&p.from_slug); let filter_kinds = kinds_to_i32(p.filter_kinds.as_ref()).map_err(rmcp::ErrorData::from)?; let req = pb::RetargetReferencesRequest { from: Some(pb::EntityRef { kind: kind as i32, id: Some(validated_id_from( &p.from_namespace, &p.from_slug, p.from_version, )?), }), to: Some(pb::EntityRef { kind: kind as i32, id: Some(validated_id_from(to_ns, to_slug, p.to_version)?), }), dry_run: p.dry_run, filter_kinds, }; let mut client = self.client(); self.ensure_can_mutate(&mut client, req.dry_run).await?; let resp = client .retarget_references(req) .await .map_err(tool_err("retarget_references"))? .into_inner(); Ok(self.scoped_envelope(&resp)) } #[tool( description = "Slice projection: hydrate a slice plus the one-hop entities it references.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_slice_projection( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let req = pb::GetSliceProjectionRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }; let mut client = self.client(); let resp = client .get_slice_projection(req) .await .map_err(tool_err("get_slice_projection"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Storyboard projection: storyboard + inlined slices + every transitively referenced entity.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_storyboard_projection( &self, Parameters(p): Parameters, ) -> Result { let req = pb::GetStoryboardProjectionRequest { id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }; let mut client = self.client(); let resp = client .get_storyboard_projection(req) .await .map_err(tool_err("get_storyboard_projection"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Event-model projection: the model plus every transitively reachable entity. Set include_cross_model=true to follow cross-namespace references. Set as_of_changeset to read the baseline model as it was right after that changeset.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_event_model_projection( &self, Parameters(p): Parameters, ) -> Result { let req = pb::GetEventModelProjectionRequest { id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), include_cross_model: p.include_cross_model, as_of_changeset: p.as_of_changeset.unwrap_or_default(), }; let mut client = self.client(); let resp = client .get_event_model_projection(req) .await .map_err(tool_err("get_event_model_projection"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Extract a subgraph: synthesizes a new (unpersisted) EventModel covering the closure of the given root entities.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn extract_subgraph( &self, Parameters(p): Parameters, ) -> Result { let scope = scope_from( &p.root.kind, &p.root.namespace, &p.root.slug, p.root.version, ) .map_err(rmcp::ErrorData::from)?; let req = pb::ExtractSubgraphRequest { scope: Some(scope), new_event_model_id: None, include_cross_model: false, }; let mut client = self.client(); let resp = client .extract_subgraph(req) .await .map_err(tool_err("extract_subgraph"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Structural diff between two entities (typically two versions of the same entity).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn diff_entities( &self, Parameters(p): Parameters, ) -> Result { let kind_a = parse_kind(&p.a.kind).map_err(rmcp::ErrorData::from)?; let kind_b = parse_kind(&p.b.kind).map_err(rmcp::ErrorData::from)?; let req = pb::DiffEntitiesRequest { a: Some(pb::EntityRef { kind: kind_a as i32, id: Some(validated_id_from(&p.a.namespace, &p.a.slug, p.a.version)?), }), b: Some(pb::EntityRef { kind: kind_b as i32, id: Some(validated_id_from(&p.b.namespace, &p.b.slug, p.b.version)?), }), }; let mut client = self.client(); let resp = client .diff_entities(req) .await .map_err(tool_err("diff_entities"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Create or replace an entity. The entity payload is a base64-encoded prost binpb of an Entity message (obtain via get_entity, edit binpb_base64, and pass back). A failed write is an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never a success envelope. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64: fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn put_entity( &self, Parameters(p): Parameters, ) -> Result { const MAX_B64_BYTES: usize = MAX_JSON_BYTES * 4 / 3 + 4; if p.entity_b64.len() > MAX_B64_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "entity_b64 exceeds the size limit ({} bytes)", p.entity_b64.len() ), None, )); } let entity = decode_entity_b64(&p.entity_b64).map_err(rmcp::ErrorData::from)?; let operation_id = resolve_operation_id(p.operation_id, p.validate_only)?; let req = pb::PutEntityRequest { entity: Some(entity), create_only: p.create_only, if_match: p.if_match, validate_only: p.validate_only, force: p.force, operation_id: operation_id.clone(), }; let mut client = self.client(); self.ensure_can_mutate(&mut client, req.validate_only) .await?; let resp = client .put_entity(req) .await .map_err(tool_err("put_entity")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); Ok(self.scoped_envelope_with_issues( &resp, &issues_json(&resp.validation), resp.coverage.as_ref(), )) } #[tool( description = "Fetch a single entity as proto3 JSON, including Specification artifact content, member IDs and association IDs. The canonical JSON workflow is get_entity_json -> edit -> put_entity_json; preserve returned identities and retained bytes.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_entity_json( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let req = pb::GetEntityRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }; let mut client = self.client(); let resp = client .get_entity(req) .await .map_err(tool_err("get_entity_json"))? .into_inner(); let entity_json = match resp.entity.as_ref() { Some(entity) => { let mut scope = TypeScope::default(); scope.add_namespace(p.namespace.clone()); tenant_types_for(&mut client, &scope) .await? .entity_json_value(entity) .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))? } None => serde_json::Value::Null, }; let body = serde_json::json!({ "etag": resp.etag, "json": entity_json }).to_string(); Ok(CallToolResult::success(vec![ContentBlock::text(body)])) } #[tool( description = "Create or replace an entity from proto3 JSON (no protobuf tooling required). entity_json is an Entity message, e.g. {\"event\": {\"id\": {\"namespace\": \"shop\", \"slug\": \"order-placed\", \"version\": 1}, \"title\": \"Order placed\"}}. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64: fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn put_entity_json( &self, Parameters(p): Parameters, ) -> Result { let entity_json = p.entity_json.to_json_text(); if entity_json.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "entity_json exceeds the 4 MiB limit ({} bytes)", entity_json.len() ), None, )); } let mut client = self.client(); let entity: pb::Entity = tenant_types_for(&mut client, &json_type_scope(&entity_json)) .await? .message_from_json("trogonatlas.eventmodel.v1alpha1.Entity", &entity_json) .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; let operation_id = resolve_operation_id(p.operation_id, p.validate_only)?; let req = pb::PutEntityRequest { entity: Some(entity), create_only: p.create_only, if_match: p.if_match, validate_only: p.validate_only, force: p.force, operation_id: operation_id.clone(), }; self.ensure_can_mutate(&mut client, req.validate_only) .await?; let resp = client .put_entity(req) .await .map_err(tool_err("put_entity_json")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); Ok(self.scoped_envelope_with_issues( &resp, &issues_json(&resp.validation), resp.coverage.as_ref(), )) } #[tool( description = "Atomic batch of put/delete operations expressed as a proto3 JSON BatchMutateRequest (no protobuf tooling required). Failure rolls back the whole batch and is reported as an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never as a success envelope with status=STATUS_FAILED inside it. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64, each tagged with op_index and op_entity of the put or delete op that produced it (validate_only dry runs included): fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = false ) )] async fn batch_mutate_json( &self, Parameters(p): Parameters, ) -> Result { let request_json = p.request_json.to_json_text(); if request_json.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "request_json exceeds the 4 MiB limit ({} bytes)", request_json.len() ), None, )); } let mut client = self.client(); let req: pb::BatchMutateRequest = tenant_types_for(&mut client, &json_type_scope(&request_json)) .await? .message_from_json( "trogonatlas.api.eventmodel.v1alpha1.BatchMutateRequest", &request_json, ) .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; self.ensure_can_mutate(&mut client, req.validate_only) .await?; let op_entities = batch_op_entities(&req.ops); let resp = client .batch_mutate(req) .await .map_err(tool_err("batch_mutate_json"))? .into_inner(); if resp.status == pb::batch_mutate_response::Status::Failed as i32 { return Err(McpError::from(batch_mutate_failure(&resp)).into()); } Ok(self.scoped_envelope_with_issues( &resp, &batch_issues_json(&op_entities, &resp), resp.coverage.as_ref(), )) } #[tool( description = "Delete an entity. mode = 'fail_if_referenced' (default), 'force', or 'dry_run'. A refused delete is an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never a success envelope. Non-blocking strategic findings the delete leaves behind (for example REALIZES_DANGLING on a BoundedContext whose Subdomain is gone, SUBDOMAIN_UNREALIZED) come back in the `issues` array next to binpb_base64; a dry_run previews them. Fix every warning and error.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = false ) )] async fn delete_entity( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let mode = parse_delete_mode(p.mode.as_ref())?; let dry_run = mode == pb::delete_entity_request::Mode::DryRun; let operation_id = resolve_operation_id(p.operation_id, dry_run)?; let req = pb::DeleteEntityRequest { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), mode: mode as i32, if_match: p.if_match, operation_id: operation_id.clone(), }; let mut client = self.client(); self.ensure_can_mutate(&mut client, dry_run).await?; let resp = client .delete_entity(req) .await .map_err(tool_err("delete_entity")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); let mut result = self.scoped_envelope_with_issues(&resp, &issues_json(&resp.validation), None); if resp.referrers_incomplete { result.content.push(ContentBlock::text( "Incoming-reference report is incomplete; missing records do not prove absence or deletion safety.", )); } Ok(result) } #[tool( description = "Atomic batch of put/delete operations. Each op specifies op='put' (with entity_b64) or op='delete' (with kind/namespace/slug/version). Failure rolls back the whole batch and is reported as an MCP tool error whose data carries category/next_action (and code/next_action_detail when applicable), never as a success envelope with status=STATUS_FAILED inside it. Non-blocking validation findings, including strategic ones (BoundedContext, Domain, Subdomain rules), come back in the `issues` array next to binpb_base64, each tagged with op_index and op_entity of the put or delete op that produced it (validate_only dry runs included): fix every warning and error; an empty array means none.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = false ) )] async fn batch_mutate( &self, Parameters(p): Parameters, ) -> Result { const MAX_OPS: usize = 50; if p.ops.len() > MAX_OPS { return Err(rmcp::ErrorData::invalid_params( format!( "batch_mutate accepts at most {} ops, got {}", MAX_OPS, p.ops.len() ), None, )); } const MAX_B64_BYTES: usize = MAX_JSON_BYTES * 4 / 3 + 4; // Cap the total encoded proto payload across all ops at the gRPC max // message size (4 MiB, matching --max-message-bytes / the channel cap // set in McpServer::new). Without this the server rejects the // request with an opaque ResourceExhausted instead of a clear error. const MAX_BATCH_PAYLOAD_BYTES: usize = MAX_JSON_BYTES; let mut batch_payload_bytes: usize = 0; let mut ops = Vec::with_capacity(p.ops.len()); for op in p.ops { let inner = match op.op.trim().to_ascii_lowercase().as_str() { "put" => { let b64 = op.entity_b64.ok_or_else(|| { rmcp::ErrorData::invalid_params("put op requires entity_b64", None) })?; if b64.len() > MAX_B64_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "entity_b64 in batch op exceeds the size limit ({} bytes)", b64.len() ), None, )); } let entity = decode_entity_b64(&b64).map_err(rmcp::ErrorData::from)?; let encoded_len = entity.encoded_len(); batch_payload_bytes = batch_payload_bytes.saturating_add(encoded_len); if batch_payload_bytes > MAX_BATCH_PAYLOAD_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "batch total decoded payload exceeds the {MAX_BATCH_PAYLOAD_BYTES} byte cap; \ split into smaller batches" ), None, )); } pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { entity: Some(entity), create_only: op.create_only, if_match: op.if_match, force: op.force, validate_only: false, operation_id: String::new(), }) } "delete" => { let kind_str = op.kind.ok_or_else(|| { rmcp::ErrorData::invalid_params("delete op requires kind", None) })?; let ns = op.namespace.ok_or_else(|| { rmcp::ErrorData::invalid_params("delete op requires namespace", None) })?; let slug = op.slug.ok_or_else(|| { rmcp::ErrorData::invalid_params("delete op requires slug", None) })?; let version = op.version.ok_or_else(|| { rmcp::ErrorData::invalid_params("delete op requires version", None) })?; if version == 0 { return Err(rmcp::ErrorData::invalid_params( "delete op version must be > 0", None, )); } let kind = parse_kind(&kind_str).map_err(rmcp::ErrorData::from)?; let mode = parse_delete_mode(op.mode.as_ref())?; pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { kind: kind as i32, id: Some(validated_id_from(&ns, &slug, version)?), mode: mode as i32, if_match: op.if_match, operation_id: String::new(), }) } other => { return Err(rmcp::ErrorData::invalid_params( format!("unknown op {other}"), None, )); } }; ops.push(pb::BatchMutateOp { op: Some(inner) }); } let operation_id = resolve_operation_id(p.operation_id, p.validate_only)?; let req = pb::BatchMutateRequest { ops, validate_only: p.validate_only, operation_id: operation_id.clone(), }; let mut client = self.client(); self.ensure_can_mutate(&mut client, req.validate_only) .await?; let op_entities = batch_op_entities(&req.ops); let resp = client .batch_mutate(req) .await .map_err(tool_err("batch_mutate")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); if resp.status == pb::batch_mutate_response::Status::Failed as i32 { let err: rmcp::ErrorData = McpError::from(batch_mutate_failure(&resp)).into(); return Err(attach_operation_id(err, &operation_id)); } Ok(self.scoped_envelope_with_issues( &resp, &batch_issues_json(&op_entities, &resp), resp.coverage.as_ref(), )) } #[tool( description = "Paged change feed. Empty since_token returns an opaque cursor positioned at 'now'; pass it on subsequent calls to read changes from that point.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_changes( &self, Parameters(p): Parameters, ) -> Result { let page_size = i32::try_from(p.page_size.clamp(1, 500)) .map_err(|_| rmcp::ErrorData::invalid_params("page_size overflows i32", None))?; let req = pb::ListChangesRequest { since_token: p.since_token.unwrap_or_default(), scopes: Vec::new(), page_size, }; let mut client = self.client(); let resp = client .list_changes(req) .await .map_err(tool_err("list_changes"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Durable changeset log, newest first: what landed together, who did it, when. Unlike list_changes these records never expire. Omit page_token for the newest page, then pass the returned next_page_token to walk backwards.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_changesets( &self, Parameters(p): Parameters, ) -> Result { let page_size = i32::try_from(p.page_size.clamp(1, 500)) .map_err(|_| rmcp::ErrorData::invalid_params("page_size overflows i32", None))?; let req = pb::ListChangesetsRequest { page_token: p.page_token.unwrap_or_default(), page_size, branch: p.branch.unwrap_or_default(), namespace: p.namespace.unwrap_or_default(), }; let mut client = self.client(); let resp = client .list_changesets(req) .await .map_err(tool_err("list_changesets"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Fetch one changeset by id, including every entity it touched. Use the changeset_id carried on a change event to attribute that change.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_changeset( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .get_changeset(pb::GetChangesetRequest { id: p.id }) .await .map_err(tool_err("get_changeset"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Edit log for one entity, newest first: every changeset that touched it, with the entity's content before and after. Answers 'when did this field change and who changed it'. Writes made without changeset attribution (server-side import) are absent.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_entity_history( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let page_size = i32::try_from(p.page_size.clamp(1, 500)) .map_err(|_| rmcp::ErrorData::invalid_params("page_size overflows i32", None))?; let req = pb::GetEntityHistoryRequest { r#ref: Some(pb::EntityRef { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }), page_token: p.page_token.unwrap_or_default(), page_size, }; let mut client = self.client(); let resp = client .get_entity_history(req) .await .map_err(tool_err("get_entity_history"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Undo a changeset by writing its inverse as a new changeset; nothing is erased. Refuses when an entity it touched has been edited since, or when the original write carried no attribution. Use dry_run to see the inverse ops first.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = false ) )] async fn revert_changeset( &self, Parameters(p): Parameters, ) -> Result { let operation_id = resolve_operation_id(p.operation_id, p.dry_run)?; let mut client = self.client(); self.ensure_can_mutate(&mut client, p.dry_run).await?; let resp = client .revert_changeset(pb::RevertChangesetRequest { id: p.id, dry_run: p.dry_run, operation_id: operation_id.clone(), }) .await .map_err(tool_err("revert_changeset")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); Ok(self.scoped_envelope(&resp)) } #[tool( description = "Run full validation over a stored EventModel: structural findings over the model and its members, plus the strategic chain of the model's namespace (its BoundedContext, the Subdomains it realizes, their Domains, and any Domain declared in the namespace). Fix Errors and Warnings.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn validate_event_model( &self, Parameters(p): Parameters, ) -> Result { let req = pb::ValidateEventModelRequest { event_model: None, event_model_id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }; let mut client = self.client(); let resp = client .validate_event_model(req) .await .map_err(tool_err("validate_event_model"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.ValidateEventModelResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "Static catalog of every validation rule the validator can emit (code, default severity, title, doc, subject_kind/subject_field hints, category). Use this to render legends, link findings to docs, or to power CI severity gates without reverse-engineering the validator.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_validation_rules( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .list_validation_rules(pb::ListValidationRulesRequest {}) .await .map_err(tool_err("list_validation_rules"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.ListValidationRulesResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "Validate every EventModel in a project, domain or namespace in one call instead of N. Returns per-model issue lists plus aggregate Error/Warning/Info counts. Strategic findings (BoundedContext, Domain, Subdomain, Project rules) appear once, on the store-level report whose event_model_id is empty, not on any model report. A namespace, project or domain scope also covers problem-space namespaces that hold no EventModel, so use it after editing only Project/Domain/Subdomain or BoundedContext entities. Scope by project_namespace+project_slug, by domain_namespace+domain_slug, OR by namespace -- set exactly one.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn validate_project( &self, Parameters(p): Parameters, ) -> Result { let has_project = p.project_namespace.is_some() || p.project_slug.is_some(); let has_domain = p.domain_namespace.is_some() || p.domain_slug.is_some(); let has_namespace = p.namespace.is_some(); if [has_project, has_domain, has_namespace] .into_iter() .filter(|set| *set) .count() > 1 { return Err(rmcp::ErrorData::invalid_params( "scope is exclusive: set project_namespace+project_slug, domain_namespace+domain_slug OR namespace, not several", None, )); } let scope = if let Some(ns) = p.namespace.as_ref() { if ns.is_empty() { return Err(rmcp::ErrorData::invalid_params( "namespace must not be empty", None, )); } pb::validate_project_request::Scope::Namespace(ns.clone()) } else if let (Some(ns), Some(slug)) = (p.project_namespace.as_ref(), p.project_slug.as_ref()) { let version = p.project_version.ok_or_else(|| { rmcp::ErrorData::invalid_params( "project_version is required and must be > 0 when project scope is set", None, ) })?; if version == 0 { return Err(rmcp::ErrorData::invalid_params( "project_version must be > 0", None, )); } pb::validate_project_request::Scope::ProjectId(validated_id_from(ns, slug, version)?) } else if let (Some(ns), Some(slug)) = (p.domain_namespace.as_ref(), p.domain_slug.as_ref()) { let version = p.domain_version.ok_or_else(|| { rmcp::ErrorData::invalid_params( "domain_version is required and must be > 0 when domain scope is set", None, ) })?; if version == 0 { return Err(rmcp::ErrorData::invalid_params( "domain_version must be > 0", None, )); } pb::validate_project_request::Scope::DomainId(validated_id_from(ns, slug, version)?) } else { return Err(rmcp::ErrorData::invalid_params( "scope is required: set project_namespace+project_slug, domain_namespace+domain_slug OR namespace", None, )); }; let req = pb::ValidateProjectRequest { scope: Some(scope) }; let mut client = self.client(); let resp = client .validate_project(req) .await .map_err(tool_err("validate_project"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.ValidateProjectResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "Bulk delete entities matching a structural query (project / namespace / kind / slug). Replaces the list-then-batch client-side pattern. Mode = 'fail_if_referenced' (default), 'force', or 'dry_run'. Pass max_deletes>0 to refuse runaway deletes.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn delete_by_query( &self, Parameters(p): Parameters, ) -> Result { let kind = match p.kind.as_deref() { Some(s) if !s.is_empty() => parse_kind(s).map_err(rmcp::ErrorData::from)? as i32, _ => pb::EntityKind::Unspecified as i32, }; let mode = parse_delete_mode(p.mode.as_ref())? as i32; if p.max_deletes == 0 { return Err(rmcp::ErrorData::invalid_params( "max_deletes must be > 0 (server refuses uncapped deletes)", None, )); } let max_deletes = i32::try_from(p.max_deletes) .map_err(|_| rmcp::ErrorData::invalid_params("max_deletes overflows i32", None))?; let req = pb::DeleteByQueryRequest { project: p.project.unwrap_or_default(), namespace: p.namespace.unwrap_or_default(), kind, slug: p.slug.unwrap_or_default(), mode, max_deletes, }; let mut client = self.client(); self.ensure_can_mutate( &mut client, mode == pb::delete_entity_request::Mode::DryRun as i32, ) .await?; let resp = client .delete_by_query(req) .await .map_err(tool_err("delete_by_query"))? .into_inner(); Ok(self.scoped_response( "trogonatlas.api.eventmodel.v1alpha1.DeleteByQueryResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "JSON Schema (draft 2020-12) for one entity kind's manifest document, cut from the same generator as `trogon-atlas schema`, with schema_sha256 identifying it. Also returns a minimal skeleton, the placeholders in it, and the validation rules whose subject is that kind. The skeleton satisfies the schema only: replace every placeholder and add the references and context the kind needs before apply_manifests accepts it. Tenant types are not included; resolve them with resolve_type. An unknown kind is invalid_params; list_entity_kinds names every kind.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_entity_schema( &self, Parameters(p): Parameters, ) -> Result { use trogon_atlas_client::manifest_schema; let kind = parse_kind(&p.kind)?; let schema = manifest_schema::generate_for_kind(kind) .map_err(|e| McpError::Internal(format!("{e:#}")))?; let mut client = self.client(); let rules: Vec = client .list_validation_rules(pb::ListValidationRulesRequest {}) .await .map_err(tool_err("get_entity_schema"))? .into_inner() .rules .iter() .filter(|rule| { rule.subject_kind == kind as i32 || rule.subject_kind == pb::EntityKind::Unspecified as i32 }) .map(rule_json) .collect(); let placeholders: Vec = manifest_schema::SKELETON_PLACEHOLDERS .iter() .map(|placeholder| { serde_json::json!({ "pointer": placeholder.pointer(), "marker": placeholder.marker, "meaning": placeholder.meaning, }) }) .collect(); let body = serde_json::json!({ "kind": pb::canonical::kind_short(kind), "api_version": trogon_atlas_client::manifest::API_VERSION, "dialect": schema["$schema"], "schema_sha256": schema_sha256(&schema), "skeleton": manifest_schema::skeleton(kind), "skeleton_validity": "schema_only", "placeholders": placeholders, "rules": rules, "schema": schema, }); Ok(CallToolResult::success(vec![ContentBlock::text( body.to_string(), )])) } #[tool( description = "Static catalog of every EntityKind the system stores: numeric enum, proto enum name, JSON discriminator (the field in the Entity oneof), human title, plural, and doc. Avoids hardcoding the three-name map in clients.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_entity_kinds( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .list_entity_kinds(pb::ListEntityKindsRequest {}) .await .map_err(tool_err("list_entity_kinds"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.ListEntityKindsResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "List every entity that traces back to a Domain via the BoundedContext.realizes chain. The server walks EM->BC->Subdomain->Domain so clients don't have to. Optional `kinds` filters to one or more entity kinds.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_entities_by_domain( &self, Parameters(p): Parameters, ) -> Result { if p.version == 0 { return Err(rmcp::ErrorData::invalid_params("version must be > 0", None)); } let domain_id = validated_id_from(&p.namespace, &p.slug, p.version)?; let kinds = match p.kinds.as_ref() { Some(ks) if !ks.is_empty() => ks .iter() .map(|s| parse_kind(s).map(|k| k as i32)) .collect::, _>>() .map_err(rmcp::ErrorData::from)?, _ => Vec::new(), }; let page_size = match p.page_size { None | Some(0) => 0i32, Some(v) => i32::try_from(v.min(500)) .map_err(|_| rmcp::ErrorData::invalid_params("page_size overflows i32", None))?, }; let req = pb::ListEntitiesByDomainRequest { domain_id: Some(domain_id), kinds, page_size, page_token: p.page_token.unwrap_or_default(), latest_versions_only: p.latest_versions_only.unwrap_or(true), }; let mut client = self.client(); let resp = client .list_entities_by_domain(req) .await .map_err(tool_err("list_entities_by_domain"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.ListEntitiesByDomainResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "Infer field-level data flow across a slice / storyboard / event_model. Returns InferredFieldMapping entries with confidence and rationale.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = true ) )] async fn infer_data_flow( &self, Parameters(p): Parameters, ) -> Result { let scope = scope_from(&p.kind, &p.namespace, &p.slug, p.version).map_err(rmcp::ErrorData::from)?; let req = pb::InferDataFlowRequest { scope: Some(scope) }; let mut client = self.client(); let resp = client .infer_data_flow(req) .await .map_err(tool_err("infer_data_flow"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Surface information-completeness gaps (missing source, ambiguous source, dangling input, undeclared fields, etc.) for a slice / storyboard / event_model scope.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = false, open_world_hint = true ) )] async fn check_information_completeness( &self, Parameters(p): Parameters, ) -> Result { let scope = scope_from(&p.kind, &p.namespace, &p.slug, p.version).map_err(rmcp::ErrorData::from)?; let req = pb::CheckInformationCompletenessRequest { scope: Some(scope) }; let mut client = self.client(); let resp = client .check_information_completeness(req) .await .map_err(tool_err("check_information_completeness"))? .into_inner(); Ok(ok_envelope(&resp)) } #[tool( description = "Apply one or more YAML manifests (same format as trogon-atlas apply): create missing entities, update changed ones, skip unchanged ones, as one atomic batch. Set dry_run to validate without persisting. Non-blocking validation findings, strategic ones included, come back in the `issues` array as {severity, code, subject, field, message, op_index, op_entity} (op_index is the position among the changed manifests sent, op_entity the manifest's entity id): fix every warning and error. Each write is bound to the revision it was planned against: if another writer changed or created an entity in between, nothing is written and the call fails with data.category \"stale_state\" and data.next_action \"replan\", naming the entity with expected_etag and actual_etag (null means absent); call again to plan from fresh state.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn apply_manifests( &self, Parameters(p): Parameters, ) -> Result { let documents = trogon_atlas_client::manifest::read_str(&p.manifests_yaml, "apply_manifests") .map_err(|e| rmcp::ErrorData::invalid_params(format!("{e:#}"), None))?; let operation_id = resolve_operation_id(p.operation_id, p.dry_run)?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, p.dry_run).await?; let resolved = trogon_atlas_client::manifest::load_with_tenant_types(&mut client, documents) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let plan = trogon_atlas_client::apply::plan(&mut client, resolved.manifests) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let outcome = trogon_atlas_client::apply::apply( &mut client, plan, p.dry_run, (!operation_id.is_empty()).then_some(operation_id.as_str()), ) .await .map_err(|e| { attach_operation_id(rmcp::ErrorData::from(McpError::from(e)), &operation_id) })?; let (scope, branch) = self.scope_fields(); let mut body = serde_json::json!({ "scope": scope, "branch": branch, "lines": outcome.lines, "issues": apply_issues_json(&outcome.op_issues), "operation_id": (!operation_id.is_empty()).then_some(operation_id.as_str()), }); trogon_atlas_client::apply::insert_coverage(&mut body, outcome.coverage.as_ref()); Ok(CallToolResult::success(vec![ContentBlock::text( body.to_string(), )])) } #[tool( description = "Diff one or more YAML manifests (same format as trogon-atlas apply) against live state without changing anything. Returns a unified diff and whether anything would change.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn diff_manifests( &self, Parameters(p): Parameters, ) -> Result { let documents = trogon_atlas_client::manifest::read_str(&p.manifests_yaml, "diff_manifests") .map_err(|e| rmcp::ErrorData::invalid_params(format!("{e:#}"), None))?; let mut client = self.client(); let resolved = trogon_atlas_client::manifest::load_with_tenant_types(&mut client, documents) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let plan = trogon_atlas_client::apply::plan(&mut client, resolved.manifests) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let (diff, changed) = trogon_atlas_client::apply::render_diff(&plan, &resolved.types) .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let body = serde_json::json!({ "changed": changed, "diff": diff }).to_string(); Ok(CallToolResult::success(vec![ContentBlock::text(body)])) } #[tool( description = "Export every entity in a namespace back into native manifest YAML, one file per entity kind. Specification exports retain exact artifact text, digests, member IDs and association IDs for apply_manifests round trips.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn export_namespace( &self, Parameters(p): Parameters, ) -> Result { validate_id_component("namespace", &p.namespace)?; let mut client = self.client(); let outcome = trogon_atlas_client::export::export_namespace(&mut client, &p.namespace) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let body = serde_json::json!({ "files": outcome.files, "skipped": outcome.skipped, }) .to_string(); Ok(CallToolResult::success(vec![ContentBlock::text(body)])) } #[tool( description = "Export a namespace's ServiceLevelIndicators, ServiceLevelObjectives, AlertPolicies, AlertNotificationTargets, and referenced Components as vendor-neutral OpenSLO v1 multi-document YAML (same behavior as trogon-atlas openslo export). bindings_yaml supplies the DataSource/metricSource query each SLI's Signal needs, which the model does not carry; a Signal missing from it fails the export naming the indicator, unless allow_placeholder_metrics is set.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn export_openslo( &self, Parameters(p): Parameters, ) -> Result { validate_id_component("namespace", &p.namespace)?; let bindings = trogon_atlas_client::openslo_bindings::BindingsConfig::parse(&p.bindings_yaml) .map_err(|e| rmcp::ErrorData::invalid_params(format!("{e:#}"), None))?; let mut client = self.client(); let outcome = trogon_atlas_client::openslo::export_namespace_openslo( &mut client, &p.namespace, &bindings, p.allow_placeholder_metrics, ) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let body = serde_json::json!({ "yaml": outcome.yaml, "skipped": outcome.skipped, }) .to_string(); Ok(CallToolResult::success(vec![ContentBlock::text(body)])) } #[tool( description = "Export a stored EventModel as an attested bundle: its manifests, its validation findings and coverage, and the snapshot both were read from, sealed by one digest that verify_attested_bundle checks offline. Reads only what the caller may see. Refuses with EXPORT_INCOMPLETE (data.next_action_detail.incomplete_reasons) when the export cannot cover its whole scope, unless allow_incomplete is set; then complete is false and incomplete_reasons and unresolved say why. Set include_bundle to receive the bundle itself in bundle_json; a bundle over 4 MiB is left out with bundle_omitted exceeds_mcp_limit.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn export_attested_model( &self, Parameters(p): Parameters, ) -> Result { validated_id_from(&p.namespace, &p.slug, p.version)?; let request = trogon_atlas_client::attested::ExportRequest { address: trogon_atlas_client::view::EntityAddress { kind: pb::EntityKind::EventModel, namespace: p.namespace, slug: p.slug, version: Some(p.version), }, as_of_changeset: p.as_of_changeset.filter(|c| !c.is_empty()), allow_incomplete: p.allow_incomplete, }; let mut client = self.client(); let (bundle, verified) = trogon_atlas_client::attested::export_attested_model(&mut client, &request) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; let rendering = |e: trogon_atlas_core::attested_bundle::BundleError| { rmcp::ErrorData::internal_error(e.to_string(), None) }; // A summary only reports the size, so it counts the bytes instead of // holding a rendered copy of every manifest and finding. let json = if p.include_bundle { Some(trogon_atlas_core::attested_bundle::bundle_to_json(&bundle).map_err(rendering)?) } else { None }; let bundle_bytes = match &json { Some(json) => json.len(), None => { trogon_atlas_core::attested_bundle::bundle_json_len(&bundle).map_err(rendering)? } }; let mut body = trogon_atlas_client::attested::outcome_json(&bundle, &verified); if let Some(state) = &bundle.state { body["state"] = serde_json::json!({ "mode": state.mode().as_str_name(), "snapshot_id": state.snapshot_id, "closure_id": state.closure_id, "baseline_changeset_id": state.baseline_changeset_id, "branch_changeset_id": state.branch_changeset_id, }); } body["bundle_bytes"] = bundle_bytes.into(); match json { None => body["bundle_omitted"] = "not_requested".into(), Some(_) if bundle_bytes > MAX_JSON_BYTES => { body["bundle_omitted"] = "exceeds_mcp_limit".into(); } Some(json) => body["bundle_json"] = json.into(), } Ok(CallToolResult::success(vec![ContentBlock::text( body.to_string(), )])) } #[tool( description = "Check an attested bundle offline, without reading the store: its digest, and that what it says about its own completeness holds. An intact bundle returns verified true with complete, incomplete_reasons and unresolved; a malformed or altered one returns a tool error with verified false and the first check that failed. Proves integrity only, not which server sealed it: authenticity is unsigned or signature_unchecked, never authenticated, because this tool holds no trusted keys.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn verify_attested_bundle( &self, Parameters(p): Parameters, ) -> Result { if p.bundle_json.len() > MAX_JSON_BYTES { return Err(rmcp::ErrorData::invalid_params( format!( "bundle_json exceeds the 4 MiB limit ({} bytes)", p.bundle_json.len() ), None, )); } let body = match trogon_atlas_client::attested::verify_json(&p.bundle_json) { Ok((bundle, verified)) => { let mut body = trogon_atlas_client::attested::outcome_json(&bundle, &verified); body["verified"] = true.into(); body } Err(e) => { let body = serde_json::json!({ "verified": false, "error": e.to_string() }); return Ok(CallToolResult::error(vec![ContentBlock::text( body.to_string(), )])); } }; Ok(CallToolResult::success(vec![ContentBlock::text( body.to_string(), )])) } #[tool( description = "Create a new branch (Phase 1: Isolation): a server-side overlay of copy-on-write deltas over the baseline store. Fails if a branch with this name already exists. Scope subsequent tool calls to it by sending the branch name in the x-trogon-atlas-branch HTTP header on each MCP request.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn create_branch( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); self.ensure_can_mutate(&mut client, false).await?; let info = trogon_atlas_client::branch::create_branch(&mut client, &p.name, &p.doc) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; Ok(CallToolResult::success(vec![ContentBlock::text(format!( "{info:#?}" ))])) } #[tool( description = "List every registered branch (Phase 1: Isolation).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn list_branches( &self, Parameters(_p): Parameters, ) -> Result { let mut client = self.client(); let branches = trogon_atlas_client::branch::list_branches(&mut client) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; Ok(CallToolResult::success(vec![ContentBlock::text(format!( "{branches:#?}" ))])) } #[tool( description = "Delete a branch and every delta it holds (Phase 1: Isolation). Entities previously visible only through this branch's deltas become unreachable (or fall through to baseline, if baseline has the key).", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = false ) )] async fn delete_branch( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); self.ensure_can_mutate(&mut client, false).await?; trogon_atlas_client::branch::delete_branch(&mut client, &p.name) .await .map_err(|e| rmcp::ErrorData::from(McpError::from(e)))?; Ok(CallToolResult::success(vec![ContentBlock::text(format!( "{} deleted", p.name ))])) } #[tool( description = "Diff a branch's live deltas against the current baseline (Phase 2: Review and merge). Read-only. Each entry carries a status (ADDED/CHANGED/DELETED/CONVERGED/CONFLICT_EDIT_EDIT/CONFLICT_EDIT_DELETE/CONFLICT_DELETE_EDIT); CONFLICT_EDIT_EDIT entries also carry conflict_field_paths.", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn diff_branch( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); let resp = client .diff_branch(pb::DiffBranchRequest { name: p.name }) .await .map_err(tool_err("diff_branch"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.DiffBranchResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "Merge a branch onto baseline (Phase 2: Review and merge). All-or-nothing: any conflict (see conflicts) or post-merge validation Error (see validation) persists nothing. Set dry_run to run full detection and validation without persisting even on success; the result splits the landable entries into added_count, changed_count and deleted_count and lists the post-merge findings, strategic ones included, in validation and, as JSON, in `issues` (Warning and Info findings are non-blocking; fix them). keep_branch controls whether a successful, non-dry-run merge deletes the branch afterward (default: deletes it). Set auto_merge to land the combined result for edit/edit conflicts whose two sides changed different fields.", annotations( read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false ) )] async fn merge_branch( &self, Parameters(p): Parameters, ) -> Result { let operation_id = resolve_operation_id(p.operation_id, p.dry_run)?; let mut client = self.client(); self.ensure_can_mutate(&mut client, p.dry_run).await?; let resp = client .merge_branch(pb::MergeBranchRequest { name: p.name, dry_run: p.dry_run, keep_branch: p.keep_branch, auto_merge: p.auto_merge, operation_id: operation_id.clone(), }) .await .map_err(tool_err("merge_branch")) .map_err(|e| attach_operation_id(e, &operation_id))? .into_inner(); Ok(with_coverage( with_issues( ok_response( "trogonatlas.api.eventmodel.v1alpha1.MergeBranchResponse", &resp, p.json.unwrap_or(false), ), issues_json(&resp.validation), ), resp.coverage.as_ref(), )) } #[tool( description = "Rebase a branch's non-conflicting deltas forward to the current baseline (Phase 2: Review and merge, the pull/rebase analog): CONVERGED entries are dropped, others advance base/base_etag. Conflicting entries are left untouched and returned in conflicts for resolve_branch_entry.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn update_branch( &self, Parameters(p): Parameters, ) -> Result { let mut client = self.client(); self.ensure_can_mutate(&mut client, false).await?; let resp = client .update_branch(pb::UpdateBranchRequest { name: p.name }) .await .map_err(tool_err("update_branch"))? .into_inner(); Ok(ok_response( "trogonatlas.api.eventmodel.v1alpha1.UpdateBranchResponse", &resp, p.json.unwrap_or(false), )) } #[tool( description = "Resolve a single conflicting entry on a branch (Phase 2: Review and merge). resolution must be \"take_theirs\" (discard the branch's edit, adopting current baseline content), \"keep_ours\" (keep the branch's value, rebasing forward so the conflict clears), or \"take_merged\" (take both sides' edits combined, available only when the two sides changed different fields). expected_state binds the decision to the entry state it was made from; if the entry moved since, the call fails with data.category \"stale_state\" and data.next_action \"replan\", carrying expected_state and actual_state, and nothing changes.", annotations( read_only_hint = false, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn resolve_branch_entry( &self, Parameters(p): Parameters, ) -> Result { let kind = parse_kind(&p.kind).map_err(rmcp::ErrorData::from)?; let resolution = match p.resolution.as_str() { "take_theirs" => Resolution::TakeTheirs, "keep_ours" => Resolution::KeepOurs, "take_merged" => Resolution::TakeMerged, other => { return Err(rmcp::ErrorData::invalid_params( format!( "resolution must be \"take_theirs\", \"keep_ours\" or \"take_merged\", got \ {other:?}" ), None, )); } }; let expected = BranchEntryState::from_wire(&pb::BranchEntryState { base_etag: p.expected_state.base_etag, ours_etag: p.expected_state.ours_etag, theirs_etag: p.expected_state.theirs_etag, }) .map_err(|e| rmcp::ErrorData::invalid_params(format!("expected_state: {e:#}"), None))?; let entity_ref = pb::EntityRef { kind: kind as i32, id: Some(validated_id_from(&p.namespace, &p.slug, p.version)?), }; let operation_id = resolve_operation_id(p.operation_id, false)?; let mut client = self.client(); self.ensure_can_apply_plan(&mut client, false).await?; trogon_atlas_client::branch::resolve_branch_entry( &mut client, &p.name, entity_ref, resolution, &expected, (!operation_id.is_empty()).then_some(operation_id.as_str()), ) .await .map_err(|e| { attach_operation_id(rmcp::ErrorData::from(McpError::from(e)), &operation_id) })?; Ok(CallToolResult::success(vec![ContentBlock::text( "resolved", )])) } #[tool( description = "Report the status of a previously used operation_id: pending (claimed, not yet settled), applied (persisted; a receipt exists), rejected/not_applied (did not persist; safe to retry under a new operation_id), or unknown (never claimed, settled long enough ago to be forgotten, or belongs to a branch-scoped mutation, which keeps no durable receipt).", annotations( read_only_hint = true, destructive_hint = false, idempotent_hint = true, open_world_hint = false ) )] async fn get_operation( &self, Parameters(p): Parameters, ) -> Result { trogon_atlas_core::validate_operation_id(&p.operation_id) .map_err(|e| rmcp::ErrorData::invalid_params(format!("operation_id {e}"), None))?; let mut client = self.client(); let resp = client .get_operation(pb::GetOperationRequest { operation_id: p.operation_id, }) .await .map_err(tool_err("get_operation"))? .into_inner(); Ok(ok_envelope(&resp)) } } #[allow(clippy::unused_async_trait_impl)] #[tool_handler] impl ServerHandler for McpServer { fn get_info(&self) -> ServerConfig { let config = ServerConfig::new(ServerCapabilities::builder().enable_tools().build()); match self.profile { None => config.with_instructions(SERVER_INSTRUCTIONS), Some(profile) => { let mut config = config.with_instructions(format!( "{SERVER_INSTRUCTIONS}\n\nActive tool profile: {profile}. Tools outside it are neither listed nor callable." )); let mut meta = serde_json::Map::new(); meta.insert(PROFILE_META_KEY.to_owned(), profile.as_str().into()); config.meta = Some(rmcp::model::MetaObject(meta)); config } } } /// Reports the profile this request runs under, which a header may name, /// so the answer reflects the request rather than the shared instance. async fn initialize( &self, request: rmcp::model::InitializeRequestParams, context: rmcp::service::RequestContext, ) -> Result { let session = self.for_request(&context)?; context.peer.set_peer_info(request.clone()); session.negotiate_initialize(&request) } /// Runs the tool as the caller who sent this request, never as the /// service: the server sees the caller's own bearer token. async fn call_tool( &self, request: rmcp::model::CallToolRequestParams, context: rmcp::service::RequestContext, ) -> Result { let caller = self.for_request(&context)?; if let Some(profile) = caller.profile { let offered = self .tool_router .get(&request.name) .is_some_and(|tool| profile.admits(tool)); if !offered { return Err(rmcp::ErrorData::invalid_params( format!( "tool `{}` is not in the `{profile}` tool profile", request.name ), None, )); } } let tcc = rmcp::handler::server::tool::ToolCallContext::new(&caller, request, context); self.tool_router.call(tcc).await } /// Filters the tool list down to what the caller's role could actually /// call, so an agent never offers itself a tool the server would answer /// with `PERMISSION_DENIED`. `call_tool` stays unfiltered: the server is /// the real enforcement point, and this listing is only a courtesy that /// degrades to "list everything" whenever the role is unknown (see /// `McpServer::learn_role`). async fn list_tools( &self, _request: Option, context: rmcp::service::RequestContext, ) -> Result { let session = self.for_request(&context)?; let role = session.learn_role().await; let tools: Vec<_> = self .tool_router .list_all() .into_iter() .filter(|tool| session.offers(tool)) .collect(); let tools = match role { None => tools, Some(role) => tools .into_iter() .filter(|tool| tool_allowed_for(&tool.name, role)) .collect(), }; Ok(rmcp::model::ListToolsResult::with_all_items(tools)) } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; #[test] fn parse_kind_accepts_all_variants() { for (s, expected) in [ ("event", pb::EntityKind::Event), ("Command", pb::EntityKind::Command), ("read_model", pb::EntityKind::ReadModel), ("readmodel", pb::EntityKind::ReadModel), ("command_slice", pb::EntityKind::CommandSlice), ("event_model", pb::EntityKind::EventModel), ("Channel", pb::EntityKind::Channel), ("data_store", pb::EntityKind::DataStore), ("DataStore", pb::EntityKind::DataStore), ("specification", pb::EntityKind::Specification), ("Specification", pb::EntityKind::Specification), ] { assert_eq!(parse_kind(s).unwrap(), expected, "kind {s}"); } assert!(parse_kind("nonsense").is_err()); } #[test] fn entity_b64_roundtrip() { let entity = pb::Entity { system: None, kind: Some(pb::entity::Kind::Event(pb::Event { id: Some(id_from("shop", "order-placed", 1)), title: "Order placed".into(), ..Default::default() })), }; let b64 = encode_b64(&entity); let decoded = decode_entity_b64(&b64).unwrap(); assert_eq!(decoded, entity); assert!(decode_entity_b64("!!!not-base64!!!").is_err()); } #[test] fn batch_mutate_failure_with_precondition_failure_is_a_stale_plan() { let resp = pb::BatchMutateResponse { status: pb::batch_mutate_response::Status::Failed as i32, precondition_failure: Some(pb::PreconditionFailure { subject: Some(pb::EntityRef { kind: pb::EntityKind::Event as i32, id: Some(id_from("shop", "order-placed", 1)), }), expected_etag: String::new(), actual_etag: "7".into(), }), ..Default::default() }; let err = batch_mutate_failure(&resp); let failure = trogon_atlas_client::failure::Failure::classify(&err); assert_eq!( failure.category, trogon_atlas_client::failure::FailureCategory::StaleState ); } #[test] fn batch_mutate_failure_with_a_known_issue_code_classifies_from_it() { let resp = pb::BatchMutateResponse { status: pb::batch_mutate_response::Status::Failed as i32, failed_op_index: 0, failure: vec![pb::ValidationIssue { code: "ENTITY_NOT_FOUND".into(), message: "entity does not exist".into(), ..Default::default() }], ..Default::default() }; let err = batch_mutate_failure(&resp); let failure = trogon_atlas_client::failure::Failure::classify(&err); assert_eq!( failure.category, trogon_atlas_client::failure::FailureCategory::NotFound ); assert_eq!( failure .code .as_ref() .map(trogon_atlas_client::failure::FailureCode::as_str), Some("ENTITY_NOT_FOUND") ); } #[test] fn entity_json_roundtrip() { let entity = pb::Entity { system: None, kind: Some(pb::entity::Kind::Event(pb::Event { id: Some(id_from("shop", "order-placed", 1)), title: "Order placed".into(), ..Default::default() })), }; let json = message_to_json("trogonatlas.eventmodel.v1alpha1.Entity", &entity).unwrap(); let back: pb::Entity = message_from_json("trogonatlas.eventmodel.v1alpha1.Entity", &json).unwrap(); assert_eq!(back, entity); } #[test] fn entity_json_accepts_handwritten_payload() { let json = r#"{"event": {"id": {"namespace": "shop", "slug": "order-placed", "version": 1}, "title": "Order placed"}}"#; let entity: pb::Entity = message_from_json("trogonatlas.eventmodel.v1alpha1.Entity", json).unwrap(); match entity.kind { Some(pb::entity::Kind::Event(e)) => assert_eq!(e.title, "Order placed"), other => panic!("expected event, got {other:?}"), } } #[test] fn entity_json_rejects_unknown_shape() { let err: Result = message_from_json("trogonatlas.eventmodel.v1alpha1.Entity", r#"{"bogus": 1}"#); assert!(err.is_err()); } #[test] fn scope_from_rejects_non_scope_kinds() { assert!(scope_from("event", "shop", "x", 1).is_err()); assert!(scope_from("command_slice", "shop", "x", 1).is_ok()); assert!(scope_from("storyboard", "shop", "x", 1).is_ok()); } #[test] fn scope_from_picks_slice_for_each_slice_kind() { for kind in [ "command_slice", "read_model_slice", "automation_slice", "ui_slice", ] { let scope = scope_from(kind, "shop", "x", 1).unwrap(); assert!( matches!(scope.scope, Some(pb::analysis_scope::Scope::Slice(_))), "{kind} did not map to a Slice scope" ); } } #[test] fn scope_from_storyboard_and_event_model_are_distinct_variants() { let sb = scope_from("storyboard", "shop", "x", 1).unwrap(); assert!(matches!( sb.scope, Some(pb::analysis_scope::Scope::Storyboard(_)) )); let em = scope_from("event_model", "shop", "x", 1).unwrap(); assert!(matches!( em.scope, Some(pb::analysis_scope::Scope::EventModel(_)) )); } #[test] fn parse_delete_mode_accepts_aliases() { use pb::delete_entity_request::Mode; assert!(matches!( parse_delete_mode(Some("force".to_string()).as_ref()), Ok(Mode::Force) )); assert!(matches!( parse_delete_mode(Some("FORCE".to_string()).as_ref()), Ok(Mode::Force) )); assert!(matches!( parse_delete_mode(Some("dry_run".to_string()).as_ref()), Ok(Mode::DryRun) )); assert!(matches!( parse_delete_mode(Some("dryrun".to_string()).as_ref()), Ok(Mode::DryRun) )); assert!(matches!( parse_delete_mode(Some("fail_if_referenced".to_string()).as_ref()), Ok(Mode::FailIfReferenced) )); assert!(matches!( parse_delete_mode(None), Ok(Mode::FailIfReferenced) )); } // Tool description enumerates fail_if_referenced / force / dry_run. A typo // like "froce" must not silently become FailIfReferenced (agents think they // forced a delete when they did not). #[test] fn parse_delete_mode_rejects_unknown_mode() { let err = parse_delete_mode(Some("garbage".to_string()).as_ref()).unwrap_err(); assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); assert!( err.message.contains("garbage") || err.message.contains("mode"), "{}", err.message ); } #[test] fn search_query_byte_limit_matches_server() { assert_eq!( MAX_SEARCH_QUERY_BYTES, 2048, "MCP search must use the server 2048-byte query cap, not a higher local limit" ); } #[test] fn server_instructions_must_not_claim_debug_envelope() { assert!( !SERVER_INSTRUCTIONS.contains("human-readable Debug"), "ok_envelope no longer returns Debug; instructions must not claim it" ); assert!( SERVER_INSTRUCTIONS.contains("binpb_base64"), "instructions should still describe the binpb_base64 envelope" ); } #[test] fn server_instructions_cover_strategic_layout_and_validation_followup() { for needle in [ "ONE problem-space namespace", "DOMAIN_MISSING_PROJECT", "validate_project with the namespace or project scope", "`issues` array", "Errors and Warnings", ] { assert!( SERVER_INSTRUCTIONS.contains(needle), "doctrine lost: {needle}" ); } } fn strategic_issue() -> pb::ValidationIssue { pb::ValidationIssue { severity: pb::validation_issue::Severity::Warning as i32, code: "DOMAIN_IN_CONTEXT_NAMESPACE".into(), message: "domain lives in a context namespace".into(), subject: Some(pb::EntityRef { kind: pb::EntityKind::Domain as i32, id: Some(id_from("shop", "shop", 1)), }), subject_field: "id.namespace".into(), ..Default::default() } } #[test] fn issue_json_carries_severity_code_subject_field_and_message() { let value = issue_json(&strategic_issue()); assert_eq!(value["severity"], "warning"); assert_eq!(value["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); assert_eq!(value["message"], "domain lives in a context namespace"); assert_eq!(value["field"], "id.namespace"); assert!( value["subject"].as_str().unwrap().contains("shop"), "{value}" ); let bare = issue_json(&pb::ValidationIssue::default()); assert!(bare["field"].is_null(), "{bare}"); assert!(bare["subject"].is_null(), "{bare}"); } fn issue_with_evidence() -> pb::ValidationIssue { pb::ValidationIssue { code: "SLICE_STALE_REF".into(), evidence: vec![pb::ValidationEvidence { role: pb::validation_evidence::Role::StalePin as i32, entity: Some(pb::EntityRef { kind: pb::EntityKind::CommandSlice as i32, id: Some(id_from("shop", "place", 1)), }), field: "command".into(), target: Some(pb::EntityRef { kind: pb::EntityKind::Command as i32, id: Some(id_from("shop", "place", 1)), }), occurrence: Some(pb::StoryboardOccurrence { storyboard: Some(id_from("shop", "checkout", 1)), position: 2, }), ..Default::default() }], ..Default::default() } } #[test] fn issue_json_carries_evidence() { let value = issue_json(&issue_with_evidence()); let evidence = &value["evidence"][0]; assert_eq!(evidence["role"], "stale_pin", "{value}"); assert_eq!(evidence["entity"], "command_slice:shop/place@1"); assert_eq!(evidence["field"], "command"); assert_eq!(evidence["target"], "command:shop/place@1"); assert_eq!(evidence["occurrence"]["position"], 2); } #[test] fn validate_json_response_carries_evidence() { let resp = pb::ValidateEventModelResponse { issues: vec![issue_with_evidence()], ..Default::default() }; let json = message_to_json( "trogonatlas.api.eventmodel.v1alpha1.ValidateEventModelResponse", &resp, ) .unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); let evidence = &value["issues"][0]["evidence"][0]; assert_eq!(evidence["role"], "ROLE_STALE_PIN", "{value}"); assert_eq!(evidence["field"], "command"); assert_eq!(evidence["occurrence"]["position"], 2); assert_eq!(evidence["target"]["id"]["slug"], "place"); } #[test] fn batch_issues_json_tags_each_finding_with_its_op_index_and_entity() { let put = |issues: Vec| pb::BatchMutateOpResult { result: Some(pb::batch_mutate_op_result::Result::Put( pb::PutEntityResponse { validation: issues, ..Default::default() }, )), }; let domain_put = |slug: &str| pb::BatchMutateOp { op: Some(pb::batch_mutate_op::Op::Put(pb::PutEntityRequest { entity: Some(pb::Entity { system: None, kind: Some(pb::entity::Kind::Domain(pb::Domain { id: Some(id_from("shop", slug, 1)), ..Default::default() })), }), ..Default::default() })), }; let delete_op = pb::BatchMutateOp { op: Some(pb::batch_mutate_op::Op::Delete(pb::DeleteEntityRequest { kind: pb::EntityKind::Subdomain as i32, id: Some(id_from("shop", "gone", 1)), ..Default::default() })), }; let entities = batch_op_entities(&[ domain_put("clean"), delete_op.clone(), domain_put("noisy"), delete_op, ]); let resp = pb::BatchMutateResponse { results: vec![ put(vec![]), pb::BatchMutateOpResult { result: Some(pb::batch_mutate_op_result::Result::Delete( pb::DeleteEntityResponse { validation: vec![strategic_issue()], ..Default::default() }, )), }, put(vec![strategic_issue()]), pb::BatchMutateOpResult { result: Some(pb::batch_mutate_op_result::Result::Delete( pb::DeleteEntityResponse::default(), )), }, ], ..Default::default() }; let issues = batch_issues_json(&entities, &resp); assert_eq!(issues.len(), 2, "{issues:?}"); assert_eq!(issues[0]["op_index"], 1); assert!( issues[0]["op_entity"].as_str().unwrap().contains("gone"), "{:?}", issues[0] ); assert_eq!(issues[1]["op_index"], 2); assert!( issues[1]["op_entity"].as_str().unwrap().contains("noisy"), "{:?}", issues[1] ); assert_eq!(issues[1]["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); } #[test] fn with_issues_adds_the_array_and_keeps_the_rest_of_the_body() { let base = ok_response( "trogonatlas.api.eventmodel.v1alpha1.MergeBranchResponse", &pb::MergeBranchResponse::default(), false, ); let result = with_issues(base, issues_json(&[strategic_issue()])); let text = result.content[0].as_text().unwrap().text.clone(); let body: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(body["format"], "binpb_base64"); assert!(body["binpb_base64"].is_string(), "{body}"); assert_eq!(body["issues"][0]["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); } #[test] fn kinds_to_i32_translates_and_short_circuits_on_first_unknown() { let ok = kinds_to_i32(Some(&vec!["event".into(), "command".into()])).unwrap(); assert_eq!( ok, vec![pb::EntityKind::Event as i32, pb::EntityKind::Command as i32] ); assert!(kinds_to_i32(Some(&vec!["event".into(), "nonsense".into()])).is_err()); assert_eq!(kinds_to_i32(None).unwrap(), [] as [i32; 0]); } #[test] fn id_from_assembles_id() { let id = id_from("shop", "order", 7); assert_eq!(id.namespace, "shop"); assert_eq!(id.slug, "order"); assert_eq!(id.version, 7); } #[test] fn descriptor_pool_resolves_canonical_messages() { // Sanity: every message type the MCP layer mentions by FQN string // must resolve through the cached descriptor pool. If a future // proto rename misses one of these strings, the matching tool // handler would return garbage at runtime; this test catches it. for name in [ "trogonatlas.eventmodel.v1alpha1.Entity", "trogonatlas.api.eventmodel.v1alpha1.BatchMutateRequest", ] { assert!( trogon_atlas_core::transcode::message_descriptor(name).is_ok(), "message_descriptor({name}) failed: proto rename / drift?", ); } } #[test] fn mcp_error_unknown_kind_is_invalid_params() { let err: rmcp::ErrorData = McpError::UnknownKind("ghost".into()).into(); assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); assert!(err.message.contains("ghost")); } #[test] fn mcp_error_internal_is_internal_error() { let err: rmcp::ErrorData = McpError::Internal("boom".into()).into(); assert_eq!(err.code, rmcp::model::ErrorCode::INTERNAL_ERROR); assert!(err.message.contains("boom")); } // Item 5: page_size clamp boundaries. The `clamp(1, 500)` applied before // conversion to i32 means zero input becomes 1 and inputs above 500 become 500. #[test] fn page_size_clamp_zero_becomes_one() { assert_eq!(0u32.clamp(1, 500), 1); } #[test] fn page_size_clamp_above_max_becomes_five_hundred() { assert_eq!(501u32.clamp(1, 500), 500); assert_eq!(u32::MAX.clamp(1, 500), 500); } #[test] fn page_size_clamp_boundaries_are_inclusive() { assert_eq!(1u32.clamp(1, 500), 1); assert_eq!(500u32.clamp(1, 500), 500); } // Item 1: validate_id_component rejects the same characters as // trogon_atlas_core::validate_id_component (see trogon-atlas-core/src/id_component.rs). #[test] fn validate_id_component_accepts_valid_slugs() { for v in ["orders", "order-placed", "order.placed", "v2", "a_b"] { assert!( validate_id_component("test", v).is_ok(), "rejected valid slug {v:?}" ); } } #[test] fn validate_id_component_rejects_empty() { assert!(validate_id_component("test", "").is_err()); } #[test] fn validate_id_component_rejects_dot_and_dotdot() { assert!(validate_id_component("test", ".").is_err()); assert!(validate_id_component("test", "..").is_err()); } #[test] fn validate_id_component_rejects_leading_dot() { assert!(validate_id_component("test", ".git").is_err()); } #[test] fn validate_id_component_rejects_spaces_and_control_chars() { assert!(validate_id_component("test", "bad slug").is_err()); assert!(validate_id_component("test", "a\nb").is_err()); } #[test] fn validate_id_component_rejects_overlong() { let long = "a".repeat(201); assert!(validate_id_component("test", &long).is_err()); } #[test] fn mcp_error_invalid_id_component_is_invalid_params() { let err: rmcp::ErrorData = McpError::InvalidIdComponent { field: "namespace", detail: "must not be empty".into(), } .into(); assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS); assert!(err.message.contains("namespace")); } // ------------------------------------------------------------------ // ToolAnnotations coverage // ------------------------------------------------------------------ // // Every #[tool(...)] above must declare all four `ToolAnnotations` hints // explicitly, and `read_only_hint` must agree with the server's RBAC // role map (`trogon_atlas_server::auth::required_role`) for every gRPC // method the tool invokes. This catches a tool added without // annotations, a tool invoking an RPC the role map does not know about, // and an annotation that drifts from what the server actually requires // to call the tool. use std::collections::HashSet; use trogon_atlas_server::auth::{required_role, Role}; const EVENT_MODEL_SERVICE: &str = "/trogonatlas.api.eventmodel.v1alpha1.EventModelService/"; impl From for ToolRole { fn from(role: Role) -> Self { match role { Role::Reader => ToolRole::Reader, Role::Writer => ToolRole::Writer, Role::Admin => ToolRole::Admin, } } } /// `get_operation` is the only tool whose `read_only_hint` does not /// equal `required_role(..) == Role::Reader`. `auth::required_role` /// classifies GetOperation as `Role::Writer` only because operation /// receipts are scoped to the caller's own principal, not because the /// RPC mutates anything (see the comment above that match arm, /// crates/trogon-atlas-server/src/auth.rs:246-249). The tool itself /// only reads, so it is deliberately annotated read_only_hint = true. const READ_ONLY_ROLE_EXEMPT: &[&str] = &["get_operation"]; /// Tools that work only on their input and never call the server, so /// they have no RPC to take a role from and are offered to every role. const OFFLINE_TOOLS: &[&str] = &["verify_attested_bundle"]; #[test] fn tool_policy_names_are_unique() { let mut names = HashSet::new(); for policy in TOOL_POLICIES { assert!( names.insert(policy.tool), "duplicate TOOL_POLICIES row for {}", policy.tool ); } } #[test] fn every_tool_has_annotations_matching_the_role_map() { let live_tools = McpServer::tool_router().list_all(); let live_names: HashSet = live_tools.iter().map(|t| t.name.to_string()).collect(); let table_names: HashSet<&str> = TOOL_POLICIES.iter().map(|r| r.tool).collect(); let live_without_a_row: Vec<&String> = live_names .iter() .filter(|n| !table_names.contains(n.as_str())) .collect(); let rows_without_a_live_tool: Vec<&&str> = table_names .iter() .filter(|n| !live_names.contains(**n)) .collect(); assert!( live_without_a_row.is_empty() && rows_without_a_live_tool.is_empty(), "tool set mismatch: live tools missing a TOOL_POLICIES row: {live_without_a_row:?}; \ TOOL_POLICIES rows with no matching live tool: {rows_without_a_live_tool:?}" ); for tool in &live_tools { let name = tool.name.as_ref(); let row = TOOL_POLICIES .iter() .find(|r| r.tool == name) .unwrap_or_else(|| panic!("tool {name}: no TOOL_POLICIES row (checked above)")); if OFFLINE_TOOLS.contains(&name) { assert!(row.rpcs.is_empty(), "offline tool {name} must call no RPC"); assert_eq!(row.min_role, ToolRole::Reader, "offline tool {name}"); let annotations = tool.annotations.as_ref().unwrap(); assert_eq!( annotations.read_only_hint, Some(true), "offline tool {name}" ); assert_eq!( annotations.destructive_hint, Some(false), "offline tool {name}" ); continue; } assert!(!row.rpcs.is_empty(), "tool {name}: empty rpcs list"); let annotations = tool .annotations .as_ref() .unwrap_or_else(|| panic!("tool {name}: missing ToolAnnotations")); let read_only_hint = annotations .read_only_hint .unwrap_or_else(|| panic!("tool {name}: missing read_only_hint")); let destructive_hint = annotations .destructive_hint .unwrap_or_else(|| panic!("tool {name}: missing destructive_hint")); let _idempotent_hint = annotations .idempotent_hint .unwrap_or_else(|| panic!("tool {name}: missing idempotent_hint")); let _open_world_hint = annotations .open_world_hint .unwrap_or_else(|| panic!("tool {name}: missing open_world_hint")); let max_role = row .rpcs .iter() .map(|rpc| { let path = format!("{EVENT_MODEL_SERVICE}{rpc}"); required_role(&path).unwrap_or_else(|| { panic!( "tool {name}: RPC {rpc} is not in auth::required_role \ (new RPC missing from the server's role map?)" ) }) }) .max() .expect("non-empty, checked above"); if READ_ONLY_ROLE_EXEMPT.contains(&name) { assert!( read_only_hint, "tool {name} is in READ_ONLY_ROLE_EXEMPT and must have read_only_hint = true" ); } else { assert_eq!( read_only_hint, max_role == Role::Reader, "tool {name}: read_only_hint ({read_only_hint}) does not match the role \ map (max role {max_role:?} over {:?})", row.rpcs ); } assert!( !(read_only_hint && destructive_hint), "tool {name}: read_only_hint and destructive_hint are both true" ); assert_eq!( row.min_role, ToolRole::from(max_role), "tool {name}: ToolPolicy.min_role ({:?}) does not match the max role \ required_role reports over {:?} ({max_role:?})", row.min_role, row.rpcs ); } } #[test] fn apply_issues_json_uses_the_batch_issue_shape_with_op_tags() { let op_issues = vec![trogon_atlas_client::apply::OpIssues { op_index: 1, op_entity: "domain:shop/shop@1".into(), issues: vec![strategic_issue()], }]; let issues = apply_issues_json(&op_issues); assert_eq!(issues.len(), 1); assert_eq!(issues[0]["code"], "DOMAIN_IN_CONTEXT_NAMESPACE"); assert_eq!(issues[0]["severity"], "warning"); assert_eq!(issues[0]["field"], "id.namespace"); assert_eq!(issues[0]["op_index"], 1); assert_eq!(issues[0]["op_entity"], "domain:shop/shop@1"); assert_eq!(apply_issues_json(&[]), Vec::::new()); } }