coala-cli 0.2.1__tar.gz → 0.2.4__tar.gz
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.
- coala_cli-0.2.4/PKG-INFO +32 -0
- coala_cli-0.2.4/README.md +11 -0
- {coala_cli-0.2.1 → coala_cli-0.2.4}/coala/mcp_api.py +132 -47
- {coala_cli-0.2.1 → coala_cli-0.2.4}/coala/remote_api.py +29 -13
- coala_cli-0.2.4/coala/tool_logic.py +222 -0
- {coala_cli-0.2.1 → coala_cli-0.2.4}/pyproject.toml +9 -9
- coala_cli-0.2.1/PKG-INFO +0 -158
- coala_cli-0.2.1/README.md +0 -139
- coala_cli-0.2.1/coala/tool_logic.py +0 -109
- {coala_cli-0.2.1 → coala_cli-0.2.4}/coala/__init__.py +0 -0
- {coala_cli-0.2.1 → coala_cli-0.2.4}/coala/agent.py +0 -0
coala_cli-0.2.4/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coala-cli
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Command-line LLM-agent Adapter
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Qiang
|
|
7
|
+
Requires-Python: >=3.12,<3.15
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: cwltool (>=3.2.20260413085819,<4.0.0)
|
|
14
|
+
Requires-Dist: fastapi (>=0.115.0)
|
|
15
|
+
Requires-Dist: mcp (>=1.12.0,<2.0.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.10.0,<3.0.0)
|
|
17
|
+
Requires-Dist: requests (>=2.33.0,<3.0.0)
|
|
18
|
+
Requires-Dist: uvicorn (>=0.32.0,<0.33.0)
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Coala: Command-line LLM-agent Adapter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Introduction
|
|
25
|
+
|
|
26
|
+
Coala is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
|
|
27
|
+
|
|
28
|
+
Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
|
|
29
|
+
|
|
30
|
+
Please find Coala documuentations through [Coala website](https://coala.info/), including [overview](https://coala.info/guide/), [installation](https://coala.info/guide/installation.html), [quick start](https://coala.info/guide/quick-start.html), and [use cases](https://coala.info/use-cases/).
|
|
31
|
+
|
|
32
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Coala: Command-line LLM-agent Adapter
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## Introduction
|
|
5
|
+
|
|
6
|
+
Coala is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
|
|
7
|
+
|
|
8
|
+
Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
|
|
9
|
+
|
|
10
|
+
Please find Coala documuentations through [Coala website](https://coala.info/), including [overview](https://coala.info/guide/), [installation](https://coala.info/guide/installation.html), [quick start](https://coala.info/guide/quick-start.html), and [use cases](https://coala.info/use-cases/).
|
|
11
|
+
|
|
@@ -5,7 +5,7 @@ warnings.filterwarnings('ignore', message='.*default.*Field.*')
|
|
|
5
5
|
warnings.filterwarnings('ignore', message='.*UnsupportedFieldAttributeWarning.*')
|
|
6
6
|
|
|
7
7
|
from fastapi import FastAPI, UploadFile, File
|
|
8
|
-
from pydantic import create_model, Field
|
|
8
|
+
from pydantic import create_model, Field, ConfigDict
|
|
9
9
|
from pydantic.warnings import UnsupportedFieldAttributeWarning
|
|
10
10
|
import logging
|
|
11
11
|
import uvicorn
|
|
@@ -29,6 +29,13 @@ logger.setLevel(logging.INFO)
|
|
|
29
29
|
# Use stderr for logging to avoid interfering with stdio transport
|
|
30
30
|
logger.addHandler(logging.StreamHandler(sys.stderr))
|
|
31
31
|
|
|
32
|
+
# ponytail: pydantic forbids these as field names; alias + by_alias keeps CWL ids intact
|
|
33
|
+
_PYDANTIC_RESERVED_FIELDS = frozenset({'model_config'})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pydantic_field_name(cwl_name: str) -> str:
|
|
37
|
+
return f'{cwl_name}_' if cwl_name in _PYDANTIC_RESERVED_FIELDS else cwl_name
|
|
38
|
+
|
|
32
39
|
|
|
33
40
|
class mcp_api():
|
|
34
41
|
def __init__(self, host='0.0.0.0', port=8000, container_runner=None):
|
|
@@ -72,29 +79,69 @@ tool_version: <TOOL_VERSION>
|
|
|
72
79
|
# tmp.write(contents)
|
|
73
80
|
# return {"filename": file.filename, "filepath": tmp.name}
|
|
74
81
|
|
|
82
|
+
def _cwl_type_hint(self, type_val):
|
|
83
|
+
"""
|
|
84
|
+
Render a human-readable type hint from a CWL type value.
|
|
85
|
+
|
|
86
|
+
Handles optional unions (list containing 'null'), dict-form arrays
|
|
87
|
+
({'type': 'array', 'items': ...}) and string shorthand arrays
|
|
88
|
+
(e.g. 'float[]'). Returns an empty string when no hint can be
|
|
89
|
+
determined.
|
|
90
|
+
"""
|
|
91
|
+
if type_val is None or type_val == '':
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
type_list = type_val if isinstance(type_val, list) else [type_val]
|
|
95
|
+
non_null_types = [t for t in type_list if t != 'null']
|
|
96
|
+
|
|
97
|
+
is_array = False
|
|
98
|
+
base_type = None
|
|
99
|
+
|
|
100
|
+
for t in non_null_types:
|
|
101
|
+
if isinstance(t, dict) and t.get('type') == 'array':
|
|
102
|
+
is_array = True
|
|
103
|
+
base_type = t.get('items', 'string')
|
|
104
|
+
break
|
|
105
|
+
t_str = str(t)
|
|
106
|
+
if '[]' in t_str:
|
|
107
|
+
is_array = True
|
|
108
|
+
base_type = t_str.replace('[]', '')
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
if base_type is None and non_null_types:
|
|
112
|
+
base_type = non_null_types[0]
|
|
113
|
+
|
|
114
|
+
base_str = str(base_type) if base_type is not None else ''
|
|
115
|
+
|
|
116
|
+
if 'Directory' in base_str:
|
|
117
|
+
base_hint = "directory path"
|
|
118
|
+
elif 'File' in base_str:
|
|
119
|
+
base_hint = "file path"
|
|
120
|
+
elif 'string' in base_str:
|
|
121
|
+
base_hint = "str"
|
|
122
|
+
elif 'double' in base_str or 'float' in base_str:
|
|
123
|
+
base_hint = "float"
|
|
124
|
+
elif 'int' in base_str:
|
|
125
|
+
base_hint = "int"
|
|
126
|
+
elif 'boolean' in base_str:
|
|
127
|
+
base_hint = "bool"
|
|
128
|
+
else:
|
|
129
|
+
base_hint = ""
|
|
130
|
+
|
|
131
|
+
if not base_hint:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
return f"array of {base_hint}" if is_array else base_hint
|
|
135
|
+
|
|
75
136
|
def _build_field_description(self, field_name, input_field, model_field):
|
|
76
137
|
"""
|
|
77
138
|
Build field description with type hints.
|
|
78
139
|
"""
|
|
79
140
|
doc = input_field.get('doc', '')
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
type_str = ' '.join(str(t) for t in type_list)
|
|
83
|
-
|
|
84
|
-
type_hint = ""
|
|
85
|
-
if 'File' in type_str:
|
|
86
|
-
type_hint = "file path"
|
|
87
|
-
elif 'string' in type_str:
|
|
88
|
-
type_hint = "str"
|
|
89
|
-
elif 'double' in type_str or 'float' in type_str:
|
|
90
|
-
type_hint = "float"
|
|
91
|
-
elif 'int' in type_str:
|
|
92
|
-
type_hint = "int"
|
|
93
|
-
elif 'boolean' in type_str:
|
|
94
|
-
type_hint = "bool"
|
|
95
|
-
|
|
141
|
+
type_hint = self._cwl_type_hint(input_field.get('type', ''))
|
|
142
|
+
|
|
96
143
|
annotation = model_field.annotation.__name__ if hasattr(model_field.annotation, '__name__') else str(model_field.annotation)
|
|
97
|
-
|
|
144
|
+
|
|
98
145
|
if type_hint:
|
|
99
146
|
return f"{field_name}: {doc}, {annotation}, {type_hint}"
|
|
100
147
|
else:
|
|
@@ -106,22 +153,8 @@ tool_version: <TOOL_VERSION>
|
|
|
106
153
|
"""
|
|
107
154
|
field_name = output_field.get('name', '')
|
|
108
155
|
doc = output_field.get('doc', '')
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
type_str = ' '.join(str(t) for t in type_list)
|
|
112
|
-
|
|
113
|
-
type_hint = ""
|
|
114
|
-
if 'File' in type_str:
|
|
115
|
-
type_hint = "file path"
|
|
116
|
-
elif 'string' in type_str:
|
|
117
|
-
type_hint = "str"
|
|
118
|
-
elif 'double' in type_str or 'float' in type_str:
|
|
119
|
-
type_hint = "float"
|
|
120
|
-
elif 'int' in type_str:
|
|
121
|
-
type_hint = "int"
|
|
122
|
-
elif 'boolean' in type_str:
|
|
123
|
-
type_hint = "bool"
|
|
124
|
-
|
|
156
|
+
type_hint = self._cwl_type_hint(output_field.get('type', ''))
|
|
157
|
+
|
|
125
158
|
if type_hint:
|
|
126
159
|
return f"{field_name}: {doc}, {type_hint}"
|
|
127
160
|
else:
|
|
@@ -132,6 +165,7 @@ tool_version: <TOOL_VERSION>
|
|
|
132
165
|
Transform input values based on their expected type.
|
|
133
166
|
|
|
134
167
|
- For File types: If value is just a filename, try to resolve to full path
|
|
168
|
+
- For Directory types: If value is a path, try to resolve to an absolute path
|
|
135
169
|
- For string types: If value is a full path, extract just the filename
|
|
136
170
|
- For array types: Transform each element in the array
|
|
137
171
|
|
|
@@ -173,7 +207,7 @@ tool_version: <TOOL_VERSION>
|
|
|
173
207
|
type_str = str(base_type) if not isinstance(base_type, dict) else base_type.get('type', '')
|
|
174
208
|
|
|
175
209
|
# Check if it's a File type
|
|
176
|
-
if 'File' in type_str and isinstance(value, str):
|
|
210
|
+
if 'File' in type_str and 'Directory' not in type_str and isinstance(value, str):
|
|
177
211
|
# If it's already a file:// URI, return as is
|
|
178
212
|
if value.startswith('file://'):
|
|
179
213
|
return value
|
|
@@ -194,6 +228,23 @@ tool_version: <TOOL_VERSION>
|
|
|
194
228
|
|
|
195
229
|
# If not found, return as is (let run_tool handle it)
|
|
196
230
|
return value
|
|
231
|
+
|
|
232
|
+
# Check if it's a Directory type
|
|
233
|
+
if 'Directory' in type_str and isinstance(value, str):
|
|
234
|
+
if value.startswith('file://'):
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
if os.path.isabs(value) and os.path.isdir(value):
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
if os.path.isdir(value):
|
|
241
|
+
return os.path.abspath(value)
|
|
242
|
+
|
|
243
|
+
cwd_path = os.path.join(os.getcwd(), value)
|
|
244
|
+
if os.path.isdir(cwd_path):
|
|
245
|
+
return os.path.abspath(cwd_path)
|
|
246
|
+
|
|
247
|
+
return value
|
|
197
248
|
|
|
198
249
|
# Check if it's a string type
|
|
199
250
|
elif 'string' in type_str and isinstance(value, str):
|
|
@@ -203,8 +254,12 @@ tool_version: <TOOL_VERSION>
|
|
|
203
254
|
dir_path = os.path.dirname(value)
|
|
204
255
|
# Only extract filename if the directory exists
|
|
205
256
|
if dir_path and os.path.isdir(dir_path):
|
|
206
|
-
#
|
|
207
|
-
|
|
257
|
+
# Trailing slashes make os.path.basename return "" — normalize first.
|
|
258
|
+
seps = os.path.sep + (os.path.altsep or "")
|
|
259
|
+
trimmed = value.rstrip(seps)
|
|
260
|
+
filename = os.path.basename(trimmed) if trimmed else value
|
|
261
|
+
if not filename:
|
|
262
|
+
return value
|
|
208
263
|
logger.info(f"Transformed string input '{field_name}': '{value}' -> '{filename}'")
|
|
209
264
|
return filename
|
|
210
265
|
# If directory doesn't exist, keep the full path as is
|
|
@@ -286,15 +341,26 @@ tool_version: <TOOL_VERSION>
|
|
|
286
341
|
items_type = raw_type.get('items', 'string')
|
|
287
342
|
base_type_str = str(items_type) if not isinstance(items_type, dict) else items_type.get('type', 'string')
|
|
288
343
|
elif non_null_types:
|
|
289
|
-
#
|
|
290
|
-
#
|
|
344
|
+
# Look through non-null types for array notation.
|
|
345
|
+
# An array can appear either as dict form
|
|
346
|
+
# ({'type': 'array', 'items': <items_type>}) or as string shorthand
|
|
347
|
+
# with '[]' suffix (e.g. 'float[]').
|
|
291
348
|
for t in non_null_types:
|
|
349
|
+
if isinstance(t, dict) and t.get('type') == 'array':
|
|
350
|
+
is_array = True
|
|
351
|
+
items_type = t.get('items', 'string')
|
|
352
|
+
base_type_str = (
|
|
353
|
+
str(items_type)
|
|
354
|
+
if not isinstance(items_type, dict)
|
|
355
|
+
else items_type.get('type', 'string')
|
|
356
|
+
)
|
|
357
|
+
break
|
|
292
358
|
t_str = str(t)
|
|
293
359
|
if '[]' in t_str:
|
|
294
360
|
is_array = True
|
|
295
361
|
base_type_str = t_str.replace('[]', '')
|
|
296
362
|
break
|
|
297
|
-
|
|
363
|
+
|
|
298
364
|
if not is_array and non_null_types:
|
|
299
365
|
# Not an array, use the first non-null type
|
|
300
366
|
base_type_str = str(non_null_types[0])
|
|
@@ -306,7 +372,7 @@ tool_version: <TOOL_VERSION>
|
|
|
306
372
|
field_doc = it.get('doc', '')
|
|
307
373
|
|
|
308
374
|
# Determine base Python type
|
|
309
|
-
if 'File' in base_type_str:
|
|
375
|
+
if 'File' in base_type_str or 'Directory' in base_type_str:
|
|
310
376
|
base_py_type = str
|
|
311
377
|
elif 'string' in base_type_str:
|
|
312
378
|
base_py_type = str
|
|
@@ -328,17 +394,35 @@ tool_version: <TOOL_VERSION>
|
|
|
328
394
|
# Create Field with description
|
|
329
395
|
# For optional fields, use (Optional[type], None) - Field() can't be used with Union types
|
|
330
396
|
# For required fields, use Field directly
|
|
331
|
-
|
|
397
|
+
# Reserved pydantic names (e.g. model_config) get a trailing _ and an alias
|
|
398
|
+
cwl_name = it['name']
|
|
399
|
+
field_name = _pydantic_field_name(cwl_name)
|
|
400
|
+
if field_name != cwl_name:
|
|
401
|
+
if is_optional:
|
|
402
|
+
it_map[field_name] = (
|
|
403
|
+
Optional[py_type],
|
|
404
|
+
Field(default=None, alias=cwl_name, description=field_doc),
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
it_map[field_name] = (py_type, Field(alias=cwl_name, description=field_doc))
|
|
408
|
+
elif is_optional:
|
|
332
409
|
# Use Optional type with None as default
|
|
333
410
|
# Note: We can't use Field() with Optional/Union types, so description will be set via field_doc in fields_desc
|
|
334
|
-
it_map[
|
|
411
|
+
it_map[field_name] = (Optional[py_type], None)
|
|
335
412
|
else:
|
|
336
|
-
it_map[
|
|
413
|
+
it_map[field_name] = (py_type, Field(description=field_doc))
|
|
337
414
|
|
|
338
|
-
|
|
415
|
+
create_kwargs = {}
|
|
416
|
+
if any(n in _PYDANTIC_RESERVED_FIELDS for n in inputs_by_name):
|
|
417
|
+
create_kwargs['__config__'] = ConfigDict(populate_by_name=True)
|
|
418
|
+
Base = create_model(f'Base_{tool_name}', **create_kwargs, **it_map)
|
|
339
419
|
|
|
340
420
|
fields_desc = "\n\n".join(
|
|
341
|
-
self._build_field_description(
|
|
421
|
+
self._build_field_description(
|
|
422
|
+
(v.alias or k),
|
|
423
|
+
inputs_by_name[v.alias or k],
|
|
424
|
+
v,
|
|
425
|
+
)
|
|
342
426
|
for k, v in Base.model_fields.items()
|
|
343
427
|
)
|
|
344
428
|
|
|
@@ -384,7 +468,8 @@ tool_version: <TOOL_VERSION>
|
|
|
384
468
|
{fields_desc}
|
|
385
469
|
"""
|
|
386
470
|
logger.info(data)
|
|
387
|
-
|
|
471
|
+
# by_alias=True restores CWL input ids when pydantic reserved names were aliased
|
|
472
|
+
params = data[0].model_dump(by_alias=True)
|
|
388
473
|
|
|
389
474
|
# Transform input values based on their types
|
|
390
475
|
# Create a mapping from field name to input type
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from fastapi import FastAPI, UploadFile, Body
|
|
2
|
-
from pydantic import create_model
|
|
2
|
+
from pydantic import create_model, Field, ConfigDict
|
|
3
3
|
import logging
|
|
4
4
|
import uvicorn
|
|
5
5
|
from tempfile import NamedTemporaryFile, mkdtemp
|
|
@@ -15,6 +15,13 @@ logger = logging.getLogger(__name__)
|
|
|
15
15
|
logger.setLevel(logging.INFO)
|
|
16
16
|
logger.addHandler(logging.StreamHandler())
|
|
17
17
|
|
|
18
|
+
# ponytail: pydantic forbids these as field names; alias + by_alias keeps CWL ids intact
|
|
19
|
+
_PYDANTIC_RESERVED_FIELDS = frozenset({'model_config'})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pydantic_field_name(cwl_name: str) -> str:
|
|
23
|
+
return f'{cwl_name}_' if cwl_name in _PYDANTIC_RESERVED_FIELDS else cwl_name
|
|
24
|
+
|
|
18
25
|
|
|
19
26
|
class tool_api():
|
|
20
27
|
def __init__(self, cwl_file, tool_name='tool', host='0.0.0.0', port=8000, read_outs=False):
|
|
@@ -53,25 +60,34 @@ class tool_api():
|
|
|
53
60
|
# it['type'] can be a list like ['null', 'org.w3id.cwl.cwl.File']
|
|
54
61
|
type_list = it['type'] if isinstance(it['type'], list) else [it['type']]
|
|
55
62
|
type_str = ' '.join(str(t) for t in type_list) # Join for checking substrings
|
|
56
|
-
|
|
63
|
+
cwl_name = it['name']
|
|
64
|
+
field_name = _pydantic_field_name(cwl_name)
|
|
65
|
+
|
|
57
66
|
if 'File' in type_str:
|
|
58
|
-
|
|
67
|
+
py_type, default = str, None
|
|
59
68
|
elif 'string' in type_str:
|
|
60
|
-
|
|
69
|
+
py_type, default = str, None
|
|
61
70
|
elif 'double' in type_str:
|
|
62
|
-
|
|
71
|
+
py_type, default = float, None
|
|
63
72
|
elif 'int' in type_str:
|
|
64
|
-
|
|
73
|
+
py_type, default = int, None
|
|
65
74
|
elif 'boolean' in type_str:
|
|
66
|
-
|
|
75
|
+
py_type, default = bool, None
|
|
67
76
|
else:
|
|
68
|
-
|
|
77
|
+
py_type, default = str, None
|
|
69
78
|
|
|
70
79
|
if 'null' in type_list:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
py_type = Optional[py_type]
|
|
81
|
+
|
|
82
|
+
if field_name != cwl_name:
|
|
83
|
+
it_map[field_name] = (py_type, Field(default=default, alias=cwl_name))
|
|
84
|
+
else:
|
|
85
|
+
it_map[field_name] = (py_type, default)
|
|
86
|
+
|
|
87
|
+
create_kwargs = {}
|
|
88
|
+
if any(it['name'] in _PYDANTIC_RESERVED_FIELDS for it in self.inputs):
|
|
89
|
+
create_kwargs['__config__'] = ConfigDict(populate_by_name=True)
|
|
90
|
+
self.Base = create_model('Base', **create_kwargs, **it_map)
|
|
75
91
|
|
|
76
92
|
# define tool
|
|
77
93
|
# fastapi
|
|
@@ -87,7 +103,7 @@ class tool_api():
|
|
|
87
103
|
@self.app.post(f"/{self.tool_name}/")
|
|
88
104
|
def tool(data: List[self.Base] = Body(...)):
|
|
89
105
|
logger.info(data)
|
|
90
|
-
params = data[0].model_dump()
|
|
106
|
+
params = data[0].model_dump(by_alias=True)
|
|
91
107
|
outs = run_tool(self.tool, params, self.outputs, self.read_outs)
|
|
92
108
|
logger.info(outs)
|
|
93
109
|
return outs
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# coala/tool_logic.py
|
|
2
|
+
import os
|
|
3
|
+
import os.path
|
|
4
|
+
import gzip
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse, unquote
|
|
7
|
+
from urllib.request import url2pathname
|
|
8
|
+
from cwltool.context import RuntimeContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _canonical_file_uri(uri: str) -> str:
|
|
12
|
+
"""Decode file:// to a local path, resolve it, return canonical file:// URI."""
|
|
13
|
+
parsed = urlparse(uri)
|
|
14
|
+
if parsed.scheme != "file":
|
|
15
|
+
raise ValueError("expected file URI")
|
|
16
|
+
path_part = unquote(parsed.path or "")
|
|
17
|
+
if os.name == "nt":
|
|
18
|
+
local = url2pathname(path_part)
|
|
19
|
+
else:
|
|
20
|
+
local = path_part
|
|
21
|
+
return Path(local).resolve().as_uri()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _local_path_to_file_uri(path: str) -> str:
|
|
25
|
+
"""Build a file:// URI from a bare or relative local path (resolved)."""
|
|
26
|
+
p = Path(path).expanduser()
|
|
27
|
+
if not p.is_absolute():
|
|
28
|
+
p = Path(os.getcwd()) / p
|
|
29
|
+
return p.resolve().as_uri()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _remote_uri_string(s: str) -> bool:
|
|
33
|
+
"""True if s is a non-file URI (http(s), ftp, s3, …) — CWL string, not File."""
|
|
34
|
+
if s.startswith(("http://", "https://", "ftp://")):
|
|
35
|
+
return True
|
|
36
|
+
return "://" in s and not s.startswith("file://")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def configure_container_runner(runtime_context: RuntimeContext, container_runner: str) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Configure the runtime context with the specified container runner.
|
|
42
|
+
|
|
43
|
+
Parameters:
|
|
44
|
+
runtime_context: The RuntimeContext to configure
|
|
45
|
+
container_runner: 'docker', 'podman', 'singularity', 'udocker', or 'xcrunner'
|
|
46
|
+
"""
|
|
47
|
+
runtime_context.default_container = container_runner
|
|
48
|
+
runtime_context.singularity = (container_runner == 'singularity')
|
|
49
|
+
runtime_context.podman = (container_runner == 'podman')
|
|
50
|
+
if container_runner == 'xcrunner':
|
|
51
|
+
import shutil
|
|
52
|
+
# xcrunner = xcodon-runtime; its command is `xcrunner` (the `xcodon` command is the agent).
|
|
53
|
+
import os, sys
|
|
54
|
+
beside_python = os.path.join(os.path.dirname(sys.executable), 'xcrunner')
|
|
55
|
+
runtime_context.user_space_docker_cmd = (
|
|
56
|
+
(beside_python if os.access(beside_python, os.X_OK) else None)
|
|
57
|
+
or shutil.which('xcrunner') or 'xcrunner'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _read_file_content(filepath):
|
|
61
|
+
"""Read file content, handling gzipped files."""
|
|
62
|
+
try:
|
|
63
|
+
if filepath.endswith('.gz'):
|
|
64
|
+
with gzip.open(filepath, 'rt', encoding='utf-8') as f:
|
|
65
|
+
return f.read().replace('\n', '')
|
|
66
|
+
else:
|
|
67
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
68
|
+
return f.read().replace('\n', '')
|
|
69
|
+
except (UnicodeDecodeError, OSError):
|
|
70
|
+
# If reading fails (binary file, etc.), return the filepath instead
|
|
71
|
+
return filepath
|
|
72
|
+
|
|
73
|
+
def run_tool(tool, params, outputs, read_outs=False, container_runner=None):
|
|
74
|
+
"""
|
|
75
|
+
Execute a CWL tool with the given parameters.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
tool: The CWL tool object (created via factory.Factory().make())
|
|
79
|
+
params: Dictionary of input parameters
|
|
80
|
+
outputs: List of output field definitions
|
|
81
|
+
read_outs: Whether to read output file contents (default: False)
|
|
82
|
+
container_runner: Container runtime to use (default: None, uses tool's default)
|
|
83
|
+
Valid values: 'docker', 'podman', 'singularity', 'udocker', etc.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Dictionary mapping output field names to their values
|
|
87
|
+
"""
|
|
88
|
+
# Prepare params for CWL tool
|
|
89
|
+
inputs = tool.t.inputs_record_schema['fields']
|
|
90
|
+
in_dict = {}
|
|
91
|
+
for i in inputs:
|
|
92
|
+
in_dict[i['name']] = i['type']
|
|
93
|
+
|
|
94
|
+
def path_to_file_obj(path_or_file):
|
|
95
|
+
"""Local paths and file:// become CWL File; remote URIs stay plain strings."""
|
|
96
|
+
if path_or_file is None:
|
|
97
|
+
return None
|
|
98
|
+
if isinstance(path_or_file, dict):
|
|
99
|
+
loc = path_or_file.get("location")
|
|
100
|
+
if isinstance(loc, str):
|
|
101
|
+
if _remote_uri_string(loc):
|
|
102
|
+
return loc
|
|
103
|
+
if loc.startswith("file://"):
|
|
104
|
+
return {"class": "File", "location": _canonical_file_uri(loc)}
|
|
105
|
+
return {"class": "File", "location": _local_path_to_file_uri(loc)}
|
|
106
|
+
return path_or_file
|
|
107
|
+
if isinstance(path_or_file, str):
|
|
108
|
+
if _remote_uri_string(path_or_file):
|
|
109
|
+
return path_or_file
|
|
110
|
+
if path_or_file.startswith("file://"):
|
|
111
|
+
return {"class": "File", "location": _canonical_file_uri(path_or_file)}
|
|
112
|
+
return {"class": "File", "location": _local_path_to_file_uri(path_or_file)}
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def path_to_directory_obj(path_or_dir):
|
|
116
|
+
"""Local paths and file:// become CWL Directory; remote URIs stay plain strings."""
|
|
117
|
+
if path_or_dir is None:
|
|
118
|
+
return None
|
|
119
|
+
if isinstance(path_or_dir, dict):
|
|
120
|
+
loc = path_or_dir.get("location")
|
|
121
|
+
if isinstance(loc, str):
|
|
122
|
+
if _remote_uri_string(loc):
|
|
123
|
+
return loc
|
|
124
|
+
if loc.startswith("file://"):
|
|
125
|
+
return {"class": "Directory", "location": _canonical_file_uri(loc)}
|
|
126
|
+
return {"class": "Directory", "location": _local_path_to_file_uri(loc)}
|
|
127
|
+
return path_or_dir
|
|
128
|
+
if isinstance(path_or_dir, str):
|
|
129
|
+
if _remote_uri_string(path_or_dir):
|
|
130
|
+
return path_or_dir
|
|
131
|
+
if path_or_dir.startswith("file://"):
|
|
132
|
+
return {"class": "Directory", "location": _canonical_file_uri(path_or_dir)}
|
|
133
|
+
return {"class": "Directory", "location": _local_path_to_file_uri(path_or_dir)}
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def _is_path_array_type(typ, path_kind):
|
|
137
|
+
"""True if typ is array of File or Directory (dict-like or in a union list)."""
|
|
138
|
+
if hasattr(typ, 'get') and typ.get('type') == 'array':
|
|
139
|
+
items = typ.get('items', '')
|
|
140
|
+
return path_kind in str(items)
|
|
141
|
+
if isinstance(typ, list):
|
|
142
|
+
for t in typ:
|
|
143
|
+
if t == 'null':
|
|
144
|
+
continue
|
|
145
|
+
if hasattr(t, 'get') and t.get('type') == 'array':
|
|
146
|
+
if path_kind in str(t.get('items', '')):
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def _is_single_path_type(typ, path_kind):
|
|
152
|
+
"""True if typ is single File or Directory (not array)."""
|
|
153
|
+
if hasattr(typ, 'get') and typ.get('type') == 'array':
|
|
154
|
+
return False
|
|
155
|
+
if isinstance(typ, list):
|
|
156
|
+
for t in typ:
|
|
157
|
+
if t != 'null' and hasattr(t, 'get') and t.get('type') == 'array':
|
|
158
|
+
return False
|
|
159
|
+
return path_kind in ' '.join(str(t) for t in typ)
|
|
160
|
+
return path_kind in str(typ) and '[]' not in str(typ)
|
|
161
|
+
|
|
162
|
+
path_converters = {
|
|
163
|
+
'File': path_to_file_obj,
|
|
164
|
+
'Directory': path_to_directory_obj,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for k, v in params.items():
|
|
168
|
+
if k not in in_dict or v is None:
|
|
169
|
+
continue
|
|
170
|
+
type_val = in_dict[k]
|
|
171
|
+
type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
|
|
172
|
+
for path_kind, converter in path_converters.items():
|
|
173
|
+
if path_kind not in type_str:
|
|
174
|
+
continue
|
|
175
|
+
is_path_array = _is_path_array_type(type_val, path_kind)
|
|
176
|
+
if is_path_array and isinstance(v, list):
|
|
177
|
+
converted = [converter(item) for item in v]
|
|
178
|
+
if all(x is not None for x in converted):
|
|
179
|
+
params[k] = converted
|
|
180
|
+
elif _is_single_path_type(type_val, path_kind):
|
|
181
|
+
to_convert = v[0] if isinstance(v, list) and len(v) > 0 else v
|
|
182
|
+
path_obj = converter(to_convert)
|
|
183
|
+
if path_obj is not None:
|
|
184
|
+
params[k] = path_obj
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
# Modify the tool's runtime context if container runner is specified
|
|
188
|
+
if container_runner:
|
|
189
|
+
# Try to get the original runtime context from the tool
|
|
190
|
+
original_runtime_context = None
|
|
191
|
+
if hasattr(tool, 'runtime_context'):
|
|
192
|
+
original_runtime_context = tool.runtime_context
|
|
193
|
+
elif hasattr(tool, 't') and hasattr(tool.t, 'runtime_context'):
|
|
194
|
+
original_runtime_context = tool.t.runtime_context
|
|
195
|
+
|
|
196
|
+
# If we found the runtime context, modify it in place
|
|
197
|
+
if original_runtime_context:
|
|
198
|
+
configure_container_runner(original_runtime_context, container_runner)
|
|
199
|
+
|
|
200
|
+
# Execute tool (no need to pass runtime_context if we modified it in place)
|
|
201
|
+
res = tool(**params)
|
|
202
|
+
outs = {}
|
|
203
|
+
for ot in outputs:
|
|
204
|
+
out_content = res[ot['name']]
|
|
205
|
+
# Handle both list and string types (e.g., ['null', 'File'] or 'File?')
|
|
206
|
+
# Convert each item to str to handle CommentedMap from ruamel.yaml (enum types)
|
|
207
|
+
type_val = ot['type']
|
|
208
|
+
type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
|
|
209
|
+
if read_outs and 'File' in type_str:
|
|
210
|
+
# Handle both single File and File[] (array) outputs
|
|
211
|
+
file_result = res[ot['name']]
|
|
212
|
+
if isinstance(file_result, list):
|
|
213
|
+
# File[] - read first file
|
|
214
|
+
if len(file_result) > 0:
|
|
215
|
+
out_file = file_result[0]['location'].replace('file://', '')
|
|
216
|
+
out_content = _read_file_content(out_file)
|
|
217
|
+
else:
|
|
218
|
+
# Single File
|
|
219
|
+
out_file = file_result['location'].replace('file://', '')
|
|
220
|
+
out_content = _read_file_content(out_file)
|
|
221
|
+
outs[ot['name']] = out_content
|
|
222
|
+
return outs
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "coala-cli"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.2.4"
|
|
4
|
+
description = "Command-line LLM-agent Adapter"
|
|
5
5
|
authors = ["Qiang"]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
packages = [{ include = "coala" }]
|
|
9
9
|
|
|
10
10
|
[tool.poetry.dependencies]
|
|
11
|
-
python = ">=3.12,<3.
|
|
12
|
-
fastapi = ">=0.
|
|
13
|
-
requests = "^2.
|
|
14
|
-
pydantic = "^2.
|
|
15
|
-
uvicorn = "^0.
|
|
16
|
-
cwltool = "^3.
|
|
17
|
-
mcp = "^1.
|
|
11
|
+
python = ">=3.12,<3.15"
|
|
12
|
+
fastapi = ">=0.115.0"
|
|
13
|
+
requests = "^2.33.0"
|
|
14
|
+
pydantic = "^2.10.0"
|
|
15
|
+
uvicorn = "^0.32.0"
|
|
16
|
+
cwltool = "^3.2.20260413085819"
|
|
17
|
+
mcp = "^1.12.0"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
[build-system]
|
coala_cli-0.2.1/PKG-INFO
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: coala-cli
|
|
3
|
-
Version: 0.2.1
|
|
4
|
-
Summary: Convert any CMD tool into a LLM agent
|
|
5
|
-
License: MIT
|
|
6
|
-
Author: Qiang
|
|
7
|
-
Requires-Python: >=3.12,<3.14
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
-
Classifier: Programming Language :: Python :: 3
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
-
Requires-Dist: cwltool (>=3.1.20240909164951,<4.0.0)
|
|
13
|
-
Requires-Dist: fastapi (>=0.114.0)
|
|
14
|
-
Requires-Dist: mcp (>=1.9.0,<2.0.0)
|
|
15
|
-
Requires-Dist: pydantic (>=2.9.2,<3.0.0)
|
|
16
|
-
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
17
|
-
Requires-Dist: uvicorn (>=0.30.6,<0.31.0)
|
|
18
|
-
Description-Content-Type: text/markdown
|
|
19
|
-
|
|
20
|
-
# coala-cli
|
|
21
|
-
======================
|
|
22
|
-
|
|
23
|
-
## Overview
|
|
24
|
-
|
|
25
|
-
Coala, implemented as a Python package, is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
|
|
26
|
-
|
|
27
|
-
## How the Framework Works
|
|
28
|
-
|
|
29
|
-
Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
|
|
30
|
-
|
|
31
|
-
### Core Components
|
|
32
|
-
- **Client Layer:** Any MCP-compliant client application (e.g., Claude Desktop, Cursor, or custom interfaces) that utilizes LLMs (such as Gemini, GPT-5, or Claude) to enable natural language interaction.
|
|
33
|
-
- **Bridge Layer:** A local, generic MCP server that acts as a schema translator. Unlike standard MCP servers that require custom Python wrappers for each tool, the bridge layer automatically parses CWL definitions and exposes the CWL-described command-line tools as executable MCP utilities.
|
|
34
|
-
- **Execution Layer:** A standard CWL runner that executes the underlying binaries within containerized environments (Docker). This ensures that analyses are reproducible and isolated from the host system's dependencies.
|
|
35
|
-
|
|
36
|
-
### Quick Start
|
|
37
|
-
|
|
38
|
-
1. **Initialize:** Create a local MCP server instance using `mcp_api()`.
|
|
39
|
-
2. **Register:** Load your domain-specific tools described in CWL via `add_tool()` (supports local files or repositories).
|
|
40
|
-
3. **Serve:** Start the MCP server using `mcp.serve()`.
|
|
41
|
-
|
|
42
|
-
### The Workflow
|
|
43
|
-
|
|
44
|
-
- **Interact:** The user sends a natural language query to the MCP Client (e.g., Claude Desktop).
|
|
45
|
-
- **Discover & Select:** The Client retrieves the tool list from the MCP server. The LLM selects the appropriate tool and sends a structured request for the analysis.
|
|
46
|
-
- **Execute:** Coala translates this selection into a CWL job and executes it within a container (Docker), ensuring reproducibility.
|
|
47
|
-
- **Respond:** The execution logs and results are returned to the LLM, which interprets them and presents the final answer to the user.
|
|
48
|
-
|
|
49
|
-
## Get Started
|
|
50
|
-
|
|
51
|
-
### Requirements
|
|
52
|
-
|
|
53
|
-
* Python 3.12 or later
|
|
54
|
-
* FastAPI
|
|
55
|
-
* Requests
|
|
56
|
-
* Pydantic
|
|
57
|
-
* Uvicorn
|
|
58
|
-
* cwltool
|
|
59
|
-
* mcp (Model Context Protocol SDK)
|
|
60
|
-
|
|
61
|
-
### Installation
|
|
62
|
-
|
|
63
|
-
To install coala-cli, run the following command:
|
|
64
|
-
```bash
|
|
65
|
-
pip install coala-cli
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### Use Cases
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<!-- This text is a hidden note and will not be displayed in the rendered README.
|
|
72
|
-
|
|
73
|
-
### MCP server
|
|
74
|
-
|
|
75
|
-
The framework allows you to set up an MCP server with predefined tools for specific domains. For example, to create a bioinformatics-focused MCP server, you can use the following setup (as shown in [`examples/bioinfo_question.py`](examples/bioinfo_question.py)):
|
|
76
|
-
|
|
77
|
-
```python
|
|
78
|
-
from cmdagent.mcp_api import mcp_api
|
|
79
|
-
|
|
80
|
-
mcp = mcp_api(host='0.0.0.0', port=8000)
|
|
81
|
-
mcp.add_tool('examples/ncbi_datasets_gene.cwl', 'ncbi_datasets_gene')
|
|
82
|
-
mcp.add_tool('examples/bcftools_view.cwl', 'bcftools_view', read_outs=False)
|
|
83
|
-
mcp.serve()
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
This creates an MCP server that exposes two bioinformatics tools:
|
|
87
|
-
- `ncbi_datasets_gene`: Retrieves gene metadata from NCBI datasets
|
|
88
|
-
- `bcftools_view`: Subsets and filters VCF/BCF files
|
|
89
|
-
|
|
90
|
-
Once the server is running, you can configure your MCP client (e.g., in Cursor) to connect to it:
|
|
91
|
-
|
|
92
|
-
```json
|
|
93
|
-
{
|
|
94
|
-
"mcpServers": {
|
|
95
|
-
"cmdagent": {
|
|
96
|
-
"url": "http://localhost:8000/mcp",
|
|
97
|
-
"transport": "streamable-http"
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
With this setup, you can ask the LLM natural language questions like:
|
|
104
|
-
- "Give me a summary about gene BRCA1"
|
|
105
|
-
- "Subset variants in the gene BRCA1 from the https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz"
|
|
106
|
-
|
|
107
|
-
The LLM will automatically discover the available tools, understand their parameters, invoke the appropriate tool with the correct arguments, and present the results in a user-friendly format.
|
|
108
|
-
|
|
109
|
-
* Start MCP server
|
|
110
|
-
```
|
|
111
|
-
python examples/bioinfo_question.py
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
* Call by MCP client from Cursor
|
|
115
|
-
[](https://www.youtube.com/watch?v=QqevFmQbTDU)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
### Function call
|
|
119
|
-
* Creating an API
|
|
120
|
-
|
|
121
|
-
To create an API, import the `tool_api` function from `cmdagent.remote_api` and pass in the path to a CWL file and the name of the tool:
|
|
122
|
-
```python
|
|
123
|
-
from cmdagent.remote_api import tool_api
|
|
124
|
-
|
|
125
|
-
api = tool_api(cwl_file='tests/dockstore-tool-md5sum.cwl', tool_name='md5sum')
|
|
126
|
-
api.serve()
|
|
127
|
-
```
|
|
128
|
-
The `api.serve()` method will start a RESTful API as a service, allowing you to run the tool remotely from the cloud or locally.
|
|
129
|
-
|
|
130
|
-
* Creating a Tool Agent
|
|
131
|
-
|
|
132
|
-
To create a tool agent, import the `cmdagent` function from `cmdagent.agent` and pass in the API instance:
|
|
133
|
-
```python
|
|
134
|
-
from cmdagent.agent import tool_agent
|
|
135
|
-
|
|
136
|
-
ta = tool_agent(api)
|
|
137
|
-
md5 = ta.create_tool()
|
|
138
|
-
md5(input_file="tests/dockstore-tool-md5sum.cwl")
|
|
139
|
-
```
|
|
140
|
-
Function `md5` is created automatically based on the `api`.
|
|
141
|
-
|
|
142
|
-
* Function call with Gemini
|
|
143
|
-
|
|
144
|
-
To integrate the tool agent with Gemini, import the `GenerativeModel` class from `google.generativeai` and create a new instance:
|
|
145
|
-
```python
|
|
146
|
-
import google.generativeai as genai
|
|
147
|
-
|
|
148
|
-
genai.configure(api_key="******")
|
|
149
|
-
model = genai.GenerativeModel(model_name='gemini-1.5-flash', tools=[md5])
|
|
150
|
-
|
|
151
|
-
chat = model.start_chat(enable_automatic_function_calling=True)
|
|
152
|
-
response = chat.send_message("what is md5 of tests/dockstore-tool-md5sum.cwl?")
|
|
153
|
-
response.text
|
|
154
|
-
```
|
|
155
|
-
```
|
|
156
|
-
'The md5sum of tests/dockstore-tool-md5sum.cwl is ad59d9e9ed6344f5c20ee7e0143c6c12. \n'
|
|
157
|
-
```
|
|
158
|
-
-->
|
coala_cli-0.2.1/README.md
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
# coala-cli
|
|
2
|
-
======================
|
|
3
|
-
|
|
4
|
-
## Overview
|
|
5
|
-
|
|
6
|
-
Coala, implemented as a Python package, is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
|
|
7
|
-
|
|
8
|
-
## How the Framework Works
|
|
9
|
-
|
|
10
|
-
Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
|
|
11
|
-
|
|
12
|
-
### Core Components
|
|
13
|
-
- **Client Layer:** Any MCP-compliant client application (e.g., Claude Desktop, Cursor, or custom interfaces) that utilizes LLMs (such as Gemini, GPT-5, or Claude) to enable natural language interaction.
|
|
14
|
-
- **Bridge Layer:** A local, generic MCP server that acts as a schema translator. Unlike standard MCP servers that require custom Python wrappers for each tool, the bridge layer automatically parses CWL definitions and exposes the CWL-described command-line tools as executable MCP utilities.
|
|
15
|
-
- **Execution Layer:** A standard CWL runner that executes the underlying binaries within containerized environments (Docker). This ensures that analyses are reproducible and isolated from the host system's dependencies.
|
|
16
|
-
|
|
17
|
-
### Quick Start
|
|
18
|
-
|
|
19
|
-
1. **Initialize:** Create a local MCP server instance using `mcp_api()`.
|
|
20
|
-
2. **Register:** Load your domain-specific tools described in CWL via `add_tool()` (supports local files or repositories).
|
|
21
|
-
3. **Serve:** Start the MCP server using `mcp.serve()`.
|
|
22
|
-
|
|
23
|
-
### The Workflow
|
|
24
|
-
|
|
25
|
-
- **Interact:** The user sends a natural language query to the MCP Client (e.g., Claude Desktop).
|
|
26
|
-
- **Discover & Select:** The Client retrieves the tool list from the MCP server. The LLM selects the appropriate tool and sends a structured request for the analysis.
|
|
27
|
-
- **Execute:** Coala translates this selection into a CWL job and executes it within a container (Docker), ensuring reproducibility.
|
|
28
|
-
- **Respond:** The execution logs and results are returned to the LLM, which interprets them and presents the final answer to the user.
|
|
29
|
-
|
|
30
|
-
## Get Started
|
|
31
|
-
|
|
32
|
-
### Requirements
|
|
33
|
-
|
|
34
|
-
* Python 3.12 or later
|
|
35
|
-
* FastAPI
|
|
36
|
-
* Requests
|
|
37
|
-
* Pydantic
|
|
38
|
-
* Uvicorn
|
|
39
|
-
* cwltool
|
|
40
|
-
* mcp (Model Context Protocol SDK)
|
|
41
|
-
|
|
42
|
-
### Installation
|
|
43
|
-
|
|
44
|
-
To install coala-cli, run the following command:
|
|
45
|
-
```bash
|
|
46
|
-
pip install coala-cli
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### Use Cases
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<!-- This text is a hidden note and will not be displayed in the rendered README.
|
|
53
|
-
|
|
54
|
-
### MCP server
|
|
55
|
-
|
|
56
|
-
The framework allows you to set up an MCP server with predefined tools for specific domains. For example, to create a bioinformatics-focused MCP server, you can use the following setup (as shown in [`examples/bioinfo_question.py`](examples/bioinfo_question.py)):
|
|
57
|
-
|
|
58
|
-
```python
|
|
59
|
-
from cmdagent.mcp_api import mcp_api
|
|
60
|
-
|
|
61
|
-
mcp = mcp_api(host='0.0.0.0', port=8000)
|
|
62
|
-
mcp.add_tool('examples/ncbi_datasets_gene.cwl', 'ncbi_datasets_gene')
|
|
63
|
-
mcp.add_tool('examples/bcftools_view.cwl', 'bcftools_view', read_outs=False)
|
|
64
|
-
mcp.serve()
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
This creates an MCP server that exposes two bioinformatics tools:
|
|
68
|
-
- `ncbi_datasets_gene`: Retrieves gene metadata from NCBI datasets
|
|
69
|
-
- `bcftools_view`: Subsets and filters VCF/BCF files
|
|
70
|
-
|
|
71
|
-
Once the server is running, you can configure your MCP client (e.g., in Cursor) to connect to it:
|
|
72
|
-
|
|
73
|
-
```json
|
|
74
|
-
{
|
|
75
|
-
"mcpServers": {
|
|
76
|
-
"cmdagent": {
|
|
77
|
-
"url": "http://localhost:8000/mcp",
|
|
78
|
-
"transport": "streamable-http"
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
With this setup, you can ask the LLM natural language questions like:
|
|
85
|
-
- "Give me a summary about gene BRCA1"
|
|
86
|
-
- "Subset variants in the gene BRCA1 from the https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz"
|
|
87
|
-
|
|
88
|
-
The LLM will automatically discover the available tools, understand their parameters, invoke the appropriate tool with the correct arguments, and present the results in a user-friendly format.
|
|
89
|
-
|
|
90
|
-
* Start MCP server
|
|
91
|
-
```
|
|
92
|
-
python examples/bioinfo_question.py
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
* Call by MCP client from Cursor
|
|
96
|
-
[](https://www.youtube.com/watch?v=QqevFmQbTDU)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
### Function call
|
|
100
|
-
* Creating an API
|
|
101
|
-
|
|
102
|
-
To create an API, import the `tool_api` function from `cmdagent.remote_api` and pass in the path to a CWL file and the name of the tool:
|
|
103
|
-
```python
|
|
104
|
-
from cmdagent.remote_api import tool_api
|
|
105
|
-
|
|
106
|
-
api = tool_api(cwl_file='tests/dockstore-tool-md5sum.cwl', tool_name='md5sum')
|
|
107
|
-
api.serve()
|
|
108
|
-
```
|
|
109
|
-
The `api.serve()` method will start a RESTful API as a service, allowing you to run the tool remotely from the cloud or locally.
|
|
110
|
-
|
|
111
|
-
* Creating a Tool Agent
|
|
112
|
-
|
|
113
|
-
To create a tool agent, import the `cmdagent` function from `cmdagent.agent` and pass in the API instance:
|
|
114
|
-
```python
|
|
115
|
-
from cmdagent.agent import tool_agent
|
|
116
|
-
|
|
117
|
-
ta = tool_agent(api)
|
|
118
|
-
md5 = ta.create_tool()
|
|
119
|
-
md5(input_file="tests/dockstore-tool-md5sum.cwl")
|
|
120
|
-
```
|
|
121
|
-
Function `md5` is created automatically based on the `api`.
|
|
122
|
-
|
|
123
|
-
* Function call with Gemini
|
|
124
|
-
|
|
125
|
-
To integrate the tool agent with Gemini, import the `GenerativeModel` class from `google.generativeai` and create a new instance:
|
|
126
|
-
```python
|
|
127
|
-
import google.generativeai as genai
|
|
128
|
-
|
|
129
|
-
genai.configure(api_key="******")
|
|
130
|
-
model = genai.GenerativeModel(model_name='gemini-1.5-flash', tools=[md5])
|
|
131
|
-
|
|
132
|
-
chat = model.start_chat(enable_automatic_function_calling=True)
|
|
133
|
-
response = chat.send_message("what is md5 of tests/dockstore-tool-md5sum.cwl?")
|
|
134
|
-
response.text
|
|
135
|
-
```
|
|
136
|
-
```
|
|
137
|
-
'The md5sum of tests/dockstore-tool-md5sum.cwl is ad59d9e9ed6344f5c20ee7e0143c6c12. \n'
|
|
138
|
-
```
|
|
139
|
-
-->
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
# coala/tool_logic.py
|
|
2
|
-
import os.path
|
|
3
|
-
import gzip
|
|
4
|
-
from cwltool.context import RuntimeContext
|
|
5
|
-
|
|
6
|
-
def configure_container_runner(runtime_context: RuntimeContext, container_runner: str) -> None:
|
|
7
|
-
"""
|
|
8
|
-
Configure the runtime context with the specified container runner.
|
|
9
|
-
|
|
10
|
-
Parameters:
|
|
11
|
-
runtime_context: The RuntimeContext to configure
|
|
12
|
-
container_runner: Container runtime to use ('docker', 'podman', 'singularity', 'udocker', etc.)
|
|
13
|
-
"""
|
|
14
|
-
runtime_context.default_container = container_runner
|
|
15
|
-
# Set boolean flags for specific container runners
|
|
16
|
-
runtime_context.singularity = (container_runner == 'singularity')
|
|
17
|
-
runtime_context.podman = (container_runner == 'podman')
|
|
18
|
-
|
|
19
|
-
def _read_file_content(filepath):
|
|
20
|
-
"""Read file content, handling gzipped files."""
|
|
21
|
-
try:
|
|
22
|
-
if filepath.endswith('.gz'):
|
|
23
|
-
with gzip.open(filepath, 'rt', encoding='utf-8') as f:
|
|
24
|
-
return f.read().replace('\n', '')
|
|
25
|
-
else:
|
|
26
|
-
with open(filepath, 'r', encoding='utf-8') as f:
|
|
27
|
-
return f.read().replace('\n', '')
|
|
28
|
-
except (UnicodeDecodeError, OSError):
|
|
29
|
-
# If reading fails (binary file, etc.), return the filepath instead
|
|
30
|
-
return filepath
|
|
31
|
-
|
|
32
|
-
def run_tool(tool, params, outputs, read_outs=False, container_runner=None):
|
|
33
|
-
"""
|
|
34
|
-
Execute a CWL tool with the given parameters.
|
|
35
|
-
|
|
36
|
-
Parameters:
|
|
37
|
-
tool: The CWL tool object (created via factory.Factory().make())
|
|
38
|
-
params: Dictionary of input parameters
|
|
39
|
-
outputs: List of output field definitions
|
|
40
|
-
read_outs: Whether to read output file contents (default: False)
|
|
41
|
-
container_runner: Container runtime to use (default: None, uses tool's default)
|
|
42
|
-
Valid values: 'docker', 'podman', 'singularity', 'udocker', etc.
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
Dictionary mapping output field names to their values
|
|
46
|
-
"""
|
|
47
|
-
# Prepare params for CWL tool
|
|
48
|
-
inputs = tool.t.inputs_record_schema['fields']
|
|
49
|
-
in_dict = {}
|
|
50
|
-
for i in inputs:
|
|
51
|
-
in_dict[i['name']] = i['type']
|
|
52
|
-
|
|
53
|
-
for k, v in params.items():
|
|
54
|
-
if k in in_dict:
|
|
55
|
-
type_val = in_dict[k]
|
|
56
|
-
# Handle both list and string types (e.g., ['null', 'File'] or 'File?')
|
|
57
|
-
# Convert each item to str to handle CommentedMap from ruamel.yaml (enum types)
|
|
58
|
-
type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
|
|
59
|
-
if 'File' in type_str and v is not None:
|
|
60
|
-
if type(v) is dict and 'location' in v:
|
|
61
|
-
location = v['location']
|
|
62
|
-
elif isinstance(v, str) and v.startswith('file://'):
|
|
63
|
-
location = v
|
|
64
|
-
elif isinstance(v, str) and os.path.isfile(v):
|
|
65
|
-
location = f"file://{v}"
|
|
66
|
-
else:
|
|
67
|
-
continue # Do nothing if v is not a file
|
|
68
|
-
|
|
69
|
-
params[k] = {
|
|
70
|
-
"class": "File",
|
|
71
|
-
"location": location
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
# Modify the tool's runtime context if container runner is specified
|
|
75
|
-
if container_runner:
|
|
76
|
-
# Try to get the original runtime context from the tool
|
|
77
|
-
original_runtime_context = None
|
|
78
|
-
if hasattr(tool, 'runtime_context'):
|
|
79
|
-
original_runtime_context = tool.runtime_context
|
|
80
|
-
elif hasattr(tool, 't') and hasattr(tool.t, 'runtime_context'):
|
|
81
|
-
original_runtime_context = tool.t.runtime_context
|
|
82
|
-
|
|
83
|
-
# If we found the runtime context, modify it in place
|
|
84
|
-
if original_runtime_context:
|
|
85
|
-
configure_container_runner(original_runtime_context, container_runner)
|
|
86
|
-
|
|
87
|
-
# Execute tool (no need to pass runtime_context if we modified it in place)
|
|
88
|
-
res = tool(**params)
|
|
89
|
-
outs = {}
|
|
90
|
-
for ot in outputs:
|
|
91
|
-
out_content = res[ot['name']]
|
|
92
|
-
# Handle both list and string types (e.g., ['null', 'File'] or 'File?')
|
|
93
|
-
# Convert each item to str to handle CommentedMap from ruamel.yaml (enum types)
|
|
94
|
-
type_val = ot['type']
|
|
95
|
-
type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
|
|
96
|
-
if read_outs and 'File' in type_str:
|
|
97
|
-
# Handle both single File and File[] (array) outputs
|
|
98
|
-
file_result = res[ot['name']]
|
|
99
|
-
if isinstance(file_result, list):
|
|
100
|
-
# File[] - read first file
|
|
101
|
-
if len(file_result) > 0:
|
|
102
|
-
out_file = file_result[0]['location'].replace('file://', '')
|
|
103
|
-
out_content = _read_file_content(out_file)
|
|
104
|
-
else:
|
|
105
|
-
# Single File
|
|
106
|
-
out_file = file_result['location'].replace('file://', '')
|
|
107
|
-
out_content = _read_file_content(out_file)
|
|
108
|
-
outs[ot['name']] = out_content
|
|
109
|
-
return outs
|
|
File without changes
|
|
File without changes
|