Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ All notable changes to `synpareia-trust-mcp` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.0] - 2026-07-03

Privacy-completion release: makes the data-protection posture the prove/vet/bind
copy already promises actually true in the product. Closes the two GDPR §6 gaps
the 0.6.3 publish-gate `legal` perspective surfaced (both amplified by
network-on-by-default). Adds one tool (33 total) — backward-compatible; minor
bump.

### Added

- **`forget_counterparty(identifier)`** — first-class Tier-1 erasure. Permanently
removes a counterparty record and *all* your evaluations of them from the local
**journal**; the local-data counterpart to the directory-side `delete_profile`.
Idempotent (forgetting an absent identifier returns `forgotten: false`, no
error). This is the concrete mechanism behind "erasure stays under your
control" **for the counterparty journal** (GDPR Art. 17, on the data subject's
own machine) — previously the README could only point agents at hand-editing
`counterparties.json`. Scope is deliberately the journal: signed
conversation/recording chains (`conversations/conv_<id>.json`) are
tamper-evident audit trails and are not erased by this tool (the response says
so), preserving the audit-integrity-vs-erasure trade the data-protection design
already names.
- **First-run identity disclosure (GDPR §6).** When the server mints a brand-new
identity, it now discloses — on stderr (never stdout, the stdio-MCP protocol
channel) — the DID, the on-disk location, and that *nothing has been sent
anywhere* (publishing and witnessing are always explicit calls). `orient` also
carries an `identity.first_run` notice for the session in which the identity
was created. This matters more since 0.6 defaulted the network ON: the operator
should know a fresh identity is local-only until they make an explicit call.

## [0.6.3] - 2026-07-03

Positioning + copy release ahead of the MCP-marketplace listings, and the first
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Start by calling `orient` — it maps your situation to the right tools and poin
| `recall_counterparty` | Look up what you know about a counterparty | Yes |
| `add_evaluation` | Attach your own note/score to a counterparty | Yes |
| `find_evaluations` | Search your evaluations by tag | Yes |
| `forget_counterparty` | Erase a counterparty + all your evaluations of them | Yes |
| `witness_info` | Witness identity, public key, service URL | No |
| `witness_seal_timestamp` | Timestamp seal over a block hash | No |
| `witness_seal_state` | State seal over a chain head | No |
Expand All @@ -73,7 +74,7 @@ Start by calling `orient` — it maps your situation to the right tools and poin
| `delete_profile_history` | Delete a prior published card version | No |
| `delete_profile` | Tombstone your published card | No |

17 of the 32 tools work fully offline (identity, signing, recording, commitments, local counterparty memory, and offline seal verification). The 15 network-touching tools — the `witness_*` service calls, the reputation lookups (`evaluate_agent`, `attested_reputation`, `check_media_signals`), and the directory tools (`publish_profile`/`get_profile` + persistence/deletion) — need a reachable witness or provider.
18 of the 33 tools work fully offline (identity, signing, recording, commitments, local counterparty memory including erasure, and offline seal verification). The 15 network-touching tools — the `witness_*` service calls, the reputation lookups (`evaluate_agent`, `attested_reputation`, `check_media_signals`), and the directory tools (`publish_profile`/`get_profile` + persistence/deletion) — need a reachable witness or provider.

### Upgrading from 0.2.0

Expand Down Expand Up @@ -183,9 +184,10 @@ What's stored:
When you record an evaluation about a counterparty, that observation stays on
your disk — there is no automatic upload, no shared reputation database, no
cross-agent broadcast.
- **Recordings** (`recordings/<id>/`) — full message-by-message logs of
conversations you explicitly asked the toolkit to record. Same locality
guarantees.
- **Conversation/recording chains** (`conversations/conv_<id>.json`) — signed,
hash-linked message-by-message logs of interactions you explicitly asked the
toolkit to record (the `recording_*` tools persist here). Tamper-evident and
local; same locality guarantees.

What flows off-machine (only when the corresponding tool is invoked):

Expand All @@ -212,8 +214,14 @@ Subject-rights / GDPR notes (where the GDPR applies to your agent's
operations):

