Skip to content

Commit 1ab58bb

Browse files
committed
mcp commands
1 parent 4493886 commit 1ab58bb

5 files changed

Lines changed: 390 additions & 5 deletions

File tree

clarifai/cli/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ def run(ctx, script, context=None):
733733
('Auth', ['login', 'whoami', 'logout']),
734734
('Config', ['config']),
735735
('Models', ['model']),
736+
('MCP', ['mcp']),
736737
('Skills', ['skills']),
737738
('Pipelines', ['pipeline', 'pipelinestep', 'pipelinerun', 'pipelinetemplate']),
738739
('Compute', ['list-instances', 'computecluster', 'nodepool', 'deployment']),

clarifai/cli/mcp.py

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
"""MCP CLI: merchandised shortcuts over the model engine.
2+
3+
Wraps `clarifai model init --toolkit mcp`, `clarifai model deploy`, and
4+
list-models so MCP tool builders get an opinionated, friendly surface
5+
without losing access to the general-purpose `model` commands.
6+
"""
7+
8+
import os
9+
import shutil
10+
11+
import click
12+
13+
from clarifai.cli.base import cli
14+
from clarifai.errors import UserError
15+
from clarifai.utils.cli import (
16+
AliasedGroup,
17+
from_yaml,
18+
validate_context,
19+
)
20+
from clarifai.utils.logging import logger
21+
22+
23+
@cli.group(
24+
['mcp'],
25+
cls=AliasedGroup,
26+
context_settings={'max_content_width': shutil.get_terminal_size().columns - 10},
27+
)
28+
def mcp():
29+
"""Build, deploy, and manage MCP (Model Context Protocol) servers.
30+
31+
\b
32+
Workflow: init → (model serve) → deploy → list
33+
34+
\b
35+
MCP servers are models under the hood. These commands are opinionated
36+
shortcuts. For everything else, use the full model engine:
37+
38+
\b
39+
clarifai model serve # local testing
40+
clarifai model upload # upload without deploying
41+
clarifai model logs # stream deployment logs
42+
clarifai model status # check deployment health
43+
clarifai model undeploy # tear down a deployment
44+
clarifai model list # list all models (not just MCP)
45+
"""
46+
47+
48+
@mcp.command()
49+
@click.argument("model_path", type=click.Path(), required=False, default=None)
50+
@click.pass_context
51+
def init(ctx, model_path):
52+
"""Scaffold a new MCP server project.
53+
54+
\b
55+
Creates a ready-to-use MCP server directory with everything needed
56+
to define tools, resources, and prompts — then serve or deploy them.
57+
58+
\b
59+
MODEL_PATH Project directory name or path (default: current directory).
60+
61+
\b
62+
Examples:
63+
clarifai mcp init my-search-tool
64+
clarifai mcp init # scaffold in current directory
65+
66+
\b
67+
Next steps:
68+
clarifai model serve ./my-search-tool # test locally
69+
clarifai mcp deploy ./my-search-tool # deploy to Clarifai
70+
"""
71+
# Defer import to avoid cycles and to honor the lazy-loading pattern.
72+
from clarifai.cli.model import init as model_init
73+
74+
ctx.invoke(
75+
model_init,
76+
model_path=model_path,
77+
toolkit='mcp',
78+
model_name=None,
79+
streaming_video=False,
80+
)
81+
82+
_apply_mcp_compute_defaults(model_path)
83+
84+
85+
def _apply_mcp_compute_defaults(model_path):
86+
"""Override the template's GPU default with a CPU instance for MCP projects.
87+
88+
The shared model-init template hard-codes `compute.instance: g5.xlarge`,
89+
which is wrong for MCP servers. MCP runs on CPU (see compute_presets.py).
90+
"""
91+
target_dir = os.path.abspath(model_path) if model_path else os.getcwd()
92+
config_path = os.path.join(target_dir, 'config.yaml')
93+
if not os.path.isfile(config_path):
94+
return
95+
96+
try:
97+
import yaml
98+
99+
with open(config_path, 'r') as f:
100+
cfg = yaml.safe_load(f) or {}
101+
102+
if (cfg.get('model') or {}).get('model_type_id') != 'mcp':
103+
return
104+
105+
compute = cfg.setdefault('compute', {})
106+
# Only override the template's GPU default; respect any explicit user choice.
107+
if compute.get('instance') in (None, '', 'g5.xlarge'):
108+
compute['instance'] = 't3a.2xlarge'
109+
with open(config_path, 'w') as f:
110+
yaml.safe_dump(cfg, f, sort_keys=False)
111+
except Exception as exc: # noqa: BLE001
112+
logger.debug(f"Could not apply MCP compute defaults: {exc}")
113+
114+
115+
def _read_mcp_config(model_path):
116+
"""Read config.yaml and verify it's an MCP project.
117+
118+
Returns the parsed config dict on success. Raises UserError otherwise.
119+
"""
120+
config_path = os.path.join(model_path, 'config.yaml')
121+
if not os.path.isfile(config_path):
122+
raise UserError(
123+
f"No config.yaml found at '{config_path}'.\n"
124+
" Initialize an MCP project first:\n"
125+
" clarifai mcp init"
126+
)
127+
config = from_yaml(config_path)
128+
model_type_id = config.get('model', {}).get('model_type_id')
129+
if model_type_id != 'mcp':
130+
raise UserError(
131+
f"'{model_path}' is not an MCP project (model_type_id='{model_type_id}').\n"
132+
" Use 'clarifai model deploy' for non-MCP models, or scaffold an MCP\n"
133+
" project with 'clarifai mcp init'."
134+
)
135+
return config
136+
137+
138+
def _make_url_helper(base_url, pat):
139+
"""Construct a ClarifaiUrlHelper bound to the active context's base/PAT."""
140+
from clarifai.client.auth.helper import ClarifaiAuthHelper
141+
from clarifai.urls.helper import ClarifaiUrlHelper
142+
143+
auth = ClarifaiAuthHelper(
144+
user_id="",
145+
app_id="",
146+
pat=pat or "",
147+
base=base_url or "https://api.clarifai.com",
148+
validate=False,
149+
)
150+
return ClarifaiUrlHelper(auth=auth)
151+
152+
153+
def _print_mcp_endpoint(result, base_url, pat):
154+
"""Append an MCP-specific footer (endpoint URL + connect hint) to deploy output."""
155+
from clarifai.runners.models import deploy_output as out
156+
157+
helper = _make_url_helper(base_url, pat)
158+
endpoint = helper.mcp_api_url(
159+
user_id=result['user_id'],
160+
app_id=result['app_id'],
161+
model_id=result['model_id'],
162+
)
163+
164+
click.echo()
165+
out.phase_header("MCP Endpoint")
166+
out.link("URL", endpoint)
167+
click.echo()
168+
click.echo(click.style(" Connect an MCP client:", bold=True))
169+
click.echo(f" URL: {endpoint}")
170+
click.echo(" Header: Authorization: Bearer $CLARIFAI_PAT")
171+
172+
173+
@mcp.command()
174+
@click.argument('model_path', type=click.Path(), required=False, default=None)
175+
@click.option(
176+
'--instance',
177+
default=None,
178+
help='Hardware instance type. Auto-selects a CPU instance if omitted (MCP servers rarely need GPU).',
179+
)
180+
@click.option(
181+
'--model-url',
182+
default=None,
183+
help='Deploy an already-uploaded MCP model by its Clarifai URL (skips upload).',
184+
)
185+
@click.option(
186+
'--model-version-id',
187+
default=None,
188+
help='Specific model version to deploy (default: latest).',
189+
)
190+
@click.option(
191+
'--min-replicas',
192+
default=1,
193+
type=int,
194+
show_default=True,
195+
help='Minimum number of running replicas.',
196+
)
197+
@click.option(
198+
'--max-replicas',
199+
default=5,
200+
type=int,
201+
show_default=True,
202+
help='Maximum replicas for autoscaling.',
203+
)
204+
@click.option(
205+
'--cloud',
206+
default=None,
207+
help='Cloud provider (e.g., aws, gcp). Auto-detected from --instance if omitted.',
208+
)
209+
@click.option(
210+
'--region',
211+
default=None,
212+
help='Cloud region (e.g., us-east-1). Auto-detected from --instance if omitted.',
213+
)
214+
@click.option(
215+
'--compute-cluster-id',
216+
default=None,
217+
help='[Advanced] Existing compute cluster ID (skip auto-creation).',
218+
)
219+
@click.option(
220+
'--nodepool-id',
221+
default=None,
222+
help='[Advanced] Existing nodepool ID (skip auto-creation).',
223+
)
224+
@click.option(
225+
'-v',
226+
'--verbose',
227+
is_flag=True,
228+
help='Show detailed build, upload, and deployment logs.',
229+
)
230+
@click.pass_context
231+
def deploy(
232+
ctx,
233+
model_path,
234+
instance,
235+
model_url,
236+
model_version_id,
237+
min_replicas,
238+
max_replicas,
239+
cloud,
240+
region,
241+
compute_cluster_id,
242+
nodepool_id,
243+
verbose,
244+
):
245+
"""Deploy an MCP server to Clarifai cloud compute.
246+
247+
\b
248+
Wraps `clarifai model deploy` with MCP-aware defaults: a CPU instance
249+
is auto-selected when --instance is omitted, and the MCP endpoint URL
250+
is printed alongside the standard deploy output.
251+
252+
\b
253+
MODEL_PATH Local MCP project directory (default: ".").
254+
Not needed when using --model-url.
255+
256+
\b
257+
Examples:
258+
clarifai mcp deploy ./my-search-tool
259+
clarifai mcp deploy --model-url https://clarifai.com/user/app/models/my-mcp
260+
clarifai mcp deploy ./my-search-tool --instance t3a.2xlarge
261+
"""
262+
validate_context(ctx)
263+
264+
if model_path and model_url:
265+
raise UserError("Specify only one of: MODEL_PATH or --model-url.")
266+
267+
# Default to current directory when neither is given (matches model deploy)
268+
resolved_path = None
269+
if not model_url:
270+
resolved_path = os.path.abspath(model_path or ".")
271+
if not os.path.isdir(resolved_path):
272+
raise click.BadParameter(f"Model path '{resolved_path}' is not a directory.")
273+
# Pre-flight: confirm it's an MCP project (gives a friendly error early,
274+
# before the model is uploaded).
275+
_read_mcp_config(resolved_path)
276+
277+
from clarifai.cli.model import _print_deploy_result
278+
from clarifai.runners.models.model_deploy import ModelDeployer
279+
280+
user_id = ctx.obj.current.user_id
281+
app_id = getattr(ctx.obj.current, 'app_id', None)
282+
pat = ctx.obj.current.pat
283+
base_url = ctx.obj.current.api_base
284+
285+
deployer = ModelDeployer(
286+
model_path=resolved_path,
287+
model_url=model_url,
288+
user_id=user_id,
289+
app_id=app_id,
290+
model_version_id=model_version_id,
291+
instance_type=instance,
292+
cloud_provider=cloud,
293+
region=region,
294+
compute_cluster_id=compute_cluster_id,
295+
nodepool_id=nodepool_id,
296+
min_replicas=min_replicas,
297+
max_replicas=max_replicas,
298+
pat=pat,
299+
base_url=base_url,
300+
verbose=verbose,
301+
)
302+
303+
result = deployer.deploy()
304+
_print_deploy_result(result)
305+
_print_mcp_endpoint(result, base_url=base_url, pat=pat)
306+
307+
308+
@mcp.command(name="list")
309+
@click.option(
310+
'-u',
311+
'--user-id',
312+
type=str,
313+
default=None,
314+
help='User ID to list MCP servers for (default: current user). Use "all" for the public catalog.',
315+
)
316+
@click.option(
317+
'-a',
318+
'--app-id',
319+
type=str,
320+
default=None,
321+
help='Filter by app ID.',
322+
)
323+
@click.pass_context
324+
def list_mcp(ctx, user_id, app_id):
325+
"""List MCP servers (filtered view of model deployments).
326+
327+
\b
328+
Shows MCP models you've created along with their MCP endpoint URLs.
329+
For deployment status, run 'clarifai model status <user>/<app>/models/<id>'.
330+
331+
\b
332+
Examples:
333+
clarifai mcp list
334+
clarifai mcp list --app-id my-app
335+
clarifai mcp list --user-id all # public MCP servers
336+
"""
337+
validate_context(ctx)
338+
339+
from tabulate import tabulate
340+
341+
from clarifai.client.user import User
342+
343+
pat = ctx.obj.current.pat
344+
base_url = ctx.obj.current.api_base
345+
effective_user_id = user_id or ctx.obj.current.user_id
346+
347+
user = User(user_id=effective_user_id, pat=pat, base_url=base_url)
348+
all_models = user.list_models(
349+
user_id=effective_user_id, app_id=app_id, show=False, return_clarifai_model=False
350+
)
351+
352+
helper = _make_url_helper(base_url, pat)
353+
354+
rows = []
355+
for m in all_models:
356+
if m.get('model_type') != 'mcp':
357+
continue
358+
endpoint = helper.mcp_api_url(user_id=m['user_id'], app_id=m['app_id'], model_id=m['id'])
359+
rows.append(
360+
{
361+
'NAME': m['id'],
362+
'APP': f"{m['user_id']}/{m['app_id']}",
363+
'MCP ENDPOINT': endpoint,
364+
}
365+
)
366+
367+
if not rows:
368+
click.echo("No MCP servers found.")
369+
click.echo()
370+
click.echo(" Create one with:")
371+
click.echo(" clarifai mcp init my-mcp-server")
372+
click.echo(" clarifai mcp deploy ./my-mcp-server")
373+
return
374+
375+
click.echo(tabulate(rows, headers="keys"))
376+
click.echo()
377+
click.echo(
378+
f" {len(rows)} MCP server(s). Use 'clarifai model status <user>/<app>/models/<id>' for deployment details."
379+
)
380+
381+
# Reference logger so unused-import lints stay clean if we later add diagnostics.
382+
_ = logger

0 commit comments

Comments
 (0)