Skip to content

Gate client (HTTP/MCP)

This is the third door: HttpMcpTransport speaks POST /mcp, the tool catalog an MCP host sees. The gate's dg.* verbs — an agent's KnowledgeClient and the operator's CurationClient over the Unix socket or POST /rpc — are on the gate clients page. All three raise one GateError (doublegate_sdk.errors).

doublegate_sdk.client connects an application or agent to a running gate over the product's public integration surface: POST /mcp (ADR-0050). It is an explicit, optional import using only the Python standard library, and it adds no connection, endpoint discovery or credential discovery on import or on construction.

It is a transport, not an authority. A read is not an admission and a proposal is not an approval.

The narrowed contract — read this first

The client speaks the tools the maintained client tier actually serves, and nothing else. Two absences matter:

Not available Why
inventory / inventory_pages The client tier serves no inventory tool. Rather than ship a method that could only ever fail, there is none.
Whole-gate status() doublegate.status requires an artifact id. There is no gate-wide status call on this surface.

The served catalog is doublegate.remember, recall, status, why, tip, pending, rank, hide, ban. Ask a specific server what it serves rather than assuming:

transport.discover()    # server/discover: protocol versions, serverInfo, capabilities
transport.tool_names()  # tools/list: the tool names this server actually lists

A tool outside the SDK's declared catalog raises ValueError locally instead of being sent and blamed on the server.

Connect

import os
from doublegate_sdk import connect

client = connect('https://gate.example.org/mcp', token=os.environ['GATE_TOKEN'])

record = client.status('sha256:' + '…')
print(record['state'])

answers = client.recall('basalt specimens', limit=5)
for hit in answers['results']:
    print(hit['artifact_id'])

connect() is the one call most callers need. It builds an HttpMcpTransport and wraps it in a GateClient, and takes the same bounds and credential arguments; assemble the two by hand only when you want to hold the transport itself, for discover() or tool_names():

from doublegate_sdk.client import GateClient, HttpMcpTransport

transport = HttpMcpTransport('https://gate.example.org/mcp', token=token)
client = GateClient(transport)
print(transport.tool_names())

connect() also exposes the transport as client.transport, so the low-level form is rarely necessary.

HTTPS is the default and the only option for a remote endpoint. Plain HTTP is refused unless you pass allow_insecure_loopback=True and the host is loopback — that combination exists for tests and for a daemon on this host, and it still refuses a remote host:

connect('http://127.0.0.1:8480/mcp', allow_insecure_loopback=True)

The endpoint is always explicit. The SDK reads no configuration file, no environment variable and no well-known path to find a gate.

Propose

Writes are off unless you turn them on. The transport itself refuses a write-annotated tool, so a read-only client cannot be talked into writing by a caller that reaches past GateClient:

writer = connect(endpoint, token=token, allow_writes=True)
proposal = writer.propose(
    'The laboratory labels basalt specimens by collection date.',
    content_type='memory', trust_class='T-4', source_uri='application://observation/123',
)
state = writer.status(proposal['artifact_id'])['state']

content is text. Bytes are accepted only when they are valid UTF-8, and are decoded; binary is refused rather than base64-smuggled into a field the service would store as literal base64 text. The service's remember schema has no encoding parameter, so there is nothing to smuggle it into honestly.

Writer identity is never sent. The service derives identity from the connection and refuses a request carrying writer_identity or deployment_id.

recall excludes the caller's own unreviewed pending echoes, so an application does not read back its own un-admitted proposal and mistake it for an answer. Provisional knowledge is excluded by default; pass include_provisional=True when your policy permits it. Each hit keeps its own provenance flags, and no score is relabelled as trust or confidence.

Bounds and errors

timeout (default 10s) is one total deadline for the whole exchange, not a per-socket operation timeout: it covers connect, send, the status line, the response headers and the body, and is re-checked before every read, so a peer cannot extend it by answering one byte at a time. It is enforced on the calling thread — no background worker is started, so nothing can outlive the call.

One thing the deadline does not cover: name resolution. The standard library resolves the host inside connect() with no cancellation hook, so a hanging DNS server is bounded by your resolver's own timeout, not by this. Pass an IP literal if that matters to you.

Responses are capped at 1 MiB (max_response_bytes) and requests at 1 MiB (max_request_bytes) — separate budgets, so lowering one does not silently trip the other. A 200 whose Content-Type is not application/json (or a +json type) is refused as invalid_response, even when its body would have parsed: a captive portal or proxy interstitial is not this gate's answer.

The endpoint itself is validated at construction, so a local configuration mistake is a ValueError you get immediately rather than a GateError that looks like the gate being down:

  • credentials in the URL are refused. https://user:[email protected]/mcp raises — urlsplit strips userinfo, so such a credential is never sent, and keeping it would only leave it in transport.endpoint waiting to be logged. Pass token= instead. An empty username (https://@gate.example/mcp) is still userinfo and is still refused.
  • control characters and whitespace in the path are refused, as are tab, CR and LF anywhere in the URL — urlsplit deletes those silently, which would otherwise turn /mcp\tx into /mcpx behind your back.
  • a token the HTTP header encoding cannot carry is refused at construction. Headers are Latin-1; a token with CJK, emoji or Cyrillic raises ValueError when you build the transport rather than UnicodeEncodeError mid-request.

