fiftyone-mcp-server 0.1.0__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.
@@ -0,0 +1,575 @@
1
+ """
2
+ Operator execution tools for FiftyOne MCP server.
3
+
4
+ | Copyright 2017-2025, Voxel51, Inc.
5
+ | `voxel51.com <https://voxel51.com/>`_
6
+ |
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import re
13
+ import traceback
14
+
15
+ import fiftyone as fo
16
+ from eta.core.utils import PackageError
17
+ from fiftyone.operators import registry as op_registry
18
+ from fiftyone.operators.executor import (
19
+ ExecutionContext,
20
+ Executor,
21
+ execute_or_delegate_operator,
22
+ )
23
+ from mcp.types import Tool, TextContent
24
+
25
+ from .utils import format_response, safe_serialize
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _context_manager = None
31
+
32
+
33
+ def _build_dependency_error_response(error, operator_uri):
34
+ """Builds a structured error response for missing dependencies.
35
+
36
+ Args:
37
+ error: the exception that was raised
38
+ operator_uri: the URI of the operator that failed
39
+
40
+ Returns:
41
+ a dict with error details and installation instructions
42
+ """
43
+ error_str = str(error)
44
+
45
+ match = re.search(
46
+ r"requires that ['\"]([^'\"]+)['\"] is installed", error_str
47
+ )
48
+ package = match.group(1) if match else "unknown"
49
+
50
+ return format_response(
51
+ None,
52
+ success=False,
53
+ error_type="missing_dependency",
54
+ error=f"Operator '{operator_uri}' requires '{package}' which is not installed",
55
+ missing_package=package,
56
+ install_command=f"pip install {package}",
57
+ )
58
+
59
+
60
+ def get_context_manager():
61
+ """Gets the global context manager instance.
62
+
63
+ Returns:
64
+ a :class:`ContextManager` instance
65
+ """
66
+ global _context_manager
67
+ if _context_manager is None:
68
+ _context_manager = ContextManager()
69
+
70
+ return _context_manager
71
+
72
+
73
+ def set_context(
74
+ dataset_name,
75
+ view_stages=None,
76
+ selected_samples=None,
77
+ selected_labels=None,
78
+ current_sample=None,
79
+ ):
80
+ """Sets the execution context for operators.
81
+
82
+ Args:
83
+ dataset_name: the name of the dataset to work with
84
+ view_stages (None): an optional list of DatasetView stages to
85
+ filter/transform the dataset
86
+ selected_samples (None): an optional list of selected sample IDs
87
+ selected_labels (None): an optional list of selected labels
88
+ current_sample (None): an optional ID of the current sample being
89
+ viewed
90
+
91
+ Returns:
92
+ a dict with context state summary
93
+ """
94
+ cm = get_context_manager()
95
+ return cm.set_context(
96
+ dataset_name,
97
+ view_stages=view_stages,
98
+ selected_samples=selected_samples,
99
+ selected_labels=selected_labels,
100
+ current_sample=current_sample,
101
+ )
102
+
103
+
104
+ def get_context():
105
+ """Gets the current execution context state.
106
+
107
+ Returns:
108
+ a dict with current context state
109
+ """
110
+ cm = get_context_manager()
111
+ return cm.get_context()
112
+
113
+
114
+ def clear_context():
115
+ """Clears the execution context.
116
+
117
+ Returns:
118
+ a dict with success message
119
+ """
120
+ cm = get_context_manager()
121
+ return cm.clear_context()
122
+
123
+
124
+ def list_operators(builtin_only=None, operator_type=None):
125
+ """Lists all available FiftyOne operators.
126
+
127
+ Args:
128
+ builtin_only (None): if True, only builtin operators. If False, only
129
+ custom operators. If None, all operators
130
+ operator_type (None): filter by type: ``"operator"`` or ``"panel"``
131
+
132
+ Returns:
133
+ a dict containing list of operators
134
+ """
135
+ try:
136
+ builtin = "all"
137
+ if builtin_only is True:
138
+ builtin = True
139
+ elif builtin_only is False:
140
+ builtin = False
141
+
142
+ operators = op_registry.list_operators(
143
+ enabled=True, builtin=builtin, type=operator_type
144
+ )
145
+
146
+ operator_list = []
147
+ for op in operators:
148
+ operator_list.append(
149
+ {
150
+ "uri": op.uri,
151
+ "name": op.name,
152
+ "label": op.config.label,
153
+ "description": op.config.description,
154
+ "plugin_name": op.plugin_name,
155
+ "builtin": op.builtin,
156
+ "dynamic": op.config.dynamic,
157
+ }
158
+ )
159
+
160
+ return format_response(
161
+ {"count": len(operator_list), "operators": operator_list}
162
+ )
163
+
164
+ except Exception as e:
165
+ logger.error(f"Failed to list operators: {e}")
166
+ return format_response(None, success=False, error=str(e))
167
+
168
+
169
+ def get_operator_schema(operator_uri):
170
+ """Gets the input schema for a specific operator.
171
+
172
+ Args:
173
+ operator_uri: the URI of the operator (e.g.,
174
+ ``"@voxel51/operators/tag_samples"``)
175
+
176
+ Returns:
177
+ a dict containing the operator's input schema
178
+ """
179
+ try:
180
+ operator = op_registry.get_operator(operator_uri)
181
+ if operator is None:
182
+ return format_response(
183
+ None,
184
+ success=False,
185
+ error=f"Operator '{operator_uri}' not found",
186
+ )
187
+
188
+ cm = get_context_manager()
189
+ ctx = cm.get_execution_context()
190
+ if ctx is None:
191
+ return format_response(
192
+ None,
193
+ success=False,
194
+ error="Context not set. Use set_context first to get dynamic schema.",
195
+ )
196
+
197
+ input_property = operator.resolve_input(ctx)
198
+ schema = input_property.to_json() if input_property else {}
199
+
200
+ return format_response(
201
+ {
202
+ "operator_uri": operator_uri,
203
+ "operator_label": operator.config.label,
204
+ "input_schema": schema,
205
+ }
206
+ )
207
+
208
+ except Exception as e:
209
+ logger.error(
210
+ f"Failed to get operator schema for '{operator_uri}': {e}"
211
+ )
212
+ return format_response(None, success=False, error=str(e))
213
+
214
+
215
+ async def execute_operator_async(operator_uri, params=None):
216
+ """Executes a FiftyOne operator asynchronously.
217
+
218
+ Uses FiftyOne's execute_or_delegate_operator which properly handles
219
+ generators, delegated execution, and other operator execution modes.
220
+
221
+ Args:
222
+ operator_uri: the URI of the operator to execute
223
+ params (None): an optional dict of parameters for the operator
224
+
225
+ Returns:
226
+ a dict containing execution result
227
+ """
228
+ try:
229
+ operator = op_registry.get_operator(operator_uri)
230
+ if operator is None:
231
+ return format_response(
232
+ None,
233
+ success=False,
234
+ error=f"Operator '{operator_uri}' not found",
235
+ )
236
+
237
+ cm = get_context_manager()
238
+ if cm.request_params:
239
+ request_params = dict(cm.request_params)
240
+ else:
241
+ request_params = {}
242
+
243
+ request_params["params"] = params or {}
244
+
245
+ execution_result = await execute_or_delegate_operator(
246
+ operator_uri,
247
+ request_params,
248
+ exhaust=True,
249
+ )
250
+
251
+ execution_result.raise_exceptions()
252
+
253
+ return format_response(
254
+ {
255
+ "operator_uri": operator_uri,
256
+ "success": True,
257
+ "result": (
258
+ safe_serialize(execution_result.result)
259
+ if execution_result
260
+ else None
261
+ ),
262
+ }
263
+ )
264
+
265
+ except (ImportError, ModuleNotFoundError, PackageError) as e:
266
+ return _build_dependency_error_response(e, operator_uri)
267
+
268
+ except Exception as e:
269
+ logger.error(f"Failed to execute operator '{operator_uri}': {e}")
270
+ return format_response(
271
+ None,
272
+ success=False,
273
+ error=str(e),
274
+ traceback=traceback.format_exc(),
275
+ )
276
+
277
+
278
+ def execute_operator(operator_uri, params=None):
279
+ """Executes a FiftyOne operator.
280
+
281
+ Synchronous wrapper around execute_operator_async.
282
+
283
+ Args:
284
+ operator_uri: the URI of the operator to execute
285
+ params (None): an optional dict of parameters for the operator
286
+
287
+ Returns:
288
+ a dict containing execution result
289
+ """
290
+ return asyncio.run(execute_operator_async(operator_uri, params))
291
+
292
+
293
+ def get_operator_tools():
294
+ """Gets the list of operator-related MCP tools.
295
+
296
+ Returns:
297
+ a list of :class:`mcp.types.Tool` instances
298
+ """
299
+ return [
300
+ Tool(
301
+ name="set_context",
302
+ description="Set the execution context for FiftyOne operators. REQUIRED before executing operators or getting schemas. This defines what dataset, view, and selection subsequent operators will work with. The context persists across multiple operator executions until changed.",
303
+ inputSchema={
304
+ "type": "object",
305
+ "properties": {
306
+ "dataset_name": {
307
+ "type": "string",
308
+ "description": "Name of the dataset to work with",
309
+ },
310
+ "view_stages": {
311
+ "type": "array",
312
+ "description": "Optional DatasetView stages to filter/transform the dataset",
313
+ "items": {"type": "object"},
314
+ },
315
+ "selected_samples": {
316
+ "type": "array",
317
+ "description": "Optional list of selected sample IDs",
318
+ "items": {"type": "string"},
319
+ },
320
+ "selected_labels": {
321
+ "type": "array",
322
+ "description": "Optional list of selected labels",
323
+ "items": {"type": "object"},
324
+ },
325
+ "current_sample": {
326
+ "type": "string",
327
+ "description": "Optional ID of the current sample being viewed",
328
+ },
329
+ },
330
+ "required": ["dataset_name"],
331
+ },
332
+ ),
333
+ Tool(
334
+ name="get_context",
335
+ description="Get the current execution context state including dataset, view, and selection information.",
336
+ inputSchema={"type": "object", "properties": {}},
337
+ ),
338
+ Tool(
339
+ name="list_operators",
340
+ description="Discover all available FiftyOne operators (80+ built-in operators from plugins). Returns operators from installed plugins including: @voxel51/operators (50+ core operators like tag_samples, clone_samples), @voxel51/brain (similarity, duplicates, visualization), @voxel51/utils (create_dataset, delete_dataset, clone_dataset), @voxel51/io (import/export), @voxel51/evaluation, @voxel51/annotation, @voxel51/zoo. Use this FIRST to discover what operators are available before executing them.",
341
+ inputSchema={
342
+ "type": "object",
343
+ "properties": {
344
+ "builtin_only": {
345
+ "type": "boolean",
346
+ "description": "If true, only return built-in operators. If false, only custom operators. If not provided, return all.",
347
+ },
348
+ "operator_type": {
349
+ "type": "string",
350
+ "enum": ["operator", "panel"],
351
+ "description": "Filter by operator type. Omit to return all types.",
352
+ },
353
+ },
354
+ },
355
+ ),
356
+ Tool(
357
+ name="get_operator_schema",
358
+ description="Get the dynamic input schema for a specific operator. Schemas are context-aware and change based on the current dataset/view/selection. Use this AFTER list_operators to understand what parameters an operator accepts. Requires context to be set via set_context first.",
359
+ inputSchema={
360
+ "type": "object",
361
+ "properties": {
362
+ "operator_uri": {
363
+ "type": "string",
364
+ "description": "The URI of the operator from list_operators (e.g., '@voxel51/brain/compute_similarity', '@voxel51/utils/create_dataset')",
365
+ }
366
+ },
367
+ "required": ["operator_uri"],
368
+ },
369
+ ),
370
+ Tool(
371
+ name="execute_operator",
372
+ description="Execute any FiftyOne operator with the current execution context. This provides access to 80+ operators from the plugin ecosystem. WORKFLOW: (1) Call list_operators to discover available operators, (2) Call get_operator_schema to see required parameters, (3) Call execute_operator with the operator URI and params. Common examples: '@voxel51/brain/compute_similarity' for image similarity, '@voxel51/utils/create_dataset' for dataset creation, '@voxel51/operators/tag_samples' for tagging. Requires context set via set_context first.",
373
+ inputSchema={
374
+ "type": "object",
375
+ "properties": {
376
+ "operator_uri": {
377
+ "type": "string",
378
+ "description": "The URI of the operator to execute (from list_operators)",
379
+ },
380
+ "params": {
381
+ "type": "object",
382
+ "description": "Parameters for the operator. Use get_operator_schema to see what parameters are required.",
383
+ },
384
+ },
385
+ "required": ["operator_uri"],
386
+ },
387
+ ),
388
+ ]
389
+
390
+
391
+ async def handle_tool_call(name, arguments):
392
+ """Handles operator tool calls.
393
+
394
+ Args:
395
+ name: the name of the tool
396
+ arguments: a dict of arguments for the tool
397
+
398
+ Returns:
399
+ a list of :class:`mcp.types.TextContent` instances
400
+ """
401
+ if name == "set_context":
402
+ result = set_context(
403
+ dataset_name=arguments["dataset_name"],
404
+ view_stages=arguments.get("view_stages"),
405
+ selected_samples=arguments.get("selected_samples"),
406
+ selected_labels=arguments.get("selected_labels"),
407
+ current_sample=arguments.get("current_sample"),
408
+ )
409
+ elif name == "get_context":
410
+ result = get_context()
411
+ elif name == "list_operators":
412
+ result = list_operators(
413
+ builtin_only=arguments.get("builtin_only"),
414
+ operator_type=arguments.get("operator_type"),
415
+ )
416
+ elif name == "get_operator_schema":
417
+ result = get_operator_schema(arguments["operator_uri"])
418
+ elif name == "execute_operator":
419
+ result = await execute_operator_async(
420
+ operator_uri=arguments["operator_uri"],
421
+ params=arguments.get("params", {}),
422
+ )
423
+ else:
424
+ result = format_response(
425
+ None, success=False, error=f"Unknown tool: {name}"
426
+ )
427
+
428
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
429
+
430
+
431
+ class ContextManager(object):
432
+ """Manages the execution context state for FiftyOne operators.
433
+
434
+ This class maintains the current dataset, view, and selection state that
435
+ operators use for execution.
436
+ """
437
+
438
+ def __init__(self):
439
+ self.request_params = {}
440
+
441
+ def set_context(
442
+ self,
443
+ dataset_name,
444
+ view_stages=None,
445
+ selected_samples=None,
446
+ selected_labels=None,
447
+ current_sample=None,
448
+ ):
449
+ """Sets the execution context.
450
+
451
+ Args:
452
+ dataset_name: the name of the dataset to work with
453
+ view_stages (None): an optional list of DatasetView stages
454
+ selected_samples (None): an optional list of selected sample IDs
455
+ selected_labels (None): an optional list of selected labels
456
+ current_sample (None): an optional ID of the current sample
457
+
458
+ Returns:
459
+ a dict with context state summary
460
+ """
461
+ try:
462
+ if not fo.dataset_exists(dataset_name):
463
+ return format_response(
464
+ None,
465
+ success=False,
466
+ error=f"Dataset '{dataset_name}' does not exist",
467
+ )
468
+
469
+ self.request_params = {
470
+ "dataset_name": dataset_name,
471
+ "view": view_stages or [],
472
+ "selected": selected_samples or [],
473
+ "selected_labels": selected_labels or [],
474
+ "params": {},
475
+ }
476
+
477
+ if current_sample:
478
+ self.request_params["current_sample"] = current_sample
479
+
480
+ dataset = fo.load_dataset(dataset_name)
481
+
482
+ return format_response(
483
+ {
484
+ "dataset_name": dataset_name,
485
+ "dataset_info": {
486
+ "num_samples": len(dataset),
487
+ "media_type": dataset.media_type,
488
+ },
489
+ "view_stages_count": (
490
+ len(view_stages) if view_stages else 0
491
+ ),
492
+ "selected_samples_count": (
493
+ len(selected_samples) if selected_samples else 0
494
+ ),
495
+ "selected_labels_count": (
496
+ len(selected_labels) if selected_labels else 0
497
+ ),
498
+ "has_current_sample": current_sample is not None,
499
+ }
500
+ )
501
+
502
+ except Exception as e:
503
+ logger.error(f"Failed to set context: {e}")
504
+ return format_response(None, success=False, error=str(e))
505
+
506
+ def get_context(self):
507
+ """Gets the current execution context state.
508
+
509
+ Returns:
510
+ a dict with current context state
511
+ """
512
+ try:
513
+ if not self.request_params:
514
+ return format_response(
515
+ {
516
+ "context_set": False,
517
+ "message": "No context set. Use set_context first.",
518
+ }
519
+ )
520
+
521
+ dataset_name = self.request_params.get("dataset_name")
522
+ dataset = fo.load_dataset(dataset_name) if dataset_name else None
523
+
524
+ return format_response(
525
+ {
526
+ "context_set": True,
527
+ "dataset_name": dataset_name,
528
+ "dataset_info": (
529
+ {
530
+ "num_samples": len(dataset),
531
+ "media_type": dataset.media_type,
532
+ }
533
+ if dataset
534
+ else None
535
+ ),
536
+ "view_stages_count": len(
537
+ self.request_params.get("view", [])
538
+ ),
539
+ "selected_samples_count": len(
540
+ self.request_params.get("selected", [])
541
+ ),
542
+ "selected_labels_count": len(
543
+ self.request_params.get("selected_labels", [])
544
+ ),
545
+ "has_current_sample": "current_sample"
546
+ in self.request_params,
547
+ }
548
+ )
549
+
550
+ except Exception as e:
551
+ logger.error(f"Failed to get context: {e}")
552
+ return format_response(None, success=False, error=str(e))
553
+
554
+ def get_execution_context(self):
555
+ """Builds an ExecutionContext from current state.
556
+
557
+ Returns:
558
+ an :class:`ExecutionContext` instance, or None if context not set
559
+ """
560
+ if not self.request_params:
561
+ return None
562
+
563
+ return ExecutionContext(
564
+ request_params=self.request_params,
565
+ executor=Executor(),
566
+ )
567
+
568
+ def clear_context(self):
569
+ """Clears the execution context.
570
+
571
+ Returns:
572
+ a dict with success message
573
+ """
574
+ self.request_params = {}
575
+ return format_response({"message": "Context cleared"})