deepquery-sdk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepquery_sdk/__init__.py +101 -0
- deepquery_sdk/action.py +104 -0
- deepquery_sdk/auth/__init__.py +45 -0
- deepquery_sdk/auth/base.py +96 -0
- deepquery_sdk/auth/oauth2.py +106 -0
- deepquery_sdk/auth/strategies.py +91 -0
- deepquery_sdk/classification.py +43 -0
- deepquery_sdk/cli/__init__.py +5 -0
- deepquery_sdk/cli/__main__.py +64 -0
- deepquery_sdk/cli/commands.py +208 -0
- deepquery_sdk/cli/loader.py +95 -0
- deepquery_sdk/compat.py +79 -0
- deepquery_sdk/connector.py +283 -0
- deepquery_sdk/gate.py +83 -0
- deepquery_sdk/harness/__init__.py +5 -0
- deepquery_sdk/harness/mock_agent.py +119 -0
- deepquery_sdk/manifest.py +78 -0
- deepquery_sdk/mcp_emit/__init__.py +5 -0
- deepquery_sdk/mcp_emit/emitter.py +174 -0
- deepquery_sdk/provenance.py +65 -0
- deepquery_sdk/resource.py +65 -0
- deepquery_sdk/templates/connector/.gitignore.tmpl +9 -0
- deepquery_sdk/templates/connector/README.md.tmpl +22 -0
- deepquery_sdk/templates/connector/connector.py.tmpl +74 -0
- deepquery_sdk/templates/connector/pyproject.toml.tmpl +23 -0
- deepquery_sdk/templates/connector/tests/test_connector.py.tmpl +41 -0
- deepquery_sdk/validation.py +201 -0
- deepquery_sdk-1.0.0.dist-info/METADATA +195 -0
- deepquery_sdk-1.0.0.dist-info/RECORD +32 -0
- deepquery_sdk-1.0.0.dist-info/WHEEL +4 -0
- deepquery_sdk-1.0.0.dist-info/entry_points.txt +2 -0
- deepquery_sdk-1.0.0.dist-info/licenses/LICENSE +190 -0
deepquery_sdk/gate.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The approval gate — the runtime mechanism behind preview → execute.
|
|
2
|
+
|
|
3
|
+
Every action passes through this gate. Calling an action *requests* it: the gate
|
|
4
|
+
records the previewed effect and mints a single-use, argument-bound approval
|
|
5
|
+
token. Execution requires presenting that token. The human-confirmation decision
|
|
6
|
+
itself lives in the Agent Layer / gateway above the SDK (SDK_GUIDE.md §4.3, §5);
|
|
7
|
+
this gate is what makes "execute can't happen without a preview of these exact
|
|
8
|
+
arguments" enforceable at the connector boundary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import secrets
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActionStatus(str, Enum):
|
|
22
|
+
PREVIEWED = "previewed" # preview generated, awaiting a decision
|
|
23
|
+
EXECUTED = "executed" # approved and run (terminal)
|
|
24
|
+
REJECTED = "rejected" # declined, never run (terminal)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PendingAction(BaseModel):
|
|
28
|
+
"""One requested-but-not-yet-decided action, keyed by its approval token."""
|
|
29
|
+
|
|
30
|
+
token: str
|
|
31
|
+
action_name: str
|
|
32
|
+
arguments: dict[str, Any]
|
|
33
|
+
preview: str
|
|
34
|
+
status: ActionStatus = ActionStatus.PREVIEWED
|
|
35
|
+
requested_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GateError(Exception):
|
|
39
|
+
"""Raised when the gate is driven into an invalid state transition."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApprovalGate:
|
|
43
|
+
"""In-process registry of pending actions and their lifecycle."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._pending: dict[str, PendingAction] = {}
|
|
47
|
+
|
|
48
|
+
def request(self, action_name: str, arguments: dict[str, Any], preview: str) -> PendingAction:
|
|
49
|
+
"""Record a previewed action and mint its single-use approval token."""
|
|
50
|
+
token = secrets.token_urlsafe(24)
|
|
51
|
+
pending = PendingAction(
|
|
52
|
+
token=token,
|
|
53
|
+
action_name=action_name,
|
|
54
|
+
arguments=dict(arguments),
|
|
55
|
+
preview=preview,
|
|
56
|
+
)
|
|
57
|
+
self._pending[token] = pending
|
|
58
|
+
return pending
|
|
59
|
+
|
|
60
|
+
def get(self, token: str) -> PendingAction:
|
|
61
|
+
pending = self._pending.get(token)
|
|
62
|
+
if pending is None:
|
|
63
|
+
raise GateError(f"unknown or already-consumed approval token: {token!r}")
|
|
64
|
+
return pending
|
|
65
|
+
|
|
66
|
+
def mark_executed(self, token: str) -> PendingAction:
|
|
67
|
+
"""Transition PREVIEWED → EXECUTED. The token is consumed (single use)."""
|
|
68
|
+
pending = self.get(token)
|
|
69
|
+
if pending.status is not ActionStatus.PREVIEWED:
|
|
70
|
+
raise GateError(f"action {pending.action_name!r} is already {pending.status.value}")
|
|
71
|
+
pending.status = ActionStatus.EXECUTED
|
|
72
|
+
return pending
|
|
73
|
+
|
|
74
|
+
def reject(self, token: str) -> PendingAction:
|
|
75
|
+
"""Transition PREVIEWED → REJECTED. The token is consumed (single use)."""
|
|
76
|
+
pending = self.get(token)
|
|
77
|
+
if pending.status is not ActionStatus.PREVIEWED:
|
|
78
|
+
raise GateError(f"action {pending.action_name!r} is already {pending.status.value}")
|
|
79
|
+
pending.status = ActionStatus.REJECTED
|
|
80
|
+
return pending
|
|
81
|
+
|
|
82
|
+
def pending_tokens(self) -> list[str]:
|
|
83
|
+
return [t for t, p in self._pending.items() if p.status is ActionStatus.PREVIEWED]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Local dev harness — a mock agent that drives a connector like Deep Query would.
|
|
2
|
+
|
|
3
|
+
The mock agent discovers a connector's capabilities through its emitted MCP
|
|
4
|
+
interface (a real in-memory client session, not direct method calls), lets the
|
|
5
|
+
developer issue reads and inspect the provenance envelope, and drives an action
|
|
6
|
+
through the exact preview → approval → execute sequence the real Agent Layer
|
|
7
|
+
uses — including simulating both an approval and a rejection (SDK_GUIDE.md §10).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mcp.shared.memory import create_connected_server_and_client_session
|
|
15
|
+
|
|
16
|
+
from ..connector import Connector
|
|
17
|
+
from ..mcp_emit.emitter import EXECUTE_TOOL, REJECT_TOOL, emit_server
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HarnessError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MockAgent:
|
|
25
|
+
"""Drives a connector over an in-memory MCP session."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, connector: Connector) -> None:
|
|
28
|
+
self.connector = connector
|
|
29
|
+
self._cm = None
|
|
30
|
+
self.session = None
|
|
31
|
+
|
|
32
|
+
async def __aenter__(self) -> "MockAgent":
|
|
33
|
+
self._cm = create_connected_server_and_client_session(emit_server(self.connector))
|
|
34
|
+
self.session = await self._cm.__aenter__()
|
|
35
|
+
await self.session.initialize()
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
async def __aexit__(self, *exc) -> None:
|
|
39
|
+
assert self._cm is not None
|
|
40
|
+
await self._cm.__aexit__(*exc)
|
|
41
|
+
|
|
42
|
+
# -- discovery --------------------------------------------------------
|
|
43
|
+
async def capabilities(self) -> dict[str, list[dict[str, Any]]]:
|
|
44
|
+
"""Group the advertised tools by kind, with their dq.* classification."""
|
|
45
|
+
tools = (await self.session.list_tools()).tools
|
|
46
|
+
grouped: dict[str, list[dict[str, Any]]] = {"resources": [], "actions": [], "controls": []}
|
|
47
|
+
for t in tools:
|
|
48
|
+
meta = t.meta or {}
|
|
49
|
+
kind = meta.get("dq.kind", "unknown")
|
|
50
|
+
entry = {"name": t.name, "description": t.description, "dq.mutates": meta.get("dq.mutates")}
|
|
51
|
+
if kind == "resource":
|
|
52
|
+
grouped["resources"].append(entry)
|
|
53
|
+
elif kind == "action":
|
|
54
|
+
grouped["actions"].append(entry)
|
|
55
|
+
else:
|
|
56
|
+
grouped["controls"].append(entry)
|
|
57
|
+
return grouped
|
|
58
|
+
|
|
59
|
+
# -- reads ------------------------------------------------------------
|
|
60
|
+
async def read(self, name: str, **arguments: Any) -> list[dict[str, Any]]:
|
|
61
|
+
"""Call a read tool and return its records, validating the provenance
|
|
62
|
+
envelope the way the gateway would (surfacing problems as errors)."""
|
|
63
|
+
result = await self.session.call_tool(name, arguments)
|
|
64
|
+
if result.isError:
|
|
65
|
+
raise HarnessError(self._error_text(result, name))
|
|
66
|
+
payload = result.structuredContent or {}
|
|
67
|
+
records = payload.get("records")
|
|
68
|
+
if records is None:
|
|
69
|
+
raise HarnessError(f"read '{name}' did not return a 'records' list")
|
|
70
|
+
for i, rec in enumerate(records):
|
|
71
|
+
prov = rec.get("provenance")
|
|
72
|
+
if not prov:
|
|
73
|
+
raise HarnessError(f"record {i} from '{name}' is missing its provenance envelope (§6)")
|
|
74
|
+
for required in ("connector_name", "source_object_id", "retrieved_at", "title_or_label"):
|
|
75
|
+
if not prov.get(required):
|
|
76
|
+
raise HarnessError(
|
|
77
|
+
f"record {i} from '{name}' is missing provenance field '{required}' (§6)"
|
|
78
|
+
)
|
|
79
|
+
return records
|
|
80
|
+
|
|
81
|
+
# -- gated actions ----------------------------------------------------
|
|
82
|
+
async def request_action(self, name: str, **arguments: Any) -> dict[str, Any]:
|
|
83
|
+
"""Step 1: request the action; returns the preview + approval token."""
|
|
84
|
+
result = await self.session.call_tool(name, arguments)
|
|
85
|
+
if result.isError:
|
|
86
|
+
raise HarnessError(self._error_text(result, name))
|
|
87
|
+
payload = result.structuredContent or {}
|
|
88
|
+
if payload.get("status") != "preview":
|
|
89
|
+
raise HarnessError(f"action '{name}' did not return a preview (got {payload!r})")
|
|
90
|
+
return payload
|
|
91
|
+
|
|
92
|
+
async def approve(self, token: str) -> dict[str, Any]:
|
|
93
|
+
"""Step 2a: approve and execute by token."""
|
|
94
|
+
result = await self.session.call_tool(EXECUTE_TOOL, {"approval_token": token})
|
|
95
|
+
if result.isError:
|
|
96
|
+
raise HarnessError(self._error_text(result, EXECUTE_TOOL))
|
|
97
|
+
return result.structuredContent or {}
|
|
98
|
+
|
|
99
|
+
async def reject(self, token: str) -> dict[str, Any]:
|
|
100
|
+
"""Step 2b: reject by token; the action never runs."""
|
|
101
|
+
result = await self.session.call_tool(REJECT_TOOL, {"approval_token": token})
|
|
102
|
+
if result.isError:
|
|
103
|
+
raise HarnessError(self._error_text(result, REJECT_TOOL))
|
|
104
|
+
return result.structuredContent or {}
|
|
105
|
+
|
|
106
|
+
async def run_action(self, name: str, *, approve: bool, **arguments: Any) -> dict[str, Any]:
|
|
107
|
+
"""Drive the full sequence: preview, then approve→execute or reject."""
|
|
108
|
+
preview = await self.request_action(name, **arguments)
|
|
109
|
+
token = preview["approval_token"]
|
|
110
|
+
outcome = await self.approve(token) if approve else await self.reject(token)
|
|
111
|
+
return {"preview": preview, "outcome": outcome}
|
|
112
|
+
|
|
113
|
+
# -- helpers ----------------------------------------------------------
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _error_text(result, name: str) -> str:
|
|
116
|
+
for block in result.content:
|
|
117
|
+
if getattr(block, "text", None):
|
|
118
|
+
return f"tool '{name}' errored: {block.text}"
|
|
119
|
+
return f"tool '{name}' errored"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Connector manifest — identity, capabilities, auth, deployment compatibility.
|
|
2
|
+
|
|
3
|
+
The manifest is what the Connector Infrastructure layer reads to populate the
|
|
4
|
+
admin approval directory and the user-facing connector list. It also declares
|
|
5
|
+
the SDK major version the connector targets, so the gateway can refuse a
|
|
6
|
+
connector built against an incompatible SDK rather than failing at call time.
|
|
7
|
+
|
|
8
|
+
See SDK_GUIDE.md §3 and §11.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
# Single source of truth for the SDK's version. The package version in
|
|
18
|
+
# pyproject.toml must match SDK_VERSION (guarded by a test). The contract major
|
|
19
|
+
# version — what a connector manifest targets — is the major of SDK_VERSION.
|
|
20
|
+
SDK_VERSION = "1.0.0"
|
|
21
|
+
SDK_MAJOR_VERSION = int(SDK_VERSION.split(".")[0])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthMethod(str, Enum):
|
|
25
|
+
"""How a connector authenticates to its external system.
|
|
26
|
+
|
|
27
|
+
Connectors never store credentials — the gateway owns credential lifecycle
|
|
28
|
+
and injects a valid token at call time (SDK_GUIDE.md §7). This enum only
|
|
29
|
+
declares *which* method the connector needs.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
NONE = "none"
|
|
33
|
+
API_KEY = "api_key"
|
|
34
|
+
OAUTH2 = "oauth2" # OAuth 2.1 authorization-code flow with PKCE
|
|
35
|
+
BASIC = "basic"
|
|
36
|
+
MTLS = "mtls"
|
|
37
|
+
CUSTOM = "custom"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ResourceManifest(BaseModel):
|
|
41
|
+
name: str
|
|
42
|
+
description: str
|
|
43
|
+
mutates: bool = False # always False for resources; present for uniformity
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ActionManifest(BaseModel):
|
|
47
|
+
name: str
|
|
48
|
+
description: str
|
|
49
|
+
mutates: bool = True # always True for actions
|
|
50
|
+
has_preview: bool = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DeploymentCompat(BaseModel):
|
|
54
|
+
"""Deployment-mode honesty: a connector must declare its network needs so
|
|
55
|
+
air-gapped deployments can refuse network-dependent connectors."""
|
|
56
|
+
|
|
57
|
+
requires_network: bool = Field(..., description="Does the connector need external network access?")
|
|
58
|
+
air_gapped_capable: bool = Field(..., description="Can it run in an air-gapped deployment?")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Manifest(BaseModel):
|
|
62
|
+
"""The full connector manifest."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
version: str
|
|
66
|
+
description: str = ""
|
|
67
|
+
sdk_major_version: int = SDK_MAJOR_VERSION
|
|
68
|
+
auth_method: AuthMethod = AuthMethod.NONE
|
|
69
|
+
auth_scopes: list[str] = Field(
|
|
70
|
+
default_factory=list,
|
|
71
|
+
description="OAuth scopes the connector requests; should be least-privilege (§13).",
|
|
72
|
+
)
|
|
73
|
+
resources: list[ResourceManifest] = Field(default_factory=list)
|
|
74
|
+
actions: list[ActionManifest] = Field(default_factory=list)
|
|
75
|
+
deployment: DeploymentCompat
|
|
76
|
+
|
|
77
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
78
|
+
return self.model_dump_json(indent=indent)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Emit a compliant MCP server from a `Connector`.
|
|
2
|
+
|
|
3
|
+
This is the layer that makes "everything emits MCP" real (SDK_GUIDE.md §2). It
|
|
4
|
+
wraps the official MCP server SDK's low-level `Server` — it does **not**
|
|
5
|
+
reimplement MCP. Every capability becomes an MCP tool:
|
|
6
|
+
|
|
7
|
+
- Resources are emitted as **read-only** tools (`annotations.readOnlyHint=True`,
|
|
8
|
+
`dq.mutates=False`) so the agent can call them freely during context assembly.
|
|
9
|
+
They return their records wrapped in the provenance envelope.
|
|
10
|
+
- Actions are emitted as tools tagged `dq.mutates=True` /
|
|
11
|
+
`annotations.destructiveHint=True`. Calling an action *requests* it: the server
|
|
12
|
+
runs `preview`, mints a single-use approval token, and returns
|
|
13
|
+
`{status, approval_token, preview, arguments}` without executing. The gateway
|
|
14
|
+
then drives the decision through two control tools:
|
|
15
|
+
`dq.execute_action(approval_token)` and `dq.reject_action(approval_token)`.
|
|
16
|
+
|
|
17
|
+
Reads are modelled as read-only tools (rather than static MCP resources) because
|
|
18
|
+
a connector read takes query parameters from the agent; tools carry an input
|
|
19
|
+
schema, so this is the faithful MCP mapping and keeps `dq.mutates` uniform
|
|
20
|
+
across every capability.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from mcp import types
|
|
28
|
+
from mcp.server.lowlevel import Server
|
|
29
|
+
|
|
30
|
+
from ..action import ActionSpec
|
|
31
|
+
from ..classification import DQ_CONNECTOR, DQ_KIND, DQ_MUTATES, CapabilityKind, meta_for
|
|
32
|
+
from ..connector import Connector
|
|
33
|
+
from ..resource import ResourceSpec
|
|
34
|
+
|
|
35
|
+
# Names of the two control tools that drive the gated action lifecycle.
|
|
36
|
+
EXECUTE_TOOL = "dq.execute_action"
|
|
37
|
+
REJECT_TOOL = "dq.reject_action"
|
|
38
|
+
|
|
39
|
+
_APPROVAL_SCHEMA = {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"approval_token": {"type": "string", "description": "Token returned when the action was requested."}
|
|
43
|
+
},
|
|
44
|
+
"required": ["approval_token"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _control_meta(connector_name: str, *, mutates: bool) -> dict[str, object]:
|
|
49
|
+
return {DQ_KIND: "control", DQ_MUTATES: mutates, DQ_CONNECTOR: connector_name}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_tools(connector: Connector) -> list[types.Tool]:
|
|
53
|
+
"""Build the MCP `Tool` list for a connector, with `dq.*` classification."""
|
|
54
|
+
tools: list[types.Tool] = []
|
|
55
|
+
|
|
56
|
+
for spec in connector.resources:
|
|
57
|
+
tools.append(
|
|
58
|
+
types.Tool(
|
|
59
|
+
name=spec.name,
|
|
60
|
+
description=spec.description,
|
|
61
|
+
inputSchema=spec.input_schema,
|
|
62
|
+
annotations=types.ToolAnnotations(readOnlyHint=True, destructiveHint=False),
|
|
63
|
+
_meta=meta_for(CapabilityKind.RESOURCE, connector.name),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
for spec in connector.actions:
|
|
68
|
+
tools.append(
|
|
69
|
+
types.Tool(
|
|
70
|
+
name=spec.name,
|
|
71
|
+
description=spec.description,
|
|
72
|
+
inputSchema=spec.input_schema,
|
|
73
|
+
annotations=types.ToolAnnotations(readOnlyHint=False, destructiveHint=True),
|
|
74
|
+
_meta=meta_for(CapabilityKind.ACTION, connector.name),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Control tools: only emitted when the connector has at least one action.
|
|
79
|
+
if connector.actions:
|
|
80
|
+
tools.append(
|
|
81
|
+
types.Tool(
|
|
82
|
+
name=EXECUTE_TOOL,
|
|
83
|
+
description="Execute a previously previewed action, after human approval, by its approval token.",
|
|
84
|
+
inputSchema=_APPROVAL_SCHEMA,
|
|
85
|
+
annotations=types.ToolAnnotations(readOnlyHint=False, destructiveHint=True),
|
|
86
|
+
_meta=_control_meta(connector.name, mutates=True),
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
tools.append(
|
|
90
|
+
types.Tool(
|
|
91
|
+
name=REJECT_TOOL,
|
|
92
|
+
description="Reject a previously previewed action by its approval token; it is never executed.",
|
|
93
|
+
inputSchema=_APPROVAL_SCHEMA,
|
|
94
|
+
annotations=types.ToolAnnotations(readOnlyHint=True, destructiveHint=False),
|
|
95
|
+
_meta=_control_meta(connector.name, mutates=False),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return tools
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def emit_server(connector: Connector) -> Server:
|
|
103
|
+
"""Produce a low-level MCP `Server` backed by the connector."""
|
|
104
|
+
server: Server = Server(name=connector.name, version=connector.version)
|
|
105
|
+
|
|
106
|
+
resources_by_name: dict[str, ResourceSpec] = {s.name: s for s in connector.resources}
|
|
107
|
+
actions_by_name: dict[str, ActionSpec] = {s.name: s for s in connector.actions}
|
|
108
|
+
|
|
109
|
+
@server.list_tools()
|
|
110
|
+
async def _list_tools() -> list[types.Tool]:
|
|
111
|
+
return build_tools(connector)
|
|
112
|
+
|
|
113
|
+
@server.call_tool()
|
|
114
|
+
async def _call_tool(name: str, arguments: dict[str, Any]) -> Any:
|
|
115
|
+
args = arguments or {}
|
|
116
|
+
|
|
117
|
+
if name in resources_by_name:
|
|
118
|
+
records = connector.fetch_resource(name, args)
|
|
119
|
+
# Structured content: the provenance-wrapped records. The MCP layer
|
|
120
|
+
# also serialises this to JSON text in `content` for generic clients.
|
|
121
|
+
return {"records": [r.model_dump(mode="json") for r in records]}
|
|
122
|
+
|
|
123
|
+
if name in actions_by_name:
|
|
124
|
+
# Requesting an action previews it and mints a token; it does NOT run.
|
|
125
|
+
pending = connector.request_action(name, args)
|
|
126
|
+
return {
|
|
127
|
+
"status": "preview",
|
|
128
|
+
"action": pending.action_name,
|
|
129
|
+
"approval_token": pending.token,
|
|
130
|
+
"preview": pending.preview,
|
|
131
|
+
"arguments": pending.arguments,
|
|
132
|
+
"dq_mutates": True,
|
|
133
|
+
"next": {
|
|
134
|
+
"approve": f"call {EXECUTE_TOOL} with this approval_token",
|
|
135
|
+
"reject": f"call {REJECT_TOOL} with this approval_token",
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if name == EXECUTE_TOOL:
|
|
140
|
+
token = args["approval_token"]
|
|
141
|
+
result = connector.execute_approved(token)
|
|
142
|
+
return {"status": "executed", "result": _jsonable(result)}
|
|
143
|
+
|
|
144
|
+
if name == REJECT_TOOL:
|
|
145
|
+
token = args["approval_token"]
|
|
146
|
+
pending = connector.reject_action(token)
|
|
147
|
+
return {"status": "rejected", "action": pending.action_name}
|
|
148
|
+
|
|
149
|
+
raise ValueError(f"unknown tool: {name!r}")
|
|
150
|
+
|
|
151
|
+
return server
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _jsonable(value: Any) -> Any:
|
|
155
|
+
"""Best-effort conversion of an execute() return into JSON-safe content."""
|
|
156
|
+
if hasattr(value, "model_dump"):
|
|
157
|
+
return value.model_dump(mode="json")
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def serve_stdio(connector: Connector) -> None:
|
|
162
|
+
"""Serve the connector's MCP server over stdio (async)."""
|
|
163
|
+
from mcp.server.stdio import stdio_server
|
|
164
|
+
|
|
165
|
+
server = emit_server(connector)
|
|
166
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
167
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run_stdio(connector: Connector) -> None:
|
|
171
|
+
"""Blocking entry point: serve the connector over stdio until disconnected."""
|
|
172
|
+
import anyio
|
|
173
|
+
|
|
174
|
+
anyio.run(serve_stdio, connector)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Provenance contract — how live data stays citeable.
|
|
2
|
+
|
|
3
|
+
Live connector data is never ingested or persisted. It enters an agent's
|
|
4
|
+
context for a single query, gets cited, and is discarded. For that to preserve
|
|
5
|
+
groundedness, every record a resource returns must carry enough provenance to
|
|
6
|
+
build an honest, verifiable citation.
|
|
7
|
+
|
|
8
|
+
See SDK_GUIDE.md §6.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def now_iso() -> str:
|
|
20
|
+
"""Current UTC time as an ISO 8601 string (the `retrieved_at` stamp)."""
|
|
21
|
+
return datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Provenance(BaseModel):
|
|
25
|
+
"""The required envelope wrapping every record a resource returns.
|
|
26
|
+
|
|
27
|
+
`retrieved_at` is not decoration: a document citation is stable forever, but
|
|
28
|
+
a live citation is a snapshot of a moving target. The timestamp is what lets
|
|
29
|
+
an answer say "Jira DQ-431, status 'In Review', as of 2026-06-06 14:32" —
|
|
30
|
+
true at that instant and honest that it may not be true later.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
connector_name: str = Field(..., description="Which connector produced this record.")
|
|
34
|
+
source_object_id: str = Field(..., description="Stable ID of the source object, e.g. 'DQ-431'.")
|
|
35
|
+
retrieved_at: str = Field(default_factory=now_iso, description="ISO 8601 retrieval timestamp.")
|
|
36
|
+
title_or_label: str = Field(..., description="Human-readable label for the citation.")
|
|
37
|
+
deep_link: str | None = Field(default=None, description="Direct URL to the source object, when available.")
|
|
38
|
+
mutability_note: str | None = Field(default=None, description="Hint that this data can change, e.g. 'live status field'.")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CitedRecord(BaseModel):
|
|
42
|
+
"""A single record returned by a resource: the payload plus its provenance."""
|
|
43
|
+
|
|
44
|
+
data: Any = Field(..., description="The record payload the agent will read.")
|
|
45
|
+
provenance: Provenance
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_provenance(
|
|
49
|
+
*,
|
|
50
|
+
connector_name: str,
|
|
51
|
+
source_object_id: str,
|
|
52
|
+
title_or_label: str,
|
|
53
|
+
deep_link: str | None = None,
|
|
54
|
+
mutability_note: str | None = None,
|
|
55
|
+
retrieved_at: str | None = None,
|
|
56
|
+
) -> Provenance:
|
|
57
|
+
"""Build a `Provenance` envelope, stamping `retrieved_at` to now if omitted."""
|
|
58
|
+
return Provenance(
|
|
59
|
+
connector_name=connector_name,
|
|
60
|
+
source_object_id=source_object_id,
|
|
61
|
+
retrieved_at=retrieved_at or now_iso(),
|
|
62
|
+
title_or_label=title_or_label,
|
|
63
|
+
deep_link=deep_link,
|
|
64
|
+
mutability_note=mutability_note,
|
|
65
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Resource definitions — the read side of a connector.
|
|
2
|
+
|
|
3
|
+
A resource is a read-only capability. It is declared with the `@resource`
|
|
4
|
+
decorator on a method of a `Connector` subclass. Resources are exempt from the
|
|
5
|
+
approval gate; the SDK enforces read-only classification structurally by giving
|
|
6
|
+
reads and actions different decorators that cannot be confused.
|
|
7
|
+
|
|
8
|
+
See SDK_GUIDE.md §4.2.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from .classification import CapabilityKind
|
|
16
|
+
|
|
17
|
+
# Marker attribute the Connector base class scans for at subclass-definition time.
|
|
18
|
+
_RESOURCE_MARKER = "__dq_resource__"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResourceSpec:
|
|
22
|
+
"""Declarative metadata for one resource, attached to its fetch function."""
|
|
23
|
+
|
|
24
|
+
kind = CapabilityKind.RESOURCE
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
name: str,
|
|
30
|
+
description: str,
|
|
31
|
+
input_schema: dict[str, Any],
|
|
32
|
+
attr_name: str,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.name = name
|
|
35
|
+
self.description = description
|
|
36
|
+
self.input_schema = input_schema
|
|
37
|
+
self.attr_name = attr_name # method name on the connector, used to bind at emit time
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
40
|
+
return f"ResourceSpec(name={self.name!r})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resource(
|
|
44
|
+
*,
|
|
45
|
+
description: str,
|
|
46
|
+
name: str | None = None,
|
|
47
|
+
input_schema: dict[str, Any] | None = None,
|
|
48
|
+
) -> Callable[[Callable], Callable]:
|
|
49
|
+
"""Declare a read-only resource.
|
|
50
|
+
|
|
51
|
+
The decorated method runs the search/fetch and must return an iterable of
|
|
52
|
+
`CitedRecord` (use `self.cite(...)` to build them). The `description` is read
|
|
53
|
+
by the agent's planner, so it should explain in natural language when to use
|
|
54
|
+
this resource. (It is also a prompt-injection surface — see SDK_GUIDE.md §13.)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def deco(fn: Callable) -> Callable:
|
|
58
|
+
fn.__dq_resource__ = {
|
|
59
|
+
"name": name or fn.__name__,
|
|
60
|
+
"description": description,
|
|
61
|
+
"input_schema": input_schema or {"type": "object", "properties": {}},
|
|
62
|
+
}
|
|
63
|
+
return fn
|
|
64
|
+
|
|
65
|
+
return deco
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# __CONNECTOR_TITLE__ connector
|
|
2
|
+
|
|
3
|
+
A Deep Query connector built with [DeepQuerySDK](https://deepquery.local/sdk).
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
deepquery validate connector.py # check the safety contracts (§5, §6, §13)
|
|
9
|
+
deepquery manifest connector.py # print the connector manifest
|
|
10
|
+
deepquery run-dev connector.py # drive it with the mock agent locally
|
|
11
|
+
deepquery emit connector.py --out dist/ # produce the deployable MCP server artifact
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What to fill in
|
|
15
|
+
|
|
16
|
+
1. **Auth** — set the real endpoints/scopes (or switch strategy). Request the
|
|
17
|
+
minimum scopes you need.
|
|
18
|
+
2. **`search`** — call your system's read API; wrap each record with `self.cite(...)`.
|
|
19
|
+
3. **`do_thing`** — implement `preview` (describe the effect) and `execute` (perform it).
|
|
20
|
+
|
|
21
|
+
Resources are read-only and ungated; actions always pass through
|
|
22
|
+
preview → approve → execute. `validate` enforces both.
|