deepquery-sdk 1.0.0__tar.gz → 1.1.1__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 (50) hide show
  1. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/CHANGELOG.md +30 -1
  2. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/PKG-INFO +12 -6
  3. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/README.md +7 -1
  4. deepquery_sdk-1.1.1/docs/resources-and-prompts.md +202 -0
  5. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/pyproject.toml +5 -5
  6. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/__init__.py +11 -0
  7. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/connector.py +71 -0
  8. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/manifest.py +21 -1
  9. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/mcp_emit/emitter.py +91 -0
  10. deepquery_sdk-1.1.1/src/deepquery_sdk/mcp_resource.py +83 -0
  11. deepquery_sdk-1.1.1/src/deepquery_sdk/prompt.py +75 -0
  12. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/validation.py +10 -1
  13. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/tests/test_phase4.py +1 -1
  14. deepquery_sdk-1.1.1/tests/test_resources_prompts.py +104 -0
  15. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/.gitignore +0 -0
  16. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/LICENSE +0 -0
  17. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/MIGRATIONS.md +0 -0
  18. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/examples/bad_connector.py +0 -0
  19. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/examples/jira_connector/__init__.py +0 -0
  20. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/examples/jira_connector/connector.py +0 -0
  21. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/examples/manual_action_flow.py +0 -0
  22. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/examples/manual_client.py +0 -0
  23. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/examples/run_jira_server.py +0 -0
  24. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/my_tool/README.md +0 -0
  25. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/my_tool/connector.py +0 -0
  26. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/action.py +0 -0
  27. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/auth/__init__.py +0 -0
  28. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/auth/base.py +0 -0
  29. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/auth/oauth2.py +0 -0
  30. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/auth/strategies.py +0 -0
  31. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/classification.py +0 -0
  32. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/cli/__init__.py +0 -0
  33. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/cli/__main__.py +0 -0
  34. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/cli/commands.py +0 -0
  35. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/cli/loader.py +0 -0
  36. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/compat.py +0 -0
  37. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/gate.py +0 -0
  38. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/harness/__init__.py +0 -0
  39. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/harness/mock_agent.py +0 -0
  40. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/mcp_emit/__init__.py +0 -0
  41. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/provenance.py +0 -0
  42. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/resource.py +0 -0
  43. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/templates/connector/.gitignore.tmpl +0 -0
  44. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/templates/connector/README.md.tmpl +0 -0
  45. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/templates/connector/connector.py.tmpl +0 -0
  46. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/templates/connector/pyproject.toml.tmpl +0 -0
  47. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/src/deepquery_sdk/templates/connector/tests/test_connector.py.tmpl +0 -0
  48. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/tests/test_phase1.py +0 -0
  49. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/tests/test_phase2.py +0 -0
  50. {deepquery_sdk-1.0.0 → deepquery_sdk-1.1.1}/tests/test_phase3.py +0 -0
@@ -5,6 +5,33 @@ 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.1] — 2026-06-08
9
+
10
+ Patch — metadata/docs only, no code changes.
11
+
12
+ ### Fixed
13
+ - Corrected the project URLs (Homepage / Repository / Changelog / Documentation)
14
+ to point at the real GitHub repository.
15
+
16
+ ## [1.1.0] — 2026-06-08
17
+
18
+ Additive, backward-compatible. Connectors can now emit the other two MCP
19
+ primitives alongside tools.
20
+
21
+ ### Added
22
+ - `@mcp_resource` — declare URI-addressable **resources** (static `uri` or a
23
+ parameterized `uri_template`). Emitted via `resources/list`,
24
+ `resources/read`, and `resources/templates/list`; content is `str` or `bytes`.
25
+ - `@prompt` — declare **prompt** templates (with arguments). Emitted via
26
+ `prompts/list` and `prompts/get`; the handler returns a string or a list of
27
+ `{role, text}` messages.
28
+ - Manifest now carries `mcp_resources` and `prompts`; `validate` checks their
29
+ descriptions (present + injection markers).
30
+
31
+ Note: the existing `@resource` is unchanged — it declares a *read* and is emitted
32
+ as a read-only **tool** (for parameterized search). `@mcp_resource` is the
33
+ distinct MCP `resources` primitive.
34
+
8
35
  ## [1.0.0] — 2026-06-07
9
36
 