Nothing is retried. A write repeated without an idempotency contract is worse than a write that failed loudly. Redirects are refused, never followed: following one would carry the bearer token to whatever host the response names.

GateError.kind is a stable string; branch on it rather than parsing English.

kind Meaning
unavailable The endpoint could not be reached.
timeout The total deadline expired (connect, send, headers or body).
unauthorized / forbidden HTTP 401 / 403 from the serving surface.
redirect_refused The server answered a redirect; it was not followed.
unsupported_operation JSON-RPC -32601/-32602, or HTTP 405 — this build does not serve that.
remote_error The server refused; the numeric code is kept.
tool_error The tool answered with isError.
invalid_response The response did not satisfy the checked shape.
request_too_large / response_too_large A byte budget was exceeded.
writes_disabled A write tool was called without allow_writes=True.

Remote error text is withheld deliberately; the numeric code is kept. Server messages carry internals, and a client log is the wrong place for them. This version does not claim a universal authorization taxonomy — map server codes explicitly in your application.

GateError.outcome_unknown is true when a write may have been received but the outcome could not be read (timeout after sending, a truncated or malformed response, a server -32603). Reconcile before resubmitting; the service's content-duplicate detection is not an exactly-once or idempotency-key guarantee.

What the MCP support actually is

The server serves the 2026-07-28 stateless JSON request/response subset of Streamable HTTP, and so does this client:

  • one JSON-RPC message per POST /mcp, one JSON object back;
  • no SSE, no streaming — GET /mcp is a 405 by design;
  • no sessions — the server issues no Mcp-Session-Id and the client sends none;
  • negotiation is server/discover and tools/list. initialize is refused by the server unless an operator enables a legacy flag, so the SDK does not send it.

This is not a general MCP framework and does not claim to be one. describe_client()['mcp'] reports these facts, and a test asserts the SDK never claims streaming or session support.

Explicit transport injection

GateClient accepts any McpTransport implementing call(tool, arguments) -> dict. A host application can supply its own supported transport — an MCP client it already runs, for instance — without the SDK importing an agent framework. A custom injected transport owns its own write-enablement contract; none of these client-side flags grant server authority.

Verified scope

Proved by the scripted-responder suite (tests/test_memory_client.py, real loopback HTTP servers, real bytes — not mock transport objects): tool mapping, bearer header presence and absence, token never appearing in an error, response and request bounds as separate budgets, redirect refusal, 401/403/405 mapping, JSON-RPC error-code mapping, write opt-in at both client and transport layers, UTF-8 enforcement, unknown-outcome flagging, and the absence of an inventory method. The suite also asserts that no socket transport is re-exported, so a removed transport cannot quietly return.