- All journal data lives on the data subject's own machine. Erasure is
achieved by deleting the relevant record — there is no `forget_counterparty`
tool yet; today, edit `counterparties.json` directly.
achieved with `forget_counterparty(identifier)`, which permanently removes a
counterparty and all your evaluations of them from the local journal (the
Tier-1 counterpart to the directory-side `delete_profile`). You can also edit
`counterparties.json` directly. Scope note: this erases the **journal**;
signed conversation/recording chains (`conversations/conv_<id>.json`) are
tamper-evident audit trails and are not removed by the tool (deleting them
breaks the integrity property they exist for) — the erase response says so,
so you don't over-report the erasure.
- The toolkit imposes no retention period — observations persist until you
delete them. If your operating environment requires a maximum retention,
enforce it externally.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "synpareia-trust-mcp"
version = "0.6.3"
version = "0.7.0"
description = "Trust tools for agent-to-agent dealings — prove, vet, and bind with verifiable evidence (MCP server)"
readme = "README.md"
license = "Apache-2.0"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "io.github.synpareia/trust-mcp",
"title": "Synpareia Trust Toolkit",
"description": "Verifiable dealings with other agents: prove what you did, vet who you deal with, bind agreements",
"version": "0.6.3",
"version": "0.7.0",
"websiteUrl": "https://synpareia.com",
"repository": {
"url": "https://github.com/synpareia/trust-mcp",
Expand All @@ -13,7 +13,7 @@
{
"registryType": "pypi",
"identifier": "synpareia-trust-mcp",
"version": "0.6.3",
"version": "0.7.0",
"transport": {
"type": "stdio"
},
Expand Down
2 changes: 1 addition & 1 deletion src/synpareia_trust_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Synpareia Trust Toolkit — identity and trust tools for AI agents."""

__version__ = "0.6.3"
__version__ = "0.7.0"
32 changes: 32 additions & 0 deletions src/synpareia_trust_mcp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
# Generate or load the agent's identity (first run creates a new keypair)
profile_manager.ensure_profile()

# First-run disclosure (GDPR §6 data-protection-by-design). When a brand-new
# identity is minted, tell the operator — on stderr, never stdout, which is
# the stdio-MCP protocol channel — that an identity now exists locally and
# that nothing has been sent anywhere. This matters more since 0.6 defaulted
# the network ON: the operator should know the identity is local-only until
# they make an explicit publishing call.
if profile_manager.newly_generated:
_emit_first_run_disclosure(config, profile_manager.profile.id)

# Initialize witness client if URL is configured
witness_client = _create_witness_client(config)

Expand All @@ -64,6 +73,29 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
await witness_client.close()


def _emit_first_run_disclosure(config: Config, did: str) -> None:
"""Print the first-run identity disclosure to stderr (GDPR §6).

stderr, not stdout: stdout carries the stdio-MCP JSON-RPC stream and must
not be polluted. MCP hosts surface a server's stderr in their logs, so this
reaches the operator.
"""
import sys

profile_path = config.data_dir / "profile.json"
print(
"[synpareia-trust-mcp] Generated a new agent identity.\n"
f" DID: {did}\n"
f" Stored: {profile_path} (private key, mode 0600 — back it up; "
"losing it loses the identity)\n"
" Network: nothing has been sent anywhere. Publishing to the "
"synpareia directory is an explicit publish_profile call; witnessing "
"is an explicit witness_* call.",
file=sys.stderr,
flush=True,
)


def _create_witness_client(config: Config) -> WitnessClient | None:
"""Create a WitnessClient if the witness URL is configured and httpx is available."""
if not config.witness_url:
Expand Down
45 changes: 44 additions & 1 deletion src/synpareia_trust_mcp/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import json
import math
import os
import sys
import uuid
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
Expand Down Expand Up @@ -268,6 +269,29 @@ def find_evaluations(self, tag: str) -> list[dict[str, Any]]:
)
return results

def delete(self, identifier: str) -> AgentRecord | None:
"""Erase a counterparty record (and all its evaluations) by identifier.

Matches on the primary `identifier` or any alias (a DID, etc.). Removes
the whole record — its display-name history, custom fields, and every
evaluation you attached — and rewrites the journal. Returns the removed
record so the caller can report what was erased, or ``None`` if no
record matched (erasure is idempotent: forgetting something already
gone is not an error).

This is the local-journal counterpart to the directory-side
`delete_profile`: it is the concrete mechanism behind "erasure stays
under your control" for Tier-1 data (GDPR Art. 17 on the data subject's
own machine).
"""
records = self._load()
target = _find_by_identifier(records, identifier)
if target is None:
return None
remaining = [r for r in records if r is not target]
self._save(remaining)
return target

def all(self) -> list[AgentRecord]:
return self._load()

Expand All @@ -278,7 +302,26 @@ def _load(self) -> list[AgentRecord]:
data = json.loads(self._path.read_text())
except (json.JSONDecodeError, UnicodeDecodeError):
return []
return [AgentRecord.from_dict(item) for item in data]
if not isinstance(data, list):
return []
# Skip individual malformed rows (a hand-edited or partially-written
# counterparties.json) rather than raising out of a read tool: a single
# bad row must not crash recall/remember/add_evaluation/find/forget. The
# skip is logged to stderr (never stdout — the stdio-MCP JSON-RPC
# channel). Whole-file corruption is handled above (returns empty);
# deeper quarantine-vs-drop durability is tracked separately (task #66).
records: list[AgentRecord] = []
for item in data:
try:
records.append(AgentRecord.from_dict(item))
except (KeyError, TypeError, ValueError) as exc:
print(
f"[synpareia-trust-mcp] skipping malformed journal record "
f"({type(exc).__name__}: {exc}) in {self._path}",
file=sys.stderr,
flush=True,
)
return records

def _save(self, records: list[AgentRecord]) -> None:
self._data_dir.mkdir(parents=True, exist_ok=True)
Expand Down
6 changes: 6 additions & 0 deletions src/synpareia_trust_mcp/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ def __init__(self, data_dir: Path, private_key_b64: str | None = None) -> None:
self._data_dir = data_dir
self._private_key_b64 = private_key_b64
self._profile: synpareia.Profile | None = None
# True iff ensure_profile() minted a brand-new keypair this session
# (as opposed to loading a persisted one or importing an env key).
# Drives the first-run "nothing has been sent to the network yet"
# disclosure (GDPR §6 data-protection-by-design).
self.newly_generated: bool = False

@property
def profile(self) -> synpareia.Profile:
Expand Down Expand Up @@ -148,4 +153,5 @@ def _save_profile(self, path: Path, profile: synpareia.Profile) -> None:
def _generate_and_save(self, path: Path) -> synpareia.Profile:
profile = synpareia.generate()
self._save_profile(path, profile)
self.newly_generated = True
return profile
19 changes: 19 additions & 0 deletions src/synpareia_trust_mcp/tools/orient.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,25 @@ def orient(ctx: Context) -> dict[str, Any]:
"has_private_key": profile_data["has_private_key"],
"directory": directory_state,
}
# First-run disclosure (GDPR §6): when this identity was minted in the
# current session, tell the caller it's local-only and nothing has been
# sent — the agent-discoverable twin of the stderr message emitted at
# startup. Only present on a fresh identity; absent once loaded from disk.
if app.profile_manager.newly_generated:
# Durable-true phrasing: the flag persists for the whole session, so
# the notice must stay accurate even after the agent publishes or
# witnesses. It states the standing property (local-until-you-act +
# opt-in) rather than an absolute "nothing has been sent", which would
# go stale the moment a network call is made.
identity["first_run"] = {
"new_identity": True,
"notice": (
"This identity was generated locally this session. It lives only "
"on this machine unless you make it otherwise — publishing to the "
"directory (publish_profile) and witnessing (witness_*) are always "
"explicit, opt-in calls, never automatic."
),
}

# Configuration status
services = {
Expand Down
76 changes: 76 additions & 0 deletions src/synpareia_trust_mcp/tools/recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- `recall_counterparty` — read-only lookup by identifier or display name.
- `add_evaluation` — write an agent-authored note/tags/score to a record.
- `find_evaluations` — search evaluations across all records by tag.
- `forget_counterparty` — erase a record and all its evaluations (Art. 17).

These tools form a closed Tier-1 loop: nothing leaves the local data dir
and no external services are contacted. Agents can build durable,
Expand Down Expand Up @@ -138,6 +139,81 @@ def add_evaluation(
}


@mcp.tool()
def forget_counterparty(
identifier: str,
ctx: Context,
) -> dict[str, Any]:
"""Erase a counterparty and all your evaluations of them from the local journal.

Permanently removes the Tier-1 record matched by `identifier` (a
`local:...` id or a DID alias) — its display-name history, custom fields,
and every note/score you attached. This is the local-data counterpart to
the directory-side `delete_profile`: it is how "erasure stays under your
control" is enforced for **the counterparty journal** (GDPR Art. 17, on
your own machine). Your private notes were never uploaded, so there is no
journal copy elsewhere to recall.

