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.
Files changed (49) hide show
  1. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/CHANGELOG.md +19 -0
  2. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/PKG-INFO +8 -2
  3. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/README.md +7 -1
  4. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/pyproject.toml +1 -1
  5. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/__init__.py +11 -0
  6. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/connector.py +71 -0
  7. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/manifest.py +21 -1
  8. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/mcp_emit/emitter.py +91 -0
  9. deepquery_sdk-1.1.0/src/deepquery_sdk/mcp_resource.py +83 -0
  10. deepquery_sdk-1.1.0/src/deepquery_sdk/prompt.py +75 -0
  11. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/validation.py +10 -1
  12. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase4.py +1 -1
  13. deepquery_sdk-1.1.0/tests/test_resources_prompts.py +104 -0
  14. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/.gitignore +0 -0
  15. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/LICENSE +0 -0
  16. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/MIGRATIONS.md +0 -0
  17. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/bad_connector.py +0 -0
  18. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/jira_connector/__init__.py +0 -0
  19. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/jira_connector/connector.py +0 -0
  20. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/manual_action_flow.py +0 -0
  21. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/manual_client.py +0 -0
  22. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/examples/run_jira_server.py +0 -0
  23. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/my_tool/README.md +0 -0
  24. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/my_tool/connector.py +0 -0
  25. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/action.py +0 -0
  26. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/__init__.py +0 -0
  27. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/base.py +0 -0
  28. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/oauth2.py +0 -0
  29. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/auth/strategies.py +0 -0
  30. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/classification.py +0 -0
  31. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/__init__.py +0 -0
  32. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/__main__.py +0 -0
  33. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/commands.py +0 -0
  34. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/cli/loader.py +0 -0
  35. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/compat.py +0 -0
  36. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/gate.py +0 -0
  37. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/harness/__init__.py +0 -0
  38. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/harness/mock_agent.py +0 -0
  39. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/mcp_emit/__init__.py +0 -0
  40. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/provenance.py +0 -0
  41. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/resource.py +0 -0
  42. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/.gitignore.tmpl +0 -0
  43. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/README.md.tmpl +0 -0
  44. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/connector.py.tmpl +0 -0
  45. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/pyproject.toml.tmpl +0 -0
  46. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/src/deepquery_sdk/templates/connector/tests/test_connector.py.tmpl +0 -0
  47. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase1.py +0 -0
  48. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.0}/tests/test_phase2.py +0 -0
  49. {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.0.0
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.0.0 — all four build phases complete.** Core contracts (Phase 1),
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.0.0 — all four build phases complete.** Core contracts (Phase 1),
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.0.0"
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.0.0"
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
- for spec in list(connector.resources) + list(connector.actions):
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.0.0"
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