fastmcp 2.8.1__py3-none-any.whl → 2.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/cli/cli.py +99 -1
- fastmcp/cli/run.py +1 -3
- fastmcp/client/auth/oauth.py +1 -2
- fastmcp/client/client.py +23 -7
- fastmcp/client/logging.py +1 -2
- fastmcp/client/messages.py +126 -0
- fastmcp/client/transports.py +17 -2
- fastmcp/contrib/mcp_mixin/README.md +79 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -0
- fastmcp/prompts/prompt.py +109 -13
- fastmcp/prompts/prompt_manager.py +119 -43
- fastmcp/resources/resource.py +27 -1
- fastmcp/resources/resource_manager.py +249 -76
- fastmcp/resources/template.py +44 -2
- fastmcp/server/auth/providers/bearer.py +62 -13
- fastmcp/server/context.py +113 -10
- fastmcp/server/http.py +8 -0
- fastmcp/server/low_level.py +35 -0
- fastmcp/server/middleware/__init__.py +6 -0
- fastmcp/server/middleware/error_handling.py +206 -0
- fastmcp/server/middleware/logging.py +165 -0
- fastmcp/server/middleware/middleware.py +236 -0
- fastmcp/server/middleware/rate_limiting.py +231 -0
- fastmcp/server/middleware/timing.py +156 -0
- fastmcp/server/proxy.py +250 -140
- fastmcp/server/server.py +446 -280
- fastmcp/settings.py +2 -2
- fastmcp/tools/tool.py +22 -2
- fastmcp/tools/tool_manager.py +114 -45
- fastmcp/tools/tool_transform.py +42 -16
- fastmcp/utilities/components.py +22 -2
- fastmcp/utilities/inspect.py +326 -0
- fastmcp/utilities/json_schema.py +67 -23
- fastmcp/utilities/mcp_config.py +13 -7
- fastmcp/utilities/openapi.py +75 -5
- fastmcp/utilities/tests.py +1 -1
- fastmcp/utilities/types.py +90 -1
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/METADATA +2 -2
- fastmcp-2.9.1.dist-info/RECORD +78 -0
- fastmcp-2.8.1.dist-info/RECORD +0 -69
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.8.1.dist-info → fastmcp-2.9.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/prompts/prompt.py
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations as _annotations
|
|
4
4
|
|
|
5
5
|
import inspect
|
|
6
|
+
import json
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
8
|
from collections.abc import Awaitable, Callable, Sequence
|
|
8
|
-
from typing import
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
11
|
import pydantic_core
|
|
11
12
|
from mcp.types import Prompt as MCPPrompt
|
|
12
13
|
from mcp.types import PromptArgument as MCPPromptArgument
|
|
13
14
|
from mcp.types import PromptMessage, Role, TextContent
|
|
14
|
-
from pydantic import Field, TypeAdapter
|
|
15
|
+
from pydantic import Field, TypeAdapter
|
|
15
16
|
|
|
16
17
|
from fastmcp.exceptions import PromptError
|
|
17
18
|
from fastmcp.server.dependencies import get_context
|
|
@@ -25,10 +26,6 @@ from fastmcp.utilities.types import (
|
|
|
25
26
|
get_cached_typeadapter,
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
if TYPE_CHECKING:
|
|
29
|
-
pass
|
|
30
|
-
|
|
31
|
-
|
|
32
29
|
logger = get_logger(__name__)
|
|
33
30
|
|
|
34
31
|
|
|
@@ -73,6 +70,22 @@ class Prompt(FastMCPComponent, ABC):
|
|
|
73
70
|
default=None, description="Arguments that can be passed to the prompt"
|
|
74
71
|
)
|
|
75
72
|
|
|
73
|
+
def enable(self) -> None:
|
|
74
|
+
super().enable()
|
|
75
|
+
try:
|
|
76
|
+
context = get_context()
|
|
77
|
+
context._queue_prompt_list_changed() # type: ignore[private-use]
|
|
78
|
+
except RuntimeError:
|
|
79
|
+
pass # No context available
|
|
80
|
+
|
|
81
|
+
def disable(self) -> None:
|
|
82
|
+
super().disable()
|
|
83
|
+
try:
|
|
84
|
+
context = get_context()
|
|
85
|
+
context._queue_prompt_list_changed() # type: ignore[private-use]
|
|
86
|
+
except RuntimeError:
|
|
87
|
+
pass # No context available
|
|
88
|
+
|
|
76
89
|
def to_mcp_prompt(self, **overrides: Any) -> MCPPrompt:
|
|
77
90
|
"""Convert the prompt to an MCP prompt."""
|
|
78
91
|
arguments = [
|
|
@@ -155,7 +168,7 @@ class FunctionPrompt(Prompt):
|
|
|
155
168
|
if param.kind == inspect.Parameter.VAR_KEYWORD:
|
|
156
169
|
raise ValueError("Functions with **kwargs are not supported as prompts")
|
|
157
170
|
|
|
158
|
-
description = description or fn
|
|
171
|
+
description = description or inspect.getdoc(fn)
|
|
159
172
|
|
|
160
173
|
# if the fn is a callable class, we need to get the __call__ method from here out
|
|
161
174
|
if not inspect.isroutine(fn):
|
|
@@ -181,17 +194,43 @@ class FunctionPrompt(Prompt):
|
|
|
181
194
|
arguments: list[PromptArgument] = []
|
|
182
195
|
if "properties" in parameters:
|
|
183
196
|
for param_name, param in parameters["properties"].items():
|
|
197
|
+
arg_description = param.get("description")
|
|
198
|
+
|
|
199
|
+
# For non-string parameters, append JSON schema info to help users
|
|
200
|
+
# understand the expected format when passing as strings (MCP requirement)
|
|
201
|
+
if param_name in sig.parameters:
|
|
202
|
+
sig_param = sig.parameters[param_name]
|
|
203
|
+
if (
|
|
204
|
+
sig_param.annotation != inspect.Parameter.empty
|
|
205
|
+
and sig_param.annotation is not str
|
|
206
|
+
and param_name != context_kwarg
|
|
207
|
+
):
|
|
208
|
+
# Get the JSON schema for this specific parameter type
|
|
209
|
+
try:
|
|
210
|
+
param_adapter = get_cached_typeadapter(sig_param.annotation)
|
|
211
|
+
param_schema = param_adapter.json_schema()
|
|
212
|
+
|
|
213
|
+
# Create compact schema representation
|
|
214
|
+
schema_str = json.dumps(param_schema, separators=(",", ":"))
|
|
215
|
+
|
|
216
|
+
# Append schema info to description
|
|
217
|
+
schema_note = f"Provide as a JSON string matching the following schema: {schema_str}"
|
|
218
|
+
if arg_description:
|
|
219
|
+
arg_description = f"{arg_description}\n\n{schema_note}"
|
|
220
|
+
else:
|
|
221
|
+
arg_description = schema_note
|
|
222
|
+
except Exception:
|
|
223
|
+
# If schema generation fails, skip enhancement
|
|
224
|
+
pass
|
|
225
|
+
|
|
184
226
|
arguments.append(
|
|
185
227
|
PromptArgument(
|
|
186
228
|
name=param_name,
|
|
187
|
-
description=
|
|
229
|
+
description=arg_description,
|
|
188
230
|
required=param_name in parameters.get("required", []),
|
|
189
231
|
)
|
|
190
232
|
)
|
|
191
233
|
|
|
192
|
-
# ensure the arguments are properly cast
|
|
193
|
-
fn = validate_call(fn)
|
|
194
|
-
|
|
195
234
|
return cls(
|
|
196
235
|
name=func_name,
|
|
197
236
|
description=description,
|
|
@@ -201,6 +240,60 @@ class FunctionPrompt(Prompt):
|
|
|
201
240
|
fn=fn,
|
|
202
241
|
)
|
|
203
242
|
|
|
243
|
+
def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
244
|
+
"""Convert string arguments to expected types based on function signature."""
|
|
245
|
+
from fastmcp.server.context import Context
|
|
246
|
+
|
|
247
|
+
sig = inspect.signature(self.fn)
|
|
248
|
+
converted_kwargs = {}
|
|
249
|
+
|
|
250
|
+
# Find context parameter name if any
|
|
251
|
+
context_param_name = find_kwarg_by_type(self.fn, kwarg_type=Context)
|
|
252
|
+
|
|
253
|
+
for param_name, param_value in kwargs.items():
|
|
254
|
+
if param_name in sig.parameters:
|
|
255
|
+
param = sig.parameters[param_name]
|
|
256
|
+
|
|
257
|
+
# Skip Context parameters - they're handled separately
|
|
258
|
+
if param_name == context_param_name:
|
|
259
|
+
converted_kwargs[param_name] = param_value
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
# If parameter has no annotation or annotation is str, pass as-is
|
|
263
|
+
if (
|
|
264
|
+
param.annotation == inspect.Parameter.empty
|
|
265
|
+
or param.annotation is str
|
|
266
|
+
):
|
|
267
|
+
converted_kwargs[param_name] = param_value
|
|
268
|
+
# If argument is not a string, pass as-is (already properly typed)
|
|
269
|
+
elif not isinstance(param_value, str):
|
|
270
|
+
converted_kwargs[param_name] = param_value
|
|
271
|
+
else:
|
|
272
|
+
# Try to convert string argument using type adapter
|
|
273
|
+
try:
|
|
274
|
+
adapter = get_cached_typeadapter(param.annotation)
|
|
275
|
+
# Try JSON parsing first for complex types
|
|
276
|
+
try:
|
|
277
|
+
converted_kwargs[param_name] = adapter.validate_json(
|
|
278
|
+
param_value
|
|
279
|
+
)
|
|
280
|
+
except (ValueError, TypeError, pydantic_core.ValidationError):
|
|
281
|
+
# Fallback to direct validation
|
|
282
|
+
converted_kwargs[param_name] = adapter.validate_python(
|
|
283
|
+
param_value
|
|
284
|
+
)
|
|
285
|
+
except (ValueError, TypeError, pydantic_core.ValidationError) as e:
|
|
286
|
+
# If conversion fails, provide informative error
|
|
287
|
+
raise PromptError(
|
|
288
|
+
f"Could not convert argument '{param_name}' with value '{param_value}' "
|
|
289
|
+
f"to expected type {param.annotation}. Error: {e}"
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
# Parameter not in function signature, pass as-is
|
|
293
|
+
converted_kwargs[param_name] = param_value
|
|
294
|
+
|
|
295
|
+
return converted_kwargs
|
|
296
|
+
|
|
204
297
|
async def render(
|
|
205
298
|
self,
|
|
206
299
|
arguments: dict[str, Any] | None = None,
|
|
@@ -223,6 +316,9 @@ class FunctionPrompt(Prompt):
|
|
|
223
316
|
if context_kwarg and context_kwarg not in kwargs:
|
|
224
317
|
kwargs[context_kwarg] = get_context()
|
|
225
318
|
|
|
319
|
+
# Convert string arguments to expected types when needed
|
|
320
|
+
kwargs = self._convert_string_arguments(kwargs)
|
|
321
|
+
|
|
226
322
|
# Call function and check if result is a coroutine
|
|
227
323
|
result = self.fn(**kwargs)
|
|
228
324
|
if inspect.iscoroutine(result):
|
|
@@ -259,6 +355,6 @@ class FunctionPrompt(Prompt):
|
|
|
259
355
|
raise PromptError("Could not convert prompt result to message.")
|
|
260
356
|
|
|
261
357
|
return messages
|
|
262
|
-
except Exception
|
|
263
|
-
logger.exception(f"Error rendering prompt {self.name}
|
|
358
|
+
except Exception:
|
|
359
|
+
logger.exception(f"Error rendering prompt {self.name}")
|
|
264
360
|
raise PromptError(f"Error rendering prompt {self.name}.")
|
|
@@ -13,7 +13,7 @@ from fastmcp.settings import DuplicateBehavior
|
|
|
13
13
|
from fastmcp.utilities.logging import get_logger
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
|
-
|
|
16
|
+
from fastmcp.server.server import MountedServer
|
|
17
17
|
|
|
18
18
|
logger = get_logger(__name__)
|
|
19
19
|
|
|
@@ -27,6 +27,7 @@ class PromptManager:
|
|
|
27
27
|
mask_error_details: bool | None = None,
|
|
28
28
|
):
|
|
29
29
|
self._prompts: dict[str, Prompt] = {}
|
|
30
|
+
self._mounted_servers: list[MountedServer] = []
|
|
30
31
|
self.mask_error_details = mask_error_details or settings.mask_error_details
|
|
31
32
|
|
|
32
33
|
# Default to "warn" if None is provided
|
|
@@ -41,15 +42,74 @@ class PromptManager:
|
|
|
41
42
|
|
|
42
43
|
self.duplicate_behavior = duplicate_behavior
|
|
43
44
|
|
|
44
|
-
def
|
|
45
|
+
def mount(self, server: MountedServer) -> None:
|
|
46
|
+
"""Adds a mounted server as a source for prompts."""
|
|
47
|
+
self._mounted_servers.append(server)
|
|
48
|
+
|
|
49
|
+
async def _load_prompts(self, *, via_server: bool = False) -> dict[str, Prompt]:
|
|
50
|
+
"""
|
|
51
|
+
The single, consolidated recursive method for fetching prompts. The 'via_server'
|
|
52
|
+
parameter determines the communication path.
|
|
53
|
+
|
|
54
|
+
- via_server=False: Manager-to-manager path for complete, unfiltered inventory
|
|
55
|
+
- via_server=True: Server-to-server path for filtered MCP requests
|
|
56
|
+
"""
|
|
57
|
+
all_prompts: dict[str, Prompt] = {}
|
|
58
|
+
|
|
59
|
+
for mounted in self._mounted_servers:
|
|
60
|
+
try:
|
|
61
|
+
if via_server:
|
|
62
|
+
# Use the server-to-server filtered path
|
|
63
|
+
child_results = await mounted.server._list_prompts()
|
|
64
|
+
else:
|
|
65
|
+
# Use the manager-to-manager unfiltered path
|
|
66
|
+
child_results = await mounted.server._prompt_manager.list_prompts()
|
|
67
|
+
|
|
68
|
+
# The combination logic is the same for both paths
|
|
69
|
+
child_dict = {p.key: p for p in child_results}
|
|
70
|
+
if mounted.prefix:
|
|
71
|
+
for prompt in child_dict.values():
|
|
72
|
+
prefixed_prompt = prompt.with_key(
|
|
73
|
+
f"{mounted.prefix}_{prompt.key}"
|
|
74
|
+
)
|
|
75
|
+
all_prompts[prefixed_prompt.key] = prefixed_prompt
|
|
76
|
+
else:
|
|
77
|
+
all_prompts.update(child_dict)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
# Skip failed mounts silently, matches existing behavior
|
|
80
|
+
logger.warning(
|
|
81
|
+
f"Failed to get prompts from mounted server '{mounted.prefix}': {e}"
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# Finally, add local prompts, which always take precedence
|
|
86
|
+
all_prompts.update(self._prompts)
|
|
87
|
+
return all_prompts
|
|
88
|
+
|
|
89
|
+
async def has_prompt(self, key: str) -> bool:
|
|
90
|
+
"""Check if a prompt exists."""
|
|
91
|
+
prompts = await self.get_prompts()
|
|
92
|
+
return key in prompts
|
|
93
|
+
|
|
94
|
+
async def get_prompt(self, key: str) -> Prompt:
|
|
45
95
|
"""Get prompt by key."""
|
|
46
|
-
|
|
47
|
-
|
|
96
|
+
prompts = await self.get_prompts()
|
|
97
|
+
if key in prompts:
|
|
98
|
+
return prompts[key]
|
|
48
99
|
raise NotFoundError(f"Unknown prompt: {key}")
|
|
49
100
|
|
|
50
|
-
def get_prompts(self) -> dict[str, Prompt]:
|
|
51
|
-
"""
|
|
52
|
-
|
|
101
|
+
async def get_prompts(self) -> dict[str, Prompt]:
|
|
102
|
+
"""
|
|
103
|
+
Gets the complete, unfiltered inventory of all prompts.
|
|
104
|
+
"""
|
|
105
|
+
return await self._load_prompts(via_server=False)
|
|
106
|
+
|
|
107
|
+
async def list_prompts(self) -> list[Prompt]:
|
|
108
|
+
"""
|
|
109
|
+
Lists all prompts, applying protocol filtering.
|
|
110
|
+
"""
|
|
111
|
+
prompts_dict = await self._load_prompts(via_server=True)
|
|
112
|
+
return list(prompts_dict.values())
|
|
53
113
|
|
|
54
114
|
def add_prompt_from_fn(
|
|
55
115
|
self,
|
|
@@ -71,24 +131,22 @@ class PromptManager:
|
|
|
71
131
|
)
|
|
72
132
|
return self.add_prompt(prompt) # type: ignore
|
|
73
133
|
|
|
74
|
-
def add_prompt(self, prompt: Prompt
|
|
134
|
+
def add_prompt(self, prompt: Prompt) -> Prompt:
|
|
75
135
|
"""Add a prompt to the manager."""
|
|
76
|
-
key = key or prompt.name
|
|
77
|
-
|
|
78
136
|
# Check for duplicates
|
|
79
|
-
existing = self._prompts.get(key)
|
|
137
|
+
existing = self._prompts.get(prompt.key)
|
|
80
138
|
if existing:
|
|
81
139
|
if self.duplicate_behavior == "warn":
|
|
82
|
-
logger.warning(f"Prompt already exists: {key}")
|
|
83
|
-
self._prompts[key] = prompt
|
|
140
|
+
logger.warning(f"Prompt already exists: {prompt.key}")
|
|
141
|
+
self._prompts[prompt.key] = prompt
|
|
84
142
|
elif self.duplicate_behavior == "replace":
|
|
85
|
-
self._prompts[key] = prompt
|
|
143
|
+
self._prompts[prompt.key] = prompt
|
|
86
144
|
elif self.duplicate_behavior == "error":
|
|
87
|
-
raise ValueError(f"Prompt already exists: {key}")
|
|
145
|
+
raise ValueError(f"Prompt already exists: {prompt.key}")
|
|
88
146
|
elif self.duplicate_behavior == "ignore":
|
|
89
147
|
return existing
|
|
90
148
|
else:
|
|
91
|
-
self._prompts[key] = prompt
|
|
149
|
+
self._prompts[prompt.key] = prompt
|
|
92
150
|
return prompt
|
|
93
151
|
|
|
94
152
|
async def render_prompt(
|
|
@@ -96,30 +154,48 @@ class PromptManager:
|
|
|
96
154
|
name: str,
|
|
97
155
|
arguments: dict[str, Any] | None = None,
|
|
98
156
|
) -> GetPromptResult:
|
|
99
|
-
"""
|
|
100
|
-
prompt
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
raise
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
157
|
+
"""
|
|
158
|
+
Internal API for servers: Finds and renders a prompt, respecting the
|
|
159
|
+
filtered protocol path.
|
|
160
|
+
"""
|
|
161
|
+
# 1. Check local prompts first. The server will have already applied its filter.
|
|
162
|
+
if name in self._prompts:
|
|
163
|
+
prompt = await self.get_prompt(name)
|
|
164
|
+
if not prompt:
|
|
165
|
+
raise NotFoundError(f"Unknown prompt: {name}")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
messages = await prompt.render(arguments)
|
|
169
|
+
return GetPromptResult(
|
|
170
|
+
description=prompt.description, messages=messages
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Pass through PromptErrors as-is
|
|
174
|
+
except PromptError as e:
|
|
175
|
+
logger.exception(f"Error rendering prompt {name!r}")
|
|
176
|
+
raise e
|
|
177
|
+
|
|
178
|
+
# Handle other exceptions
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.exception(f"Error rendering prompt {name!r}")
|
|
181
|
+
if self.mask_error_details:
|
|
182
|
+
# Mask internal details
|
|
183
|
+
raise PromptError(f"Error rendering prompt {name!r}") from e
|
|
184
|
+
else:
|
|
185
|
+
# Include original error details
|
|
186
|
+
raise PromptError(f"Error rendering prompt {name!r}: {e}") from e
|
|
187
|
+
|
|
188
|
+
# 2. Check mounted servers using the filtered protocol path.
|
|
189
|
+
for mounted in reversed(self._mounted_servers):
|
|
190
|
+
prompt_key = name
|
|
191
|
+
if mounted.prefix:
|
|
192
|
+
if name.startswith(f"{mounted.prefix}_"):
|
|
193
|
+
prompt_key = name.removeprefix(f"{mounted.prefix}_")
|
|
194
|
+
else:
|
|
195
|
+
continue
|
|
196
|
+
try:
|
|
197
|
+
return await mounted.server._get_prompt(prompt_key, arguments)
|
|
198
|
+
except NotFoundError:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
raise NotFoundError(f"Unknown prompt: {name}")
|
fastmcp/resources/resource.py
CHANGED
|
@@ -44,6 +44,22 @@ class Resource(FastMCPComponent, abc.ABC):
|
|
|
44
44
|
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
def enable(self) -> None:
|
|
48
|
+
super().enable()
|
|
49
|
+
try:
|
|
50
|
+
context = get_context()
|
|
51
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
52
|
+
except RuntimeError:
|
|
53
|
+
pass # No context available
|
|
54
|
+
|
|
55
|
+
def disable(self) -> None:
|
|
56
|
+
super().disable()
|
|
57
|
+
try:
|
|
58
|
+
context = get_context()
|
|
59
|
+
context._queue_resource_list_changed() # type: ignore[private-use]
|
|
60
|
+
except RuntimeError:
|
|
61
|
+
pass # No context available
|
|
62
|
+
|
|
47
63
|
@staticmethod
|
|
48
64
|
def from_function(
|
|
49
65
|
fn: Callable[[], Any],
|
|
@@ -101,6 +117,16 @@ class Resource(FastMCPComponent, abc.ABC):
|
|
|
101
117
|
def __repr__(self) -> str:
|
|
102
118
|
return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
|
|
103
119
|
|
|
120
|
+
@property
|
|
121
|
+
def key(self) -> str:
|
|
122
|
+
"""
|
|
123
|
+
The key of the component. This is used for internal bookkeeping
|
|
124
|
+
and may reflect e.g. prefixes or other identifiers. You should not depend on
|
|
125
|
+
keys having a certain value, as the same tool loaded from different
|
|
126
|
+
hierarchies of servers may have different keys.
|
|
127
|
+
"""
|
|
128
|
+
return self._key or str(self.uri)
|
|
129
|
+
|
|
104
130
|
|
|
105
131
|
class FunctionResource(Resource):
|
|
106
132
|
"""A resource that defers data loading by wrapping a function.
|
|
@@ -135,7 +161,7 @@ class FunctionResource(Resource):
|
|
|
135
161
|
fn=fn,
|
|
136
162
|
uri=uri,
|
|
137
163
|
name=name or fn.__name__,
|
|
138
|
-
description=description or fn
|
|
164
|
+
description=description or inspect.getdoc(fn),
|
|
139
165
|
mime_type=mime_type or "text/plain",
|
|
140
166
|
tags=tags or set(),
|
|
141
167
|
enabled=enabled if enabled is not None else True,
|