Scope — read this before reporting an erasure to a data subject: this
erases the **journal** record only. Signed conversation/recording chains
(stored in ``conversations/`` as ``conv_<id>.json``) that reference the
same counterparty are NOT touched by this tool (deleting them would break
the tamper-evidence property they exist for); the response says so on every
successful erase so you don't over-report.

Erasure is idempotent: forgetting an identifier that isn't (or is no
longer) in the journal returns ``forgotten: false`` without error — the
end state (no such record) is what erasure guarantees. Call
`recall_counterparty` first if you want to confirm the identifier before
erasing.
"""
app: AppContext = ctx.request_context.lifespan_context
# _load now skips malformed rows centrally (journal.py), so a corrupt row
# no longer raises here. This guards the remaining write-path failure (an
# OSError from _save: disk full / permission) so the documented
# idempotent-no-error erasure contract holds even then. Narrow, not bare —
# a programming error in delete() still surfaces loudly (reviewer nit).
try:
removed = app.journal_store.delete(identifier)
except OSError as exc:
return {
"ok": False,
"forgotten": False,
"identifier": identifier,
"error": f"{type(exc).__name__}: {exc}"[:200],
"message": (
"Could not complete erasure — the local journal could not be "
"rewritten (disk full or permissions?). No record was removed. "
"Inspect the counterparties.json data file."
),
}
if removed is None:
return {
"ok": True,
"forgotten": False,
"identifier": identifier,
"message": (
f"No journal record matched '{identifier}' — nothing to erase. "
"It may already be gone, or the identifier is wrong "
"(recall_counterparty to check)."
),
}
return {
"ok": True,
"forgotten": True,
"identifier": removed.identifier,
"display_names": list(removed.display_names),
"evaluations_erased": len(removed.evaluations),
"scope": "local_journal_only",
"message": (
"Erased from your local journal — this counterparty record and all "
"your evaluations of them are permanently removed. Note: any signed "
"conversation/recording chains (in conversations/) reference the "
"counterparty by DID and are NOT erased by this tool."
),
}


@mcp.tool()
def find_evaluations(
tag: str,
Expand Down
Loading
Loading