Browse Source

initial commit, scaffolding

Lukas Goldschmidt 1 tháng trước cách đây
commit
00c5616db0
21 tập tin đã thay đổi với 1492 bổ sung0 xóa
  1. 10 0
      .env.example
  2. 9 0
      .gitignore
  3. 29 0
      PROJECT.md
  4. 28 0
      README.md
  5. 1 0
      app/__init__.py
  6. 280 0
      app/atlas_model.py
  7. 443 0
      app/atlas_store.py
  8. 32 0
      app/main.py
  9. 39 0
      app/mcp_server.py
  10. 47 0
      app/remote_sparql_client.py
  11. 86 0
      app/resolve.py
  12. 37 0
      killserver.sh
  13. 211 0
      ontology/atlas.ttl
  14. 94 0
      ontology/joe.ttl
  15. 9 0
      requirements.txt
  16. 61 0
      resolve_scheme.md
  17. 9 0
      restart.sh
  18. 25 0
      run.sh
  19. 17 0
      tests.sh
  20. 7 0
      tests/conftest.py
  21. 18 0
      tests/test_resolve_tool.py

+ 10 - 0
.env.example

@@ -0,0 +1,10 @@
+# This project must be configured exclusively via a single .env in the project root.
+
+# The remote MCP server (HTTP/SSE) that exposes sparql_query / sparql_update.
+REMOTE_MCP_SSE_URL=http://192.168.0.249:8501/mcp/sse
+REMOTE_MCP_TIMEOUT=10
+REMOTE_MCP_SSE_READ_TIMEOUT=300
+
+# Graph to query (used in the scaffolding SPARQL templates).
+RESOLUTION_GRAPH_IRI=http://world.eu.org/atlas_data#
+

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+logs/
+*.pid
+__pycache__/
+.pytest_cache/
+.venv/
+venv/
+.env
+*.log
+

+ 29 - 0
PROJECT.md

@@ -0,0 +1,29 @@
+# atlas2-mcp — project notes
+
+## Goal
+Implement the **atlas2 resolution** tool as an MCP server.
+
+## Tool contract (resolve)
+Based on `resolve_scheme.md`:
+1) Input: `resolve("Joe Biden")`
+2) Cache lookup: alias index + identifier index
+3) If hit:
+   - load RDF -> Python model
+4) If miss:
+   - call Wikidata
+   - build Entity
+   - store in RDF
+   - update cache
+5) Return JSON
+
+## Where code lives
+- `app/mcp_server.py` — MCP tool registration (`resolve`)
+- `app/main.py` — FastAPI + HTTP/SSE mount at `/mcp`
+- `app/resolve.py` — resolve service (currently stubbed)
+- `app/atlas_store.py` — SPARQL persistence (SPARQL endpoint OR Virtuoso MCP tools)
+
+## Next steps
+1) Replace the stub in `app/resolve.py` with the cache + resolve flow.
+2) Implement alias/identifier indexes.
+3) Wire Wikidata calls + RDF storage using `atlas_store.py`.
+

+ 28 - 0
README.md

@@ -0,0 +1,28 @@
+# atlas2-mcp
+
+Python FastMCP server scaffold for the *atlas2* resolution flow.
+
+## Current status (v0)
+- Exposes **one tool**: `resolve()`
+- `resolve()` is intentionally stubbed and returns: `{ "status": "ok" }`
+- SPARQL/Virtuoso integration is scaffolded and prepared for the next iteration.
+
+## How to run
+
+1) Create config:
+```bash
+cp .env.example .env
+```
+
+2) Install dependencies:
+```bash
+pip install -r requirements.txt
+```
+
+3) Start:
+```bash
+./run.sh
+```
+
+Server runs on **port 8550** and mounts the MCP endpoint at **`/mcp`**.
+

+ 1 - 0
app/__init__.py

@@ -0,0 +1 @@
+

+ 280 - 0
app/atlas_model.py

