fast-agent-mcp 0.1.8__py3-none-any.whl → 0.1.9__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.
- {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.9.dist-info}/METADATA +26 -4
- {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.9.dist-info}/RECORD +43 -22
- {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.9.dist-info}/entry_points.txt +1 -0
- mcp_agent/agents/agent.py +5 -11
- mcp_agent/core/agent_app.py +89 -13
- mcp_agent/core/fastagent.py +13 -3
- mcp_agent/core/mcp_content.py +222 -0
- mcp_agent/core/prompt.py +132 -0
- mcp_agent/core/proxies.py +41 -36
- mcp_agent/logging/transport.py +30 -3
- mcp_agent/mcp/mcp_aggregator.py +11 -10
- mcp_agent/mcp/mime_utils.py +69 -0
- mcp_agent/mcp/prompt_message_multipart.py +64 -0
- mcp_agent/mcp/prompt_serialization.py +447 -0
- mcp_agent/mcp/prompts/__init__.py +0 -0
- mcp_agent/mcp/prompts/__main__.py +10 -0
- mcp_agent/mcp/prompts/prompt_server.py +508 -0
- mcp_agent/mcp/prompts/prompt_template.py +469 -0
- mcp_agent/mcp/resource_utils.py +203 -0
- mcp_agent/resources/examples/internal/agent.py +1 -1
- mcp_agent/resources/examples/internal/fastagent.config.yaml +2 -2
- mcp_agent/resources/examples/internal/sizer.py +0 -5
- mcp_agent/resources/examples/prompting/__init__.py +3 -0
- mcp_agent/resources/examples/prompting/agent.py +23 -0
- mcp_agent/resources/examples/prompting/fastagent.config.yaml +44 -0
- mcp_agent/resources/examples/prompting/image_server.py +56 -0
- mcp_agent/workflows/llm/anthropic_utils.py +101 -0
- mcp_agent/workflows/llm/augmented_llm.py +139 -66
- mcp_agent/workflows/llm/augmented_llm_anthropic.py +127 -251
- mcp_agent/workflows/llm/augmented_llm_openai.py +149 -305
- mcp_agent/workflows/llm/augmented_llm_passthrough.py +43 -0
- mcp_agent/workflows/llm/augmented_llm_playback.py +109 -0
- mcp_agent/workflows/llm/model_factory.py +20 -3
- mcp_agent/workflows/llm/openai_utils.py +65 -0
- mcp_agent/workflows/llm/providers/__init__.py +8 -0
- mcp_agent/workflows/llm/providers/multipart_converter_anthropic.py +348 -0
- mcp_agent/workflows/llm/providers/multipart_converter_openai.py +426 -0
- mcp_agent/workflows/llm/providers/openai_multipart.py +197 -0
- mcp_agent/workflows/llm/providers/sampling_converter_anthropic.py +258 -0
- mcp_agent/workflows/llm/providers/sampling_converter_openai.py +229 -0
- mcp_agent/workflows/llm/sampling_format_converter.py +39 -0
- mcp_agent/core/server_validation.py +0 -44
- mcp_agent/core/simulator_registry.py +0 -22
- mcp_agent/workflows/llm/enhanced_passthrough.py +0 -70
- {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.9.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.1.8.dist-info → fast_agent_mcp-0.1.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,469 @@
|
|
1
|
+
"""
|
2
|
+
Prompt Template Module
|
3
|
+
|
4
|
+
Handles prompt templating, variable extraction, and substitution for the prompt server.
|
5
|
+
Provides clean, testable classes for managing template substitution.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import re
|
9
|
+
from pathlib import Path
|
10
|
+
from typing import Dict, List, Set, Any, Optional, Literal
|
11
|
+
|
12
|
+
from pydantic import BaseModel, field_validator
|
13
|
+
|
14
|
+
from mcp.types import TextContent, EmbeddedResource, TextResourceContents
|
15
|
+
from mcp_agent.mcp.prompt_message_multipart import PromptMessageMultipart
|
16
|
+
from mcp_agent.mcp.prompt_serialization import (
|
17
|
+
multipart_messages_to_delimited_format,
|
18
|
+
)
|
19
|
+
|
20
|
+
|
21
|
+
class PromptMetadata(BaseModel):
|
22
|
+
"""Metadata about a prompt file"""
|
23
|
+
|
24
|
+
name: str
|
25
|
+
description: str
|
26
|
+
template_variables: Set[str] = set()
|
27
|
+
resource_paths: List[str] = []
|
28
|
+
file_path: Path
|
29
|
+
|
30
|
+
|
31
|
+
# Define valid message roles for better type safety
|
32
|
+
MessageRole = Literal["user", "assistant"]
|
33
|
+
|
34
|
+
|
35
|
+
class PromptContent(BaseModel):
|
36
|
+
"""Content of a prompt, which may include template variables"""
|
37
|
+
|
38
|
+
text: str
|
39
|
+
role: str = "user"
|
40
|
+
resources: List[str] = []
|
41
|
+
|
42
|
+
@field_validator("role")
|
43
|
+
@classmethod
|
44
|
+
def validate_role(cls, role: str) -> str:
|
45
|
+
"""Validate that the role is a known value"""
|
46
|
+
if role not in ("user", "assistant"):
|
47
|
+
raise ValueError(f"Invalid role: {role}. Must be one of: user, assistant")
|
48
|
+
return role
|
49
|
+
|
50
|
+
def apply_substitutions(self, context: Dict[str, Any]) -> "PromptContent":
|
51
|
+
"""Apply variable substitutions to the text and resources"""
|
52
|
+
|
53
|
+
# Define placeholder pattern once to avoid repetition
|
54
|
+
def make_placeholder(key: str) -> str:
|
55
|
+
return f"{{{{{key}}}}}"
|
56
|
+
|
57
|
+
# Apply substitutions to text
|
58
|
+
result = self.text
|
59
|
+
for key, value in context.items():
|
60
|
+
result = result.replace(make_placeholder(key), str(value))
|
61
|
+
|
62
|
+
# Apply substitutions to resource paths
|
63
|
+
substituted_resources = []
|
64
|
+
for resource in self.resources:
|
65
|
+
substituted = resource
|
66
|
+
for key, value in context.items():
|
67
|
+
substituted = substituted.replace(make_placeholder(key), str(value))
|
68
|
+
substituted_resources.append(substituted)
|
69
|
+
|
70
|
+
return PromptContent(
|
71
|
+
text=result, role=self.role, resources=substituted_resources
|
72
|
+
)
|
73
|
+
|
74
|
+
|
75
|
+
class PromptTemplate:
|
76
|
+
"""
|
77
|
+
A template for a prompt that can have variables substituted.
|
78
|
+
"""
|
79
|
+
|
80
|
+
def __init__(
|
81
|
+
self,
|
82
|
+
template_text: str,
|
83
|
+
delimiter_map: Optional[Dict[str, str]] = None,
|
84
|
+
template_file_path: Optional[Path] = None,
|
85
|
+
):
|
86
|
+
"""
|
87
|
+
Initialize a prompt template.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
template_text: The text of the template
|
91
|
+
delimiter_map: Optional map of delimiters to roles (e.g. {"---USER": "user"})
|
92
|
+
template_file_path: Optional path to the template file (for resource resolution)
|
93
|
+
"""
|
94
|
+
self.template_text = template_text
|
95
|
+
self.template_file_path = template_file_path
|
96
|
+
self.delimiter_map = delimiter_map or {
|
97
|
+
"---USER": "user",
|
98
|
+
"---ASSISTANT": "assistant",
|
99
|
+
"---RESOURCE": "resource",
|
100
|
+
}
|
101
|
+
self._template_variables = self._extract_template_variables(template_text)
|
102
|
+
self._parsed_content = self._parse_template()
|
103
|
+
|
104
|
+
@classmethod
|
105
|
+
def from_multipart_messages(
|
106
|
+
cls,
|
107
|
+
messages: List[PromptMessageMultipart],
|
108
|
+
delimiter_map: Optional[Dict[str, str]] = None,
|
109
|
+
) -> "PromptTemplate":
|
110
|
+
"""
|
111
|
+
Create a PromptTemplate from a list of PromptMessageMultipart objects.
|
112
|
+
|
113
|
+
Args:
|
114
|
+
messages: List of PromptMessageMultipart objects
|
115
|
+
delimiter_map: Optional map of delimiters to roles
|
116
|
+
|
117
|
+
Returns:
|
118
|
+
A new PromptTemplate object
|
119
|
+
"""
|
120
|
+
# Use default delimiter map if none provided
|
121
|
+
delimiter_map = delimiter_map or {
|
122
|
+
"---USER": "user",
|
123
|
+
"---ASSISTANT": "assistant",
|
124
|
+
"---RESOURCE": "resource",
|
125
|
+
}
|
126
|
+
|
127
|
+
# Convert to delimited format
|
128
|
+
delimited_content = multipart_messages_to_delimited_format(
|
129
|
+
messages,
|
130
|
+
user_delimiter=next(
|
131
|
+
(k for k, v in delimiter_map.items() if v == "user"), "---USER"
|
132
|
+
),
|
133
|
+
assistant_delimiter=next(
|
134
|
+
(k for k, v in delimiter_map.items() if v == "assistant"),
|
135
|
+
"---ASSISTANT",
|
136
|
+
),
|
137
|
+
)
|
138
|
+
|
139
|
+
# Join into a single string
|
140
|
+
content = "\n".join(delimited_content)
|
141
|
+
|
142
|
+
# Create and return the template
|
143
|
+
return cls(content, delimiter_map)
|
144
|
+
|
145
|
+
@property
|
146
|
+
def template_variables(self) -> Set[str]:
|
147
|
+
"""Get the template variables in this template"""
|
148
|
+
return self._template_variables
|
149
|
+
|
150
|
+
@property
|
151
|
+
def content_sections(self) -> List[PromptContent]:
|
152
|
+
"""Get the parsed content sections"""
|
153
|
+
return self._parsed_content
|
154
|
+
|
155
|
+
def apply_substitutions(self, context: Dict[str, Any]) -> List[PromptContent]:
|
156
|
+
"""
|
157
|
+
Apply variable substitutions to the template.
|
158
|
+
|
159
|
+
Args:
|
160
|
+
context: Dictionary of variable names to values
|
161
|
+
|
162
|
+
Returns:
|
163
|
+
List of PromptContent with substitutions applied
|
164
|
+
"""
|
165
|
+
result = []
|
166
|
+
for section in self._parsed_content:
|
167
|
+
result.append(section.apply_substitutions(context))
|
168
|
+
return result
|
169
|
+
|
170
|
+
def apply_substitutions_to_multipart(
|
171
|
+
self, context: Dict[str, Any]
|
172
|
+
) -> List[PromptMessageMultipart]:
|
173
|
+
"""
|
174
|
+
Apply variable substitutions to the template and return PromptMessageMultipart objects.
|
175
|
+
|
176
|
+
Args:
|
177
|
+
context: Dictionary of variable names to values
|
178
|
+
|
179
|
+
Returns:
|
180
|
+
List of PromptMessageMultipart objects with substitutions applied
|
181
|
+
"""
|
182
|
+
content_sections = self.apply_substitutions(context)
|
183
|
+
multiparts = []
|
184
|
+
|
185
|
+
for section in content_sections:
|
186
|
+
# Handle text content
|
187
|
+
content_items = [TextContent(type="text", text=section.text)]
|
188
|
+
|
189
|
+
# Handle resources (if any)
|
190
|
+
for resource_path in section.resources:
|
191
|
+
# In a real implementation, you would load the resource here
|
192
|
+
# For now, we'll just create a placeholder
|
193
|
+
content_items.append(
|
194
|
+
EmbeddedResource(
|
195
|
+
type="resource",
|
196
|
+
resource=TextResourceContents(
|
197
|
+
uri=f"resource://fast-agent/{resource_path}",
|
198
|
+
mimeType="text/plain",
|
199
|
+
text=f"Content of {resource_path}",
|
200
|
+
),
|
201
|
+
)
|
202
|
+
)
|
203
|
+
|
204
|
+
multiparts.append(
|
205
|
+
PromptMessageMultipart(role=section.role, content=content_items)
|
206
|
+
)
|
207
|
+
|
208
|
+
return multiparts
|
209
|
+
|
210
|
+
def _extract_template_variables(self, text: str) -> Set[str]:
|
211
|
+
"""Extract template variables from text using regex"""
|
212
|
+
variable_pattern = r"{{([^}]+)}}"
|
213
|
+
matches = re.findall(variable_pattern, text)
|
214
|
+
return set(matches)
|
215
|
+
|
216
|
+
def to_multipart_messages(self) -> List[PromptMessageMultipart]:
|
217
|
+
"""
|
218
|
+
Convert this template to a list of PromptMessageMultipart objects.
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
List of PromptMessageMultipart objects
|
222
|
+
"""
|
223
|
+
multiparts = []
|
224
|
+
|
225
|
+
for section in self._parsed_content:
|
226
|
+
# Convert each section to a multipart message
|
227
|
+
content_items = [TextContent(type="text", text=section.text)]
|
228
|
+
|
229
|
+
# Add any resources as embedded resources
|
230
|
+
for resource_path in section.resources:
|
231
|
+
# In a real implementation, you would determine the MIME type
|
232
|
+
# and load the resource appropriately. Here we'll just use a placeholder.
|
233
|
+
content_items.append(
|
234
|
+
EmbeddedResource(
|
235
|
+
type="resource",
|
236
|
+
resource=TextResourceContents(
|
237
|
+
uri=f"resource://{resource_path}",
|
238
|
+
mimeType="text/plain",
|
239
|
+
text=f"Content of {resource_path}",
|
240
|
+
),
|
241
|
+
)
|
242
|
+
)
|
243
|
+
|
244
|
+
multiparts.append(
|
245
|
+
PromptMessageMultipart(role=section.role, content=content_items)
|
246
|
+
)
|
247
|
+
|
248
|
+
return multiparts
|
249
|
+
|
250
|
+
def _parse_template(self) -> List[PromptContent]:
|
251
|
+
"""
|
252
|
+
Parse the template into sections based on delimiters.
|
253
|
+
If no delimiters are found, treat the entire template as a single user message.
|
254
|
+
|
255
|
+
Resources are now collected within their parent sections, keeping the same role.
|
256
|
+
"""
|
257
|
+
lines = self.template_text.split("\n")
|
258
|
+
|
259
|
+
# Check if we're in simple mode (no delimiters)
|
260
|
+
first_non_empty_line = next((line for line in lines if line.strip()), "")
|
261
|
+
delimiter_values = set(self.delimiter_map.keys())
|
262
|
+
|
263
|
+
is_simple_mode = (
|
264
|
+
first_non_empty_line and first_non_empty_line not in delimiter_values
|
265
|
+
)
|
266
|
+
|
267
|
+
if is_simple_mode:
|
268
|
+
# Simple mode: treat the entire content as a single user message
|
269
|
+
return [PromptContent(text=self.template_text, role="user", resources=[])]
|
270
|
+
|
271
|
+
# Standard mode with delimiters
|
272
|
+
sections = []
|
273
|
+
current_role = None
|
274
|
+
current_content = ""
|
275
|
+
current_resources = []
|
276
|
+
|
277
|
+
i = 0
|
278
|
+
while i < len(lines):
|
279
|
+
line = lines[i]
|
280
|
+
|
281
|
+
# Check if we hit a delimiter
|
282
|
+
if line.strip() in self.delimiter_map:
|
283
|
+
role_type = self.delimiter_map[line.strip()]
|
284
|
+
|
285
|
+
# If we're moving to a new user/assistant section (not resource)
|
286
|
+
if role_type != "resource":
|
287
|
+
# Save the previous section if it exists
|
288
|
+
if current_role is not None and current_content:
|
289
|
+
sections.append(
|
290
|
+
PromptContent(
|
291
|
+
text=current_content.strip(),
|
292
|
+
role=current_role,
|
293
|
+
resources=current_resources,
|
294
|
+
)
|
295
|
+
)
|
296
|
+
|
297
|
+
# Start a new section
|
298
|
+
current_role = role_type
|
299
|
+
current_content = ""
|
300
|
+
current_resources = []
|
301
|
+
|
302
|
+
# Handle resource delimiters within sections
|
303
|
+
elif role_type == "resource" and i + 1 < len(lines):
|
304
|
+
resource_path = lines[i + 1].strip()
|
305
|
+
current_resources.append(resource_path)
|
306
|
+
# Skip the resource path line
|
307
|
+
i += 1
|
308
|
+
|
309
|
+
# If we're in a section, add to the current content
|
310
|
+
elif current_role is not None:
|
311
|
+
current_content += line + "\n"
|
312
|
+
|
313
|
+
i += 1
|
314
|
+
|
315
|
+
# Add the last section if there is one
|
316
|
+
if current_role is not None and current_content:
|
317
|
+
sections.append(
|
318
|
+
PromptContent(
|
319
|
+
text=current_content.strip(),
|
320
|
+
role=current_role,
|
321
|
+
resources=current_resources,
|
322
|
+
)
|
323
|
+
)
|
324
|
+
|
325
|
+
return sections
|
326
|
+
|
327
|
+
|
328
|
+
class PromptTemplateLoader:
|
329
|
+
"""
|
330
|
+
Loads and processes prompt templates from files.
|
331
|
+
"""
|
332
|
+
|
333
|
+
def __init__(self, delimiter_map: Optional[Dict[str, str]] = None):
|
334
|
+
"""
|
335
|
+
Initialize the loader with optional custom delimiters.
|
336
|
+
|
337
|
+
Args:
|
338
|
+
delimiter_map: Optional map of delimiters to roles
|
339
|
+
"""
|
340
|
+
self.delimiter_map = delimiter_map or {
|
341
|
+
"---USER": "user",
|
342
|
+
"---ASSISTANT": "assistant",
|
343
|
+
"---RESOURCE": "resource",
|
344
|
+
}
|
345
|
+
|
346
|
+
def load_from_file(self, file_path: Path) -> PromptTemplate:
|
347
|
+
"""
|
348
|
+
Load a prompt template from a file.
|
349
|
+
|
350
|
+
Args:
|
351
|
+
file_path: Path to the template file
|
352
|
+
|
353
|
+
Returns:
|
354
|
+
A PromptTemplate object
|
355
|
+
"""
|
356
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
357
|
+
content = f.read()
|
358
|
+
|
359
|
+
return PromptTemplate(content, self.delimiter_map, template_file_path=file_path)
|
360
|
+
|
361
|
+
def load_from_multipart(
|
362
|
+
self, messages: List[PromptMessageMultipart]
|
363
|
+
) -> PromptTemplate:
|
364
|
+
"""
|
365
|
+
Create a PromptTemplate from a list of PromptMessageMultipart objects.
|
366
|
+
|
367
|
+
Args:
|
368
|
+
messages: List of PromptMessageMultipart objects
|
369
|
+
|
370
|
+
Returns:
|
371
|
+
A PromptTemplate object
|
372
|
+
"""
|
373
|
+
delimited_content = multipart_messages_to_delimited_format(
|
374
|
+
messages,
|
375
|
+
user_delimiter=next(
|
376
|
+
(k for k, v in self.delimiter_map.items() if v == "user"), "---USER"
|
377
|
+
),
|
378
|
+
assistant_delimiter=next(
|
379
|
+
(k for k, v in self.delimiter_map.items() if v == "assistant"),
|
380
|
+
"---ASSISTANT",
|
381
|
+
),
|
382
|
+
)
|
383
|
+
|
384
|
+
content = "\n".join(delimited_content)
|
385
|
+
return PromptTemplate(content, self.delimiter_map)
|
386
|
+
|
387
|
+
def get_metadata(self, file_path: Path) -> PromptMetadata:
|
388
|
+
"""
|
389
|
+
Analyze a prompt file to extract metadata and template variables.
|
390
|
+
|
391
|
+
Args:
|
392
|
+
file_path: Path to the template file
|
393
|
+
|
394
|
+
Returns:
|
395
|
+
PromptMetadata with information about the template
|
396
|
+
"""
|
397
|
+
template = self.load_from_file(file_path)
|
398
|
+
|
399
|
+
# Generate a description based on content
|
400
|
+
lines = template.template_text.split("\n")
|
401
|
+
first_non_empty_line = next((line for line in lines if line.strip()), "")
|
402
|
+
|
403
|
+
# Check if we're in simple mode
|
404
|
+
is_simple_mode = (
|
405
|
+
first_non_empty_line and first_non_empty_line not in self.delimiter_map
|
406
|
+
)
|
407
|
+
|
408
|
+
if is_simple_mode:
|
409
|
+
# In simple mode, use first line as description if it seems like one
|
410
|
+
first_line = lines[0].strip() if lines else ""
|
411
|
+
if (
|
412
|
+
len(first_line) < 60
|
413
|
+
and "{{" not in first_line
|
414
|
+
and "}}" not in first_line
|
415
|
+
):
|
416
|
+
description = first_line
|
417
|
+
else:
|
418
|
+
description = f"Simple prompt: {file_path.stem}"
|
419
|
+
else:
|
420
|
+
# Regular mode - find text after first delimiter for the description
|
421
|
+
description = file_path.stem
|
422
|
+
|
423
|
+
# Look for first delimiter and role
|
424
|
+
first_role = None
|
425
|
+
first_content_index = None
|
426
|
+
|
427
|
+
for i, line in enumerate(lines):
|
428
|
+
stripped = line.strip()
|
429
|
+
if stripped in self.delimiter_map:
|
430
|
+
first_role = self.delimiter_map[stripped]
|
431
|
+
first_content_index = i + 1
|
432
|
+
break
|
433
|
+
|
434
|
+
if first_role and first_content_index and first_content_index < len(lines):
|
435
|
+
# Get up to 3 non-empty lines after the delimiter for a preview
|
436
|
+
preview_lines = []
|
437
|
+
for j in range(
|
438
|
+
first_content_index, min(first_content_index + 10, len(lines))
|
439
|
+
):
|
440
|
+
stripped = lines[j].strip()
|
441
|
+
if stripped and stripped not in self.delimiter_map:
|
442
|
+
preview_lines.append(stripped)
|
443
|
+
if len(preview_lines) >= 3:
|
444
|
+
break
|
445
|
+
|
446
|
+
if preview_lines:
|
447
|
+
preview = " ".join(preview_lines)
|
448
|
+
if len(preview) > 50:
|
449
|
+
preview = preview[:47] + "..."
|
450
|
+
# Include role in the description but not the filename
|
451
|
+
description = f"[{first_role.upper()}] {preview}"
|
452
|
+
|
453
|
+
# Extract resource paths from all sections that come after RESOURCE delimiters
|
454
|
+
resource_paths = []
|
455
|
+
resource_delimiter = next(
|
456
|
+
(k for k, v in self.delimiter_map.items() if v == "resource"), "---RESOURCE"
|
457
|
+
)
|
458
|
+
for i, line in enumerate(lines):
|
459
|
+
if line.strip() == resource_delimiter:
|
460
|
+
if i + 1 < len(lines) and lines[i + 1].strip():
|
461
|
+
resource_paths.append(lines[i + 1].strip())
|
462
|
+
|
463
|
+
return PromptMetadata(
|
464
|
+
name=file_path.stem,
|
465
|
+
description=description,
|
466
|
+
template_variables=template.template_variables,
|
467
|
+
resource_paths=resource_paths,
|
468
|
+
file_path=file_path,
|
469
|
+
)
|
@@ -0,0 +1,203 @@
|
|
1
|
+
import base64
|
2
|
+
from pathlib import Path
|
3
|
+
from typing import List, Optional, Tuple
|
4
|
+
from mcp.types import (
|
5
|
+
EmbeddedResource,
|
6
|
+
TextResourceContents,
|
7
|
+
BlobResourceContents,
|
8
|
+
ImageContent,
|
9
|
+
)
|
10
|
+
from pydantic import AnyUrl
|
11
|
+
import mcp_agent.mcp.mime_utils as mime_utils
|
12
|
+
|
13
|
+
HTTP_TIMEOUT = 10 # Default timeout for HTTP requests
|
14
|
+
|
15
|
+
# Define a type alias for resource content results
|
16
|
+
ResourceContent = Tuple[str, str, bool]
|
17
|
+
|
18
|
+
|
19
|
+
def find_resource_file(resource_path: str, prompt_files: List[Path]) -> Optional[Path]:
|
20
|
+
"""Find a resource file relative to one of the prompt files"""
|
21
|
+
for prompt_file in prompt_files:
|
22
|
+
potential_path = prompt_file.parent / resource_path
|
23
|
+
if potential_path.exists():
|
24
|
+
return potential_path
|
25
|
+
return None
|
26
|
+
|
27
|
+
|
28
|
+
# TODO -- decide how to deal with this. Both Anthropic and OpenAI allow sending URLs in
|
29
|
+
# input message
|
30
|
+
# TODO -- used?
|
31
|
+
# async def fetch_remote_resource(
|
32
|
+
# url: str, timeout: int = HTTP_TIMEOUT
|
33
|
+
# ) -> ResourceContent:
|
34
|
+
# """
|
35
|
+
# Fetch a remote resource from a URL
|
36
|
+
|
37
|
+
# Returns:
|
38
|
+
# Tuple of (content, mime_type, is_binary)
|
39
|
+
# - content: Text content or base64-encoded binary content
|
40
|
+
# - mime_type: The MIME type of the resource
|
41
|
+
# - is_binary: Whether the content is binary (and base64-encoded)
|
42
|
+
# """
|
43
|
+
|
44
|
+
# async with httpx.AsyncClient(timeout=timeout) as client:
|
45
|
+
# response = await client.get(url)
|
46
|
+
# response.raise_for_status()
|
47
|
+
|
48
|
+
# # Get the content type or guess from URL
|
49
|
+
# mime_type = response.headers.get("content-type", "").split(";")[0]
|
50
|
+
# if not mime_type:
|
51
|
+
# mime_type = mime_utils.guess_mime_type(url)
|
52
|
+
|
53
|
+
# # Check if this is binary content
|
54
|
+
# is_binary = mime_utils.is_binary_content(mime_type)
|
55
|
+
|
56
|
+
# if is_binary:
|
57
|
+
# # For binary responses, get the binary content and base64 encode it
|
58
|
+
# content = base64.b64encode(response.content).decode("utf-8")
|
59
|
+
# else:
|
60
|
+
# # For text responses, just get the text
|
61
|
+
# content = response.text
|
62
|
+
|
63
|
+
# return content, mime_type, is_binary
|
64
|
+
|
65
|
+
|
66
|
+
def load_resource_content(
|
67
|
+
resource_path: str, prompt_files: List[Path]
|
68
|
+
) -> ResourceContent:
|
69
|
+
"""
|
70
|
+
Load a resource's content and determine its mime type
|
71
|
+
|
72
|
+
Args:
|
73
|
+
resource_path: Path to the resource file
|
74
|
+
prompt_files: List of prompt files (to find relative paths)
|
75
|
+
|
76
|
+
Returns:
|
77
|
+
Tuple of (content, mime_type, is_binary)
|
78
|
+
- content: String content for text files, base64-encoded string for binary files
|
79
|
+
- mime_type: The MIME type of the resource
|
80
|
+
- is_binary: Whether the content is binary (and base64-encoded)
|
81
|
+
|
82
|
+
Raises:
|
83
|
+
FileNotFoundError: If the resource cannot be found
|
84
|
+
"""
|
85
|
+
# Try to locate the resource file
|
86
|
+
resource_file = find_resource_file(resource_path, prompt_files)
|
87
|
+
if resource_file is None:
|
88
|
+
raise FileNotFoundError(f"Resource not found: {resource_path}")
|
89
|
+
|
90
|
+
# Determine mime type
|
91
|
+
mime_type = mime_utils.guess_mime_type(str(resource_file))
|
92
|
+
is_binary = mime_utils.is_binary_content(mime_type)
|
93
|
+
|
94
|
+
if is_binary:
|
95
|
+
# For binary files, read as binary and base64 encode
|
96
|
+
with open(resource_file, "rb") as f:
|
97
|
+
content = base64.b64encode(f.read()).decode("utf-8")
|
98
|
+
else:
|
99
|
+
# For text files, read as text
|
100
|
+
with open(resource_file, "r", encoding="utf-8") as f:
|
101
|
+
content = f.read()
|
102
|
+
|
103
|
+
return content, mime_type, is_binary
|
104
|
+
|
105
|
+
|
106
|
+
# Create a safe way to generate resource URIs that Pydantic accepts
|
107
|
+
def create_resource_uri(path: str) -> str:
|
108
|
+
"""Create a resource URI from a path"""
|
109
|
+
return f"resource://fast-agent/{Path(path).name}"
|
110
|
+
|
111
|
+
|
112
|
+
def create_embedded_resource(
|
113
|
+
resource_path: str, content: str, mime_type: str, is_binary: bool = False
|
114
|
+
) -> EmbeddedResource:
|
115
|
+
"""Create an embedded resource content object"""
|
116
|
+
# Format a valid resource URI string
|
117
|
+
resource_uri_str = create_resource_uri(resource_path)
|
118
|
+
|
119
|
+
# Create common resource args dict to reduce duplication
|
120
|
+
resource_args = {
|
121
|
+
"uri": resource_uri_str, # type: ignore
|
122
|
+
"mimeType": mime_type,
|
123
|
+
}
|
124
|
+
|
125
|
+
if is_binary:
|
126
|
+
return EmbeddedResource(
|
127
|
+
type="resource",
|
128
|
+
resource=BlobResourceContents(
|
129
|
+
**resource_args,
|
130
|
+
blob=content,
|
131
|
+
),
|
132
|
+
)
|
133
|
+
else:
|
134
|
+
return EmbeddedResource(
|
135
|
+
type="resource",
|
136
|
+
resource=TextResourceContents(
|
137
|
+
**resource_args,
|
138
|
+
text=content,
|
139
|
+
),
|
140
|
+
)
|
141
|
+
|
142
|
+
|
143
|
+
def create_image_content(data: str, mime_type: str) -> ImageContent:
|
144
|
+
"""Create an image content object from base64-encoded data"""
|
145
|
+
return ImageContent(
|
146
|
+
type="image",
|
147
|
+
data=data,
|
148
|
+
mimeType=mime_type,
|
149
|
+
)
|
150
|
+
|
151
|
+
|
152
|
+
def normalize_uri(uri_or_filename: str) -> str:
|
153
|
+
"""
|
154
|
+
Normalize a URI or filename to ensure it's a valid URI.
|
155
|
+
Converts simple filenames to file:// URIs if needed.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
uri_or_filename: A URI string or simple filename
|
159
|
+
|
160
|
+
Returns:
|
161
|
+
A properly formatted URI string
|
162
|
+
"""
|
163
|
+
if not uri_or_filename:
|
164
|
+
return ""
|
165
|
+
|
166
|
+
# Check if it's already a valid URI with a scheme
|
167
|
+
if "://" in uri_or_filename:
|
168
|
+
return uri_or_filename
|
169
|
+
|
170
|
+
# Handle Windows-style paths with backslashes
|
171
|
+
normalized_path = uri_or_filename.replace("\\", "/")
|
172
|
+
|
173
|
+
# If it's a simple filename or relative path, convert to file:// URI
|
174
|
+
# Make sure it has three slashes for an absolute path
|
175
|
+
if normalized_path.startswith("/"):
|
176
|
+
return f"file://{normalized_path}"
|
177
|
+
else:
|
178
|
+
return f"file:///{normalized_path}"
|
179
|
+
|
180
|
+
|
181
|
+
def extract_title_from_uri(uri: AnyUrl) -> str:
|
182
|
+
"""Extract a readable title from a URI."""
|
183
|
+
# Simple attempt to get filename from path
|
184
|
+
uri_str = uri._url
|
185
|
+
try:
|
186
|
+
# For HTTP(S) URLs
|
187
|
+
if uri.scheme in ("http", "https"):
|
188
|
+
# Get the last part of the path
|
189
|
+
path_parts = uri.path.split("/")
|
190
|
+
filename = next((p for p in reversed(path_parts) if p), "")
|
191
|
+
return filename if filename else uri_str
|
192
|
+
|
193
|
+
# For file URLs or other schemes
|
194
|
+
elif uri.path:
|
195
|
+
import os.path
|
196
|
+
|
197
|
+
return os.path.basename(uri.path)
|
198
|
+
|
199
|
+
except Exception:
|
200
|
+
pass
|
201
|
+
|
202
|
+
# Fallback to the full URI if parsing fails
|
203
|
+
return uri_str
|
@@ -44,9 +44,9 @@ mcp:
|
|
44
44
|
# args: ["c:/Program Files/nodejs/node_modules/@modelcontextprotocol/server-brave-search/dist/index.js"]
|
45
45
|
command: "npx"
|
46
46
|
args: ["-y", "@modelcontextprotocol/server-brave-search"]
|
47
|
-
|
47
|
+
sizing_setup:
|
48
48
|
command: "uv"
|
49
|
-
args: ["run", "
|
49
|
+
args: ["run", "prompt_sizing1.py"]
|
50
50
|
category:
|
51
51
|
command: "uv"
|
52
52
|
args: ["run", "prompt_category.py"]
|