Proved by one isolated real-service run (examples/verify_memory_lifecycle.py, against the product's own console POST /mcp on an ephemeral loopback port): server/discover and tools/list answered by the real server, a propose → status → recall cycle with the record's state coming from the service, own pending excluded from recall, provisional requiring opt-in, a read-only client refusing a write, and the served catalog containing no inventory tool.

Not proved, and why

The MCP endpoint on the maintained client tier is keyless

console.py dispatches POST /mcp before its _authed() check; only /api/* routes enforce the console bearer token. On that tier, loopback is the credential (INV-SEC-11 / FR-65).

So the real-service run proves the functional path over MCP, and proves the product's actual token guard separately on /api/status (401 without a token, 200 with). It does not prove an authenticated remote MCP client. The SDK's own 401/403 handling is proved against scripted responders. No server-side change was made to close this; it is the service's to close.

Also not covered: HTTPS against a real certificate chain, a remote (non-loopback) deployment, an org-tier keyed MCP surface, cross-tenant authorization, installed-wheel or Windows execution, and organization delivery. This is source-mode verification on Linux/WSL.

Generated reference

doublegate_sdk.client

Explicit gate client over the product's public HTTP/MCP endpoint.

The gate's supported network surface is POST /mcp (ADR-0050): one JSON-RPC message in, one JSON object out. :class:HttpMcpTransport speaks that and only that. It is not an authorization layer, not a general MCP framework, and not a streaming client — the server issues no session id and offers no SSE stream, so neither does this.

Nothing connects on import or on construction. There is no endpoint discovery, no credential discovery, no redirect following and no automatic retry.

The tool catalog below is the maintained client tier's, read from the service's own schema. It is deliberately narrow: there is no inventory tool, and status requires an artifact id. See docs/api/client.md.

GateError

Bases: DoublegateError, RuntimeError

A fixed diagnostic. str(exc) is the kind (and code); the server's text is detail.

Source code in src/doublegate_sdk/errors.py
class GateError(DoublegateError, RuntimeError):
    """A fixed diagnostic. ``str(exc)`` is the kind (and code); the server's text is ``detail``."""

    def __init__(self, kind: str, code: int | None = None, *, retry_after_ms: int | None = None,
                 outcome_unknown: bool = False, detail: str | None = None):
        if kind not in KINDS:
            raise ValueError(f'unknown error kind {kind!r}')
        self.kind, self.code = kind, code
        self.retry_after_ms = retry_after_ms
        self.outcome_unknown = outcome_unknown
        self.detail = None if detail is None else str(detail)[:DETAIL_MAX]
        super().__init__(kind if code is None else f'{kind} ({code})')

    def __repr__(self) -> str:
        return f'GateError(kind={self.kind!r}, code={self.code!r}, outcome_unknown={self.outcome_unknown!r})'

retry_after_ms instance-attribute

retry_after_ms = retry_after_ms

outcome_unknown instance-attribute

outcome_unknown = outcome_unknown

detail instance-attribute

detail = None if detail is None else str(detail)[:DETAIL_MAX]

McpTransport

Bases: Protocol

Explicit tool transport over POST /mcp; the receiving gate remains authoritative.

Distinct from doublegate_sdk.transport.GateTransport, whose call takes a dg.* verb for POST /rpc or the Unix socket. Same shape, different vocabulary.

Source code in src/doublegate_sdk/client.py
class McpTransport(Protocol):
    """Explicit tool transport over ``POST /mcp``; the receiving gate remains authoritative.

    Distinct from ``doublegate_sdk.transport.GateTransport``, whose ``call`` takes a
    ``dg.*`` verb for ``POST /rpc`` or the Unix socket. Same shape, different vocabulary.
    """

    def call(self, tool: str, arguments: dict[str, Any]) -> dict[str, Any]: ...

call

call(tool: str, arguments: dict[str, Any]) -> dict[str, Any]
Source code in src/doublegate_sdk/client.py
def call(self, tool: str, arguments: dict[str, Any]) -> dict[str, Any]: ...

HttpMcpTransport

The product's POST /mcp endpoint, bounded and explicit.

HTTPS is the default and the only remote option. allow_insecure_loopback exists for tests and for a daemon on this host; it refuses a remote host.

Writes are off unless allow_writes=True — the transport refuses a write-annotated tool itself, so a read-only client cannot be talked into writing by a caller that reaches past :class:GateClient.

timeout is a total deadline for the whole exchange, not a per-socket operation timeout: it covers connect, send, the status line, the headers and the body, and is re-checked before every read, so a peer cannot extend it by answering slowly. It is enforced on the calling thread with no background worker. One thing it does not cover: name resolution, which the standard library performs inside connect() with no cancellation hook — a DNS server that hangs is bounded by the resolver, not by this. Use an IP literal or a resolver timeout if that matters to you.

Nothing is retried: a write repeated without an idempotency contract is worse than a write that failed loudly.

Source code in src/doublegate_sdk/client.py
class HttpMcpTransport:
    """The product's ``POST /mcp`` endpoint, bounded and explicit.

    HTTPS is the default and the only remote option. ``allow_insecure_loopback``
    exists for tests and for a daemon on this host; it refuses a remote host.

    Writes are off unless ``allow_writes=True`` — the transport refuses a
    write-annotated tool itself, so a read-only client cannot be talked into
    writing by a caller that reaches past :class:`GateClient`.

    ``timeout`` is a total deadline for the whole exchange, not a per-socket
    operation timeout: it covers connect, send, the status line, the headers and
    the body, and is re-checked before every read, so a peer cannot extend it by
    answering slowly. It is enforced on the calling thread with no background
    worker. One thing it does **not** cover: name resolution, which the standard
    library performs inside ``connect()`` with no cancellation hook — a DNS
    server that hangs is bounded by the resolver, not by this. Use an IP literal
    or a resolver timeout if that matters to you.

    Nothing is retried: a write repeated without an idempotency contract is
    worse than a write that failed loudly.
    """

    #: The stateless JSON request/response subset the server actually serves.
    #: No ``Mcp-Session-Id``, no SSE, no ``initialize`` handshake by default.
    PROTOCOL_SUBSET = 'stateless-json-request-response'

    def __init__(self, endpoint: str, *, token: str | None = None, timeout: float = 10.0,
                 max_response_bytes: int = 1_048_576, max_request_bytes: int = 1_048_576,
                 allow_writes: bool = False, allow_insecure_loopback: bool = False,
                 ssl_context: ssl.SSLContext | None = None):
        if type(allow_insecure_loopback) is not bool:
            raise ValueError('allow_insecure_loopback must be boolean')
        if type(allow_writes) is not bool:
            raise ValueError('allow_writes must be boolean')
        if not _positive_finite(timeout):
            raise ValueError('timeout must be finite and positive')
        if not _integer(max_response_bytes, 1):
            raise ValueError('max_response_bytes must be a positive integer')
        if not _integer(max_request_bytes, 1):
            raise ValueError('max_request_bytes must be a positive integer')
        if token is not None:
            if (not isinstance(token, str) or not token
                    or any(c in token for c in '\r\n')):
                raise ValueError('token must be nonempty single-line text')
            try:
                # ``http.client`` encodes headers as Latin-1. A token outside
                # that range would otherwise blow up inside ``request()`` as a
                # raw UnicodeEncodeError, mid-call and outside the taxonomy.
                # A credential problem belongs at construction.
                token.encode('latin-1')
            except UnicodeEncodeError:
                raise ValueError('token must be nonempty single-line text') from None
        self._target = _parse_endpoint(endpoint, allow_insecure_loopback=allow_insecure_loopback)
        self.endpoint = endpoint
        self._token = token
        self._timeout = float(timeout)
        self.max_response_bytes = max_response_bytes
        self.max_request_bytes = max_request_bytes
        self._allow_writes = allow_writes
        self._ssl_context = ssl_context

    # ---- protocol negotiation (only what the server supports) ----

    def discover(self) -> dict[str, Any]:
        """``server/discover`` — protocol versions, server info, capabilities.

        This is the negotiation method the maintained server always answers.
        ``initialize`` is refused unless the operator turned on a legacy flag,
        so the SDK does not send it.
        """
        return self._rpc('server/discover', {}, writing=False)

    def tool_names(self) -> tuple[str, ...]:
        """The tools this server actually lists, in its own order."""
        result = self._rpc('tools/list', {}, writing=False)
        tools = result.get('tools')
        _require(isinstance(tools, list))
        names = []
        for tool in tools:
            _require(isinstance(tool, dict) and isinstance(tool.get('name'), str))
            names.append(tool['name'])
        return tuple(names)

    # ---- tool calls ----

    def call(self, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
        if tool not in _TOOLS:
            raise ValueError(f'unknown tool for this client tier: {tool!r}')
        writing = _TOOLS[tool]
        if writing and not self._allow_writes:
            raise GateError('writes_disabled')
        result = self._rpc('tools/call', {'name': tool, 'arguments': arguments}, writing=writing)
        if result.get('isError'):
            raise GateError('tool_error', outcome_unknown=writing)
        payload = result.get('structuredContent')
        if not isinstance(payload, dict):
            # The text block carries the same payload, but a response missing
            # the structured half is not one this client will guess at.
            raise GateError('invalid_response', outcome_unknown=writing)
        return payload

    # ---- the wire ----

    def _rpc(self, method: str, params: dict[str, Any], *, writing: bool) -> dict[str, Any]:
        try:
            body = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': method,
                               'params': params}, allow_nan=False).encode()
        except (ValueError, TypeError):
            raise ValueError('arguments must be JSON-serializable without NaN or Infinity') from None
        if len(body) > self.max_request_bytes:
            raise GateError('request_too_large')

        headers = {'Content-Type': 'application/json', 'Accept': 'application/json',
                   'Content-Length': str(len(body))}
        if self._token is not None:
            headers['Authorization'] = f'Bearer {self._token}'

        deadline = time.monotonic() + self._timeout
        sending = False
        connection = None
        try:
            connection = self._connect(deadline)
            # ``sending`` stays conservative: it is set before the socket is
            # touched, so a failure anywhere from connect onwards reports a
            # write's outcome as unknown rather than guessing it did not land.
            sending = True
            connection.connect()          # explicit: ``sock`` must exist to arm
            connection.sock.settimeout(self._remaining(deadline))
            connection.request('POST', self._target.path, body=body, headers=headers)
            # Read the status line, the headers and the body through one
            # deadline-bounded file. A slow drip of *headers* is bounded by the
            # same budget as a slow drip of body bytes.
            connection.sock = _DeadlineSocket(connection.sock,
                                              lambda: self._remaining(deadline))
            response = connection.getresponse()
            status = response.status
            content_type = response.getheader('Content-Type')
            if status in (301, 302, 303, 307, 308):
                # Following a redirect would carry the bearer token to whatever
                # host the answer names. It is refused, not followed.
                raise GateError('redirect_refused', status)
            raw = self._read_bounded(response, deadline)
            if len(raw) > self.max_response_bytes:
                raise GateError('response_too_large', outcome_unknown=writing)
        except GateError:
            raise
        except TimeoutError:
            raise GateError('timeout', outcome_unknown=writing and sending) from None
        except (OSError, http.client.HTTPException):
            raise GateError('unavailable', outcome_unknown=writing and sending) from None
        finally:
            if connection is not None:
                connection.close()

        if status == 401:
            raise GateError('unauthorized', 401)
        if status == 403:
            raise GateError('forbidden', 403)
        if status == 413:
            raise GateError('request_too_large', 413)
        if status == 405:
            raise GateError('unsupported_operation', 405)
        if status == 202:
            # A notification was accepted. This client sends none.
            raise GateError('invalid_response', 202, outcome_unknown=writing)
        if status != 200:
            raise GateError('remote_error', status, outcome_unknown=writing)
        if not _is_json_media_type(content_type):
            # The request said ``Accept: application/json``. A 200 labelled
            # anything else is an interstitial or a proxy, not this gate's
            # answer — even when its body happens to parse.
            raise GateError('invalid_response', outcome_unknown=writing)
        return self._parse(raw, writing=writing)

    def _read_bounded(self, response: Any, deadline: float) -> bytes:
        """Read at most ``max_response_bytes + 1`` bytes, deadline enforced.

        ``read(n)`` would loop inside the buffered reader until it had all ``n``
        bytes; ``read1`` returns what one recv produced, so the budget is checked
        between chunks as well as before each recv.
        """
        limit = self.max_response_bytes + 1
        chunks: list[bytes] = []
        total = 0
        while total < limit:
            self._remaining(deadline)
            chunk = response.read1(limit - total)
            if not chunk:
                break
            chunks.append(chunk)
            total += len(chunk)
        return b''.join(chunks)

    def _connect(self, deadline: float) -> Any:
        timeout = self._remaining(deadline)
        if self._target.scheme == 'https':
            context = self._ssl_context or ssl.create_default_context()
            return http.client.HTTPSConnection(self._target.host, self._target.port,
                                               timeout=timeout, context=context)
        return http.client.HTTPConnection(self._target.host, self._target.port, timeout=timeout)

    def _remaining(self, deadline: float) -> float:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError
        return remaining

    def _parse(self, raw: bytes, *, writing: bool) -> dict[str, Any]:
        try:
            message = json.loads(raw, object_pairs_hook=_unique_object)
            _require(isinstance(message, dict) and message.get('jsonrpc') == '2.0')
            _require(type(message.get('id')) is int and message['id'] == 1)
            _require(('result' in message) != ('error' in message))
            if 'error' in message:
                code = message['error']['code']
                _require(type(code) is int)
                kind = 'unsupported_operation' if code in _UNSUPPORTED_CODES else 'remote_error'
                # -32603 is the server's own internal failure: a write may or
                # may not have landed, and saying otherwise would be a guess.
                raise GateError(kind, code, outcome_unknown=writing and code == -32603)
            _require(isinstance(message['result'], dict))
            return message['result']
        except GateError as error:
            if error.kind == 'invalid_response' and writing:
                error.outcome_unknown = True
            raise
        except (ValueError, KeyError, TypeError, RecursionError):
            # RecursionError: ``json.loads`` on a deeply nested document, well
            # inside the byte bound. ``package.py`` already treats it as bad
            # input; a caller's ``except DoublegateError`` must catch it here
            # too, and a write must still report its outcome as unknown.
            raise GateError('invalid_response', outcome_unknown=writing) from None

PROTOCOL_SUBSET class-attribute instance-attribute

PROTOCOL_SUBSET = 'stateless-json-request-response'

endpoint instance-attribute

endpoint = endpoint

max_response_bytes instance-attribute

max_response_bytes = max_response_bytes

max_request_bytes instance-attribute

max_request_bytes = max_request_bytes

discover

discover() -> dict[str, Any]

server/discover — protocol versions, server info, capabilities.

This is the negotiation method the maintained server always answers. initialize is refused unless the operator turned on a legacy flag, so the SDK does not send it.

Source code in src/doublegate_sdk/client.py
def discover(self) -> dict[str, Any]:
    """``server/discover`` — protocol versions, server info, capabilities.

    This is the negotiation method the maintained server always answers.
    ``initialize`` is refused unless the operator turned on a legacy flag,
    so the SDK does not send it.
    """
    return self._rpc('server/discover', {}, writing=False)

tool_names

tool_names() -> tuple[str, ...]

The tools this server actually lists, in its own order.

Source code in src/doublegate_sdk/client.py
def tool_names(self) -> tuple[str, ...]:
    """The tools this server actually lists, in its own order."""
    result = self._rpc('tools/list', {}, writing=False)
    tools = result.get('tools')
    _require(isinstance(tools, list))
    names = []
    for tool in tools:
        _require(isinstance(tool, dict) and isinstance(tool.get('name'), str))
        names.append(tool['name'])
    return tuple(names)

call

call(tool: str, arguments: dict[str, Any]) -> dict[str, Any]
Source code in src/doublegate_sdk/client.py
def call(self, tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
    if tool not in _TOOLS:
        raise ValueError(f'unknown tool for this client tier: {tool!r}')
    writing = _TOOLS[tool]
    if writing and not self._allow_writes:
        raise GateError('writes_disabled')
    result = self._rpc('tools/call', {'name': tool, 'arguments': arguments}, writing=writing)
    if result.get('isError'):
        raise GateError('tool_error', outcome_unknown=writing)
    payload = result.get('structuredContent')
    if not isinstance(payload, dict):
        # The text block carries the same payload, but a response missing
        # the structured half is not one this client will guess at.
        raise GateError('invalid_response', outcome_unknown=writing)
    return payload

GateClient

Propose observations and read gate answers through an explicit transport.

Usually built by :func:connect. Presence in a read is not admission, and a proposal is not an approval. The gate decides; this object carries bytes.

Source code in src/doublegate_sdk/client.py
class GateClient:
    """Propose observations and read gate answers through an explicit transport.

    Usually built by :func:`connect`. Presence in a read is not admission, and a
    proposal is not an approval. The gate decides; this object carries bytes.
    """

    def __init__(self, transport: McpTransport):
        self._transport = transport

    @property
    def transport(self) -> McpTransport:
        """The underlying transport — for ``discover()`` / ``tool_names()``."""
        return self._transport

    @staticmethod
    def _artifact_id(artifact_id: Any) -> str:
        if not isinstance(artifact_id, str) or not artifact_id.strip():
            raise ValueError('artifact_id must be a nonempty string')
        return artifact_id

    def status(self, artifact_id: str) -> dict[str, Any]:
        """One artifact's position in the register.

        The maintained tool requires an artifact id; there is no whole-gate
        status call on this surface.
        """
        result = self._transport.call('doublegate.status',
                                      {'artifact_id': self._artifact_id(artifact_id)})
        if not isinstance(result, dict) or not isinstance(result.get('state'), str):
            raise GateError('invalid_response')
        return result

    def why(self, artifact_id: str) -> dict[str, Any]:
        """Every ledger event that moved one artifact, in order."""
        result = self._transport.call('doublegate.why',
                                      {'artifact_id': self._artifact_id(artifact_id)})
        if not isinstance(result, dict) or not isinstance(result.get('events'), list):
            raise GateError('invalid_response')
        return result

    def propose(self, content: str | bytes, *, content_type: str, source_uri: str,
                trust_class: str | None = None, space: str | None = None,
                derives_from: list[str] | None = None) -> dict[str, Any]:
        """Submit content for admission. Not an approval, and never retried.

        ``content`` is text. Bytes are accepted only when they are valid UTF-8
        and are decoded; binary is refused rather than base64-smuggled into a
        text field the service would store verbatim.

        Writer identity is never sent: the service derives it from the
        connection and refuses a request that carries it.
        """
        if isinstance(content, bytes):
            try:
                content = content.decode('utf-8')
            except UnicodeDecodeError:
                raise ValueError('content must be UTF-8 text; this surface stores no binary') from None
        if not isinstance(content, str) or not content:
            raise ValueError('content must be nonempty text')
        if any(not isinstance(v, str) or not v for v in (content_type, source_uri)):
            raise ValueError('content_type and source_uri are required nonempty strings')
        arguments: dict[str, Any] = {'content': content, 'content_type': content_type,
                                     'source_uri': source_uri}
        for name, value in (('trust_class', trust_class), ('space', space)):
            if value is not None:
                if not isinstance(value, str) or not value:
                    raise ValueError(f'{name} must be nonempty text when given')
                arguments[name] = value
        if derives_from is not None:
            if (not isinstance(derives_from, list)
                    or any(not isinstance(p, str) or not p for p in derives_from)):
                raise ValueError('derives_from must be a list of nonempty artifact ids')
            arguments['derives_from'] = list(derives_from)
        result = self._transport.call('doublegate.remember', arguments)
        if (not isinstance(result, dict) or not isinstance(result.get('artifact_id'), str)
                or not result['artifact_id'] or not isinstance(result.get('state'), str)
                or not result['state']):
            raise GateError('invalid_response', outcome_unknown=True)
        return result

    def recall(self, query: str, *, limit: int = 10, spaces: list[str] | None = None,
               include_provisional: bool = False, audience: str | None = None) -> dict[str, Any]:
        """Read served knowledge.

        Own unreviewed echoes are excluded by default, so a caller does not read
        back its own un-admitted proposal and mistake it for an answer.
        """
        if not isinstance(query, str) or not query.strip():
            raise ValueError('query must be nonempty text')
        if not _integer(limit, 1):
            raise ValueError('limit must be a positive integer')
        if type(include_provisional) is not bool:
            raise ValueError('include_provisional must be boolean')
        if spaces is not None and (not isinstance(spaces, list) or not spaces
                                   or any(not isinstance(s, str) or not s for s in spaces)):
            raise ValueError('spaces must be a nonempty list of strings')
        arguments: dict[str, Any] = {'query': query, 'k': limit, 'include_own_pending': False,
                                     'include_provisional': include_provisional}
        if spaces is not None:
            arguments['spaces'] = spaces
        if audience is not None:
            if not isinstance(audience, str) or not audience:
                raise ValueError('audience must be nonempty text when given')
            arguments['audience'] = audience
        result = self._transport.call('doublegate.recall', arguments)
        if (not isinstance(result, dict) or not isinstance(result.get('results'), list)
                or not all(isinstance(hit, dict) for hit in result['results'])
                or len(result['results']) > limit):
            raise GateError('invalid_response')
        return result

    def pending(self, *, limit: int = 100) -> dict[str, Any]:
        """What is waiting for review, as metadata only — never content."""
        if not _integer(limit, 1):
            raise ValueError('limit must be a positive integer')
        result = self._transport.call('doublegate.pending', {'limit': limit})
        if (not isinstance(result, dict) or not isinstance(result.get('pending'), list)
                or not all(isinstance(item, dict) for item in result['pending'])
                or len(result['pending']) > limit):
            raise GateError('invalid_response')
        return result

transport property

transport: McpTransport

The underlying transport — for discover() / tool_names().

status

status(artifact_id: str) -> dict[str, Any]

One artifact's position in the register.

The maintained tool requires an artifact id; there is no whole-gate status call on this surface.

Source code in src/doublegate_sdk/client.py
def status(self, artifact_id: str) -> dict[str, Any]:
    """One artifact's position in the register.

    The maintained tool requires an artifact id; there is no whole-gate
    status call on this surface.
    """
    result = self._transport.call('doublegate.status',
                                  {'artifact_id': self._artifact_id(artifact_id)})
    if not isinstance(result, dict) or not isinstance(result.get('state'), str):
        raise GateError('invalid_response')
    return result

why

why(artifact_id: str) -> dict[str, Any]

Every ledger event that moved one artifact, in order.

Source code in src/doublegate_sdk/client.py
def why(self, artifact_id: str) -> dict[str, Any]:
    """Every ledger event that moved one artifact, in order."""
    result = self._transport.call('doublegate.why',
                                  {'artifact_id': self._artifact_id(artifact_id)})
    if not isinstance(result, dict) or not isinstance(result.get('events'), list):
        raise GateError('invalid_response')
    return result

propose

propose(content: str | bytes, *, content_type: str, source_uri: str, trust_class: str | None = None, space: str | None = None, derives_from: list[str] | None = None) -> dict[str, Any]

Submit content for admission. Not an approval, and never retried.

content is text. Bytes are accepted only when they are valid UTF-8 and are decoded; binary is refused rather than base64-smuggled into a text field the service would store verbatim.

Writer identity is never sent: the service derives it from the connection and refuses a request that carries it.

Source code in src/doublegate_sdk/client.py
def propose(self, content: str | bytes, *, content_type: str, source_uri: str,
            trust_class: str | None = None, space: str | None = None,
            derives_from: list[str] | None = None) -> dict[str, Any]:
    """Submit content for admission. Not an approval, and never retried.

    ``content`` is text. Bytes are accepted only when they are valid UTF-8
    and are decoded; binary is refused rather than base64-smuggled into a
    text field the service would store verbatim.

    Writer identity is never sent: the service derives it from the
    connection and refuses a request that carries it.
    """
    if isinstance(content, bytes):
        try:
            content = content.decode('utf-8')
        except UnicodeDecodeError:
            raise ValueError('content must be UTF-8 text; this surface stores no binary') from None
    if not isinstance(content, str) or not content:
        raise ValueError('content must be nonempty text')
    if any(not isinstance(v, str) or not v for v in (content_type, source_uri)):
        raise ValueError('content_type and source_uri are required nonempty strings')
    arguments: dict[str, Any] = {'content': content, 'content_type': content_type,
                                 'source_uri': source_uri}
    for name, value in (('trust_class', trust_class), ('space', space)):
        if value is not None:
            if not isinstance(value, str) or not value:
                raise ValueError(f'{name} must be nonempty text when given')
            arguments[name] = value
    if derives_from is not None:
        if (not isinstance(derives_from, list)
                or any(not isinstance(p, str) or not p for p in derives_from)):
            raise ValueError('derives_from must be a list of nonempty artifact ids')
        arguments['derives_from'] = list(derives_from)
    result = self._transport.call('doublegate.remember', arguments)
    if (not isinstance(result, dict) or not isinstance(result.get('artifact_id'), str)
            or not result['artifact_id'] or not isinstance(result.get('state'), str)
            or not result['state']):
        raise GateError('invalid_response', outcome_unknown=True)
    return result

recall

recall(query: str, *, limit: int = 10, spaces: list[str] | None = None, include_provisional: bool = False, audience: str | None = None) -> dict[str, Any]

Read served knowledge.

Own unreviewed echoes are excluded by default, so a caller does not read back its own un-admitted proposal and mistake it for an answer.

Source code in src/doublegate_sdk/client.py
def recall(self, query: str, *, limit: int = 10, spaces: list[str] | None = None,
           include_provisional: bool = False, audience: str | None = None) -> dict[str, Any]:
    """Read served knowledge.

    Own unreviewed echoes are excluded by default, so a caller does not read
    back its own un-admitted proposal and mistake it for an answer.
    """
    if not isinstance(query, str) or not query.strip():
        raise ValueError('query must be nonempty text')
    if not _integer(limit, 1):
        raise ValueError('limit must be a positive integer')
    if type(include_provisional) is not bool:
        raise ValueError('include_provisional must be boolean')
    if spaces is not None and (not isinstance(spaces, list) or not spaces
                               or any(not isinstance(s, str) or not s for s in spaces)):
        raise ValueError('spaces must be a nonempty list of strings')
    arguments: dict[str, Any] = {'query': query, 'k': limit, 'include_own_pending': False,
                                 'include_provisional': include_provisional}
    if spaces is not None:
        arguments['spaces'] = spaces
    if audience is not None:
        if not isinstance(audience, str) or not audience:
            raise ValueError('audience must be nonempty text when given')
        arguments['audience'] = audience
    result = self._transport.call('doublegate.recall', arguments)
    if (not isinstance(result, dict) or not isinstance(result.get('results'), list)
            or not all(isinstance(hit, dict) for hit in result['results'])
            or len(result['results']) > limit):
        raise GateError('invalid_response')
    return result

pending

pending(*, limit: int = 100) -> dict[str, Any]

What is waiting for review, as metadata only — never content.

Source code in src/doublegate_sdk/client.py
def pending(self, *, limit: int = 100) -> dict[str, Any]:
    """What is waiting for review, as metadata only — never content."""
    if not _integer(limit, 1):
        raise ValueError('limit must be a positive integer')
    result = self._transport.call('doublegate.pending', {'limit': limit})
    if (not isinstance(result, dict) or not isinstance(result.get('pending'), list)
            or not all(isinstance(item, dict) for item in result['pending'])
            or len(result['pending']) > limit):
        raise GateError('invalid_response')
    return result

connect

connect(endpoint: str, *, token: str | None = None, allow_writes: bool = False, timeout: float = 10.0, allow_insecure_loopback: bool = False, max_response_bytes: int = 1048576, max_request_bytes: int = 1048576, ssl_context: SSLContext | None = None) -> 'GateClient'

Build a client for one gate endpoint. The one call most callers need.

connect('https://gate.example.org/mcp', token=...) is equivalent to wrapping :class:HttpMcpTransport in a :class:GateClient, and exists so a caller does not have to assemble a transport to make an ordinary request.

It connects to nothing: the first request happens on the first method call. Every bound and every credential is still explicit, and writes remain off unless allow_writes=True.

Source code in src/doublegate_sdk/client.py
def connect(endpoint: str, *, token: str | None = None, allow_writes: bool = False,
            timeout: float = 10.0, allow_insecure_loopback: bool = False,
            max_response_bytes: int = 1_048_576, max_request_bytes: int = 1_048_576,
            ssl_context: ssl.SSLContext | None = None) -> 'GateClient':
    """Build a client for one gate endpoint. The one call most callers need.

    ``connect('https://gate.example.org/mcp', token=...)`` is equivalent to
    wrapping :class:`HttpMcpTransport` in a :class:`GateClient`, and exists so a
    caller does not have to assemble a transport to make an ordinary request.

    It connects to nothing: the first request happens on the first method call.
    Every bound and every credential is still explicit, and writes remain off
    unless ``allow_writes=True``.
    """
    return GateClient(HttpMcpTransport(
        endpoint, token=token, timeout=timeout, allow_writes=allow_writes,
        allow_insecure_loopback=allow_insecure_loopback,
        max_response_bytes=max_response_bytes, max_request_bytes=max_request_bytes,
        ssl_context=ssl_context))

describe_client

describe_client() -> dict[str, Any]

Describe this SDK's Python API offline. Not a remote gate's capabilities.

Source code in src/doublegate_sdk/client.py
def describe_client() -> dict[str, Any]:
    """Describe this SDK's Python API offline. Not a remote gate's capabilities."""
    operations = {}
    for name, metadata in _OPERATIONS.items():
        signature = inspect.signature(getattr(GateClient, name))
        parameters = []
        for parameter in signature.parameters.values():
            if parameter.name == 'self':
                continue
            variadic = parameter.kind in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL)
            value = {'name': parameter.name, 'kind': parameter.kind.name,
                     'annotation': str(parameter.annotation),
                     'required': parameter.default is inspect.Parameter.empty and not variadic}
            if parameter.default is not inspect.Parameter.empty:
                value['default'] = parameter.default
            parameters.append(value)
        operations[name] = {**metadata, 'parameters': parameters,
                            'returns': str(signature.return_annotation)}
    return {
        'scope': 'sdk-contract-not-server-capabilities',
        'format': 'python-call-description-not-json-schema',
        'transports': ['http_mcp', 'caller_supplied'],
        'mcp': {
            'endpoint': 'POST /mcp',
            'subset': HttpMcpTransport.PROTOCOL_SUBSET,
            'streaming': False,
            'sessions': False,
            'negotiation': ['server/discover', 'tools/list'],
            'tools': sorted(_TOOLS),
        },
        'unsupported_operations': {
            'inventory': 'the maintained client tier serves no inventory tool',
            'whole_gate_status': 'doublegate.status requires an artifact id',
        },
        'proposals_are_admission': False,
        'automatic_retries': False,
        'follows_redirects': False,
        'operations': operations,
    }