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,289 @@
1
+ """
2
+ Plugin management tools for FiftyOne MCP server.
3
+
4
+ | Copyright 2017-2025, Voxel51, Inc.
5
+ | `voxel51.com <https://voxel51.com/>`_
6
+ |
7
+ """
8
+
9
+ import json
10
+ import logging
11
+
12
+ import fiftyone.plugins as fop
13
+ from mcp.types import Tool, TextContent
14
+
15
+ from .utils import format_response, safe_serialize
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def list_plugins(enabled=None):
22
+ """Lists available FiftyOne plugins.
23
+
24
+ Args:
25
+ enabled (None): whether to list only enabled plugins. If None,
26
+ lists all downloaded plugins
27
+
28
+ Returns:
29
+ a dict with success status and plugin data
30
+ """
31
+ try:
32
+ if enabled is None:
33
+ plugins = fop.list_downloaded_plugins()
34
+ else:
35
+ plugins = fop.list_plugins(enabled=enabled)
36
+
37
+ plugin_list = []
38
+ for item in plugins:
39
+ plugin_name = item if isinstance(item, str) else item.name
40
+ try:
41
+ plugin = fop.get_plugin(plugin_name)
42
+ plugin_list.append(
43
+ {
44
+ "name": plugin.name,
45
+ "version": plugin.version,
46
+ "description": plugin.description,
47
+ "operators": plugin.operators or [],
48
+ "author": getattr(plugin, "author", None),
49
+ "license": getattr(plugin, "license", None),
50
+ "builtin": plugin.builtin,
51
+ }
52
+ )
53
+ except Exception as e:
54
+ logger.warning(f"Error getting plugin {plugin_name}: {e}")
55
+ plugin_list.append({"name": str(plugin_name), "error": str(e)})
56
+
57
+ return format_response(
58
+ {"plugins": plugin_list, "count": len(plugin_list)}, success=True
59
+ )
60
+ except Exception as e:
61
+ logger.error(f"Error listing plugins: {e}")
62
+ return format_response(None, success=False, error=str(e))
63
+
64
+
65
+ def get_plugin_info(plugin_name):
66
+ """Gets detailed information about a specific plugin.
67
+
68
+ Args:
69
+ plugin_name: the name of the plugin (e.g., "@voxel51/brain")
70
+
71
+ Returns:
72
+ a dict with success status and plugin info
73
+ """
74
+ try:
75
+ plugin = fop.get_plugin(plugin_name)
76
+
77
+ info = {
78
+ "name": plugin.name,
79
+ "version": plugin.version,
80
+ "description": plugin.description,
81
+ "operators": plugin.operators or [],
82
+ "author": getattr(plugin, "author", None),
83
+ "license": getattr(plugin, "license", None),
84
+ "builtin": plugin.builtin,
85
+ "directory": str(plugin.directory),
86
+ "has_python": plugin.has_py,
87
+ "has_javascript": plugin.has_js,
88
+ }
89
+
90
+ return format_response({"plugin": info}, success=True)
91
+ except Exception as e:
92
+ logger.error(f"Error getting plugin info for {plugin_name}: {e}")
93
+ return format_response(None, success=False, error=str(e))
94
+
95
+
96
+ def download_plugin(url_or_repo, plugin_names=None, overwrite=False):
97
+ """Downloads and installs a FiftyOne plugin.
98
+
99
+ Args:
100
+ url_or_repo: a GitHub repository URL or "user/repo" string
101
+ plugin_names (None): optional list of specific plugin names to
102
+ download from the repo. If None, downloads all plugins
103
+ overwrite (False): whether to overwrite existing plugins
104
+
105
+ Returns:
106
+ a dict with success status and installation result
107
+ """
108
+ try:
109
+ fop.download_plugin(
110
+ url_or_repo, plugin_names=plugin_names, overwrite=overwrite
111
+ )
112
+
113
+ downloaded = fop.list_downloaded_plugins()
114
+ return format_response(
115
+ {
116
+ "message": "Plugin(s) downloaded successfully",
117
+ "url": url_or_repo,
118
+ "plugin_names": plugin_names,
119
+ "total_downloaded": len(downloaded),
120
+ },
121
+ success=True,
122
+ )
123
+ except Exception as e:
124
+ logger.error(f"Error downloading plugin from {url_or_repo}: {e}")
125
+ return format_response(None, success=False, error=str(e))
126
+
127
+
128
+ def enable_plugin(plugin_name):
129
+ """Enables a downloaded plugin.
130
+
131
+ Args:
132
+ plugin_name: the name of the plugin to enable
133
+
134
+ Returns:
135
+ a dict with success status
136
+ """
137
+ try:
138
+ fop.enable_plugin(plugin_name)
139
+ return format_response(
140
+ {"message": f"Plugin {plugin_name} enabled successfully"},
141
+ success=True,
142
+ )
143
+ except Exception as e:
144
+ logger.error(f"Error enabling plugin {plugin_name}: {e}")
145
+ return format_response(None, success=False, error=str(e))
146
+
147
+
148
+ def disable_plugin(plugin_name):
149
+ """Disables a plugin.
150
+
151
+ Args:
152
+ plugin_name: the name of the plugin to disable
153
+
154
+ Returns:
155
+ a dict with success status
156
+ """
157
+ try:
158
+ fop.disable_plugin(plugin_name)
159
+ return format_response(
160
+ {"message": f"Plugin {plugin_name} disabled successfully"},
161
+ success=True,
162
+ )
163
+ except Exception as e:
164
+ logger.error(f"Error disabling plugin {plugin_name}: {e}")
165
+ return format_response(None, success=False, error=str(e))
166
+
167
+
168
+ def get_plugin_tools():
169
+ """Returns the list of plugin management MCP tools.
170
+
171
+ Returns:
172
+ list of :class:`mcp.types.Tool`
173
+ """
174
+ return [
175
+ Tool(
176
+ name="list_plugins",
177
+ description="Lists available FiftyOne plugins and their operators. Plugins extend functionality by providing additional operators. Key plugins: @voxel51/brain (16 operators for similarity/duplicates/visualization), @voxel51/utils (12 operators for dataset CRUD), @voxel51/io (5 operators for import/export), @voxel51/evaluation (5 operators), @voxel51/annotation (6 operators), @voxel51/zoo (2 operators). Use this to discover what plugins are installed and what operators they provide.",
178
+ inputSchema={
179
+ "type": "object",
180
+ "properties": {
181
+ "enabled": {
182
+ "type": "boolean",
183
+ "description": "If true, list only enabled plugins. If false, list only disabled plugins. If not specified, lists all downloaded plugins",
184
+ }
185
+ },
186
+ },
187
+ ),
188
+ Tool(
189
+ name="get_plugin_info",
190
+ description="Gets detailed information about a specific FiftyOne plugin including its operators, version, and metadata.",
191
+ inputSchema={
192
+ "type": "object",
193
+ "properties": {
194
+ "plugin_name": {
195
+ "type": "string",
196
+ "description": "The name of the plugin (e.g., '@voxel51/brain')",
197
+ }
198
+ },
199
+ "required": ["plugin_name"],
200
+ },
201
+ ),
202
+ Tool(
203
+ name="download_plugin",
204
+ description="Downloads and installs a FiftyOne plugin from GitHub. Plugins immediately add new operators to the system (accessible via list_operators and execute_operator). Common repo: 'voxel51/fiftyone-plugins' contains all official plugins. After installation, use enable_plugin to activate the plugin's operators.",
205
+ inputSchema={
206
+ "type": "object",
207
+ "properties": {
208
+ "url_or_repo": {
209
+ "type": "string",
210
+ "description": "GitHub repository URL or 'user/repo' string (e.g., 'voxel51/fiftyone-plugins' or 'https://github.com/voxel51/fiftyone-plugins')",
211
+ },
212
+ "plugin_names": {
213
+ "type": "array",
214
+ "items": {"type": "string"},
215
+ "description": "Optional list of specific plugin names to download from the repo. If not specified, downloads all plugins in the repo",
216
+ },
217
+ "overwrite": {
218
+ "type": "boolean",
219
+ "description": "Whether to overwrite existing plugins. Default is false",
220
+ "default": False,
221
+ },
222
+ },
223
+ "required": ["url_or_repo"],
224
+ },
225
+ ),
226
+ Tool(
227
+ name="enable_plugin",
228
+ description="Enables a downloaded FiftyOne plugin, making its operators immediately available through list_operators and execute_operator. Required after download_plugin to activate the plugin's functionality.",
229
+ inputSchema={
230
+ "type": "object",
231
+ "properties": {
232
+ "plugin_name": {
233
+ "type": "string",
234
+ "description": "The name of the plugin to enable",
235
+ }
236
+ },
237
+ "required": ["plugin_name"],
238
+ },
239
+ ),
240
+ Tool(
241
+ name="disable_plugin",
242
+ description="Disables a FiftyOne plugin, removing its operators from availability.",
243
+ inputSchema={
244
+ "type": "object",
245
+ "properties": {
246
+ "plugin_name": {
247
+ "type": "string",
248
+ "description": "The name of the plugin to disable",
249
+ }
250
+ },
251
+ "required": ["plugin_name"],
252
+ },
253
+ ),
254
+ ]
255
+
256
+
257
+ async def handle_plugin_tool(name, arguments):
258
+ """Handles plugin management tool calls.
259
+
260
+ Args:
261
+ name: the tool name
262
+ arguments: dict of arguments for the tool
263
+
264
+ Returns:
265
+ list of TextContent with the result
266
+ """
267
+ if name == "list_plugins":
268
+ enabled = arguments.get("enabled")
269
+ result = list_plugins(enabled=enabled)
270
+ elif name == "get_plugin_info":
271
+ plugin_name = arguments["plugin_name"]
272
+ result = get_plugin_info(plugin_name)
273
+ elif name == "download_plugin":
274
+ url_or_repo = arguments["url_or_repo"]
275
+ plugin_names = arguments.get("plugin_names")
276
+ overwrite = arguments.get("overwrite", False)
277
+ result = download_plugin(url_or_repo, plugin_names, overwrite)
278
+ elif name == "enable_plugin":
279
+ plugin_name = arguments["plugin_name"]
280
+ result = enable_plugin(plugin_name)
281
+ elif name == "disable_plugin":
282
+ plugin_name = arguments["plugin_name"]
283
+ result = disable_plugin(plugin_name)
284
+ else:
285
+ result = format_response(
286
+ None, success=False, error=f"Unknown tool: {name}"
287
+ )
288
+
289
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
@@ -0,0 +1,352 @@
1
+ """
2
+ Session management tools for FiftyOne MCP server.
3
+
4
+ | Copyright 2017-2025, Voxel51, Inc.
5
+ | `voxel51.com <https://voxel51.com/>`_
6
+ |
7
+ """
8
+
9
+ import json
10
+ import logging
11
+
12
+ import fiftyone as fo
13
+ from fiftyone import ViewField as F
14
+ from mcp.types import Tool, TextContent
15
+
16
+ from .utils import format_response
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _active_session = None
22
+
23
+
24
+ def launch_app(dataset_name=None, port=None, remote=False):
25
+ """Launches the FiftyOne App.
26
+
27
+ Args:
28
+ dataset_name (None): optional dataset name to load in the app
29
+ port (None): optional port number for the app server
30
+ remote (False): whether to launch in remote mode
31
+
32
+ Returns:
33
+ a dict with success status and session info
34
+ """
35
+ global _active_session
36
+
37
+ try:
38
+ dataset = None
39
+ if dataset_name:
40
+ dataset = fo.load_dataset(dataset_name)
41
+
42
+ session = fo.launch_app(dataset=dataset, port=port, remote=remote)
43
+
44
+ _active_session = session
45
+
46
+ session_info = {
47
+ "url": session.url if hasattr(session, "url") else None,
48
+ "dataset": dataset_name,
49
+ "port": port,
50
+ "remote": remote,
51
+ }
52
+
53
+ return format_response(
54
+ {"message": "FiftyOne App launched successfully", **session_info},
55
+ success=True,
56
+ )
57
+ except Exception as e:
58
+ logger.error(f"Error launching FiftyOne App: {e}")
59
+ return format_response(None, success=False, error=str(e))
60
+
61
+
62
+ def close_app():
63
+ """Closes the active FiftyOne App session.
64
+
65
+ Returns:
66
+ a dict with success status
67
+ """
68
+ global _active_session
69
+
70
+ try:
71
+ fo.close_app()
72
+ _active_session = None
73
+
74
+ return format_response(
75
+ {"message": "FiftyOne App closed successfully"}, success=True
76
+ )
77
+ except Exception as e:
78
+ logger.error(f"Error closing FiftyOne App: {e}")
79
+ return format_response(None, success=False, error=str(e))
80
+
81
+
82
+ def get_session_info():
83
+ """Gets information about the active FiftyOne App session.
84
+
85
+ Returns:
86
+ a dict with success status and session details
87
+ """
88
+ global _active_session
89
+
90
+ try:
91
+ if _active_session is None:
92
+ return format_response(
93
+ {"active": False, "message": "No active session"}, success=True
94
+ )
95
+
96
+ view = _active_session.view
97
+ info = {
98
+ "active": True,
99
+ "url": (
100
+ _active_session.url
101
+ if hasattr(_active_session, "url")
102
+ else None
103
+ ),
104
+ "dataset": (
105
+ _active_session.dataset.name
106
+ if _active_session.dataset
107
+ else None
108
+ ),
109
+ "view": {
110
+ "name": view.name if view else None,
111
+ "num_samples": len(view) if view else None,
112
+ "stages": len(view._stages) if view else 0,
113
+ },
114
+ }
115
+
116
+ return format_response(info, success=True)
117
+ except Exception as e:
118
+ logger.error(f"Error getting session info: {e}")
119
+ return format_response(None, success=False, error=str(e))
120
+
121
+
122
+ def set_view(
123
+ filters=None,
124
+ match=None,
125
+ exists=None,
126
+ tags=None,
127
+ sample_ids=None,
128
+ view_name=None,
129
+ ):
130
+ """Sets a filtered view in the active FiftyOne App session.
131
+
132
+ Args:
133
+ filters (None): a dict mapping field names to values to match
134
+ match (None): a raw match expression dict for complex queries
135
+ exists (None): a field name or list of field names that must exist
136
+ tags (None): a tag or list of tags to match
137
+ sample_ids (None): a list of sample IDs to select
138
+ view_name (None): the name of a saved view to load
139
+
140
+ Returns:
141
+ a dict with view info
142
+ """
143
+ global _active_session
144
+
145
+ try:
146
+ if _active_session is None:
147
+ return format_response(
148
+ None,
149
+ success=False,
150
+ error="No active session. Use launch_app first.",
151
+ )
152
+
153
+ dataset = _active_session.dataset
154
+ if dataset is None:
155
+ return format_response(
156
+ None,
157
+ success=False,
158
+ error="No dataset loaded in session.",
159
+ )
160
+
161
+ if view_name:
162
+ if view_name not in dataset.list_saved_views():
163
+ return format_response(
164
+ None,
165
+ success=False,
166
+ error=f"Saved view '{view_name}' not found.",
167
+ )
168
+ view = dataset.load_saved_view(view_name)
169
+ _active_session.view = view
170
+ return format_response(
171
+ {
172
+ "view_name": view_name,
173
+ "num_samples": len(view),
174
+ }
175
+ )
176
+
177
+ view = dataset.view()
178
+
179
+ if filters:
180
+ for field, value in filters.items():
181
+ view = view.match(F(field) == value)
182
+
183
+ if match:
184
+ view = view.match(match)
185
+
186
+ if exists:
187
+ if isinstance(exists, str):
188
+ exists = [exists]
189
+ for field in exists:
190
+ view = view.exists(field)
191
+
192
+ if tags:
193
+ view = view.match_tags(tags)
194
+
195
+ if sample_ids:
196
+ view = view.select(sample_ids)
197
+
198
+ _active_session.view = view
199
+
200
+ return format_response(
201
+ {
202
+ "num_samples": len(view),
203
+ "stages": len(view._stages),
204
+ }
205
+ )
206
+
207
+ except Exception as e:
208
+ logger.error(f"Error setting view: {e}")
209
+ return format_response(None, success=False, error=str(e))
210
+
211
+
212
+ def clear_view():
213
+ """Clears the current view from the session.
214
+
215
+ Returns:
216
+ a dict with success status
217
+ """
218
+ global _active_session
219
+
220
+ try:
221
+ if _active_session is None:
222
+ return format_response(
223
+ None,
224
+ success=False,
225
+ error="No active session. Use launch_app first.",
226
+ )
227
+
228
+ _active_session.clear_view()
229
+
230
+ return format_response({"message": "View cleared"})
231
+
232
+ except Exception as e:
233
+ logger.error(f"Error clearing view: {e}")
234
+ return format_response(None, success=False, error=str(e))
235
+
236
+
237
+ def get_session_tools():
238
+ """Returns the list of session management MCP tools.
239
+
240
+ Returns:
241
+ list of :class:`mcp.types.Tool`
242
+ """
243
+ return [
244
+ Tool(
245
+ name="launch_app",
246
+ description="Launches the FiftyOne App server. Required for executing delegated operators that need background execution (e.g., brain operators like find_near_duplicates).",
247
+ inputSchema={
248
+ "type": "object",
249
+ "properties": {
250
+ "dataset_name": {
251
+ "type": "string",
252
+ "description": "Optional dataset name to load in the app",
253
+ },
254
+ "port": {
255
+ "type": "integer",
256
+ "description": "Optional port number for the app server. If not specified, uses default port",
257
+ },
258
+ "remote": {
259
+ "type": "boolean",
260
+ "description": "Whether to launch in remote mode. Default is false",
261
+ "default": False,
262
+ },
263
+ },
264
+ },
265
+ ),
266
+ Tool(
267
+ name="close_app",
268
+ description="Closes the active FiftyOne App session and stops the server.",
269
+ inputSchema={"type": "object", "properties": {}},
270
+ ),
271
+ Tool(
272
+ name="get_session_info",
273
+ description="Gets information about the current FiftyOne App session, including whether it's active and what dataset is loaded.",
274
+ inputSchema={"type": "object", "properties": {}},
275
+ ),
276
+ Tool(
277
+ name="set_view",
278
+ description="Sets a filtered view in the FiftyOne App. Use this to filter samples by field values, tags, existence of fields, or load saved views. The view updates immediately in the App UI.",
279
+ inputSchema={
280
+ "type": "object",
281
+ "properties": {
282
+ "filters": {
283
+ "type": "object",
284
+ "description": 'Dict mapping field names to values to match exactly (e.g., {"near_dup_id": 1})',
285
+ },
286
+ "exists": {
287
+ "type": "array",
288
+ "items": {"type": "string"},
289
+ "description": "Field name(s) that must have a non-None value",
290
+ },
291
+ "tags": {
292
+ "type": "array",
293
+ "items": {"type": "string"},
294
+ "description": "Sample tag(s) to match",
295
+ },
296
+ "sample_ids": {
297
+ "type": "array",
298
+ "items": {"type": "string"},
299
+ "description": "Specific sample IDs to select",
300
+ },
301
+ "view_name": {
302
+ "type": "string",
303
+ "description": "Name of a saved view to load",
304
+ },
305
+ },
306
+ },
307
+ ),
308
+ Tool(
309
+ name="clear_view",
310
+ description="Clears the current view from the session, showing all samples.",
311
+ inputSchema={"type": "object", "properties": {}},
312
+ ),
313
+ ]
314
+
315
+
316
+ async def handle_session_tool(name, arguments):
317
+ """Handles session management tool calls.
318
+
319
+ Args:
320
+ name: the tool name
321
+ arguments: dict of arguments for the tool
322
+
323
+ Returns:
324
+ list of TextContent with the result
325
+ """
326
+ if name == "launch_app":
327
+ result = launch_app(
328
+ dataset_name=arguments.get("dataset_name"),
329
+ port=arguments.get("port"),
330
+ remote=arguments.get("remote", False),
331
+ )
332
+ elif name == "close_app":
333
+ result = close_app()
334
+ elif name == "get_session_info":
335
+ result = get_session_info()
336
+ elif name == "set_view":
337
+ result = set_view(
338
+ filters=arguments.get("filters"),
339
+ match=arguments.get("match"),
340
+ exists=arguments.get("exists"),
341
+ tags=arguments.get("tags"),
342
+ sample_ids=arguments.get("sample_ids"),
343
+ view_name=arguments.get("view_name"),
344
+ )
345
+ elif name == "clear_view":
346
+ result = clear_view()
347
+ else:
348
+ result = format_response(
349
+ None, success=False, error=f"Unknown tool: {name}"
350
+ )
351
+
352
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]