10
37
  First stable release. Establishes the SDK contract at **major version 1**:
@@ -40,4 +67,6 @@ connectors declaring `sdk_major_version = 1` are loadable by this runtime.
40
67
  - `scaffold` generates a complete, shippable connector project.
41
68
  - Published to PyPI as `deepquery-sdk`.
42
69
 
43
- [1.0.0]: https://github.com/GilbertAshivaka/deepquery-sdk/releases/tag/v1.0.0
70
+ [1.1.1]: https://github.com/GilbertAshivaka/DeepQuerySDK/releases/tag/v1.1.1
71
+ [1.1.0]: https://github.com/GilbertAshivaka/DeepQuerySDK/releases/tag/v1.1.0
72
+ [1.0.0]: https://github.com/GilbertAshivaka/DeepQuerySDK/releases/tag/v1.0.0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepquery-sdk
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Connector Development Kit for Deep Query — build MCP-emitting connectors with built-in read/action safety classification and provenance.
5
- Project-URL: Homepage, https://github.com/GilbertAshivaka/deepquery-sdk
6
- Project-URL: Repository, https://github.com/GilbertAshivaka/deepquery-sdk
7
- Project-URL: Changelog, https://github.com/GilbertAshivaka/deepquery-sdk/blob/main/CHANGELOG.md
8
- Project-URL: Documentation, https://github.com/GilbertAshivaka/deepquery-sdk/blob/main/README.md
5
+ Project-URL: Homepage, https://github.com/GilbertAshivaka/DeepQuerySDK
6
+ Project-URL: Repository, https://github.com/GilbertAshivaka/DeepQuerySDK
7
+ Project-URL: Changelog, https://github.com/GilbertAshivaka/DeepQuerySDK/blob/main/CHANGELOG.md
8
+ Project-URL: Documentation, https://github.com/GilbertAshivaka/DeepQuerySDK/blob/main/README.md
9
9
  Author-email: Gilbert Ashivaka <gilbertashivaka@gmail.com>
10
10
  License: Apache-2.0
11
11
  License-File: LICENSE