@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+
+# ---------------------------------------------------------------------------
+# Mirrors atlas:Identifier
+#   atlas:scheme  — controlled token: "wikidata-qid" | "google-mid" | "atlas-internal"
+#   atlas:value   — raw identifier string
+# ---------------------------------------------------------------------------
+@dataclass
+class Identifier:
+    scheme: str   # "wikidata-qid" | "google-mid" | "atlas-internal"
+    value: str
+
+
+# ---------------------------------------------------------------------------
+# Mirrors atlas:Provenance
+# Attached to a Claim to record where a fact came from.
+# ---------------------------------------------------------------------------
+@dataclass
+class Provenance:
+    source: str                       # e.g. "wikidata", "google-trends"
+    method: str                       # e.g. "wbsearchentities", "trends-resolution"
+    confidence: float                 # 0.0 – 1.0
+    retrieved_at: str                 # ISO-8601 UTC, e.g. "2026-04-04T23:27:09Z"
+
+
+# ---------------------------------------------------------------------------
+# Mirrors atlas:Claim
+# Records provenance for one direct triple on the entity.
+# The triple itself must also exist directly on the Entity — Claims are the
+# audit layer, not the only place the fact lives.
+# ---------------------------------------------------------------------------
+@dataclass
+class Claim:
+    predicate: str                    # ontology property IRI, e.g. "atlas:hasIdentifier"
+    object_iri: Optional[str] = None  # IRI object,     e.g. "atlas_data:ident_qid_q6279"
+    object_literal: Optional[str] = None  # literal object, e.g. "true"
+    layer: str = "raw"                # "raw" | "derived" | "curated"
+    status: str = "active"            # "active" | "superseded" | "rejected"
+    provenance: Optional[Provenance] = None
+
+
+# ---------------------------------------------------------------------------
+# Mirrors atlas:CurateFlag
+# ---------------------------------------------------------------------------
+@dataclass
+class CurateFlag:
+    reason: str
+
+
+# ---------------------------------------------------------------------------
+# Mirrors atlas:Entity — the central node.
+#
+# aliases    : flat list of surface forms (atlas:aliasLabel "Biden"@en)
+# identifiers: flat Identifier nodes     (atlas:scheme / atlas:value)
+# attributes : arbitrary key-value facts (atlas:isAlive, atlas:latitude, …)
+#              These map to direct datatype triples on the entity.
+# raw_json   : opaque source blobs       (atlas:rawJson "…"^^xsd:string)
+#              One entry per source; source name lives inside the JSON blob.
+# claims     : provenance audit trail — one Claim per attributed triple
+# curate_flag: set when the entity needs human review
+# ---------------------------------------------------------------------------
+@dataclass
+class Entity:
+    id: str                                        # "atlas:1b0e7222c7730540"
+    label: str                                     # canonical label
+    type: Optional[str] = None                     # "atlas:Person" | "atlas:Location" | …
+    description: Optional[str] = None
+    aliases: List[str] = field(default_factory=list)
+    identifiers: List[Identifier] = field(default_factory=list)
+    attributes: Dict[str, Any] = field(default_factory=dict)   # extensible facts
+    raw_json: List[str] = field(default_factory=list)          # opaque blobs
+    claims: List[Claim] = field(default_factory=list)
+    needs_curation: bool = False
+    curate_flag: Optional[CurateFlag] = None
+
+    # ------------------------------------------------------------------
+    # Helpers
+    # ------------------------------------------------------------------
+
+    def get_identifier(self, scheme: str) -> Optional[str]:
+        """Return the value for the first identifier matching scheme, or None."""
+        for ident in self.identifiers:
+            if ident.scheme == scheme:
+                return ident.value
+        return None
+
+    def add_raw_json(self, source: str, data: Dict[str, Any]) -> None:
+        """Serialise a source payload and append it to raw_json blobs."""
+        self.raw_json.append(json.dumps({"source": source, **data}))
+
+    def _entity_iri(self) -> str:
+        return f"atlas_data:entity_{self.id.replace('atlas:', '')}"
+
+    def _identifier_iri(self, ident: Identifier) -> str:
+        slug = ident.value.replace("/", "_").replace(":", "_").lower()
+        return f"atlas_data:ident_{ident.scheme}_{slug}"
+
+    def _claim_id(self, predicate: str, obj: str) -> str:
+        h = hashlib.sha1(f"{self.id}{predicate}{obj}".encode()).hexdigest()[:8]
+        return f"atlas_data:claim_{h}"
+
+    def _prov_id(self, claim_id: str) -> str:
+        return claim_id.replace("claim_", "prov_")
+
+    # ------------------------------------------------------------------
+    # Serialisation to Turtle
+    # ------------------------------------------------------------------
+
+    def to_turtle(self) -> str:
+        lines: List[str] = [
+            "@prefix atlas:      <http://world.eu.org/atlas_ontology#> .",
+            "@prefix atlas_data: <http://world.eu.org/atlas_data#> .",
+            "@prefix xsd:        <http://www.w3.org/2001/XMLSchema#> .",
+            "",
+        ]
+
+        entity_iri = self._entity_iri()
+
+        # --- Entity node ---
+        lines.append("### Entity")
+        lines.append(f"{entity_iri}")
+        lines.append(f'  a                          atlas:Entity ;')
+        lines.append(f'  atlas:atlasId              "{self.id}" ;')
+        lines.append(f'  atlas:canonicalLabel       "{self.label}"@en ;')
+        if self.description:
+            lines.append(f'  atlas:canonicalDescription "{self.description}"@en ;')
+        if self.type:
+            lines.append(f'  atlas:hasCanonicalType     {self.type} ;')
+
+        for alias in self.aliases:
+            lines.append(f'  atlas:aliasLabel           "{alias}"@en ;')
+
+        for ident in self.identifiers:
+            lines.append(f'  atlas:hasIdentifier        {self._identifier_iri(ident)} ;')
+
+        for key, val in self.attributes.items():
+            if isinstance(val, bool):
+                lit = str(val).lower()
+                lines.append(f'  atlas:{key:<26} "{lit}"^^xsd:boolean ;')
+            elif isinstance(val, float):
+                lines.append(f'  atlas:{key:<26} "{val}"^^xsd:decimal ;')
+            elif isinstance(val, int):
+                lines.append(f'  atlas:{key:<26} "{val}"^^xsd:integer ;')
+            else:
+                lines.append(f'  atlas:{key:<26} "{val}" ;')
+
+        for blob in self.raw_json:
+            escaped = blob.replace("\\", "\\\\").replace('"', '\\"')
+            lines.append(f'  atlas:rawJson              "{escaped}"^^xsd:string ;')
+
+        for claim in self.claims:
+            obj = claim.object_iri or claim.object_literal or ""
+            cid = self._claim_id(claim.predicate, obj)
+            lines.append(f'  atlas:hasClaim             {cid} ;')
+
+        lines.append(f'  atlas:needsCuration        {str(self.needs_curation).lower()}')
+        if self.curate_flag:
+            # reopen with semicolon on previous line
+            lines[-1] += " ;"
+            lines.append(f'  atlas:hasCurateFlag        {entity_iri}_curate')
+        lines[-1] += " ."
+        lines.append("")
+
+        # --- Identifier nodes ---
+        if self.identifiers:
+            lines.append("### Identifiers")
+            for ident in self.identifiers:
+                iiri = self._identifier_iri(ident)
+                lines.append(f"{iiri}")
+                lines.append(f'  a            atlas:Identifier ;')
+                lines.append(f'  atlas:scheme "{ident.scheme}" ;')
+                lines.append(f'  atlas:value  "{ident.value}" .')
+                lines.append("")
+
+        # --- Claim + Provenance nodes ---
+        if self.claims:
+            lines.append("### Claims")
+            for claim in self.claims:
+                obj = claim.object_iri or claim.object_literal or ""
+                cid = self._claim_id(claim.predicate, obj)
+                pid = self._prov_id(cid)
+                lines.append(f"{cid}")
+                lines.append(f'  a                     atlas:Claim ;')
+                lines.append(f'  atlas:claimSubjectIri {entity_iri} ;')
+                lines.append(f'  atlas:claimPredicate  {claim.predicate} ;')
+                if claim.object_iri:
+                    lines.append(f'  atlas:claimObjectIri  {claim.object_iri} ;')
+                else:
+                    lines.append(f'  atlas:claimObjectLiteral "{claim.object_literal}" ;')
+                lines.append(f'  atlas:claimLayer      "{claim.layer}" ;')
+                lines.append(f'  atlas:claimStatus     "{claim.status}"')
+                if claim.provenance:
+                    lines[-1] += " ;"
+                    lines.append(f'  atlas:hasProvenance   {pid}')
+                lines[-1] += " ."
+                lines.append("")
+
+                if claim.provenance:
+                    p = claim.provenance
+                    lines.append(f"{pid}")
+                    lines.append(f'  a                      atlas:Provenance ;')
+                    lines.append(f'  atlas:provenanceSource "{p.source}" ;')
+                    lines.append(f'  atlas:retrievalMethod  "{p.method}" ;')
+                    lines.append(f'  atlas:confidence       "{p.confidence}"^^xsd:decimal ;')
+                    lines.append(f'  atlas:retrievedAt      "{p.retrieved_at}"^^xsd:dateTime .')
+                    lines.append("")
+
+        # --- CurateFlag node ---
+        if self.curate_flag:
+            lines.append("### Curation flag")
+            lines.append(f"{entity_iri}_curate")
+            lines.append(f'  a                    atlas:CurateFlag ;')
+            lines.append(f'  atlas:curationReason "{self.curate_flag.reason}"@en .')
+            lines.append("")
+
+        return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Usage example — reproduces joe.ttl
+# ---------------------------------------------------------------------------
+if __name__ == "__main__":
+    biden = Entity(
+        id="atlas:1b0e7222c7730540",
+        label="Joe Biden",
+        description="46th President of the United States (2021\u20132025)",
+        type="atlas:Person",
+        aliases=["Joe Biden", "Biden", "Joseph Biden"],
+        identifiers=[
+            Identifier(scheme="google-mid", value="/m/012gx2"),
+            Identifier(scheme="wikidata-qid", value="Q6279"),
+        ],
+        attributes={
+            "isAlive": True,
+        },
+        needs_curation=True,
+        curate_flag=CurateFlag(
+            reason="Fine-grained Trends type '46th U.S. President' not yet adjudicated."
+        ),
+        claims=[
+            Claim(
+                predicate="atlas:hasIdentifier",
+                object_iri="atlas_data:ident_google-mid__m_012gx2",
+                layer="raw",
+                provenance=Provenance(
+                    source="google-trends",
+                    method="trends-resolution",
+                    confidence=0.9,
+                    retrieved_at="2026-04-04T23:27:06Z",
+                ),
+            ),
+            Claim(
+                predicate="atlas:hasIdentifier",
+                object_iri="atlas_data:ident_wikidata-qid_q6279",
+                layer="raw",
+                provenance=Provenance(
+                    source="wikidata",
+                    method="wbsearchentities + entitydata",
+                    confidence=0.99,
+                    retrieved_at="2026-04-04T23:27:09Z",
+                ),
+            ),
+            Claim(
+                predicate="atlas:hasCanonicalType",
+                object_iri="atlas:Person",
+                layer="derived",
+            ),
+        ],
+    )
+
+    biden.add_raw_json("wikidata", {"qid": "Q6279", "label": "Joe Biden", "retrieved_at": "2026-04-04T23:27:09Z"})
+    biden.add_raw_json("google-trends", {"mid": "/m/012gx2", "type": "46th U.S. President", "retrieved_at": "2026-04-04T23:27:06Z"})
+
+    print(biden.to_turtle())

