fast-agent-mcp 0.3.14__py3-none-any.whl → 0.3.15__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.

Potentially problematic release.


This version of fast-agent-mcp might be problematic. Click here for more details.

@@ -225,7 +225,7 @@ def _format_capability_shorthand(
225
225
  elif instructions_enabled is False:
226
226
  entries.append(("In", "red", False))
227
227
  elif instructions_enabled is None and not template_expected:
228
- entries.append(("In", "blue", False))
228
+ entries.append(("In", "warn", False))
229
229
  elif instructions_enabled is None:
230
230
  entries.append(("In", True, False))
231
231
  elif template_expected:
@@ -793,7 +793,7 @@ async def render_mcp_status(agent, indent: str = "") -> None:
793
793
  if instr_available and status.instructions_enabled is False:
794
794
  state_segments.append(Text("instructions disabled", style=Colours.TEXT_ERROR))
795
795
  elif instr_available and not template_expected:
796
- state_segments.append(Text("template missing", style=Colours.TEXT_WARNING))
796
+ state_segments.append(Text("instr. not in sysprompt", style=Colours.TEXT_WARNING))
797
797
 
798
798
  if status.spoofing_enabled:
799
799
  state_segments.append(Text("client spoof", style=Colours.TEXT_WARNING))
@@ -0,0 +1,68 @@
1
+ """High performance truncation for plain text streaming displays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ class PlainTextTruncator:
9
+ """Trim plain text content to fit within a target terminal window."""
10
+
11
+ def __init__(self, target_height_ratio: float = 0.7) -> None:
12
+ if not 0 < target_height_ratio <= 1:
13
+ raise ValueError("target_height_ratio must be between 0 and 1")
14
+ self.target_height_ratio = target_height_ratio
15
+
16
+ def truncate(self, text: str, *, terminal_height: int, terminal_width: int) -> str:
17
+ """Return the most recent portion of text that fits the terminal window.
18
+
19
+ Args:
20
+ text: Full text buffer accumulated during streaming.
21
+ terminal_height: Terminal height in rows.
22
+ terminal_width: Terminal width in columns.
23
+
24
+ Returns:
25
+ Tail portion of the text that fits within the target height ratio.
26
+ """
27
+ if not text:
28
+ return text
29
+
30
+ if terminal_height <= 0 or terminal_width <= 0:
31
+ return text
32
+
33
+ target_rows = max(1, int(terminal_height * self.target_height_ratio))
34
+ width = max(1, terminal_width)
35
+
36
+ idx = len(text)
37
+ rows_used = 0
38
+ start_idx = 0
39
+
40
+ while idx > 0 and rows_used < target_rows:
41
+ prev_newline = text.rfind("\n", 0, idx)
42
+ line_start = prev_newline + 1 if prev_newline != -1 else 0
43
+ line = text[line_start:idx]
44
+ expanded = line.expandtabs()
45
+ line_len = len(expanded)
46
+ line_rows = max(1, math.ceil(line_len / width)) if line_len else 1
47
+
48
+ if rows_used + line_rows >= target_rows:
49
+ rows_remaining = target_rows - rows_used
50
+ if rows_remaining <= 0:
51
+ start_idx = idx
52
+ break
53
+
54
+ if line_rows <= rows_remaining:
55
+ start_idx = line_start
56
+ else:
57
+ approx_chars = width * rows_remaining
58
+ keep_chars = min(len(line), approx_chars)
59
+ start_idx = idx - keep_chars
60
+ break
61
+
62
+ rows_used += line_rows
63
+ start_idx = line_start
64
+ if prev_newline == -1:
65
+ break
66
+ idx = prev_newline
67
+
68
+ return text[start_idx:]
@@ -0,0 +1,449 @@
1
+ """Streaming buffer for markdown content with intelligent truncation.
2
+
3
+ This module provides a simple, robust streaming buffer that:
4
+ 1. Accumulates streaming chunks from LLM responses
5
+ 2. Truncates to fit terminal height (keeps most recent content)
6
+ 3. Preserves markdown context when truncating:
7
+ - Code blocks: retains opening ```language fence
8
+ - Tables: retains header + separator rows
9
+ - Code blocks: adds closing ``` if unclosed
10
+
11
+ Design Philosophy
12
+ =================
13
+ KISS (Keep It Simple, Stupid):
14
+ - No binary search (streaming is linear)
15
+ - No dual modes (streaming always keeps recent content)
16
+ - Parse once per truncation (not per chunk)
17
+ - Position-based tracking (clear, testable)
18
+ """
19
+
20
+ from dataclasses import dataclass
21
+ from math import ceil
22
+ from typing import List, Optional
23
+
24
+ from markdown_it import MarkdownIt
25
+ from markdown_it.token import Token
26
+
27
+
28
+ @dataclass
29
+ class CodeBlock:
30
+ """Position and metadata for a code block."""
31
+
32
+ start_pos: int # Character position where block starts
33
+ end_pos: int # Character position where block ends
34
+ language: str # Language identifier (e.g., "python")
35
+
36
+
37
+ @dataclass
38
+ class Table:
39
+ """Position and metadata for a table."""
40
+
41
+ start_pos: int # Character position where table starts
42
+ end_pos: int # Character position where table ends
43
+ header_lines: List[str] # Header row + separator (e.g., ["| A | B |", "|---|---|"])
44
+
45
+
46
+ class StreamBuffer:
47
+ """Buffer for streaming markdown content with smart truncation.
48
+
49
+ Usage:
50
+ buffer = StreamBuffer()
51
+ for chunk in stream:
52
+ buffer.append(chunk)
53
+ display_text = buffer.get_display_text(terminal_height)
54
+ render(display_text)
55
+ """
56
+
57
+ def __init__(self):
58
+ """Initialize the stream buffer."""
59
+ self._chunks: List[str] = []
60
+ self._parser = MarkdownIt().enable("table")
61
+
62
+ def append(self, chunk: str) -> None:
63
+ """Add a chunk to the buffer.
64
+
65
+ Args:
66
+ chunk: Text chunk from streaming response
67
+ """
68
+ if chunk:
69
+ self._chunks.append(chunk)
70
+
71
+ def get_full_text(self) -> str:
72
+ """Get the complete buffered text.
73
+
74
+ Returns:
75
+ Full concatenated text from all chunks
76
+ """
77
+ return "".join(self._chunks)
78
+
79
+ def get_display_text(
80
+ self,
81
+ terminal_height: int,
82
+ target_ratio: float = 0.7,
83
+ terminal_width: Optional[int] = None,
84
+ ) -> str:
85
+ """Get text for display, truncated to fit terminal.
86
+
87
+ This applies intelligent truncation when content exceeds terminal height:
88
+ 1. Keeps most recent content (last N lines)
89
+ 2. Preserves code block fences if truncated mid-block
90
+ 3. Preserves table headers if truncated in table data
91
+ 4. Adds closing fence if code block is unclosed
92
+
93
+ Args:
94
+ terminal_height: Height of terminal in lines
95
+ target_ratio: Keep this multiple of terminal height (default 1.5)
96
+ terminal_width: Optional terminal width for estimating wrapped lines
97
+
98
+ Returns:
99
+ Text ready for display (truncated if needed)
100
+ """
101
+ full_text = self.get_full_text()
102
+ if not full_text:
103
+ return full_text
104
+ return self._truncate_for_display(
105
+ full_text, terminal_height, target_ratio, terminal_width
106
+ )
107
+
108
+ def clear(self) -> None:
109
+ """Clear the buffer."""
110
+ self._chunks.clear()
111
+
112
+ def _truncate_for_display(
113
+ self,
114
+ text: str,
115
+ terminal_height: int,
116
+ target_ratio: float,
117
+ terminal_width: Optional[int],
118
+ ) -> str:
119
+ """Truncate text to fit display with context preservation.
120
+
121
+ Algorithm:
122
+ 1. If text fits, return as-is
123
+ 2. Otherwise, keep last N lines (where N = terminal_height * target_ratio)
124
+ 3. Parse markdown to find code blocks and tables
125
+ 4. If we truncated mid-code-block, prepend opening fence
126
+ 5. If we truncated mid-table-data, prepend table header
127
+ 6. If code block is unclosed, append closing fence
128
+
129
+ Args:
130
+ text: Full markdown text
131
+ terminal_height: Terminal height in lines
132
+ target_ratio: Multiplier for target line count
133
+
134
+ Returns:
135
+ Truncated text with preserved context
136
+ """
137
+ lines = text.split("\n")
138
+
139
+ if target_ratio <= 1:
140
+ extra_lines = 0
141
+ else:
142
+ extra_lines = int(ceil(terminal_height * (target_ratio - 1)))
143
+ raw_target_lines = terminal_height + extra_lines
144
+
145
+ # Estimate how many rendered lines the text will occupy
146
+ if terminal_width and terminal_width > 0:
147
+ # Treat each logical line as taking at least one row, expanding based on width
148
+ display_counts = self._estimate_display_counts(lines, terminal_width)
149
+ total_display_lines = sum(display_counts)
150
+ else:
151
+ display_counts = None
152
+ total_display_lines = len(lines)
153
+
154
+ # Fast path: no truncation needed if content still fits the viewport
155
+ if total_display_lines <= terminal_height:
156
+ # Still need to check for unclosed code blocks
157
+ return self._add_closing_fence_if_needed(text)
158
+
159
+ # Determine how many display lines we want to keep after truncation
160
+ desired_display_lines = min(total_display_lines, raw_target_lines)
161
+ if desired_display_lines > terminal_height:
162
+ window_lines = max(1, terminal_height // 5) # keep ~20% headroom
163
+ desired_display_lines = max(terminal_height, desired_display_lines - window_lines)
164
+ else:
165
+ desired_display_lines = terminal_height
166
+
167
+ # Determine how many logical lines we can keep based on estimated display rows
168
+ if display_counts:
169
+ running_total = 0
170
+ start_index = len(lines) - 1
171
+ for idx in range(len(lines) - 1, -1, -1):
172
+ running_total += display_counts[idx]
173
+ start_index = idx
174
+ if running_total >= desired_display_lines:
175
+ break
176
+ else:
177
+ start_index = max(len(lines) - desired_display_lines, 0)
178
+
179
+ # Compute character position where truncation occurs
180
+ truncation_pos = sum(len(line) + 1 for line in lines[:start_index])
181
+ truncated_text = text[truncation_pos:]
182
+
183
+ if terminal_width and terminal_width > 0:
184
+ truncated_text, truncation_pos = self._trim_within_line_if_needed(
185
+ text=text,
186
+ truncated_text=truncated_text,
187
+ truncation_pos=truncation_pos,
188
+ terminal_width=terminal_width,
189
+ max_display_lines=desired_display_lines,
190
+ )
191
+
192
+ # Parse markdown structures once
193
+ code_blocks = self._find_code_blocks(text)
194
+ tables = self._find_tables(text)
195
+
196
+ # Preserve code block context if needed
197
+ truncated_text = self._preserve_code_block_context(
198
+ text, truncated_text, truncation_pos, code_blocks
199
+ )
200
+
201
+ # Preserve table context if needed
202
+ truncated_text = self._preserve_table_context(
203
+ text, truncated_text, truncation_pos, tables
204
+ )
205
+
206
+ # Add closing fence if code block is unclosed
207
+ truncated_text = self._add_closing_fence_if_needed(truncated_text)
208
+
209
+ return truncated_text
210
+
211
+ def _find_code_blocks(self, text: str) -> List[CodeBlock]:
212
+ """Find all code blocks in text using markdown-it parser.
213
+
214
+ Args:
215
+ text: Markdown text to analyze
216
+
217
+ Returns:
218
+ List of CodeBlock objects with position information
219
+ """
220
+ tokens = self._parser.parse(text)
221
+ lines = text.split("\n")
222
+ blocks = []
223
+
224
+ for token in self._flatten_tokens(tokens):
225
+ if token.type in ("fence", "code_block") and token.map:
226
+ start_line = token.map[0]
227
+ end_line = token.map[1]
228
+ start_pos = sum(len(line) + 1 for line in lines[:start_line])
229
+ end_pos = sum(len(line) + 1 for line in lines[:end_line])
230
+ language = getattr(token, "info", "") or ""
231
+
232
+ blocks.append(
233
+ CodeBlock(start_pos=start_pos, end_pos=end_pos, language=language)
234
+ )
235
+
236
+ return blocks
237
+
238
+ def _find_tables(self, text: str) -> List[Table]:
239
+ """Find all tables in text using markdown-it parser.
240
+
241
+ Args:
242
+ text: Markdown text to analyze
243
+
244
+ Returns:
245
+ List of Table objects with position and header information
246
+ """
247
+ tokens = self._parser.parse(text)
248
+ lines = text.split("\n")
249
+ tables = []
250
+
251
+ for i, token in enumerate(tokens):
252
+ if token.type == "table_open" and token.map:
253
+ # Find tbody within this table to extract header
254
+ tbody_start_line = None
255
+
256
+ # Look ahead for tbody
257
+ for j in range(i + 1, len(tokens)):
258
+ if tokens[j].type == "tbody_open" and tokens[j].map:
259
+ tbody_start_line = tokens[j].map[0]
260
+ break
261
+ elif tokens[j].type == "table_close":
262
+ break
263
+
264
+ if tbody_start_line is not None:
265
+ table_start_line = token.map[0]
266
+ table_end_line = token.map[1]
267
+
268
+ # Calculate positions
269
+ start_pos = sum(len(line) + 1 for line in lines[:table_start_line])
270
+ end_pos = sum(len(line) + 1 for line in lines[:table_end_line])
271
+
272
+ # Header lines = everything before tbody (header row + separator)
273
+ header_lines = lines[table_start_line:tbody_start_line]
274
+
275
+ tables.append(
276
+ Table(start_pos=start_pos, end_pos=end_pos, header_lines=header_lines)
277
+ )
278
+
279
+ return tables
280
+
281
+ def _preserve_code_block_context(
282
+ self, original_text: str, truncated_text: str, truncation_pos: int, code_blocks: List[CodeBlock]
283
+ ) -> str:
284
+ """Prepend code block opening fence if truncation removed it.
285
+
286
+ When we truncate mid-code-block, we need to preserve the opening fence
287
+ so the remaining code still renders with syntax highlighting.
288
+
289
+ Args:
290
+ original_text: Full original text
291
+ truncated_text: Text after truncation
292
+ truncation_pos: Character position where truncation happened
293
+ code_blocks: List of code blocks in original text
294
+
295
+ Returns:
296
+ Truncated text with fence prepended if needed
297
+ """
298
+ for block in code_blocks:
299
+ # Check if we truncated within this code block
300
+ if block.start_pos < truncation_pos < block.end_pos:
301
+ # We're inside this block - did we remove the opening fence?
302
+ if truncation_pos > block.start_pos:
303
+ fence = f"```{block.language}\n"
304
+ # Avoid duplicates
305
+ if not truncated_text.startswith(fence):
306
+ return fence + truncated_text
307
+ # Found the relevant block, no need to check others
308
+ break
309
+
310
+ return truncated_text
311
+
312
+ def _preserve_table_context(
313
+ self, original_text: str, truncated_text: str, truncation_pos: int, tables: List[Table]
314
+ ) -> str:
315
+ """Prepend table header if truncation removed it.
316
+
317
+ When we truncate table data rows, we need to preserve the header
318
+ (header row + separator) so the remaining rows have context.
319
+
320
+ Design Point #4: Keep the 3 lines marking beginning of table:
321
+ - Newline before table (if present)
322
+ - Header row (e.g., "| Name | Size |")
323
+ - Separator (e.g., "|------|------|")
324
+
325
+ Args:
326
+ original_text: Full original text
327
+ truncated_text: Text after truncation
328
+ truncation_pos: Character position where truncation happened
329
+ tables: List of tables in original text
330
+
331
+ Returns:
332
+ Truncated text with header prepended if needed
333
+ """
334
+ for table in tables:
335
+ # Check if we truncated within this table
336
+ if table.start_pos < truncation_pos < table.end_pos:
337
+ # Check if we removed the header (header is at start of table)
338
+ # If truncation happened after the header, we need to restore it
339
+ lines = original_text.split("\n")
340
+ table_start_line = sum(
341
+ 1 for line in original_text[:table.start_pos].split("\n")
342
+ ) - 1
343
+
344
+ # Find where the data rows start (after separator)
345
+ # Header lines include header row + separator
346
+ data_start_line = table_start_line + len(table.header_lines)
347
+ data_start_pos = sum(len(line) + 1 for line in lines[:data_start_line])
348
+
349
+ # If we truncated in the data section, restore header
350
+ if truncation_pos >= data_start_pos:
351
+ header_text = "\n".join(table.header_lines) + "\n"
352
+ return header_text + truncated_text
353
+
354
+ # Found the relevant table, no need to check others
355
+ break
356
+
357
+ return truncated_text
358
+
359
+ def _add_closing_fence_if_needed(self, text: str) -> str:
360
+ """Add closing ``` fence if code block is unclosed.
361
+
362
+ Design Point #5: Add closing fence to bottom if we detect unclosed block.
363
+ This ensures partial code blocks render correctly during streaming.
364
+
365
+ Args:
366
+ text: Markdown text to check
367
+
368
+ Returns:
369
+ Text with closing fence added if needed
370
+ """
371
+ # Count opening vs closing fences
372
+ import re
373
+
374
+ opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
375
+ closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
376
+
377
+ # If odd number of fences, we have an unclosed block
378
+ if opening_fences > closing_fences:
379
+ # Check if text already ends with a closing fence
380
+ if not re.search(r"```\s*$", text):
381
+ return text + "\n```\n"
382
+
383
+ return text
384
+
385
+ def _flatten_tokens(self, tokens: List[Token]) -> List[Token]:
386
+ """Flatten nested token tree.
387
+
388
+ Args:
389
+ tokens: List of tokens from markdown-it
390
+
391
+ Yields:
392
+ Flattened tokens
393
+ """
394
+ for token in tokens:
395
+ is_fence = token.type == "fence"
396
+ is_image = token.tag == "img"
397
+ if token.children and not (is_image or is_fence):
398
+ yield from self._flatten_tokens(token.children)
399
+ else:
400
+ yield token
401
+
402
+ def _estimate_display_counts(self, lines: List[str], terminal_width: int) -> List[int]:
403
+ """Estimate how many terminal rows each logical line will occupy."""
404
+ return [
405
+ max(1, ceil(len(line) / terminal_width)) if line else 1
406
+ for line in lines
407
+ ]
408
+
409
+ def _estimate_display_lines(self, text: str, terminal_width: int) -> int:
410
+ """Estimate how many terminal rows the given text will occupy."""
411
+ if not text:
412
+ return 0
413
+ lines = text.split("\n")
414
+ return sum(self._estimate_display_counts(lines, terminal_width))
415
+
416
+ def _trim_within_line_if_needed(
417
+ self,
418
+ text: str,
419
+ truncated_text: str,
420
+ truncation_pos: int,
421
+ terminal_width: int,
422
+ max_display_lines: int,
423
+ ) -> tuple[str, int]:
424
+ """Trim additional characters when a single line exceeds the viewport."""
425
+ current_pos = truncation_pos
426
+ current_text = truncated_text
427
+ estimated_lines = self._estimate_display_lines(current_text, terminal_width)
428
+
429
+ while estimated_lines > max_display_lines and current_pos < len(text):
430
+ excess_display = estimated_lines - max_display_lines
431
+ chars_to_trim = excess_display * terminal_width
432
+ if chars_to_trim <= 0:
433
+ break
434
+
435
+ candidate_pos = min(len(text), current_pos + chars_to_trim)
436
+
437
+ # Prefer trimming at the next newline to keep markdown structures intact
438
+ newline_pos = text.find("\n", current_pos, candidate_pos)
439
+ if newline_pos != -1:
440
+ candidate_pos = newline_pos + 1
441
+
442
+ if candidate_pos <= current_pos:
443
+ break
444
+
445
+ current_pos = candidate_pos
446
+ current_text = text[current_pos:]
447
+ estimated_lines = self._estimate_display_lines(current_text, terminal_width)
448
+
449
+ return current_text, current_pos
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fast-agent-mcp
3
- Version: 0.3.14
3
+ Version: 0.3.15
4
4
  Summary: Define, Prompt and Test MCP enabled Agents and Workflows
5
5
  Author-email: Shaun Smith <fastagent@llmindset.co.uk>
6
6
  License: Apache License
@@ -219,8 +219,8 @@ Requires-Dist: email-validator>=2.2.0
219
219
  Requires-Dist: fastapi>=0.115.6
220
220
  Requires-Dist: google-genai>=1.33.0
221
221
  Requires-Dist: keyring>=24.3.1
222
- Requires-Dist: mcp==1.17.0
223
- Requires-Dist: openai>=2.1.0
222
+ Requires-Dist: mcp==1.18.0
223
+ Requires-Dist: openai>=2.3.0
224
224
  Requires-Dist: opentelemetry-distro>=0.55b0
225
225
  Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.7.0
226
226
  Requires-Dist: opentelemetry-instrumentation-anthropic>=0.43.1; python_version >= '3.10' and python_version < '4.0'
@@ -234,6 +234,7 @@ Requires-Dist: pyperclip>=1.9.0
234
234
  Requires-Dist: pyyaml>=6.0.2
235
235
  Requires-Dist: rich>=14.1.0
236
236
  Requires-Dist: tensorzero>=2025.7.5
237
+ Requires-Dist: textual>=6.2.1
237
238
  Requires-Dist: typer>=0.15.1
238
239
  Description-Content-Type: text/markdown
239
240