@@ -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 the **[Resources & Prompts guide](docs/resources-and-prompts.md)**
42
+ > and [CHANGELOG.md](CHANGELOG.md). Built on v1.0.0 — 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 the **[Resources & Prompts guide](docs/resources-and-prompts.md)**
12
+ > and [CHANGELOG.md](CHANGELOG.md). Built on v1.0.0 — 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`
@@ -0,0 +1,202 @@
1
+ # Resources & Prompts (DeepQuerySDK 1.1.0)
2
+
3
+ This guide explains the two MCP primitives added in 1.1.0 — **resources** and
4
+ **prompts** — in plain terms: what they are, when to reach for each, and how to
5
+ add them to a connector. It assumes you've already built a basic connector
6
+ (see the README quickstart).
7
+
8
+ ---
9
+
10
+ ## The big picture: three kinds of capability
11
+
12
+ An MCP server (which is what your connector emits) can offer **three** kinds of
13
+ thing. DeepQuerySDK now lets you declare all three:
14
+
15
+ | You write | MCP primitive | What it's for | Emitted as |
16
+ |---|---|---|---|
17
+ | `@resource` | tool | A **search/fetch** the agent runs with parameters (e.g. "search tickets for X") | a read-only **tool** |
18
+ | `@mcp_resource` | resource | A specific piece of data the agent pulls by its **address (URI)** (e.g. "the README", "article #42") | a **resource** |
19
+ | `@action` | tool | An operation that **changes** something, gated behind human approval | a mutating **tool** |
20
+ | `@prompt` | prompt | A reusable **prompt template** a person can pick (e.g. "summarize this ticket") | a **prompt** |
21
+
22
+ > **The one thing to internalize:** `@resource` and `@mcp_resource` are *different*
23
+ > despite the similar names.
24
+ > - `@resource` = "the agent asks a question and you search" → it's a **tool**.
25
+ > - `@mcp_resource` = "there's a thing at this URI, hand it over" → it's a **resource**.
26
+ >
27
+ > If your data is *looked up by parameters*, use `@resource`. If it's *addressed by
28
+ > a stable URI*, use `@mcp_resource`.
29
+
30
+ ---
31
+
32
+ ## Resources: data addressed by a URI
33
+
34
+ A resource is a piece of content that lives at a **URI** — like a file path or a
35
+ web address, but it can use any scheme you like (`docs://`, `tickets://`, …).
36
+ There are two flavours:
37
+
38
+ ### 1. Static resource — one fixed URI
39
+
40
+ ```python
41
+ from deepquery_sdk import Connector, mcp_resource
42
+
43
+ class DocsConnector(Connector):
44
+ name = "docs"
45
+ version = "1.0.0"
46
+
47
+ @mcp_resource(
48
+ uri="docs://readme",
49
+ name="README",
50
+ description="The project's README, in Markdown.",
51
+ mime_type="text/markdown",
52
+ )
53
+ def readme(self) -> str:
54
+ return "# My Project\n\nWelcome!"
55
+ ```
56
+
57
+ That's it. The method **returns the content** as a string (text) or bytes (binary
58
+ blob). The agent can now list this resource and read it by its URI `docs://readme`.
59
+
60
+ ### 2. Templated resource — a family of URIs with a parameter
61
+
62
+ When you have *many* items of the same shape (articles, tickets, records), use a
63
+ **URI template** with a `{placeholder}`. The placeholder becomes a method argument:
64
+
65
+ ```python
66
+ @mcp_resource(
67
+ uri_template="docs://article/{slug}",
68
+ name="Article",
69
+ description="A documentation article, addressed by its slug.",
70
+ mime_type="text/markdown",
71
+ )
72
+ def article(self, slug: str) -> str:
73
+ return load_article(slug) # called with slug="getting-started" for docs://article/getting-started
74
+ ```
75
+
76
+ The SDK matches an incoming URI like `docs://article/getting-started` against the
77
+ template, pulls out `slug="getting-started"`, and calls your method with it.
78
+
79
+ ### What you get for free
80
+ - The emitted server **advertises the `resources` capability** automatically (just
81
+ by having at least one `@mcp_resource`).
82
+ - Resources show up in `resources/list` and (templates) `resources/templates/list`.
83
+ - Reading a URI calls your method via `resources/read`.
84
+ - **Deep Query cites resource content by its URI**, so anything an agent reads this
85
+ way stays a verifiable, timestamped source — just like a document chunk.
86
+
87
+ ### Content types
88
+ - Return a **`str`** for text (set `mime_type` to something like `text/markdown`,
89
+ `application/json`, `text/plain` — default is `text/plain`).
90
+ - Return **`bytes`** for binary content; it's base64-encoded automatically.
91
+
92
+ ---
93
+
94
+ ## Prompts: reusable templates a person picks
95
+
96
+ A prompt is a **named template** your connector offers — think of it like a
97
+ slash-command a user can choose ("Summarize a ticket", "Draft a reply"). Unlike
98
+ tools, prompts are *user-controlled*: the host shows them to a person, who picks
99
+ one and fills in any arguments.
100
+
101
+ ```python
102
+ from deepquery_sdk import Connector, prompt
103
+
104
+ class SupportConnector(Connector):
105
+ name = "support"
106
+ version = "1.0.0"
107
+
108
+ @prompt(
109
+ description="Draft a request to summarize a support ticket.",
110
+ arguments=[
111
+ {"name": "ticket_id", "description": "The ticket to summarize", "required": True},
112
+ ],
113
+ )
114
+ def summarize_ticket(self, ticket_id: str) -> str:
115
+ return f"Please summarize support ticket {ticket_id}, including its current status."
116
+ ```
117
+
118
+ ### Return shapes
119
+ Your method returns either:
120
+ - a **string** — becomes a single message from the user, or
121
+ - a **list of messages** — `[{"role": "user", "text": "..."}, {"role": "assistant", "text": "..."}]`
122
+ — when you want a multi-turn template.
123
+
124
+ ### Arguments
125
+ Declare them as a list. Each item is either:
126
+ - a plain **name** (`["city", "state"]`), or
127
+ - a **dict** with detail: `{"name": "ticket_id", "description": "...", "required": True}`.
128
+
129
+ ### A safety note
130
+ Prompt text is **user/connector-provided content**, so Deep Query treats it as
131
+ **untrusted** — it is never executed as instructions to the agent. You don't need
132
+ to do anything special; just know your prompt text is data, not commands.
133
+
134
+ ---
135
+
136
+ ## A complete example
137
+
138
+ ```python
139
+ from deepquery_sdk import Connector, resource, action, mcp_resource, prompt
140
+
141
+ class HelpdeskConnector(Connector):
142
+ name = "helpdesk"
143
+ version = "1.0.0"
144
+ description = "Read and act on helpdesk tickets."
145
+
146
+ # A parameterized search -> emitted as a TOOL
147
+ @resource(description="Search tickets by text.",
148
+ input_schema={"type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"]})
149
+ def search(self, q: str):
150
+ return [self.cite(t, source_object_id=t["id"], title_or_label=t["id"]) for t in find(q)]
151
+
152
+ # A specific ticket by URI -> emitted as a RESOURCE (template)
153
+ @mcp_resource(uri_template="helpdesk://ticket/{ticket_id}", name="Ticket",
154
+ description="A single ticket by id.", mime_type="application/json")
155
+ def ticket(self, ticket_id: str) -> str:
156
+ return json.dumps(get_ticket(ticket_id))
157
+
158
+ # A user-pickable template -> emitted as a PROMPT
159
+ @prompt(description="Draft a reply to a ticket.",
160
+ arguments=[{"name": "ticket_id", "required": True}])
161
+ def draft_reply(self, ticket_id: str) -> str:
162
+ return f"Draft a polite reply to ticket {ticket_id}."
163
+
164
+ # A gated change -> emitted as a mutating TOOL (preview + execute)
165
+ @action(description="Close a ticket.",
166
+ input_schema={"type": "object", "properties": {"ticket_id": {"type": "string"}}, "required": ["ticket_id"]})
167
+ def close_ticket(self, ticket_id: str):
168
+ return close(ticket_id)
169
+
170
+ @close_ticket.preview
171
+ def _(self, ticket_id: str) -> str:
172
+ return f"Will close ticket {ticket_id}."
173
+ ```
174
+
175
+ ---
176
+
177
+ ## Validate before you ship
178
+
179
+ `validate` now checks resource and prompt descriptions too (they must be present,
180
+ and it flags text that looks like a prompt-injection instruction):
181
+
182
+ ```bash
183
+ deepquery validate connector.py
184
+ deepquery manifest connector.py # shows resources + prompts in the manifest
185
+ ```
186
+
187
+ ---
188
+
189
+ ## FAQ
190
+
191
+ **Do I have to add resources/prompts?** No. They're optional. A connector with only
192
+ tools (reads + actions) is still complete.
193
+
194
+ **`@resource` vs `@mcp_resource` again?** Parameter-driven lookup → `@resource`
195
+ (tool). URI-addressed item → `@mcp_resource` (resource).
196
+
197
+ **Is this a breaking change?** No — 1.1.0 is fully backward-compatible with 1.0.0
198
+ connectors. Existing connectors keep working unchanged.
199
+
200
+ **Does the connector advertise the capability only when I use it?** Yes. The server
201
+ advertises `resources` only if you declare at least one `@mcp_resource`, and
202
+ `prompts` only if you declare at least one `@prompt`.
@@ -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.1"
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"
@@ -35,10 +35,10 @@ dev = ["pytest>=8", "pytest-asyncio>=0.25", "build>=1.2"]
35
35
  deepquery = "deepquery_sdk.cli.__main__:main"
36
36
 
37
37
  [project.urls]
38
- Homepage = "https://github.com/GilbertAshivaka/deepquery-sdk"
39
- Repository = "https://github.com/GilbertAshivaka/deepquery-sdk"
40
- Changelog = "https://github.com/GilbertAshivaka/deepquery-sdk/blob/main/CHANGELOG.md"
41
- Documentation = "https://github.com/GilbertAshivaka/deepquery-sdk/blob/main/README.md"
38
+ Homepage = "https://github.com/GilbertAshivaka/DeepQuerySDK"
39
+ Repository = "https://github.com/GilbertAshivaka/DeepQuerySDK"
40
+ Changelog = "https://github.com/GilbertAshivaka/DeepQuerySDK/blob/main/CHANGELOG.md"
41
+ Documentation = "https://github.com/GilbertAshivaka/DeepQuerySDK/blob/main/README.md"
42
42
 
43
43
  [tool.hatch.build.targets.wheel]
44
44
  packages = ["src/deepquery_sdk"]
@@ -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.1"
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.1"
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_is_single_sourced():
62
+ assert SDK_VERSION == "1.1.1"
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