deepquery-sdk 1.0.0__tar.gz → 1.1.0__tar.gz
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-1.0.0 → deepquery_sdk-1.1.0}/CHANGELOG.md +19 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/PKG-INFO +8 -2
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/README.md +7 -1
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/pyproject.toml +1 -1
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/__init__.py +11 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/connector.py +71 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/manifest.py +21 -1
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/mcp_emit/emitter.py +91 -0
- deepquery_sdk-1.1.0/src/deepquery_sdk/mcp_resource.py +83 -0
- deepquery_sdk-1.1.0/src/deepquery_sdk/prompt.py +75 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/validation.py +10 -1
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase4.py +1 -1
- deepquery_sdk-1.1.0/tests/test_resources_prompts.py +104 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/.gitignore +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/LICENSE +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/MIGRATIONS.md +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/bad_connector.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/jira_connector/__init__.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/jira_connector/connector.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/manual_action_flow.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/manual_client.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/run_jira_server.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/my_tool/README.md +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/my_tool/connector.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/action.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/__init__.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/base.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/oauth2.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/strategies.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/classification.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/__init__.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/__main__.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/commands.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/loader.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/compat.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/gate.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/harness/__init__.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/harness/mock_agent.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/mcp_emit/__init__.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/provenance.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/resource.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/.gitignore.tmpl +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/README.md.tmpl +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/connector.py.tmpl +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/pyproject.toml.tmpl +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/tests/test_connector.py.tmpl +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase1.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase2.py +0 -0
- {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase3.py +0 -0
|
@@ -5,6 +5,25 @@ All notable changes to DeepQuerySDK are documented here. The SDK follows
|
|
|
5
5
|
`Connector` class, the provenance envelope, or the manifest schema is a **major**
|
|
6
6
|
bump and ships with an entry in [MIGRATIONS.md](MIGRATIONS.md).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] — 2026-06-08
|
|
9
|
+
|
|
10
|
+
Additive, backward-compatible. Connectors can now emit the other two MCP
|
|
11
|
+
primitives alongside tools.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `@mcp_resource` — declare URI-addressable **resources** (static `uri` or a
|
|
15
|
+
parameterized `uri_template`). Emitted via `resources/list`,
|
|
16
|
+
`resources/read`, and `resources/templates/list`; content is `str` or `bytes`.
|
|
17
|
+
- `@prompt` — declare **prompt** templates (with arguments). Emitted via
|
|
18
|
+
`prompts/list` and `prompts/get`; the handler returns a string or a list of
|
|
19
|
+
`{role, text}` messages.
|
|
20
|
+
- Manifest now carries `mcp_resources` and `prompts`; `validate` checks their
|
|
21
|
+
descriptions (present + injection markers).
|
|
22
|
+
|
|
23
|
+
Note: the existing `@resource` is unchanged — it declares a *read* and is emitted
|
|
24
|
+
as a read-only **tool** (for parameterized search). `@mcp_resource` is the
|
|
25
|
+
distinct MCP `resources` primitive.
|
|
26
|
+
|
|
8
27
|
## [1.0.0] — 2026-06-07
|
|
9
28
|
|
|
10
29
|
First stable release. Establishes the SDK contract at **major version 1**:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepquery-sdk
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Connector Development Kit for Deep Query — build MCP-emitting connectors with built-in read/action safety classification and provenance.
|
|
5
5
|
Project-URL: Homepage, https://github.com/GilbertAshivaka/deepquery-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/GilbertAshivaka/deepquery-sdk
|
|
@@ -35,7 +35,13 @@ standard [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server*
|
|
|
35
35
|
— so Deep Query consumes it through the exact same interface as any public MCP
|
|
36
36
|
server. You never touch JSON-RPC, transport, or schema plumbing.
|
|
37
37
|
|
|
38
|
-
> **Status: v1.
|
|
38
|
+
> **Status: v1.1.0 — connectors can now emit all three MCP primitives.**
|
|
39
|
+
> Beyond tools (reads + gated actions), use `@mcp_resource` to expose
|
|
40
|
+
> URI-addressable **resources** (static or templated) and `@prompt` to offer
|
|
41
|
+
> **prompt** templates. See [CHANGELOG.md](CHANGELOG.md). Built on v1.0.0 —
|
|
42
|
+
> all four build phases below remain.
|
|
43
|
+
>
|
|
44
|
+
> **v1.0.0 — all four build phases complete.** Core contracts (Phase 1),
|
|
39
45
|
> the gated **preview → approve → execute / reject** lifecycle + OAuth 2.1 PKCE
|
|
40
46
|
> scaffolding + credential injection (Phase 2), the `deepquery` **CLI** and
|
|
41
47
|
> **mock-agent harness** with the §5/§6/§13 contracts encoded as `validate`
|
|
@@ -5,7 +5,13 @@ standard [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server*
|
|
|
5
5
|
— so Deep Query consumes it through the exact same interface as any public MCP
|
|
6
6
|
server. You never touch JSON-RPC, transport, or schema plumbing.
|
|
7
7
|
|
|
8
|
-
> **Status: v1.
|
|
8
|
+
> **Status: v1.1.0 — connectors can now emit all three MCP primitives.**
|
|
9
|
+
> Beyond tools (reads + gated actions), use `@mcp_resource` to expose
|
|
10
|
+
> URI-addressable **resources** (static or templated) and `@prompt` to offer
|
|
11
|
+
> **prompt** templates. See [CHANGELOG.md](CHANGELOG.md). Built on v1.0.0 —
|
|
12
|
+
> all four build phases below remain.
|
|
13
|
+
>
|
|
14
|
+
> **v1.0.0 — all four build phases complete.** Core contracts (Phase 1),
|
|
9
15
|
> the gated **preview → approve → execute / reject** lifecycle + OAuth 2.1 PKCE
|
|
10
16
|
> scaffolding + credential injection (Phase 2), the `deepquery` **CLI** and
|
|
11
17
|
> **mock-agent harness** with the §5/§6/§13 contracts encoded as `validate`
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deepquery-sdk"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "Connector Development Kit for Deep Query — build MCP-emitting connectors with built-in read/action safety classification and provenance."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -42,10 +42,14 @@ from .manifest import (
|
|
|
42
42
|
AuthMethod,
|
|
43
43
|
DeploymentCompat,
|
|
44
44
|
Manifest,
|
|
45
|
+
MCPResourceManifest,
|
|
46
|
+
PromptManifest,
|
|
45
47
|
ResourceManifest,
|
|
46
48
|
SDK_MAJOR_VERSION,
|
|
47
49
|
SDK_VERSION,
|
|
48
50
|
)
|
|
51
|
+
from .mcp_resource import MCPResourceSpec, mcp_resource
|
|
52
|
+
from .prompt import PromptArgumentSpec, PromptSpec, prompt
|
|
49
53
|
from .provenance import CitedRecord, Provenance, make_provenance, now_iso
|
|
50
54
|
from .resource import ResourceSpec, resource
|
|
51
55
|
|
|
@@ -58,6 +62,13 @@ __all__ = [
|
|
|
58
62
|
"ResourceSpec",
|
|
59
63
|
"action",
|
|
60
64
|
"ActionSpec",
|
|
65
|
+
"mcp_resource",
|
|
66
|
+
"MCPResourceSpec",
|
|
67
|
+
"prompt",
|
|
68
|
+
"PromptSpec",
|
|
69
|
+
"PromptArgumentSpec",
|
|
70
|
+
"MCPResourceManifest",
|
|
71
|
+
"PromptManifest",
|
|
61
72
|
"CapabilityKind",
|
|
62
73
|
"CitedRecord",
|
|
63
74
|
"Provenance",
|
|
@@ -16,11 +16,15 @@ from typing import Any, Iterable, Iterator
|
|
|
16
16
|
from .action import ActionSpec
|
|
17
17
|
from .auth.base import AppliedAuth, AuthStrategy, Credential, CredentialError, CredentialProvider
|
|
18
18
|
from .gate import ApprovalGate, PendingAction
|
|
19
|
+
from .mcp_resource import MCPResourceSpec
|
|
20
|
+
from .prompt import PromptArgumentSpec, PromptSpec
|
|
19
21
|
from .manifest import (
|
|
20
22
|
ActionManifest,
|
|
21
23
|
AuthMethod,
|
|
22
24
|
DeploymentCompat,
|
|
23
25
|
Manifest,
|
|
26
|
+
MCPResourceManifest,
|
|
27
|
+
PromptManifest,
|
|
24
28
|
ResourceManifest,
|
|
25
29
|
SDK_MAJOR_VERSION,
|
|
26
30
|
)
|
|
@@ -66,11 +70,15 @@ class Connector:
|
|
|
66
70
|
# populated by __init_subclass__
|
|
67
71
|
_resources: list[ResourceSpec]
|
|
68
72
|
_actions: list[ActionSpec]
|
|
73
|
+
_mcp_resources: list[MCPResourceSpec]
|
|
74
|
+
_prompts: list[PromptSpec]
|
|
69
75
|
|
|
70
76
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
71
77
|
super().__init_subclass__(**kwargs)
|
|
72
78
|
cls._resources = list(cls._collect_resources())
|
|
73
79
|
cls._actions = list(cls._collect_actions())
|
|
80
|
+
cls._mcp_resources = list(cls._collect_mcp_resources())
|
|
81
|
+
cls._prompts = list(cls._collect_prompts())
|
|
74
82
|
cls._validate_contracts()
|
|
75
83
|
|
|
76
84
|
# -- collection -------------------------------------------------------
|
|
@@ -87,6 +95,34 @@ class Connector:
|
|
|
87
95
|
attr_name=attr_name,
|
|
88
96
|
)
|
|
89
97
|
|
|
98
|
+
@classmethod
|
|
99
|
+
def _collect_mcp_resources(cls) -> Iterable[MCPResourceSpec]:
|
|
100
|
+
for attr_name in dir(cls):
|
|
101
|
+
obj = getattr(cls, attr_name, None)
|
|
102
|
+
meta = getattr(obj, "__dq_mcp_resource__", None)
|
|
103
|
+
if meta is not None:
|
|
104
|
+
yield MCPResourceSpec(
|
|
105
|
+
name=meta["name"],
|
|
106
|
+
description=meta["description"],
|
|
107
|
+
uri=meta["uri"],
|
|
108
|
+
uri_template=meta["uri_template"],
|
|
109
|
+
mime_type=meta["mime_type"],
|
|
110
|
+
attr_name=attr_name,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def _collect_prompts(cls) -> Iterable[PromptSpec]:
|
|
115
|
+
for attr_name in dir(cls):
|
|
116
|
+
obj = getattr(cls, attr_name, None)
|
|
117
|
+
meta = getattr(obj, "__dq_prompt__", None)
|
|
118
|
+
if meta is not None:
|
|
119
|
+
yield PromptSpec(
|
|
120
|
+
name=meta["name"],
|
|
121
|
+
description=meta["description"],
|
|
122
|
+
arguments=[PromptArgumentSpec(**a) for a in meta["arguments"]],
|
|
123
|
+
attr_name=attr_name,
|
|
124
|
+
)
|
|
125
|
+
|
|
90
126
|
@classmethod
|
|
91
127
|
def _collect_actions(cls) -> Iterable[ActionSpec]:
|
|
92
128
|
seen: set[int] = set()
|
|
@@ -229,6 +265,30 @@ class Connector:
|
|
|
229
265
|
def actions(self) -> list[ActionSpec]:
|
|
230
266
|
return type(self)._actions
|
|
231
267
|
|
|
268
|
+
@property
|
|
269
|
+
def mcp_resources(self) -> list[MCPResourceSpec]:
|
|
270
|
+
return type(self)._mcp_resources
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def prompts(self) -> list[PromptSpec]:
|
|
274
|
+
return type(self)._prompts
|
|
275
|
+
|
|
276
|
+
def read_mcp_resource(self, spec: MCPResourceSpec, params: dict[str, str]) -> str | bytes:
|
|
277
|
+
"""Invoke an MCP resource's bound handler, returning its content."""
|
|
278
|
+
bound = getattr(self, spec.attr_name)
|
|
279
|
+
return bound(**params)
|
|
280
|
+
|
|
281
|
+
def render_prompt(self, spec: PromptSpec, arguments: dict[str, str]) -> list[dict[str, str]]:
|
|
282
|
+
"""Invoke a prompt's handler and normalise to a list of {role, text}."""
|
|
283
|
+
bound = getattr(self, spec.attr_name)
|
|
284
|
+
result = bound(**(arguments or {}))
|
|
285
|
+
if isinstance(result, str):
|
|
286
|
+
return [{"role": "user", "text": result}]
|
|
287
|
+
messages: list[dict[str, str]] = []
|
|
288
|
+
for m in result:
|
|
289
|
+
messages.append({"role": m.get("role", "user"), "text": m.get("text", "")})
|
|
290
|
+
return messages
|
|
291
|
+
|
|
232
292
|
def run_resource(self, spec: ResourceSpec, arguments: dict[str, Any]) -> list[CitedRecord]:
|
|
233
293
|
"""Invoke a resource's bound fetch method and normalise its output."""
|
|
234
294
|
bound = getattr(self, spec.attr_name)
|
|
@@ -276,6 +336,17 @@ class Connector:
|
|
|
276
336
|
ActionManifest(name=s.name, description=s.description, has_preview=s.has_preview())
|
|
277
337
|
for s in self.actions
|
|
278
338
|
],
|
|
339
|
+
mcp_resources=[
|
|
340
|
+
MCPResourceManifest(
|
|
341
|
+
name=s.name, description=s.description, address=s.address,
|
|
342
|
+
is_template=s.is_template, mime_type=s.mime_type,
|
|
343
|
+
)
|
|
344
|
+
for s in self.mcp_resources
|
|
345
|
+
],
|
|
346
|
+
prompts=[
|
|
347
|
+
PromptManifest(name=s.name, description=s.description, arguments=[a.name for a in s.arguments])
|
|
348
|
+
for s in self.prompts
|
|
349
|
+
],
|
|
279
350
|
deployment=DeploymentCompat(
|
|
280
351
|
requires_network=self.requires_network,
|
|
281
352
|
air_gapped_capable=self.air_gapped_capable,
|
|
@@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
|
|
|
17
17
|
# Single source of truth for the SDK's version. The package version in
|
|
18
18
|
# pyproject.toml must match SDK_VERSION (guarded by a test). The contract major
|
|
19
19
|
# version — what a connector manifest targets — is the major of SDK_VERSION.
|
|
20
|
-
SDK_VERSION = "1.
|
|
20
|
+
SDK_VERSION = "1.1.0"
|
|
21
21
|
SDK_MAJOR_VERSION = int(SDK_VERSION.split(".")[0])
|
|
22
22
|
|
|
23
23
|
|
|
@@ -50,6 +50,24 @@ class ActionManifest(BaseModel):
|
|
|
50
50
|
has_preview: bool = True
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
class MCPResourceManifest(BaseModel):
|
|
54
|
+
"""A URI-addressable MCP resource the connector exposes (resources primitive)."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
description: str
|
|
58
|
+
address: str # uri (static) or uri template
|
|
59
|
+
is_template: bool = False
|
|
60
|
+
mime_type: str = "text/plain"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PromptManifest(BaseModel):
|
|
64
|
+
"""A prompt template the connector offers (prompts primitive)."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
description: str
|
|
68
|
+
arguments: list[str] = Field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
53
71
|
class DeploymentCompat(BaseModel):
|
|
54
72
|
"""Deployment-mode honesty: a connector must declare its network needs so
|
|
55
73
|
air-gapped deployments can refuse network-dependent connectors."""
|
|
@@ -72,6 +90,8 @@ class Manifest(BaseModel):
|
|
|
72
90
|
)
|
|
73
91
|
resources: list[ResourceManifest] = Field(default_factory=list)
|
|
74
92
|
actions: list[ActionManifest] = Field(default_factory=list)
|
|
93
|
+
mcp_resources: list[MCPResourceManifest] = Field(default_factory=list)
|
|
94
|
+
prompts: list[PromptManifest] = Field(default_factory=list)
|
|
75
95
|
deployment: DeploymentCompat
|
|
76
96
|
|
|
77
97
|
def to_json(self, *, indent: int = 2) -> str:
|
|
@@ -24,14 +24,33 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
from typing import Any
|
|
26
26
|
|
|
27
|
+
import re
|
|
28
|
+
|
|
27
29
|
from mcp import types
|
|
28
30
|
from mcp.server.lowlevel import Server
|
|
31
|
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
32
|
+
from pydantic import AnyUrl
|
|
29
33
|
|
|
30
34
|
from ..action import ActionSpec
|
|
31
35
|
from ..classification import DQ_CONNECTOR, DQ_KIND, DQ_MUTATES, CapabilityKind, meta_for
|
|
32
36
|
from ..connector import Connector
|
|
33
37
|
from ..resource import ResourceSpec
|
|
34
38
|
|
|
39
|
+
_TEMPLATE_VAR = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _match_template(template: str, uri: str) -> dict[str, str] | None:
|
|
43
|
+
"""Match a URI against a `{var}` template, returning captured params or None."""
|
|
44
|
+
parts: list[str] = []
|
|
45
|
+
last = 0
|
|
46
|
+
for m in _TEMPLATE_VAR.finditer(template):
|
|
47
|
+
parts.append(re.escape(template[last:m.start()]))
|
|
48
|
+
parts.append(f"(?P<{m.group(1)}>[^/]+)")
|
|
49
|
+
last = m.end()
|
|
50
|
+
parts.append(re.escape(template[last:]))
|
|
51
|
+
match = re.match("^" + "".join(parts) + "$", uri)
|
|
52
|
+
return match.groupdict() if match else None
|
|
53
|
+
|
|
35
54
|
# Names of the two control tools that drive the gated action lifecycle.
|
|
36
55
|
EXECUTE_TOOL = "dq.execute_action"
|
|
37
56
|
REJECT_TOOL = "dq.reject_action"
|
|
@@ -148,6 +167,78 @@ def emit_server(connector: Connector) -> Server:
|
|
|
148
167
|
|
|
149
168
|
raise ValueError(f"unknown tool: {name!r}")
|
|
150
169
|
|
|
170
|
+
# -- resources (the MCP `resources` primitive) ------------------------
|
|
171
|
+
# Registering these handlers makes the emitted server advertise the
|
|
172
|
+
# `resources` capability automatically during the initialize handshake.
|
|
173
|
+
if connector.mcp_resources:
|
|
174
|
+
static_by_uri = {s.uri: s for s in connector.mcp_resources if not s.is_template}
|
|
175
|
+
template_specs = [s for s in connector.mcp_resources if s.is_template]
|
|
176
|
+
|
|
177
|
+
@server.list_resources()
|
|
178
|
+
async def _list_resources() -> list[types.Resource]:
|
|
179
|
+
return [
|
|
180
|
+
types.Resource(uri=AnyUrl(s.uri), name=s.name, description=s.description, mimeType=s.mime_type)
|
|
181
|
+
for s in connector.mcp_resources
|
|
182
|
+
if not s.is_template
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
@server.list_resource_templates()
|
|
186
|
+
async def _list_resource_templates() -> list[types.ResourceTemplate]:
|
|
187
|
+
return [
|
|
188
|
+
types.ResourceTemplate(
|
|
189
|
+
uriTemplate=s.uri_template, name=s.name, description=s.description, mimeType=s.mime_type
|
|
190
|
+
)
|
|
191
|
+
for s in template_specs
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
@server.read_resource()
|
|
195
|
+
async def _read_resource(uri: AnyUrl):
|
|
196
|
+
uri_str = str(uri)
|
|
197
|
+
spec = static_by_uri.get(uri_str)
|
|
198
|
+
params: dict[str, str] = {}
|
|
199
|
+
if spec is None:
|
|
200
|
+
for tspec in template_specs:
|
|
201
|
+
matched = _match_template(tspec.uri_template, uri_str)
|
|
202
|
+
if matched is not None:
|
|
203
|
+
spec, params = tspec, matched
|
|
204
|
+
break
|
|
205
|
+
if spec is None:
|
|
206
|
+
raise ValueError(f"unknown resource: {uri_str}")
|
|
207
|
+
content = connector.read_mcp_resource(spec, params)
|
|
208
|
+
return [ReadResourceContents(content=content, mime_type=spec.mime_type)]
|
|
209
|
+
|
|
210
|
+
# -- prompts (the MCP `prompts` primitive) ----------------------------
|
|
211
|
+
if connector.prompts:
|
|
212
|
+
prompts_by_name = {s.name: s for s in connector.prompts}
|
|
213
|
+
|
|
214
|
+
@server.list_prompts()
|
|
215
|
+
async def _list_prompts() -> list[types.Prompt]:
|
|
216
|
+
return [
|
|
217
|
+
types.Prompt(
|
|
218
|
+
name=s.name,
|
|
219
|
+
description=s.description,
|
|
220
|
+
arguments=[
|
|
221
|
+
types.PromptArgument(name=a.name, description=a.description, required=a.required)
|
|
222
|
+
for a in s.arguments
|
|
223
|
+
],
|
|
224
|
+
)
|
|
225
|
+
for s in connector.prompts
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
@server.get_prompt()
|
|
229
|
+
async def _get_prompt(name: str, arguments: dict[str, str] | None):
|
|
230
|
+
spec = prompts_by_name.get(name)
|
|
231
|
+
if spec is None:
|
|
232
|
+
raise ValueError(f"unknown prompt: {name!r}")
|
|
233
|
+
messages = connector.render_prompt(spec, arguments or {})
|
|
234
|
+
return types.GetPromptResult(
|
|
235
|
+
description=spec.description,
|
|
236
|
+
messages=[
|
|
237
|
+
types.PromptMessage(role=m["role"], content=types.TextContent(type="text", text=m["text"]))
|
|
238
|
+
for m in messages
|
|
239
|
+
],
|
|
240
|
+
)
|
|
241
|
+
|
|
151
242
|
return server
|
|
152
243
|
|
|
153
244
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""MCP resource objects — URI-addressable data a connector exposes.
|
|
2
|
+
|
|
3
|
+
Distinct from `@resource` (which declares a *read* and is emitted as a read-only
|
|
4
|
+
**tool** for parameterized search). An `@mcp_resource` is the actual MCP
|
|
5
|
+
`resources` primitive: data addressed by a URI (or a URI *template* with
|
|
6
|
+
parameters), read via `resources/read`. Deep Query cites resource content by its
|
|
7
|
+
URI, so a resource's content stays as citeable as a document chunk.
|
|
8
|
+
|
|
9
|
+
See the MCP spec's `resources` capability.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
# Marker the Connector base scans for at subclass-definition time.
|
|
17
|
+
_MCP_RESOURCE_MARKER = "__dq_mcp_resource__"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MCPResourceSpec:
|
|
21
|
+
"""Declarative metadata for one MCP resource (static or templated)."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
name: str,
|
|
27
|
+
description: str,
|
|
28
|
+
uri: Optional[str],
|
|
29
|
+
uri_template: Optional[str],
|
|
30
|
+
mime_type: str,
|
|
31
|
+
attr_name: str,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.name = name
|
|
34
|
+
self.description = description
|
|
35
|
+
self.uri = uri
|
|
36
|
+
self.uri_template = uri_template
|
|
37
|
+
self.mime_type = mime_type
|
|
38
|
+
self.attr_name = attr_name
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_template(self) -> bool:
|
|
42
|
+
return self.uri_template is not None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def address(self) -> str:
|
|
46
|
+
return self.uri_template if self.is_template else (self.uri or "")
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
49
|
+
return f"MCPResourceSpec(name={self.name!r}, address={self.address!r})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mcp_resource(
|
|
53
|
+
*,
|
|
54
|
+
description: str,
|
|
55
|
+
uri: Optional[str] = None,
|
|
56
|
+
uri_template: Optional[str] = None,
|
|
57
|
+
name: Optional[str] = None,
|
|
58
|
+
mime_type: str = "text/plain",
|
|
59
|
+
) -> Callable[[Callable], Callable]:
|
|
60
|
+
"""Declare a URI-addressable MCP resource.
|
|
61
|
+
|
|
62
|
+
Provide exactly one of `uri` (static) or `uri_template` (parameterized, e.g.
|
|
63
|
+
``"docs://article/{slug}"`` — the decorated method then takes a `slug` arg).
|
|
64
|
+
The method returns the resource's content as ``str`` (text) or ``bytes`` (blob).
|
|
65
|
+
"""
|
|
66
|
+
if bool(uri) == bool(uri_template):
|
|
67
|
+
raise ValueError("mcp_resource requires exactly one of `uri` or `uri_template`")
|
|
68
|
+
|
|
69
|
+
def deco(fn: Callable) -> Callable:
|
|
70
|
+
setattr(
|
|
71
|
+
fn,
|
|
72
|
+
_MCP_RESOURCE_MARKER,
|
|
73
|
+
{
|
|
74
|
+
"name": name or fn.__name__,
|
|
75
|
+
"description": description,
|
|
76
|
+
"uri": uri,
|
|
77
|
+
"uri_template": uri_template,
|
|
78
|
+
"mime_type": mime_type,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
return fn
|
|
82
|
+
|
|
83
|
+
return deco
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""MCP prompts — user-controlled prompt templates/workflows a connector offers.
|
|
2
|
+
|
|
3
|
+
An `@prompt` is the MCP `prompts` primitive: a named template (with optional
|
|
4
|
+
arguments) the host can surface to a *user*, fetched via `prompts/get`. Prompt
|
|
5
|
+
content is user-controlled and is treated as untrusted by the consuming side —
|
|
6
|
+
the connector just provides the template text.
|
|
7
|
+
|
|
8
|
+
See the MCP spec's `prompts` capability.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
_PROMPT_MARKER = "__dq_prompt__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PromptArgumentSpec:
|
|
19
|
+
def __init__(self, *, name: str, description: str = "", required: bool = False) -> None:
|
|
20
|
+
self.name = name
|
|
21
|
+
self.description = description
|
|
22
|
+
self.required = required
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PromptSpec:
|
|
26
|
+
"""Declarative metadata for one prompt template."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
name: str,
|
|
32
|
+
description: str,
|
|
33
|
+
arguments: list[PromptArgumentSpec],
|
|
34
|
+
attr_name: str,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.name = name
|
|
37
|
+
self.description = description
|
|
38
|
+
self.arguments = arguments
|
|
39
|
+
self.attr_name = attr_name
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
42
|
+
return f"PromptSpec(name={self.name!r}, args={[a.name for a in self.arguments]})"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _coerce_arguments(arguments: Optional[list[Any]]) -> list[dict[str, Any]]:
|
|
46
|
+
"""Accept either argument names (str) or dicts {name, description, required}."""
|
|
47
|
+
out: list[dict[str, Any]] = []
|
|
48
|
+
for a in arguments or []:
|
|
49
|
+
if isinstance(a, str):
|
|
50
|
+
out.append({"name": a, "description": "", "required": False})
|
|
51
|
+
elif isinstance(a, dict):
|
|
52
|
+
out.append({"name": a["name"], "description": a.get("description", ""), "required": bool(a.get("required", False))})
|
|
53
|
+
else:
|
|
54
|
+
raise TypeError(f"prompt argument must be a str or dict, got {type(a).__name__}")
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prompt(
|
|
59
|
+
*,
|
|
60
|
+
description: str,
|
|
61
|
+
name: Optional[str] = None,
|
|
62
|
+
arguments: Optional[list[Any]] = None,
|
|
63
|
+
) -> Callable[[Callable], Callable]:
|
|
64
|
+
"""Declare a prompt template.
|
|
65
|
+
|
|
66
|
+
The decorated method receives the prompt's arguments and returns either a
|
|
67
|
+
``str`` (a single user-role message) or a list of ``{"role", "text"}`` dicts.
|
|
68
|
+
"""
|
|
69
|
+
coerced = _coerce_arguments(arguments)
|
|
70
|
+
|
|
71
|
+
def deco(fn: Callable) -> Callable:
|
|
72
|
+
setattr(fn, _PROMPT_MARKER, {"name": name or fn.__name__, "description": description, "arguments": coerced})
|
|
73
|
+
return fn
|
|
74
|
+
|
|
75
|
+
return deco
|
|
@@ -106,7 +106,16 @@ class _MutationVisitor(ast.NodeVisitor):
|
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
def _check_descriptions(connector: Connector, report: ValidationReport) -> None:
|
|
109
|
-
|
|
109
|
+
# Resources/actions/mcp-resources/prompts all expose natural-language
|
|
110
|
+
# descriptions read by the agent — each must be present and is an injection
|
|
111
|
+
# surface (§13).
|
|
112
|
+
everything = (
|
|
113
|
+
list(connector.resources)
|
|
114
|
+
+ list(connector.actions)
|
|
115
|
+
+ list(connector.mcp_resources)
|
|
116
|
+
+ list(connector.prompts)
|
|
117
|
+
)
|
|
118
|
+
for spec in everything:
|
|
110
119
|
if not (spec.description or "").strip():
|
|
111
120
|
report.error("DESC_MISSING", f"capability '{spec.name}' has an empty description (§4).")
|
|
112
121
|
elif _INJECTION_MARKERS.search(spec.description):
|
|
@@ -39,7 +39,7 @@ def _run(argv: list[str]) -> int:
|
|
|
39
39
|
# Version single-source
|
|
40
40
|
# --------------------------------------------------------------------------- #
|
|
41
41
|
def test_version_is_single_sourced():
|
|
42
|
-
assert __version__ == SDK_VERSION == "1.
|
|
42
|
+
assert __version__ == SDK_VERSION == "1.1.0"
|
|
43
43
|
assert SDK_MAJOR_VERSION == 1
|
|
44
44
|
# The installed package version must match the in-code version.
|
|
45
45
|
assert importlib.metadata.version("deepquery-sdk") == SDK_VERSION
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for SDK 1.1.0 — emitting the MCP `resources` and `prompts` primitives.
|
|
2
|
+
|
|
3
|
+
A connector can declare URI-addressable resources (static + templated) with
|
|
4
|
+
`@mcp_resource` and prompt templates with `@prompt`. The emitted server must
|
|
5
|
+
advertise those capabilities and serve them over a real MCP client session.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.metadata
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from deepquery_sdk import (
|
|
15
|
+
Connector,
|
|
16
|
+
SDK_VERSION,
|
|
17
|
+
mcp_resource,
|
|
18
|
+
prompt,
|
|
19
|
+
resource,
|
|
20
|
+
)
|
|
21
|
+
from deepquery_sdk.mcp_emit import emit_server
|
|
22
|
+
|
|
23
|
+
from mcp.shared.memory import create_connected_server_and_client_session
|
|
24
|
+
from pydantic import AnyUrl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DocsConnector(Connector):
|
|
28
|
+
name = "docs"
|
|
29
|
+
version = "1.1.0"
|
|
30
|
+
description = "Exposes documents as MCP resources and a prompt template."
|
|
31
|
+
|
|
32
|
+
@resource(description="Search docs.", input_schema={"type": "object", "properties": {"q": {"type": "string"}}})
|
|
33
|
+
def search(self, q: str = ""):
|
|
34
|
+
return [self.cite({"q": q}, source_object_id="s1", title_or_label="search result")]
|
|
35
|
+
|
|
36
|
+
@mcp_resource(uri="docs://readme", name="README", description="The project readme.", mime_type="text/markdown")
|
|
37
|
+
def readme(self) -> str:
|
|
38
|
+
return "# Hello\nThis is the readme."
|
|
39
|
+
|
|
40
|
+
@mcp_resource(uri_template="docs://article/{slug}", name="Article", description="An article by slug.")
|
|
41
|
+
def article(self, slug: str) -> str:
|
|
42
|
+
return f"Article: {slug}"
|
|
43
|
+
|
|
44
|
+
@prompt(description="Summarize a ticket.",
|
|
45
|
+
arguments=[{"name": "ticket_id", "description": "The ticket id", "required": True}])
|
|
46
|
+
def summarize(self, ticket_id: str) -> str:
|
|
47
|
+
return f"Please summarize ticket {ticket_id}."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_manifest_lists_resources_and_prompts():
|
|
51
|
+
m = DocsConnector().build_manifest()
|
|
52
|
+
assert {r.name for r in m.mcp_resources} == {"README", "Article"}
|
|
53
|
+
templated = {r.name: r.is_template for r in m.mcp_resources}
|
|
54
|
+
assert templated == {"README": False, "Article": True}
|
|
55
|
+
assert [p.name for p in m.prompts] == ["summarize"]
|
|
56
|
+
assert m.prompts[0].arguments == ["ticket_id"]
|
|
57
|
+
# The existing read (@resource) is still emitted as a tool, not an mcp_resource.
|
|
58
|
+
assert [r.name for r in m.resources] == ["search"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_version_bumped_to_1_1_0():
|
|
62
|
+
assert SDK_VERSION == "1.1.0"
|
|
63
|
+
assert importlib.metadata.version("deepquery-sdk") == SDK_VERSION
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_emitted_server_serves_resources_and_prompts():
|
|
67
|
+
server = emit_server(DocsConnector())
|
|
68
|
+
async with create_connected_server_and_client_session(server) as client:
|
|
69
|
+
init = await client.initialize()
|
|
70
|
+
caps = init.capabilities
|
|
71
|
+
assert caps.tools is not None and caps.resources is not None and caps.prompts is not None
|
|
72
|
+
|
|
73
|
+
# resources/list + resources/read (static)
|
|
74
|
+
resources = (await client.list_resources()).resources
|
|
75
|
+
assert any(str(r.uri) == "docs://readme" for r in resources)
|
|
76
|
+
content = await client.read_resource(AnyUrl("docs://readme"))
|
|
77
|
+
assert "readme" in content.contents[0].text.lower()
|
|
78
|
+
assert content.contents[0].mimeType == "text/markdown"
|
|
79
|
+
|
|
80
|
+
# resource template
|
|
81
|
+
templates = (await client.list_resource_templates()).resourceTemplates
|
|
82
|
+
assert any(t.uriTemplate == "docs://article/{slug}" for t in templates)
|
|
83
|
+
article = await client.read_resource(AnyUrl("docs://article/mcp-101"))
|
|
84
|
+
assert article.contents[0].text == "Article: mcp-101"
|
|
85
|
+
|
|
86
|
+
# prompts/list + prompts/get
|
|
87
|
+
prompts = (await client.list_prompts()).prompts
|
|
88
|
+
assert prompts[0].name == "summarize"
|
|
89
|
+
assert prompts[0].arguments[0].name == "ticket_id"
|
|
90
|
+
got = await client.get_prompt("summarize", {"ticket_id": "OPS-9"})
|
|
91
|
+
assert "OPS-9" in got.messages[0].content.text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_resource_only_connector_advertises_resources():
|
|
95
|
+
class ResOnly(Connector):
|
|
96
|
+
name = "ro"
|
|
97
|
+
version = "1.1.0"
|
|
98
|
+
|
|
99
|
+
@mcp_resource(uri="x://a", name="A", description="a")
|
|
100
|
+
def a(self) -> str:
|
|
101
|
+
return "a"
|
|
102
|
+
|
|
103
|
+
tools_present = bool(ResOnly().mcp_resources)
|
|
104
|
+
assert tools_present
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/.gitignore.tmpl
RENAMED
|
File without changes
|
{deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/README.md.tmpl
RENAMED
|
File without changes
|
{deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/connector.py.tmpl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|