+ 443 - 0
app/atlas_store.py

@@ -0,0 +1,443 @@
+"""
+atlas_store.py — SPARQL persistence for Atlas Entity objects.
+
+Two public functions:
+    save_entity(entity, endpoint)  — insert all triples into Virtuoso
+    load_entity(atlas_id, endpoint) — reconstruct an Entity from the store
+
+Tested against Virtuoso 7.x (SPARQL 1.1 Update endpoint).
+The read side works against any SPARQL 1.1 endpoint.
+
+Dependencies:
+    pip install SPARQLWrapper
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import asyncio
+from typing import Any, Dict, List, Optional
+
+from SPARQLWrapper import JSON, POST, SPARQLWrapper
+
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+from atlas_model import Claim, CurateFlag, Entity, Identifier, Provenance
+
+# ---------------------------------------------------------------------------
+# Namespace constants — keep in sync with atlas.ttl
+# ---------------------------------------------------------------------------
+
+ATLAS    = "http://world.eu.org/atlas_ontology#"
+ATLAS_D  = "http://world.eu.org/atlas_data#"
+XSD      = "http://www.w3.org/2001/XMLSchema#"
+RDF      = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+
+PREFIXES = f"""\
+PREFIX atlas:      <{ATLAS}>
+PREFIX atlas_data: <{ATLAS_D}>
+PREFIX xsd:        <{XSD}>
+PREFIX rdf:        <{RDF}>
+"""
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _full_iri(prefixed: str) -> str:
+    """Expand a prefixed IRI to a full IRI string."""
+    prefixed = prefixed.strip()
+    if prefixed.startswith("atlas_data:"):
+        return ATLAS_D + prefixed[len("atlas_data:"):]
+    if prefixed.startswith("atlas:"):
+        return ATLAS + prefixed[len("atlas:"):]
+    if prefixed.startswith("<") and prefixed.endswith(">"):
+        return prefixed[1:-1]
+    return prefixed
+
+
+def _short_iri(full: str) -> str:
+    """Compress a full IRI back to a prefixed form."""
+    if full.startswith(ATLAS_D):
+        return "atlas_data:" + full[len(ATLAS_D):]
+    if full.startswith(ATLAS):
+        return "atlas:" + full[len(ATLAS):]
+    return f"<{full}>"
+
+
+def _local(full_iri: str) -> str:
+    """Return the local name of a full IRI (after # or last /)."""
+    for sep in ("#", "/"):
+        idx = full_iri.rfind(sep)
+        if idx != -1:
+            return full_iri[idx + 1:]
+    return full_iri
+
+
+def _escape(s: str) -> str:
+    """Escape a string for embedding in a SPARQL triple-quoted literal."""
+    return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
+
+
+def _sparql_update(endpoint: str, query: str) -> None:
+    # If endpoint looks like an MCP SSE URL, call the remote MCP tool.
+    if "/mcp/sse" in endpoint:
+        async def _run() -> None:
+            async with sse_client(endpoint, timeout=10, sse_read_timeout=300) as (read_stream, write_stream):
+                async with ClientSession(read_stream, write_stream) as session:
+                    await session.initialize()
+                    result = await session.call_tool("sparql_update", {"input": {"query": query}})
+                    if result.isError:
+                        raise RuntimeError(f"sparql_update failed: {result.error}")
+
+        asyncio.run(_run())
+        return
+
+    # Fallback: plain SPARQL endpoint URL.
+    sparql = SPARQLWrapper(endpoint)
+    sparql.setMethod(POST)
+    sparql.setQuery(query)
+    sparql.setReturnFormat(JSON)
+    sparql.query()
+
+def _sparql_select(endpoint: str, query: str) -> List[Dict[str, Any]]:
+    # If endpoint looks like an MCP SSE URL, call the remote MCP tool.
+    if "/mcp/sse" in endpoint:
+        async def _run() -> List[Dict[str, Any]]:
+            async with sse_client(endpoint, timeout=10, sse_read_timeout=300) as (read_stream, write_stream):
+                async with ClientSession(read_stream, write_stream) as session:
+                    await session.initialize()
+                    result = await session.call_tool("sparql_query", {"input": {"query": query}})
+                    if result.isError:
+                        raise RuntimeError(f"sparql_query failed: {result.error}")
+
+                    data = result.structuredContent if result.structuredContent is not None else result.content
+                    if isinstance(data, dict):
+                        return data.get("results", {}).get("bindings", []) or []
+                    return []
+
+        return asyncio.run(_run())
+
+    # Fallback: plain SPARQL endpoint URL.
+    sparql = SPARQLWrapper(endpoint)
+    sparql.setMethod('GET')
+    sparql.setQuery(query)
+    sparql.setReturnFormat(JSON)
+    res = sparql.query().convert()
+    return res.get("results", {}).get("bindings", [])
+
+# ---------------------------------------------------------------------------
+# Save
+# ---------------------------------------------------------------------------
+
+def save_entity(entity: Entity, endpoint: str) -> None:
+    """
+    Insert all triples for an Entity into the SPARQL store.
+
+    Uses SPARQL 1.1 INSERT DATA.  Call delete_entity() first if you need
+    to replace an existing entity (full replace pattern).
+
+    Args:
+        entity:   The Entity to persist.
+        endpoint: SPARQL Update endpoint URL,
+                  e.g. "http://localhost:8890/sparql-auth"
+    """
+    ttl_body = _build_insert_body(entity)
+    query = f"{PREFIXES}\nINSERT DATA {{\n{ttl_body}\n}}"
+    _sparql_update(endpoint, query)
+
+
+def delete_entity(atlas_id: str, endpoint: str) -> None:
+    """
+    Remove all triples where the entity or any of its blank/named nodes
+    are the subject.  Run before save_entity() for a clean replace.
+
+    Args:
+        atlas_id: e.g. "atlas:1b0e7222c7730540"
+        endpoint: SPARQL Update endpoint URL.
+    """
+    entity_iri = f"<{ATLAS_D}entity_{atlas_id.replace('atlas:', '')}>"
+    # Delete triples where entity is subject, plus all linked sub-nodes
+    # (identifiers, claims, provenance, curate flag) via a SPARQL DELETE WHERE.
+    query = f"""{PREFIXES}
+DELETE {{
+  ?s ?p ?o .
+}}
+WHERE {{
+  {{
+    BIND({entity_iri} AS ?s)
+    ?s ?p ?o .
+  }}
+  UNION
+  {{
+    {entity_iri} atlas:hasIdentifier ?s .
+    ?s ?p ?o .
+  }}
+  UNION
+  {{
+    {entity_iri} atlas:hasClaim ?s .
+    ?s ?p ?o .
+  }}
+  UNION
+  {{
+    {entity_iri} atlas:hasClaim ?claim .
+    ?claim atlas:hasProvenance ?s .
+    ?s ?p ?o .
+  }}
+  UNION
+  {{
+    {entity_iri} atlas:hasCurateFlag ?s .
+    ?s ?p ?o .
+  }}
+}}"""
+    _sparql_update(endpoint, query)
+
+
+def _build_insert_body(entity: Entity) -> str:
+    """Build the triple block (no INSERT DATA wrapper) for an Entity."""
+    lines: List[str] = []
+    e = f"<{_full_iri(entity._entity_iri())}>"
+
+    # --- Entity core ---
+    lines += [
+        f"  {e} a atlas:Entity ;",
+        f'    atlas:atlasId "{_escape(entity.id)}" ;',
+        f'    atlas:canonicalLabel "{_escape(entity.label)}"@en ;',
+    ]
+    if entity.description:
+        lines.append(f'    atlas:canonicalDescription "{_escape(entity.description)}"@en ;')
+    if entity.type:
+        lines.append(f'    atlas:hasCanonicalType <{_full_iri(entity.type)}> ;')
+    for alias in entity.aliases:
+        lines.append(f'    atlas:aliasLabel "{_escape(alias)}"@en ;')
+    for ident in entity.identifiers:
+        iiri = f"<{_full_iri(entity._identifier_iri(ident))}>"
+        lines.append(f'    atlas:hasIdentifier {iiri} ;')
+    for key, val in entity.attributes.items():
+        if isinstance(val, bool):
+            lines.append(f'    atlas:{key} "{str(val).lower()}"^^xsd:boolean ;')
+        elif isinstance(val, float):
+            lines.append(f'    atlas:{key} "{val}"^^xsd:decimal ;')
+        elif isinstance(val, int):
+            lines.append(f'    atlas:{key} "{val}"^^xsd:integer ;')
+        else:
+            lines.append(f'    atlas:{key} "{_escape(str(val))}" ;')
+    for blob in entity.raw_json:
+        lines.append(f'    atlas:rawJson "{_escape(blob)}"^^xsd:string ;')
+    for claim in entity.claims:
+        obj = claim.object_iri or claim.object_literal or ""
+        cid = f"<{_full_iri(entity._claim_id(claim.predicate, obj))}>"
+        lines.append(f'    atlas:hasClaim {cid} ;')
+    lines.append(f'    atlas:needsCuration "{str(entity.needs_curation).lower()}"^^xsd:boolean')
+    if entity.curate_flag:
+        lines[-1] += " ;"
+        curate_iri = f"<{_full_iri(entity._entity_iri())}_curate>"
+        lines.append(f'    atlas:hasCurateFlag {curate_iri}')
+    lines[-1] += " ."
+
+    # --- Identifier nodes ---
+    for ident in entity.identifiers:
+        iiri = f"<{_full_iri(entity._identifier_iri(ident))}>"
+        lines += [
+            f"  {iiri} a atlas:Identifier ;",
+            f'    atlas:scheme "{_escape(ident.scheme)}" ;',
+            f'    atlas:value  "{_escape(ident.value)}" .',
+        ]
+
+    # --- Claim + Provenance nodes ---
+    for claim in entity.claims:
+        obj = claim.object_iri or claim.object_literal or ""
+        cid_str = entity._claim_id(claim.predicate, obj)
+        cid = f"<{_full_iri(cid_str)}>"
+        pid = f"<{_full_iri(entity._prov_id(cid_str))}>"
+        pred_iri = f"<{_full_iri(claim.predicate)}>"
+
+        lines += [
+            f"  {cid} a atlas:Claim ;",
+            f'    atlas:claimSubjectIri {e} ;',
+            f'    atlas:claimPredicate {pred_iri} ;',
+        ]
+        if claim.object_iri:
+            lines.append(f'    atlas:claimObjectIri <{_full_iri(claim.object_iri)}> ;')
+        elif claim.object_literal:
+            lines.append(f'    atlas:claimObjectLiteral "{_escape(claim.object_literal)}" ;')
+        lines += [
+            f'    atlas:claimLayer "{claim.layer}" ;',
+            f'    atlas:claimStatus "{claim.status}"',
+        ]
+        if claim.provenance:
+            lines[-1] += " ;"
+            lines.append(f'    atlas:hasProvenance {pid}')
+        lines[-1] += " ."
+
+        if claim.provenance:
+            p = claim.provenance
+            lines += [
+                f"  {pid} a atlas:Provenance ;",
+                f'    atlas:provenanceSource "{_escape(p.source)}" ;',
+                f'    atlas:retrievalMethod  "{_escape(p.method)}" ;',
+                f'    atlas:confidence       "{p.confidence}"^^xsd:decimal ;',
+                f'    atlas:retrievedAt      "{p.retrieved_at}"^^xsd:dateTime .',
+            ]
+
+    # --- CurateFlag node ---
+    if entity.curate_flag:
+        curate_iri = f"<{_full_iri(entity._entity_iri())}_curate>"
+        lines += [
+            f"  {curate_iri} a atlas:CurateFlag ;",
+            f'    atlas:curationReason "{_escape(entity.curate_flag.reason)}"@en .',
+        ]
+
+    return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Load
+# ---------------------------------------------------------------------------
+
+def load_entity(atlas_id: str, endpoint: str) -> Optional[Entity]:
+    """
+    Reconstruct an Entity object from the SPARQL store.
+
+    Returns None if the entity does not exist.
+
+    Args:
+        atlas_id: e.g. "atlas:1b0e7222c7730540"
+        endpoint: SPARQL Query endpoint URL,
+                  e.g. "http://localhost:8890/sparql"
+    """
+    entity_iri = f"<{ATLAS_D}entity_{atlas_id.replace('atlas:', '')}>"
+
+    # --- 1. Core entity fields ---
+    core = _sparql_select(endpoint, f"""{PREFIXES}
+SELECT ?label ?description ?type ?needsCuration WHERE {{
+  {entity_iri} atlas:canonicalLabel ?label .
+  OPTIONAL {{ {entity_iri} atlas:canonicalDescription ?description . }}
+  OPTIONAL {{ {entity_iri} atlas:hasCanonicalType      ?type . }}
+  OPTIONAL {{ {entity_iri} atlas:needsCuration         ?needsCuration . }}
+}}""")
+
+    if not core:
+        return None
+
+    row = core[0]
+    label       = row["label"]["value"]
+    description = row.get("description", {}).get("value")
+    type_full   = row.get("type", {}).get("value")
+    entity_type = _short_iri(type_full) if type_full else None
+    needs_curation = row.get("needsCuration", {}).get("value", "false").lower() == "true"
+
+    # --- 2. Aliases ---
+    alias_rows = _sparql_select(endpoint, f"""{PREFIXES}
+SELECT ?alias WHERE {{
+  {entity_iri} atlas:aliasLabel ?alias .
+}}""")
+    aliases = [r["alias"]["value"] for r in alias_rows]
+
+    # --- 3. Identifiers ---
+    ident_rows = _sparql_select(endpoint, f"""{PREFIXES}
+SELECT ?scheme ?value WHERE {{
+  {entity_iri} atlas:hasIdentifier ?ident .
+  ?ident atlas:scheme ?scheme ;
+         atlas:value  ?value .
+}}""")
+    identifiers = [
+        Identifier(scheme=r["scheme"]["value"], value=r["value"]["value"])
+        for r in ident_rows
+    ]
+
+    # --- 4. Attributes (any atlas: datatype property not covered above) ---
+    KNOWN = {
+        "atlasId", "canonicalLabel", "canonicalDescription",
+        "aliasLabel", "needsCuration", "rawJson",
+    }
+    attr_rows = _sparql_select(endpoint, f"""{PREFIXES}
+SELECT ?pred ?val WHERE {{
+  {entity_iri} ?pred ?val .
+  FILTER(STRSTARTS(STR(?pred), "{ATLAS}"))
+  FILTER(isLiteral(?val))
+}}""")
+    attributes: Dict[str, Any] = {}
+    raw_json: List[str] = []
+    for r in attr_rows:
+        pred_local = _local(r["pred"]["value"])
+        if pred_local in KNOWN:
+            continue
+        val  = r["val"]["value"]
+        dt   = r["val"].get("datatype", "")
+        if pred_local == "rawJson":
+            raw_json.append(val)
+        elif dt.endswith("boolean"):
+            attributes[pred_local] = val.lower() == "true"
+        elif dt.endswith("decimal") or dt.endswith("float") or dt.endswith("double"):
+            attributes[pred_local] = float(val)
+        elif dt.endswith("integer") or dt.endswith("int"):
+            attributes[pred_local] = int(val)
+        else:
+            attributes[pred_local] = val
+
+    # --- 5. Claims + Provenance ---
+    claim_rows = _sparql_select(endpoint, f"""{PREFIXES}
+SELECT ?pred ?objIri ?objLit ?layer ?status
+       ?provSource ?provMethod ?provConf ?provAt
+WHERE {{
+  {entity_iri} atlas:hasClaim ?claim .
+  ?claim atlas:claimPredicate ?pred ;
+         atlas:claimLayer     ?layer ;
+         atlas:claimStatus    ?status .
+  OPTIONAL {{ ?claim atlas:claimObjectIri     ?objIri . }}
+  OPTIONAL {{ ?claim atlas:claimObjectLiteral ?objLit . }}
+  OPTIONAL {{
+    ?claim atlas:hasProvenance ?prov .
+    ?prov  atlas:provenanceSource ?provSource ;
+           atlas:retrievalMethod  ?provMethod ;
+           atlas:confidence       ?provConf ;
+           atlas:retrievedAt      ?provAt .
+  }}
+}}""")
+
+    claims: List[Claim] = []
+    for r in claim_rows:
+        prov = None
+        if "provSource" in r:
+            prov = Provenance(
+                source=r["provSource"]["value"],
+                method=r["provMethod"]["value"],
+                confidence=float(r["provConf"]["value"]),
+                retrieved_at=r["provAt"]["value"],
+            )
+        claims.append(Claim(
+            predicate=_short_iri(r["pred"]["value"]),
+            object_iri=_short_iri(r["objIri"]["value"]) if "objIri" in r else None,
+            object_literal=r["objLit"]["value"] if "objLit" in r else None,
+            layer=r["layer"]["value"],
+            status=r["status"]["value"],
+            provenance=prov,
+        ))
+
+    # --- 6. CurateFlag ---
+    curate_flag = None
+    curate_rows = _sparql_select(endpoint, f"""{PREFIXES}
+SELECT ?reason WHERE {{
+  {entity_iri} atlas:hasCurateFlag ?flag .
+  ?flag atlas:curationReason ?reason .
+}}""")
+    if curate_rows:
+        curate_flag = CurateFlag(reason=curate_rows[0]["reason"]["value"])
+
+    return Entity(
+        id=atlas_id,
+        label=label,
+        description=description,
+        type=entity_type,
+        aliases=aliases,
+        identifiers=identifiers,
+        attributes=attributes,
+        raw_json=raw_json,
+        claims=claims,
+        needs_curation=needs_curation,
+        curate_flag=curate_flag,
+    )

+ 32 - 0
app/main.py

@@ -0,0 +1,32 @@
+"""HTTP/SSE entrypoint for the atlas2-mcp server."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Dict
+
+from fastapi import FastAPI
+
+from .mcp_server import mcp
+
+START_TIME = datetime.now(timezone.utc)
+
+app = FastAPI(
+    title="Atlas2-MCP",
+    description="Atlas2 semantic resolution scaffold (single resolve tool).",
+    version="0.0.1",
+)
+
+app.mount("/mcp", mcp.sse_app())
+
+
+@app.get("/health", tags=["liveness"])
+async def health() -> Dict[str, object]:
+    now = datetime.now(timezone.utc)
+    uptime_seconds = (now - START_TIME).total_seconds()
+    return {
+        "status": "ok",
+        "uptime_seconds": round(uptime_seconds, 2),
+        "tools": ["resolve"],
+    }
+

+ 39 - 0
app/mcp_server.py

@@ -0,0 +1,39 @@
+"""FastMCP transport for Atlas2 tools."""
+
+from __future__ import annotations
+
+from mcp.server.fastmcp import FastMCP
+from mcp.server.transport_security import TransportSecuritySettings
+
+from .resolve import ResolveService
+
+
+mcp = FastMCP(
+    "atlas2",
+    transport_security=TransportSecuritySettings(
+        enable_dns_rebinding_protection=False
+    ),
+)
+
+
+@mcp.tool(
+    name="resolve",
+    description="Resolve a subject string to a canonical entity (atlas2 scaffold).",
+)
+async def resolve_tool(
+    subject: str,
+    context: dict | None = None,
+    constraints: dict | None = None,
+    hints: dict | None = None,
+    debug: dict | None = None,
+):
+    # Service pulls configuration exclusively from the project's .env.
+    svc = ResolveService()
+    return await svc.resolve(
+        subject=subject,
+        context=context,
+        constraints=constraints,
+        hints=hints,
+        debug=debug,
+    )
+

+ 47 - 0
app/remote_sparql_client.py

@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import os
+from typing import Any, Awaitable, Callable
+
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+
+CallToolFn = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
+
+
+class RemoteSparqlClient:
+    """Thin MCP->tool bridge for remote sparql_query / sparql_update.
+
+    We intentionally keep this injectable so unit tests can bypass real network.
+    """
+
+    def __init__(
+        self,
+        *,
+        sse_url: str | None = None,
+        timeout_s: float | None = None,
+        sse_read_timeout_s: float | None = None,
+    ):
+        self.sse_url = sse_url or os.getenv("REMOTE_MCP_SSE_URL", "http://192.168.0.249:8501/mcp/sse")
+        self.timeout_s = float(timeout_s or os.getenv("REMOTE_MCP_TIMEOUT", "10"))
+        self.sse_read_timeout_s = float(sse_read_timeout_s or os.getenv("REMOTE_MCP_SSE_READ_TIMEOUT", "300"))
+
+    async def call_tool(self, tool_name: str, payload: dict[str, Any]) -> dict[str, Any]:
+        async with sse_client(
+            self.sse_url,
+            timeout=self.timeout_s,
+            sse_read_timeout=self.sse_read_timeout_s,
+        ) as (read_stream, write_stream):
+            async with ClientSession(read_stream, write_stream) as session:
+                await session.initialize()
+                result = await session.call_tool(tool_name, {"input": payload})
+                if result.isError:
+                    raise RuntimeError(f"MCP tool {tool_name} failed: {result.error}")
+
+                data = result.structuredContent if result.structuredContent is not None else result.content
+                # We expect dict-like data for sparql_*.
+                if isinstance(data, dict):
+                    return data
+                return {"raw": data}
+

+ 86 - 0
app/resolve.py

@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import os
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Any, Awaitable, Callable
+
+
+CallToolFn = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
+
+
+def _extract_bindings(result_payload: Any) -> list[dict[str, Any]]:
+    """Best-effort extraction for Virtuoso/MCP-style SPARQL results."""
+    if isinstance(result_payload, dict):
+        return result_payload.get("results", {}).get("bindings", []) or []
+    return []
+
+
+def _to_float(value: Any) -> float:
+    try:
+        return float(value)
+    except Exception:
+        return 0.0
+
+
+def _now_iso() -> str:
+    # Avoid datetime imports; keep it lightweight.
+    import datetime
+
+    return datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+
+def _build_candidates_query(*, subject: str, max_candidates: int, graph_iri: str) -> str:
+    # Scaffolding query: adjust the predicate/shape once the remote schema is fixed.
+    # Keep it deterministic and parameterized by the provided subject string.
+    safe = subject.replace("\\", "\\\\").replace('"', '\\"')
+    return f"""
+PREFIX atlas: <http://world.eu.org/atlas_ontology#>
+SELECT ?id ?label ?type ?source ?confidence ?description ?uri
+WHERE {{
+  GRAPH <{graph_iri}> {{
+    ?entity a atlas:Entity ;
+            atlas:canonicalLabel ?label .
+
+    FILTER(LCASE(STR(?label)) = LCASE(\"{safe}\"))
+
+    OPTIONAL {{ ?entity atlas:hasCanonicalType ?type . }}
+    OPTIONAL {{ ?entity atlas:canonicalDescription ?description . }}
+
+    BIND(STR(?entity) AS ?uri)
+    BIND(STRAFTER(STR(?entity), '#') AS ?id)
+    BIND(0.9 AS ?confidence)
+    BIND("sparql" AS ?source)
+  }}
+}}
+LIMIT {max_candidates}
+""".strip()
+
+
+@dataclass
+class ResolveService:
+    call_tool: CallToolFn | None = None
+
+    async def _call_tool(self, tool_name: str, payload: dict[str, Any]) -> dict[str, Any]:
+        if self.call_tool is None:
+            # Important: default behavior is a stub. This scaffolding should run
+            # safely without requiring a live remote MCP/Sparql backend.
+            raise RuntimeError(
+                "REMOTE_SPASQL_MCP_NOT_CONFIGURED (stub). "
+                "Inject call_tool in tests or wire a real RemoteSparqlClient explicitly."
+            )
+        return await self.call_tool(tool_name, payload)
+
+    async def resolve(
+        self,
+        *,
+        subject: str,
+        context: dict[str, Any] | None,
+        constraints: dict[str, Any] | None,
+        hints: dict[str, Any] | None,
+        debug: dict[str, Any] | None,
+    ) -> dict[str, Any]:
+        # Stubbed implementation for “works first, decide logic later”.
+        # We intentionally ignore inputs until you confirm the app structure.
+        return {"status": "ok"}

+ 37 - 0
killserver.sh

@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${PORT:-8550}"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+echo "Killing atlas2-mcp listeners on port ${PORT} (stray uvicorn processes)..."
+
+if command -v ss >/dev/null 2>&1; then
+  # Get PIDs listening on the TCP port.
+  PIDS=$(ss -ltnp 2>/dev/null | awk -v port=":${PORT}" '$4 ~ port {print $NF}' | sed -E 's/users:\(\("([^,]+)".*pid=([0-9]+).*/\2/' | tr -d '"' || true)
+elif command -v lsof >/dev/null 2>&1; then
+  PIDS=$(lsof -t -iTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null || true)
+else
+  echo "Neither 'ss' nor 'lsof' found; cannot auto-kill by port." >&2
+  exit 1
+fi
+
+if [[ -z "${PIDS:-}" ]]; then
+  echo "No listeners found on port ${PORT}."
+  exit 0
+fi
+
+# Kill unique PIDs.
+echo "Found PIDs: ${PIDS}"
+PIDS_UNIQ=$(echo "$PIDS" | tr ' ' '\n' | awk 'NF' | sort -u)
+
+for pid in $PIDS_UNIQ; do
+  if [[ "$pid" =~ ^[0-9]+$ ]]; then
+    echo "Killing PID ${pid}..."
+    kill -9 "$pid" 2>/dev/null || true
+  fi
+done
+
+echo "Done."
+

+ 211 - 0
ontology/atlas.ttl

@@ -0,0 +1,211 @@
+@prefix atlas:  <http://world.eu.org/atlas_ontology#> .
+@prefix owl:    <http://www.w3.org/2002/07/owl#> .
+@prefix rdf:    <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
+@prefix rdfs:   <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix xsd:    <http://www.w3.org/2001/XMLSchema#> .
+@prefix schema: <http://schema.org/> .
+@prefix wd:     <http://www.wikidata.org/entity/> .
+
+atlas:Ontology a owl:Ontology ;
+  rdfs:label   "Atlas Ontology" ;
+  rdfs:comment "Entity resolution ontology for Atlas." .
+
+
+### Classes
+
+atlas:Entity a owl:Class ;
+  rdfs:label   "Entity" ;
+  rdfs:comment "One real-world referent." .
+
+atlas:EntityType a owl:Class ;
+  rdfs:label   "Entity Type" ;
+  rdfs:comment "Controlled vocabulary of canonical types." .
+
+atlas:Identifier a owl:Class ;
+  rdfs:label   "Identifier" ;
+  rdfs:comment "External identifier. atlas:scheme holds the scheme token; atlas:value holds the string." .
+
+atlas:Provenance a owl:Class ;
+  rdfs:label   "Provenance" ;
+  rdfs:comment "Source, method, timestamp and confidence for an identifier or claim." .
+
+atlas:Claim a owl:Class ;
+  rdfs:label   "Claim" ;
+  rdfs:comment "Reified triple with provenance. The triple must also exist directly on the entity." .
+
+atlas:CurateFlag a owl:Class ;
+  rdfs:label   "Curate Flag" ;
+  rdfs:comment "Signals that an entity needs human review." .
+
+
+### Object properties
+
+atlas:hasCanonicalType a owl:ObjectProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  atlas:EntityType ;
+  rdfs:label  "has canonical type" .
+
+atlas:hasIdentifier a owl:ObjectProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  atlas:Identifier ;
+  rdfs:label  "has identifier" .
+
+atlas:hasClaim a owl:ObjectProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  atlas:Claim ;
+  rdfs:label  "has claim" .
+
+atlas:claimSubjectIri a owl:ObjectProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  atlas:Entity ;
+  rdfs:label  "claim subject IRI" .
+
+atlas:claimPredicate a owl:ObjectProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  rdf:Property ;
+  rdfs:label  "claim predicate" .
+
+atlas:claimObjectIri a owl:ObjectProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  owl:Thing ;
+  rdfs:label  "claim object IRI" .
+
+atlas:hasProvenance a owl:ObjectProperty ;
+  rdfs:domain owl:Thing ;
+  rdfs:range  atlas:Provenance ;
+  rdfs:label  "has provenance" .
+
+atlas:supersedes a owl:ObjectProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  atlas:Claim ;
+  rdfs:label  "supersedes" .
+
+atlas:hasCurateFlag a owl:ObjectProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  atlas:CurateFlag ;
+  rdfs:label  "has curate flag" .
+
+
+### Datatype properties
+
+atlas:atlasId a owl:DatatypeProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "atlas id" .
+
+atlas:canonicalLabel a owl:DatatypeProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "canonical label" .
+
+atlas:canonicalDescription a owl:DatatypeProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "canonical description" .
+
+atlas:aliasLabel a owl:DatatypeProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "alias label" ;
+  rdfs:comment "Direct surface form. Add a language tag where known." .
+
+atlas:scheme a owl:DatatypeProperty ;
+  rdfs:domain atlas:Identifier ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "scheme" ;
+  rdfs:comment "Controlled tokens: wikidata-qid | google-mid | atlas-internal." .
+
+atlas:value a owl:DatatypeProperty ;
+  rdfs:domain atlas:Identifier ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "value" .
+
+atlas:provenanceSource a owl:DatatypeProperty ;
+  rdfs:domain atlas:Provenance ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "provenance source" .
+
+atlas:retrievalMethod a owl:DatatypeProperty ;
+  rdfs:domain atlas:Provenance ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "retrieval method" .
+
+atlas:retrievedAt a owl:DatatypeProperty ;
+  rdfs:domain atlas:Provenance ;
+  rdfs:range  xsd:dateTime ;
+  rdfs:label  "retrieved at" .
+
+atlas:confidence a owl:DatatypeProperty ;
+  rdfs:domain atlas:Provenance ;
+  rdfs:range  xsd:decimal ;
+  rdfs:label  "confidence" .
+
+atlas:claimLayer a owl:DatatypeProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "claim layer" ;
+  rdfs:comment "Values: raw | derived | curated." .
+
+atlas:claimStatus a owl:DatatypeProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "claim status" ;
+  rdfs:comment "Values: active | superseded | rejected." .
+
+atlas:claimObjectLiteral a owl:DatatypeProperty ;
+  rdfs:domain atlas:Claim ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "claim object literal" .
+
+atlas:needsCuration a owl:DatatypeProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  xsd:boolean ;
+  rdfs:label  "needs curation" .
+
+atlas:curationReason a owl:DatatypeProperty ;
+  rdfs:domain atlas:CurateFlag ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "curation reason" .
+
+atlas:rawJson a owl:DatatypeProperty ;
+  rdfs:domain atlas:Entity ;
+  rdfs:range  xsd:string ;
+  rdfs:label  "raw json" ;
+  rdfs:comment "Opaque JSON cache blob from any source. Source is recorded in the associated Provenance node." .
+
+
+### Canonical type catalog
+
+atlas:Person a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Person" ;
+  owl:sameAs schema:Person, wd:Q5 .
+
+atlas:Organization a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Organization" ;
+  owl:sameAs schema:Organization, wd:Q43229 .
+
+atlas:Location a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Location" ;
+  owl:sameAs schema:Place, wd:Q17334923 .
+
+atlas:CreativeWork a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Creative Work" ;
+  owl:sameAs schema:CreativeWork, wd:Q17537576 .
+
+atlas:Event a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Event" ;
+  owl:sameAs schema:Event, wd:Q1656682 .
+
+atlas:Product a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Product" ;
+  owl:sameAs schema:Product, wd:Q2424752 .
+
+atlas:Other a owl:Class ;
+  rdfs:subClassOf atlas:EntityType ;
+  rdfs:label "Other" .

+ 94 - 0
ontology/joe.ttl

@@ -0,0 +1,94 @@
+@prefix atlas:      <http://world.eu.org/atlas_ontology#> .
+@prefix atlas_data: <http://world.eu.org/atlas_data#> .
+@prefix xsd:        <http://www.w3.org/2001/XMLSchema#> .
+
+### Entity
+
+atlas_data:entity_atlas_1b0e7222c7730540
+  a                          atlas:Entity ;
+  atlas:atlasId              "atlas:1b0e7222c7730540" ;
+  atlas:canonicalLabel       "Joe Biden"@en ;
+  atlas:canonicalDescription "46th President of the United States (2021\u20132025)"@en ;
+  atlas:hasCanonicalType     atlas:Person ;
+
+  atlas:aliasLabel           "Joe Biden"@en ;
+  atlas:aliasLabel           "Biden"@en ;
+  atlas:aliasLabel           "Joseph Biden"@en ;
+
+  atlas:hasIdentifier        atlas_data:ident_mid_012gx2 ;
+  atlas:hasIdentifier        atlas_data:ident_qid_q6279 ;
+
+  atlas:hasClaim             atlas_data:claim_mid_1dcabf1f ;
+  atlas:hasClaim             atlas_data:claim_qid_f9d8f10a ;
+  atlas:hasClaim             atlas_data:claim_type_fd0fc27f ;
+
+  atlas:needsCuration        true ;
+  atlas:hasCurateFlag        atlas_data:curate_1b0e7222c7730540 ;
+
+  atlas:rawJson              "{\"source\":\"wikidata\",\"qid\":\"Q6279\",\"label\":\"Joe Biden\",\"retrieved_at\":\"2026-04-04T23:27:09Z\"}"^^xsd:string ;
+  atlas:rawJson              "{\"source\":\"google-trends\",\"mid\":\"/m/012gx2\",\"type\":\"46th U.S. President\",\"retrieved_at\":\"2026-04-04T23:27:06Z\"}"^^xsd:string .
+
+
+### Identifiers
+
+atlas_data:ident_mid_012gx2
+  a            atlas:Identifier ;
+  atlas:scheme "google-mid" ;
+  atlas:value  "/m/012gx2" .
+
+atlas_data:ident_qid_q6279
+  a            atlas:Identifier ;
+  atlas:scheme "wikidata-qid" ;
+  atlas:value  "Q6279" .
+
+
+### Claims
+# Each Claim records provenance for one direct triple on the entity above.
+# The direct triple must always exist — Claims are the audit layer, not the fact.
+
+atlas_data:claim_mid_1dcabf1f
+  a                     atlas:Claim ;
+  atlas:claimSubjectIri atlas_data:entity_atlas_1b0e7222c7730540 ;
+  atlas:claimPredicate  atlas:hasIdentifier ;
+  atlas:claimObjectIri  atlas_data:ident_mid_012gx2 ;
+  atlas:claimLayer      "raw" ;
+  atlas:claimStatus     "active" ;
+  atlas:hasProvenance   atlas_data:prov_mid_1dcabf1f .
+
+atlas_data:prov_mid_1dcabf1f
+  a                      atlas:Provenance ;
+  atlas:provenanceSource "google-trends" ;
+  atlas:retrievalMethod  "trends-resolution" ;
+  atlas:confidence       "0.9"^^xsd:decimal ;
+  atlas:retrievedAt      "2026-04-04T23:27:06Z"^^xsd:dateTime .
+
+atlas_data:claim_qid_f9d8f10a
+  a                     atlas:Claim ;
+  atlas:claimSubjectIri atlas_data:entity_atlas_1b0e7222c7730540 ;
+  atlas:claimPredicate  atlas:hasIdentifier ;
+  atlas:claimObjectIri  atlas_data:ident_qid_q6279 ;
+  atlas:claimLayer      "raw" ;
+  atlas:claimStatus     "active" ;
+  atlas:hasProvenance   atlas_data:prov_qid_f9d8f10a .
+
+atlas_data:prov_qid_f9d8f10a
+  a                      atlas:Provenance ;
+  atlas:provenanceSource "wikidata" ;
+  atlas:retrievalMethod  "wbsearchentities + entitydata" ;
+  atlas:confidence       "0.99"^^xsd:decimal ;
+  atlas:retrievedAt      "2026-04-04T23:27:09Z"^^xsd:dateTime .
+
+atlas_data:claim_type_fd0fc27f
+  a                     atlas:Claim ;
+  atlas:claimSubjectIri atlas_data:entity_atlas_1b0e7222c7730540 ;
+  atlas:claimPredicate  atlas:hasCanonicalType ;
+  atlas:claimObjectIri  atlas:Person ;
+  atlas:claimLayer      "derived" ;
+  atlas:claimStatus     "active" .
+
+
+### Curation flag
+
+atlas_data:curate_1b0e7222c7730540
+  a                    atlas:CurateFlag ;
+  atlas:curationReason "Fine-grained Trends type '46th U.S. President' not yet adjudicated."@en .

+ 9 - 0
requirements.txt

@@ -0,0 +1,9 @@
+fastapi>=0.110.0
+uvicorn[standard]>=0.23.0
+fastmcp>=0.5.0
+mcp>=1.27.0
+httpx>=0.28.1
+python-dotenv>=1.0.0
+pytest>=8.0.0
+anyio>=4.0.0
+

+ 61 - 0
resolve_scheme.md

@@ -0,0 +1,61 @@
+# Resolve Tool Communication Blueprint
+
+This document describes the contract for the **resolve** tool – the format the tool accepts as input and the structure it returns as output.
+
+## Request
+
+The request is a JSON object with the following top‑level keys:
+
+| Key | Type | Description |
+|------|------|-------------|
+| **subject** | string | The entity to resolve. **Required** |
+| **context** | object | Optional context to narrow the search.  | 
+| | `realm` | string | e.g. *"music"*, *"geography"* |
+| | `provenance` | string | Source of the query (e.g. user, system) |
+| | `time` | string | ISO‑8601 timestamp |
+| | `language` | string | BCP‑47 language tag |
+| **constraints** | object | Rules to apply while resolving. |
+| | `deterministic` | boolean | If true, the tool must always return the same result |
+| | `require_authority` | boolean | Require a trusted source |
+| | `allowed_sources` | array of strings |
+| | `max_candidates` | integer | Default 5 |
+| | `min_confidence` | number (0‑1) |
+| **hints** | object | Guidance for the resolver |
+| | `expected_type` | string |
+| | `preferred_id` | string |
+| | `aliases` | array of strings |
+| **debug** | object | Toggle verbose information |
+| | `include_explanations` | boolean |
+| | `include_candidates` | boolean |
+
+## Response
+
+The tool replies with a JSON object containing:
+
+| Key | Type | Description |
+|------|------|-------------|
+| **status** | string | One of *resolved*, *ambiguous*, *not_found*, *error* |
+| **entity** | object | The resolved entity when `status === "resolved"` |
+| | `id`, `label`, `type`, `description`, `source`, `uri`, `attributes` |
+| **confidence** | number (0‑1) |
+| **candidates** | array of objects | When ambiguous; each has `id`, `label`, `type`, `source`, `confidence` |
+| **ambiguity** | object | Details if status is *ambiguous* |
+| | `reason`, `dimension` |
+| **resolution_path** | array of steps |
+| | `phase`, `action`, `source`, `note` |
+| **meta** | object | Request ID, timestamp, duration in ms |
+| **error** | object | If status is *error*; contains `code` and `message` |
+
+### Example
+```json
+{
+  "status": "resolved",
+  "entity": {"id": "Q123", "label": "Mozart", "type": "Person", "description": "A Viennese composer", "source": "Wikidata"},
+  "confidence": 0.97,
+  "candidates": [],
+  "resolution_path": [...],
+  "meta": {"request_id": "abc123", "timestamp": "2026‑04‑05T15:30:00Z", "duration_ms": 42}
+}
+```
+
+Feel free to use this file as the reference for any implementation of the resolve tool.

+ 9 - 0
restart.sh

@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+./killserver.sh
+./run.sh
+

+ 25 - 0
run.sh

@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${PORT:-8550}"
+
+# Ensure we're running from the project root.
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+echo "Starting atlas2-mcp on port ${PORT}..."
+
+mkdir -p logs
+
+LOG_FILE="logs/server.log"
+PID_FILE="logs/server.pid"
+
+# Detach (background) and persist PID + logs.
+nohup uvicorn app.main:app \
+  --host 0.0.0.0 \
+  --port "${PORT}" \
+  >"${LOG_FILE}" 2>&1 &
+
+echo $! >"${PID_FILE}"
+
+echo "atlas2-mcp started (pid $(cat "${PID_FILE}")), logging to ${LOG_FILE}."

+ 17 - 0
tests.sh

@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+echo "Running tests (pytest)..."
+
+if command -v pytest >/dev/null 2>&1; then
+  exec pytest -q
+elif command -v python >/dev/null 2>&1; then
+  exec python -m pytest -q
+else
+  echo "pytest/python not found in PATH. Install dependencies and retry." >&2
+  exit 1
+fi
+

+ 7 - 0
tests/conftest.py

@@ -0,0 +1,7 @@
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+if str(ROOT) not in sys.path:
+    sys.path.insert(0, str(ROOT))
+

+ 18 - 0
tests/test_resolve_tool.py

@@ -0,0 +1,18 @@
+import pytest
+
+from app.resolve import ResolveService
+
+
+@pytest.mark.anyio
+async def test_resolve_tool_is_stubbed_and_returns_ok():
+    svc = ResolveService(call_tool=None)
+    result = await svc.resolve(
+        subject="anything",
+        context=None,
+        constraints=None,
+        hints=None,
+        debug=None,
+    )
+
+    assert result == {"status": "ok"}
+