@invokehq/cli 0.2.2 → 0.2.3

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.
package/agentify.py ADDED
@@ -0,0 +1,1785 @@
1
+ #!/usr/bin/env python3
2
+ """Generate small MCP wrappers for Invoke.
3
+
4
+ Examples:
5
+ invoke wrap postgresql --query "SELECT * FROM users WHERE id = :user_id"
6
+ invoke wrap my-fastapi-app --openapi openapi.json --base-url http://localhost:8000
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import getpass
14
+ import json
15
+ import os
16
+ import re
17
+ import socket
18
+ import sys
19
+ import textwrap
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from http.server import BaseHTTPRequestHandler, HTTPServer
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+
28
+ HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
29
+ LAUNCH_CONNECTORS = {"github", "notion", "linear"}
30
+ HOSTED_API_URL = "https://agentgate-ai.onrender.com"
31
+ DEFAULT_API_URL = HOSTED_API_URL
32
+ DEFAULT_TIMEOUT_SECONDS = int(os.getenv("INVOKE_TIMEOUT_SECONDS", "90"))
33
+
34
+
35
+ class CliUsageError(ValueError):
36
+ """User-facing CLI error that should print without a Python traceback."""
37
+
38
+
39
+ def slugify(value: str) -> str:
40
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
41
+ return slug or "wrapped-tool"
42
+
43
+
44
+ def tool_name(value: str) -> str:
45
+ name = re.sub(r"[^a-zA-Z0-9_]+", "_", value).strip("_").lower()
46
+ return name or "wrapped_tool"
47
+
48
+
49
+ def invoke_home() -> Path:
50
+ return Path(os.getenv("INVOKE_HOME", Path.home() / ".invoke"))
51
+
52
+
53
+ def credentials_path() -> Path:
54
+ return invoke_home() / "config.json"
55
+
56
+
57
+ def legacy_credentials_path() -> Path:
58
+ return invoke_home() / "credentials.json"
59
+
60
+
61
+ def deployments_path() -> Path:
62
+ return invoke_home() / "deployments.json"
63
+
64
+
65
+ def dev_runtime_path(project_root: Path) -> Path:
66
+ return project_root / ".invoke" / "dev.json"
67
+
68
+
69
+ def load_json_file(path: Path, default: Any) -> Any:
70
+ if not path.exists():
71
+ return default
72
+ try:
73
+ return json.loads(path.read_text(encoding="utf-8"))
74
+ except json.JSONDecodeError as exc:
75
+ raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
76
+
77
+
78
+ def write_json_file(path: Path, data: Any) -> None:
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
81
+
82
+
83
+ def package_version() -> str:
84
+ package_path = Path(__file__).with_name("package.json")
85
+ try:
86
+ data = load_json_file(package_path, {})
87
+ except ValueError:
88
+ data = {}
89
+ if isinstance(data, dict) and data.get("version"):
90
+ return str(data["version"])
91
+ return "dev"
92
+
93
+
94
+ def project_context(root: Path) -> str:
95
+ context_path = root / ".invoke" / "context.json"
96
+ if context_path.exists():
97
+ try:
98
+ context = load_json_file(context_path, {})
99
+ except ValueError:
100
+ return str(context_path)
101
+ if isinstance(context, dict):
102
+ name = context.get("slug") or context.get("name") or context.get("target")
103
+ return str(name or context_path)
104
+ try:
105
+ config = read_project(root)
106
+ except Exception:
107
+ return ""
108
+ return str(config.get("slug") or config.get("name") or root.name)
109
+
110
+
111
+ def is_placeholder_mcp_url(value: str | None) -> bool:
112
+ return not value or "replace-with-your" in value
113
+
114
+
115
+ def mask_key(value: str) -> str:
116
+ if not value:
117
+ return "(not set)"
118
+ if len(value) <= 12:
119
+ return value[:4] + "..."
120
+ return value[:8] + "..." + value[-4:]
121
+
122
+
123
+ def load_credentials() -> dict[str, str]:
124
+ stored = load_json_file(credentials_path(), {})
125
+ if not stored and legacy_credentials_path().exists():
126
+ stored = load_json_file(legacy_credentials_path(), {})
127
+ if not isinstance(stored, dict):
128
+ stored = {}
129
+ return {
130
+ "base_url": (
131
+ os.getenv("INVOKE_BASE_URL")
132
+ or stored.get("base_url")
133
+ or stored.get("baseUrl")
134
+ or DEFAULT_API_URL
135
+ ),
136
+ "api_key": os.getenv("INVOKE_API_KEY") or stored.get("api_key") or stored.get("apiKey") or "",
137
+ }
138
+
139
+
140
+ def write_credentials(base_url: str, api_key: str) -> None:
141
+ cleaned_base_url = base_url.rstrip("/")
142
+ cleaned_api_key = api_key.strip()
143
+ write_json_file(
144
+ credentials_path(),
145
+ {
146
+ "base_url": cleaned_base_url,
147
+ "baseUrl": cleaned_base_url,
148
+ "api_key": cleaned_api_key,
149
+ "apiKey": cleaned_api_key,
150
+ "updated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
151
+ },
152
+ )
153
+
154
+
155
+ def normalize_config_key(key: str) -> str:
156
+ aliases = {
157
+ "base-url": "base_url",
158
+ "base_url": "base_url",
159
+ "baseUrl": "base_url",
160
+ "api-key": "api_key",
161
+ "api_key": "api_key",
162
+ "apiKey": "api_key",
163
+ }
164
+ if key not in aliases:
165
+ raise CliUsageError("Config key must be base-url or api-key.")
166
+ return aliases[key]
167
+
168
+
169
+ def runtime_error_hint(base_url: str) -> str:
170
+ return textwrap.dedent(
171
+ f"""\
172
+
173
+ Runtime: {base_url}
174
+
175
+ Fix:
176
+ invoke login --base-url {HOSTED_API_URL} --api-key <your_key>
177
+
178
+ Local dev:
179
+ python main.py
180
+ invoke login --base-url http://localhost:8000 --api-key <your_key>
181
+
182
+ You can also set INVOKE_BASE_URL and INVOKE_API_KEY.
183
+ """
184
+ ).rstrip()
185
+
186
+
187
+ def api_request(method: str, base_url: str, path: str, api_key: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
188
+ url = f"{base_url.rstrip('/')}{path}"
189
+ data = json.dumps(body or {}).encode("utf-8") if body is not None else None
190
+ request = urllib.request.Request(
191
+ url,
192
+ data=data,
193
+ method=method,
194
+ headers={
195
+ "Content-Type": "application/json",
196
+ "X-API-Key": api_key,
197
+ },
198
+ )
199
+ try:
200
+ with urllib.request.urlopen(request, timeout=DEFAULT_TIMEOUT_SECONDS) as response:
201
+ payload = response.read().decode("utf-8")
202
+ return json.loads(payload) if payload else {}
203
+ except urllib.error.HTTPError as exc:
204
+ detail = exc.read().decode("utf-8", errors="replace")
205
+ if exc.code == 401:
206
+ raise RuntimeError(
207
+ f"{method} {url} failed with 401: invalid API key.\n"
208
+ "Run `invoke login --api-key <your_key>` or set INVOKE_API_KEY."
209
+ ) from exc
210
+ if exc.code == 404:
211
+ raise RuntimeError(
212
+ f"{method} {url} failed with 404: endpoint not found.\n"
213
+ "Your CLI may be pointed at an old runtime. "
214
+ f"Run `invoke login --base-url {HOSTED_API_URL} --api-key <your_key>`."
215
+ ) from exc
216
+ raise RuntimeError(f"{method} {url} failed with {exc.code}: {detail}") from exc
217
+ except urllib.error.URLError as exc:
218
+ reason = str(exc.reason)
219
+ if "Name or service not known" in reason or "getaddrinfo" in reason or "nodename" in reason:
220
+ raise RuntimeError(
221
+ f"Could not resolve Invoke runtime host for {base_url}.\n"
222
+ f"{runtime_error_hint(base_url)}"
223
+ ) from exc
224
+ raise RuntimeError(
225
+ f"Could not reach Invoke runtime at {base_url}: {reason}\n"
226
+ f"{runtime_error_hint(base_url)}"
227
+ ) from exc
228
+ except socket.timeout as exc:
229
+ raise RuntimeError(
230
+ f"{method} {url} timed out after {DEFAULT_TIMEOUT_SECONDS}s.\n"
231
+ "The runtime may be cold-starting or the upstream tool may be slow. Try again, or set "
232
+ "INVOKE_TIMEOUT_SECONDS=120 for heavier calls."
233
+ ) from exc
234
+
235
+
236
+ def require_credentials(args: argparse.Namespace) -> dict[str, str]:
237
+ credentials = load_credentials()
238
+ base_url = getattr(args, "base_url", None) or credentials["base_url"]
239
+ api_key = getattr(args, "api_key", None) or credentials["api_key"]
240
+ if not api_key:
241
+ raise CliUsageError("Missing API key. Run `invoke login --api-key ...` or set INVOKE_API_KEY.")
242
+ return {"base_url": base_url, "api_key": api_key}
243
+
244
+
245
+ def infer_sql_params(query: str) -> dict[str, Any]:
246
+ names = sorted(set(re.findall(r":([a-zA-Z_][a-zA-Z0-9_]*)", query)))
247
+ properties = {
248
+ name: {
249
+ "type": "string",
250
+ "description": f"Value for SQL parameter :{name}.",
251
+ }
252
+ for name in names
253
+ }
254
+ return {"type": "object", "properties": properties, "required": names}
255
+
256
+
257
+ def sql_is_read_only(query: str) -> bool:
258
+ stripped = re.sub(r"/\*.*?\*/", "", query, flags=re.S).strip()
259
+ stripped = re.sub(r"--.*?$", "", stripped, flags=re.M).strip()
260
+ return stripped.lower().startswith(("select", "with"))
261
+
262
+
263
+ def postgres_tool(args: argparse.Namespace) -> dict[str, Any]:
264
+ if not args.query:
265
+ raise ValueError("postgresql wrappers require --query")
266
+ read_only = sql_is_read_only(args.query)
267
+ if not read_only and not args.allow_write:
268
+ raise ValueError("postgresql wrappers are read-only by default; pass --allow-write for mutating SQL")
269
+
270
+ name = tool_name(args.name or "postgres_query")
271
+ description = args.description or "Run a scoped PostgreSQL query and return rows as JSON."
272
+ return {
273
+ "name": name,
274
+ "description": description,
275
+ "input_schema": infer_sql_params(args.query),
276
+ "output_schema": {
277
+ "type": "object",
278
+ "properties": {
279
+ "rows": {"type": "array", "items": {"type": "object"}},
280
+ "row_count": {"type": "integer"},
281
+ },
282
+ "required": ["rows", "row_count"],
283
+ },
284
+ "annotations": {
285
+ "title": args.name or "PostgreSQL Query",
286
+ "readOnlyHint": read_only,
287
+ "idempotentHint": read_only,
288
+ "openWorldHint": False,
289
+ },
290
+ "idempotency": {
291
+ "mode": "automatic" if read_only else "caller_provided",
292
+ "key_fields": sorted(infer_sql_params(args.query)["properties"].keys()),
293
+ },
294
+ "retry": {
295
+ "safe": read_only,
296
+ "max_attempts": 2 if read_only else 1,
297
+ "backoff_ms": 250,
298
+ },
299
+ "x-invoke": {
300
+ "kind": "postgresql",
301
+ "query": args.query,
302
+ "read_only": read_only,
303
+ "database_url_env": args.database_url_env,
304
+ },
305
+ }
306
+
307
+
308
+ def schema_for_parameter(parameter: dict[str, Any]) -> dict[str, Any]:
309
+ schema = parameter.get("schema") if isinstance(parameter.get("schema"), dict) else {}
310
+ return {
311
+ "type": schema.get("type", "string"),
312
+ "description": parameter.get("description", ""),
313
+ }
314
+
315
+
316
+ def fastapi_tools_from_openapi(args: argparse.Namespace) -> list[dict[str, Any]]:
317
+ with open(args.openapi, "r", encoding="utf-8") as fh:
318
+ spec = json.load(fh)
319
+
320
+ tools: list[dict[str, Any]] = []
321
+ for path, path_item in spec.get("paths", {}).items():
322
+ if not isinstance(path_item, dict):
323
+ continue
324
+ for method, operation in path_item.items():
325
+ if method.lower() not in HTTP_METHODS or not isinstance(operation, dict):
326
+ continue
327
+
328
+ op_name = tool_name(operation.get("operationId") or f"{method}_{path}")
329
+ properties: dict[str, Any] = {}
330
+ required: list[str] = []
331
+ parameter_locations: dict[str, str] = {}
332
+
333
+ for parameter in operation.get("parameters", []):
334
+ if not isinstance(parameter, dict) or "name" not in parameter:
335
+ continue
336
+ name = str(parameter["name"])
337
+ properties[name] = schema_for_parameter(parameter)
338
+ parameter_locations[name] = str(parameter.get("in", "query"))
339
+ if parameter.get("required"):
340
+ required.append(name)
341
+
342
+ request_body = operation.get("requestBody", {})
343
+ json_body = (
344
+ request_body.get("content", {})
345
+ .get("application/json", {})
346
+ .get("schema")
347
+ if isinstance(request_body, dict)
348
+ else None
349
+ )
350
+ if isinstance(json_body, dict):
351
+ properties["body"] = json_body
352
+ if request_body.get("required"):
353
+ required.append("body")
354
+
355
+ description = operation.get("summary") or operation.get("description") or f"Call {method.upper()} {path}."
356
+ tools.append(
357
+ {
358
+ "name": op_name,
359
+ "description": description,
360
+ "input_schema": {"type": "object", "properties": properties, "required": required},
361
+ "output_schema": {"type": "object"},
362
+ "annotations": {
363
+ "title": operation.get("summary") or op_name,
364
+ "readOnlyHint": method.lower() == "get",
365
+ "idempotentHint": method.lower() in {"get", "put", "delete"},
366
+ "openWorldHint": True,
367
+ },
368
+ "idempotency": {
369
+ "mode": "automatic" if method.lower() in {"get", "put", "delete"} else "caller_provided",
370
+ "key_fields": required,
371
+ },
372
+ "retry": {
373
+ "safe": method.lower() in {"get", "put", "delete"},
374
+ "max_attempts": 2 if method.lower() in {"get", "put", "delete"} else 1,
375
+ "backoff_ms": 250,
376
+ },
377
+ "x-invoke": {
378
+ "kind": "http",
379
+ "method": method.upper(),
380
+ "path": path,
381
+ "base_url": args.base_url,
382
+ "parameter_locations": parameter_locations,
383
+ },
384
+ }
385
+ )
386
+ if not tools:
387
+ raise ValueError(f"No HTTP operations found in {args.openapi}")
388
+ return tools
389
+
390
+
391
+ def generic_fastapi_tool(args: argparse.Namespace) -> dict[str, Any]:
392
+ name = tool_name(args.name or f"{args.target}_request")
393
+ return {
394
+ "name": name,
395
+ "description": args.description or f"Call a scoped endpoint on {args.target}.",
396
+ "input_schema": {
397
+ "type": "object",
398
+ "properties": {
399
+ "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], "default": "GET"},
400
+ "path": {"type": "string", "description": "Path beginning with /."},
401
+ "query": {"type": "object", "default": {}},
402
+ "body": {"type": "object", "default": {}},
403
+ },
404
+ "required": ["path"],
405
+ },
406
+ "output_schema": {"type": "object"},
407
+ "annotations": {"title": args.name or args.target, "readOnlyHint": False, "idempotentHint": False},
408
+ "idempotency": {"mode": "caller_provided", "key_fields": ["method", "path", "query", "body"]},
409
+ "retry": {"safe": False, "max_attempts": 1, "backoff_ms": 250},
410
+ "x-invoke": {
411
+ "kind": "http_generic",
412
+ "base_url": args.base_url,
413
+ },
414
+ }
415
+
416
+
417
+ def connector_tool(
418
+ *,
419
+ name: str,
420
+ title: str,
421
+ description: str,
422
+ input_schema: dict[str, Any],
423
+ method: str,
424
+ base_url: str,
425
+ path: str,
426
+ auth_env: str,
427
+ headers: dict[str, str] | None = None,
428
+ parameter_locations: dict[str, str] | None = None,
429
+ body_fields: list[str] | None = None,
430
+ body_template: dict[str, Any] | None = None,
431
+ graphql_query: str | None = None,
432
+ graphql_variables: list[str] | None = None,
433
+ ) -> dict[str, Any]:
434
+ kind = "graphql" if graphql_query else "http"
435
+ config: dict[str, Any] = {
436
+ "kind": kind,
437
+ "method": method,
438
+ "base_url": base_url,
439
+ "path": path,
440
+ "headers": headers or {},
441
+ "parameter_locations": parameter_locations or {},
442
+ "body_fields": body_fields or [],
443
+ "auth": {"env": auth_env, "scheme": "Bearer"},
444
+ }
445
+ if body_template:
446
+ config["body_template"] = body_template
447
+ if graphql_query:
448
+ config["query"] = graphql_query
449
+ config["variables"] = graphql_variables or []
450
+
451
+ return {
452
+ "name": name,
453
+ "description": description,
454
+ "input_schema": input_schema,
455
+ "output_schema": {"type": "object"},
456
+ "annotations": {
457
+ "title": title,
458
+ "readOnlyHint": False,
459
+ "idempotentHint": False,
460
+ "openWorldHint": True,
461
+ },
462
+ "idempotency": {"mode": "caller_provided", "key_fields": input_schema.get("required", [])},
463
+ "retry": {"safe": False, "max_attempts": 1, "backoff_ms": 250},
464
+ "x-invoke": config,
465
+ }
466
+
467
+
468
+ def launch_connector_tools(target: str) -> list[dict[str, Any]]:
469
+ normalized = slugify(target)
470
+ if normalized == "github":
471
+ return [
472
+ connector_tool(
473
+ name="github_create_issue",
474
+ title="GitHub Create Issue",
475
+ description="Create a GitHub issue after Invoke approval.",
476
+ input_schema={
477
+ "type": "object",
478
+ "properties": {
479
+ "owner": {"type": "string", "description": "Repository owner."},
480
+ "repo": {"type": "string", "description": "Repository name."},
481
+ "title": {"type": "string", "description": "Issue title."},
482
+ "body": {"type": "string", "description": "Issue body."},
483
+ "labels": {"type": "array", "items": {"type": "string"}, "default": []},
484
+ },
485
+ "required": ["owner", "repo", "title"],
486
+ },
487
+ method="POST",
488
+ base_url="https://api.github.com",
489
+ path="/repos/{owner}/{repo}/issues",
490
+ auth_env="GITHUB_TOKEN",
491
+ headers={"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"},
492
+ parameter_locations={"owner": "path", "repo": "path"},
493
+ body_fields=["title", "body", "labels"],
494
+ )
495
+ ]
496
+ if normalized == "notion":
497
+ return [
498
+ connector_tool(
499
+ name="notion_create_page",
500
+ title="Notion Create Page",
501
+ description="Create a Notion page with title and optional paragraph content.",
502
+ input_schema={
503
+ "type": "object",
504
+ "properties": {
505
+ "parent_id": {"type": "string", "description": "Parent page or database ID."},
506
+ "title": {"type": "string", "description": "Page title."},
507
+ "content": {"type": "string", "description": "Plain text paragraph content."},
508
+ },
509
+ "required": ["parent_id", "title"],
510
+ },
511
+ method="POST",
512
+ base_url="https://api.notion.com",
513
+ path="/v1/pages",
514
+ auth_env="NOTION_TOKEN",
515
+ headers={"Notion-Version": "2022-06-28"},
516
+ body_template={
517
+ "parent": {"page_id": "{parent_id}"},
518
+ "properties": {
519
+ "title": {
520
+ "title": [{"text": {"content": "{title}"}}],
521
+ }
522
+ },
523
+ "children": [
524
+ {
525
+ "object": "block",
526
+ "type": "paragraph",
527
+ "paragraph": {"rich_text": [{"type": "text", "text": {"content": "{content}"}}]},
528
+ }
529
+ ],
530
+ },
531
+ )
532
+ ]
533
+ if normalized == "linear":
534
+ return [
535
+ connector_tool(
536
+ name="linear_create_issue",
537
+ title="Linear Create Issue",
538
+ description="Create a Linear issue in a scoped team.",
539
+ input_schema={
540
+ "type": "object",
541
+ "properties": {
542
+ "team_id": {"type": "string", "description": "Linear team ID."},
543
+ "title": {"type": "string", "description": "Issue title."},
544
+ "description": {"type": "string", "description": "Issue description."},
545
+ },
546
+ "required": ["team_id", "title"],
547
+ },
548
+ method="POST",
549
+ base_url="https://api.linear.app/graphql",
550
+ path="",
551
+ auth_env="LINEAR_API_KEY",
552
+ graphql_query=(
553
+ "mutation InvokeCreateIssue($team_id: String!, $title: String!, $description: String) "
554
+ "{ issueCreate(input: {teamId: $team_id, title: $title, description: $description}) "
555
+ "{ success issue { id identifier title url } } }"
556
+ ),
557
+ graphql_variables=["team_id", "title", "description"],
558
+ )
559
+ ]
560
+ raise ValueError("launch connectors are github, notion, or linear")
561
+
562
+
563
+ def server_template(tools: list[dict[str, Any]]) -> str:
564
+ tools_json = json.dumps({tool["name"]: tool for tool in tools}, indent=2, sort_keys=True)
565
+ return (
566
+ '''#!/usr/bin/env python3
567
+ """Generated Invoke MCP wrapper."""
568
+
569
+ from __future__ import annotations
570
+
571
+ import json
572
+ import os
573
+ import re
574
+ import time
575
+ import uuid
576
+ from typing import Any
577
+
578
+ import httpx
579
+ from fastapi import FastAPI, Request
580
+ from fastapi.responses import JSONResponse
581
+
582
+
583
+ TOOLS = '''
584
+ + tools_json
585
+ + r'''
586
+
587
+ app = FastAPI(title="Generated Invoke MCP Wrapper", version="0.1.0")
588
+
589
+
590
+ def jsonrpc_result(request_id: Any, result: Any) -> JSONResponse:
591
+ return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result})
592
+
593
+
594
+ def jsonrpc_error(request_id: Any, code: int, message: str, *, retryable: bool = False, details: Any = None) -> JSONResponse:
595
+ return JSONResponse(
596
+ {
597
+ "jsonrpc": "2.0",
598
+ "id": request_id,
599
+ "error": {
600
+ "code": code,
601
+ "message": message,
602
+ "data": {
603
+ "retryable": retryable,
604
+ "details": details,
605
+ },
606
+ },
607
+ }
608
+ )
609
+
610
+
611
+ def validate_value(name: str, schema: dict[str, Any], value: Any) -> str | None:
612
+ expected = schema.get("type")
613
+ if expected == "string" and not isinstance(value, str):
614
+ return f"{name} must be a string"
615
+ if expected == "integer" and not isinstance(value, int):
616
+ return f"{name} must be an integer"
617
+ if expected == "number" and not isinstance(value, (int, float)):
618
+ return f"{name} must be a number"
619
+ if expected == "boolean" and not isinstance(value, bool):
620
+ return f"{name} must be a boolean"
621
+ if expected == "object" and not isinstance(value, dict):
622
+ return f"{name} must be an object"
623
+ if expected == "array" and not isinstance(value, list):
624
+ return f"{name} must be an array"
625
+ enum = schema.get("enum")
626
+ if enum and value not in enum:
627
+ return f"{name} must be one of {enum}"
628
+ return None
629
+
630
+
631
+ def validate_args(tool: dict[str, Any], args: dict[str, Any]) -> list[str]:
632
+ schema = tool.get("input_schema", {})
633
+ properties = schema.get("properties", {})
634
+ required = schema.get("required", [])
635
+ errors = []
636
+ for name in required:
637
+ if name not in args:
638
+ errors.append(f"{name} is required")
639
+ for name, value in args.items():
640
+ prop_schema = properties.get(name)
641
+ if isinstance(prop_schema, dict):
642
+ error = validate_value(name, prop_schema, value)
643
+ if error:
644
+ errors.append(error)
645
+ return errors
646
+
647
+
648
+ async def call_postgresql(tool: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
649
+ try:
650
+ import psycopg
651
+ from psycopg.rows import dict_row
652
+ except ImportError as exc:
653
+ raise RuntimeError("Install psycopg to run this PostgreSQL wrapper: pip install psycopg[binary]") from exc
654
+
655
+ config = tool["x-invoke"]
656
+ database_url = os.getenv(config.get("database_url_env", "DATABASE_URL"))
657
+ if not database_url:
658
+ raise RuntimeError(f"Missing {config.get('database_url_env', 'DATABASE_URL')}")
659
+
660
+ query = config["query"]
661
+ if config.get("read_only") and not re.sub(r"/\*.*?\*/", "", query, flags=re.S).strip().lower().startswith(("select", "with")):
662
+ raise RuntimeError("Generated PostgreSQL wrapper refused non-read-only SQL")
663
+
664
+ started = time.perf_counter()
665
+ with psycopg.connect(database_url, row_factory=dict_row) as conn:
666
+ with conn.cursor() as cur:
667
+ cur.execute(query, args)
668
+ rows = cur.fetchall() if cur.description else []
669
+ return {
670
+ "rows": rows,
671
+ "row_count": len(rows),
672
+ "latency_ms": round((time.perf_counter() - started) * 1000, 2),
673
+ }
674
+
675
+
676
+ def interpolate_path(path: str, args: dict[str, Any], locations: dict[str, str]) -> str:
677
+ for name, location in locations.items():
678
+ if location == "path" and name in args:
679
+ path = path.replace("{" + name + "}", str(args[name]))
680
+ return path
681
+
682
+
683
+ def render_template_value(value: Any, args: dict[str, Any]) -> Any:
684
+ if isinstance(value, str):
685
+ for key, arg_value in args.items():
686
+ value = value.replace("{" + key + "}", "" if arg_value is None else str(arg_value))
687
+ return value
688
+ if isinstance(value, list):
689
+ return [render_template_value(item, args) for item in value]
690
+ if isinstance(value, dict):
691
+ return {key: render_template_value(item, args) for key, item in value.items()}
692
+ return value
693
+
694
+
695
+ def request_headers(config: dict[str, Any]) -> dict[str, str]:
696
+ headers = dict(config.get("headers", {}))
697
+ auth = config.get("auth") or {}
698
+ if auth:
699
+ token = os.getenv(auth.get("env", "API_TOKEN"))
700
+ if not token:
701
+ raise RuntimeError(f"Missing {auth.get('env', 'API_TOKEN')}")
702
+ header_name = auth.get("header", "Authorization")
703
+ scheme = auth.get("scheme", "Bearer")
704
+ headers[header_name] = f"{scheme} {token}" if scheme else token
705
+ return headers
706
+
707
+
708
+ async def call_http(tool: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
709
+ config = tool["x-invoke"]
710
+ method = config.get("method", args.get("method", "GET")).upper()
711
+ base_url = config["base_url"].rstrip("/")
712
+ locations = config.get("parameter_locations", {})
713
+ path = interpolate_path(config.get("path", args.get("path", "/")), args, locations)
714
+ query = args.get("query", {}) if config.get("kind") == "http_generic" else {
715
+ name: value for name, value in args.items() if locations.get(name) == "query"
716
+ }
717
+ if config.get("body_template"):
718
+ body = render_template_value(config["body_template"], args)
719
+ elif config.get("body_fields"):
720
+ body = {name: args[name] for name in config["body_fields"] if name in args}
721
+ else:
722
+ body = args.get("body", {}) if config.get("kind") == "http_generic" else args.get("body")
723
+
724
+ started = time.perf_counter()
725
+ async with httpx.AsyncClient(timeout=60.0) as client:
726
+ response = await client.request(method, f"{base_url}{path}", params=query, json=body, headers=request_headers(config))
727
+ content_type = response.headers.get("content-type", "")
728
+ payload = response.json() if "application/json" in content_type else {"text": response.text}
729
+ return {
730
+ "status_code": response.status_code,
731
+ "headers": dict(response.headers),
732
+ "body": payload,
733
+ "latency_ms": round((time.perf_counter() - started) * 1000, 2),
734
+ }
735
+
736
+
737
+ async def call_graphql(tool: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
738
+ config = tool["x-invoke"]
739
+ variables = {name: args.get(name) for name in config.get("variables", []) if name in args}
740
+ started = time.perf_counter()
741
+ async with httpx.AsyncClient(timeout=60.0) as client:
742
+ response = await client.post(
743
+ config["base_url"],
744
+ json={"query": config["query"], "variables": variables},
745
+ headers=request_headers(config),
746
+ )
747
+ content_type = response.headers.get("content-type", "")
748
+ payload = response.json() if "application/json" in content_type else {"text": response.text}
749
+ return {
750
+ "status_code": response.status_code,
751
+ "headers": dict(response.headers),
752
+ "body": payload,
753
+ "latency_ms": round((time.perf_counter() - started) * 1000, 2),
754
+ }
755
+
756
+
757
+ async def dispatch_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
758
+ tool = TOOLS.get(name)
759
+ if not tool:
760
+ raise KeyError(f"Unknown tool {name}")
761
+ errors = validate_args(tool, args)
762
+ if errors:
763
+ raise ValueError("; ".join(errors))
764
+ kind = tool.get("x-invoke", {}).get("kind")
765
+ if kind == "postgresql":
766
+ return await call_postgresql(tool, args)
767
+ if kind in {"http", "http_generic"}:
768
+ return await call_http(tool, args)
769
+ if kind == "graphql":
770
+ return await call_graphql(tool, args)
771
+ raise RuntimeError(f"Unsupported generated tool kind {kind}")
772
+
773
+
774
+ @app.post("/mcp")
775
+ async def mcp(request: Request):
776
+ payload = await request.json()
777
+ request_id = payload.get("id")
778
+ method = payload.get("method")
779
+
780
+ if method == "initialize":
781
+ return jsonrpc_result(
782
+ request_id,
783
+ {
784
+ "protocolVersion": "2025-03-26",
785
+ "capabilities": {"tools": {}},
786
+ "serverInfo": {"name": "invoke-generated-wrapper", "version": "0.1.0"},
787
+ },
788
+ )
789
+ if method == "notifications/initialized":
790
+ return JSONResponse(status_code=202, content={})
791
+ if method == "tools/list":
792
+ return jsonrpc_result(
793
+ request_id,
794
+ {
795
+ "tools": [
796
+ {
797
+ "name": name,
798
+ "description": tool["description"],
799
+ "inputSchema": tool["input_schema"],
800
+ "annotations": tool.get("annotations", {}),
801
+ }
802
+ for name, tool in TOOLS.items()
803
+ ]
804
+ },
805
+ )
806
+ if method == "tools/call":
807
+ params = payload.get("params", {})
808
+ name = params.get("name")
809
+ args = params.get("arguments", {})
810
+ correlation_id = request.headers.get("idempotency-key") or str(uuid.uuid4())
811
+ try:
812
+ result = await dispatch_tool(name, args)
813
+ result["_invoke"] = {
814
+ "correlation_id": correlation_id,
815
+ "idempotency": TOOLS[name].get("idempotency", {}),
816
+ "retry": TOOLS[name].get("retry", {}),
817
+ }
818
+ return jsonrpc_result(request_id, result)
819
+ except ValueError as exc:
820
+ return jsonrpc_error(request_id, -32602, str(exc), retryable=False)
821
+ except KeyError as exc:
822
+ return jsonrpc_error(request_id, -32601, str(exc), retryable=False)
823
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
824
+ return jsonrpc_error(request_id, -32001, str(exc), retryable=True)
825
+ except Exception as exc:
826
+ return jsonrpc_error(request_id, -32000, str(exc), retryable=False)
827
+
828
+ return jsonrpc_error(request_id, -32601, f"Unsupported method {method}", retryable=False)
829
+
830
+
831
+ if __name__ == "__main__":
832
+ import uvicorn
833
+
834
+ uvicorn.run(app, host=os.getenv("HOST", "0.0.0.0"), port=int(os.getenv("PORT", "8787")))
835
+ '''
836
+ )
837
+
838
+
839
+ def registration_payload(slug: str, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
840
+ return [
841
+ {
842
+ "name": tool["name"],
843
+ "capability_card": {
844
+ "name": tool.get("annotations", {}).get("title") or tool["name"],
845
+ "description": tool["description"],
846
+ "capability": f"{slug}.{tool['name']}",
847
+ "input_schema": tool["input_schema"],
848
+ "output_schema": tool.get("output_schema", {}),
849
+ "idempotency": tool.get("idempotency", {}),
850
+ "retry": tool.get("retry", {}),
851
+ "tags": ["generated", slug],
852
+ },
853
+ "mcp_tool": tool["name"],
854
+ "mcp_url": "https://replace-with-your-hosted-wrapper.example.com/mcp",
855
+ "approval_required": not bool(tool.get("annotations", {}).get("readOnlyHint")),
856
+ "risk_level": "low" if tool.get("annotations", {}).get("readOnlyHint") else "medium",
857
+ }
858
+ for tool in tools
859
+ ]
860
+
861
+
862
+ def write_project(slug: str, tools: list[dict[str, Any]], output: str) -> Path:
863
+ root = Path(output) / slug
864
+ root.mkdir(parents=True, exist_ok=True)
865
+ (root / "server.py").write_text(server_template(tools), encoding="utf-8")
866
+ (root / "capability.json").write_text(
867
+ json.dumps({"name": slug, "mcp_endpoint": "/mcp", "tools": tools}, indent=2, sort_keys=True) + "\n",
868
+ encoding="utf-8",
869
+ )
870
+ (root / "invoke.register.json").write_text(
871
+ json.dumps(registration_payload(slug, tools), indent=2, sort_keys=True) + "\n",
872
+ encoding="utf-8",
873
+ )
874
+ requirements = ["fastapi", "uvicorn[standard]", "httpx"]
875
+ if any(tool.get("x-invoke", {}).get("kind") == "postgresql" for tool in tools):
876
+ requirements.append("psycopg[binary]")
877
+ (root / "requirements.txt").write_text("\n".join(requirements) + "\n", encoding="utf-8")
878
+ env_vars = sorted(
879
+ {
880
+ tool.get("x-invoke", {}).get("auth", {}).get("env")
881
+ for tool in tools
882
+ if tool.get("x-invoke", {}).get("auth", {}).get("env")
883
+ }
884
+ )
885
+ env_text = "\n".join(f"- `{env}`" for env in env_vars) if env_vars else "- none"
886
+ (root / "README.md").write_text(
887
+ textwrap.dedent(
888
+ f"""\
889
+ # {slug}
890
+
891
+ Generated Invoke MCP wrapper.
892
+
893
+ Required environment variables:
894
+
895
+ {env_text}
896
+
897
+ Run locally:
898
+
899
+ ```bash
900
+ pip install -r requirements.txt
901
+ python server.py
902
+ ```
903
+
904
+ Register with Invoke by POSTing each object in `invoke.register.json` to:
905
+
906
+ ```text
907
+ /providers/<provider_id>/tools
908
+ ```
909
+
910
+ Replace `mcp_url` with the hosted `/mcp` URL after deploying this wrapper.
911
+ """
912
+ ),
913
+ encoding="utf-8",
914
+ )
915
+ return root
916
+
917
+
918
+ def build_tools(args: argparse.Namespace) -> tuple[str, list[dict[str, Any]]]:
919
+ if args.target == "postgresql":
920
+ return slugify(args.name or "postgresql-query"), [postgres_tool(args)]
921
+ if slugify(args.target) in LAUNCH_CONNECTORS:
922
+ return slugify(args.name or args.target), launch_connector_tools(args.target)
923
+ if args.openapi:
924
+ return slugify(args.name or args.target), fastapi_tools_from_openapi(args)
925
+ return slugify(args.name or args.target), [generic_fastapi_tool(args)]
926
+
927
+
928
+ def wrap_command(args: argparse.Namespace) -> int:
929
+ slug, tools = build_tools(args)
930
+ root = write_project(slug, tools, args.output)
931
+ print(json.dumps({"success": True, "path": str(root), "tools": [tool["name"] for tool in tools]}, indent=2))
932
+ return 0
933
+
934
+
935
+ def login_command(args: argparse.Namespace) -> int:
936
+ base_url = args.base_url or os.getenv("INVOKE_BASE_URL") or DEFAULT_API_URL
937
+ api_key = args.api_key or os.getenv("INVOKE_API_KEY")
938
+ if not api_key:
939
+ if not sys.stdin.isatty():
940
+ raise ValueError("Pass --api-key in non-interactive shells.")
941
+ api_key = getpass.getpass("Invoke API key: ").strip()
942
+ if not api_key:
943
+ raise ValueError("API key is required")
944
+
945
+ write_credentials(base_url, api_key)
946
+ print(f"Logged in to {base_url.rstrip('/')}")
947
+ print(f"Credentials saved to {credentials_path()}")
948
+ return 0
949
+
950
+
951
+ def config_command(args: argparse.Namespace) -> int:
952
+ credentials = load_credentials()
953
+ config = {
954
+ "base_url": credentials["base_url"],
955
+ "api_key": credentials["api_key"],
956
+ "credentials_path": str(credentials_path()),
957
+ "home": str(invoke_home()),
958
+ }
959
+ if args.key:
960
+ normalized_key = normalize_config_key(args.key)
961
+ if args.value is None:
962
+ print(config[normalized_key])
963
+ return 0
964
+ updated = dict(credentials)
965
+ updated[normalized_key] = args.value.strip()
966
+ write_credentials(updated["base_url"], updated["api_key"])
967
+ print(f"Updated {args.key} in {credentials_path()}")
968
+ return 0
969
+
970
+ if args.json:
971
+ safe = dict(config)
972
+ if safe["api_key"]:
973
+ safe["api_key"] = mask_key(safe["api_key"])
974
+ print(json.dumps(safe, indent=2))
975
+ return 0
976
+
977
+ print(f"Base URL: {credentials['base_url']}")
978
+ print(f"API key: {mask_key(credentials['api_key'])}")
979
+ print(f"Config: {credentials_path()}")
980
+ return 0
981
+
982
+
983
+ def status_command(args: argparse.Namespace) -> int:
984
+ credentials = load_credentials()
985
+ base_url = args.base_url or credentials["base_url"]
986
+ api_key = args.api_key or credentials["api_key"]
987
+ print("Invoke CLI status")
988
+ print(f"Version: {package_version()}")
989
+ print(f"Runtime: {base_url}")
990
+ print(f"API key: {mask_key(api_key)}")
991
+ print(f"Config: {credentials_path()}")
992
+ project = project_context(Path.cwd())
993
+ print(f"Project: {project or '(none)'}")
994
+ if args.check:
995
+ if not api_key:
996
+ raise CliUsageError("Cannot check runtime without an API key. Run `invoke login --api-key ...` first.")
997
+ health = api_request("GET", base_url, "/health", api_key)
998
+ print(f"Health: {health.get('status', 'ok')}")
999
+ if health.get("db"):
1000
+ print(f"DB: {health.get('db')}")
1001
+ return 0
1002
+
1003
+
1004
+ def doctor_command(args: argparse.Namespace) -> int:
1005
+ credentials = load_credentials()
1006
+ base_url = args.base_url or credentials["base_url"]
1007
+ api_key = args.api_key or credentials["api_key"]
1008
+
1009
+ print("Invoke doctor")
1010
+ print(f"Runtime: {base_url}")
1011
+ print(f"API key: {mask_key(api_key)}")
1012
+ print(f"Timeout: {DEFAULT_TIMEOUT_SECONDS}s")
1013
+ print(f"Config: {credentials_path()}")
1014
+
1015
+ if not api_key:
1016
+ print("\nMissing API key.")
1017
+ print("Run: invoke login --base-url https://agentgate-ai.onrender.com --api-key <your_key>")
1018
+ return 1
1019
+
1020
+ try:
1021
+ health = api_request("GET", base_url, "/health", api_key)
1022
+ except RuntimeError as exc:
1023
+ print(f"\nRuntime check failed:\n{exc}")
1024
+ return 1
1025
+
1026
+ print(f"\nHealth: {health.get('status', 'ok')}")
1027
+ if health.get("db"):
1028
+ print(f"DB: {health.get('db')}")
1029
+ return 0
1030
+
1031
+
1032
+ def project_template(name: str, template: str) -> dict[str, Any]:
1033
+ slug = slugify(name)
1034
+ base = {
1035
+ "name": name,
1036
+ "slug": slug,
1037
+ "version": "0.1.0",
1038
+ "owner_email": "dev@example.com",
1039
+ "mcp_url": "https://replace-with-your-mcp-server.example.com/mcp",
1040
+ }
1041
+ if template == "linear":
1042
+ base["tools"] = registration_payload("linear", launch_connector_tools("linear"))
1043
+ elif template == "crm-guardrail":
1044
+ base["tools"] = [
1045
+ {
1046
+ "name": "crm_update_customer",
1047
+ "capability_card": {
1048
+ "name": "CRM Update Customer",
1049
+ "description": "Update a customer record after entity resolution and policy checks.",
1050
+ "capability": "crm.customer.update",
1051
+ "input_schema": {
1052
+ "type": "object",
1053
+ "properties": {
1054
+ "customer_id": {"type": "string"},
1055
+ "account_status": {"type": "string"},
1056
+ "note": {"type": "string"},
1057
+ },
1058
+ "required": ["customer_id"],
1059
+ },
1060
+ "idempotency": {"mode": "caller_provided", "key_fields": ["customer_id", "account_status"]},
1061
+ "retry": {"safe": False, "max_attempts": 1, "backoff_ms": 250},
1062
+ "tags": ["crm", "customer", "write"],
1063
+ },
1064
+ "mcp_tool": "crm_update_customer",
1065
+ "approval_required": True,
1066
+ "risk_level": "high",
1067
+ }
1068
+ ]
1069
+ else:
1070
+ base["tools"] = [
1071
+ {
1072
+ "name": "system_status",
1073
+ "capability_card": {
1074
+ "name": "System Status",
1075
+ "description": "Read current system status before an agent takes action.",
1076
+ "capability": "system.status.read",
1077
+ "input_schema": {"type": "object", "properties": {}},
1078
+ "idempotency": {"mode": "automatic", "key_fields": []},
1079
+ "retry": {"safe": True, "max_attempts": 2, "backoff_ms": 250},
1080
+ "tags": ["status", "read"],
1081
+ },
1082
+ "mcp_tool": "system_status",
1083
+ "approval_required": False,
1084
+ "risk_level": "low",
1085
+ }
1086
+ ]
1087
+ return base
1088
+
1089
+
1090
+ def sample_agent_source(name: str) -> str:
1091
+ return textwrap.dedent(
1092
+ f"""\
1093
+ import {{ Invoke }} from "./sdk";
1094
+
1095
+ const invoke = Invoke.fromEnv();
1096
+
1097
+ async function main() {{
1098
+ const result = await invoke.call({{
1099
+ tool: "system.status",
1100
+ params: {{}},
1101
+ agentId: "{slugify(name)}",
1102
+ }});
1103
+
1104
+ console.log(JSON.stringify(result, null, 2));
1105
+ }}
1106
+
1107
+ main().catch((error) => {{
1108
+ console.error(error);
1109
+ process.exit(1);
1110
+ }});
1111
+ """
1112
+ )
1113
+
1114
+
1115
+ def sample_sdk_source() -> str:
1116
+ return textwrap.dedent(
1117
+ """\
1118
+ export type InvokeCall = {
1119
+ tool: string;
1120
+ params: Record<string, unknown>;
1121
+ agentId?: string;
1122
+ idempotencyKey?: string;
1123
+ };
1124
+
1125
+ export class Invoke {
1126
+ constructor(
1127
+ private readonly options: {
1128
+ baseUrl: string;
1129
+ apiKey: string;
1130
+ },
1131
+ ) {}
1132
+
1133
+ static fromEnv() {
1134
+ const baseUrl = process.env.INVOKE_BASE_URL ?? "http://localhost:8000";
1135
+ const apiKey = process.env.INVOKE_API_KEY;
1136
+ if (!apiKey) throw new Error("Set INVOKE_API_KEY");
1137
+ return new Invoke({ baseUrl, apiKey });
1138
+ }
1139
+
1140
+ async call(input: InvokeCall) {
1141
+ const response = await fetch(`${this.options.baseUrl.replace(/\\/+$/, "")}/v1/call`, {
1142
+ method: "POST",
1143
+ headers: {
1144
+ "Content-Type": "application/json",
1145
+ "X-API-Key": this.options.apiKey,
1146
+ },
1147
+ body: JSON.stringify({
1148
+ tool: input.tool,
1149
+ params: input.params,
1150
+ agent_id: input.agentId ?? "default_agent",
1151
+ ...(input.idempotencyKey ? { idempotency_key: input.idempotencyKey } : {}),
1152
+ }),
1153
+ });
1154
+
1155
+ if (!response.ok) {
1156
+ throw new Error(`Invoke error ${response.status}: ${await response.text()}`);
1157
+ }
1158
+ return response.json();
1159
+ }
1160
+ }
1161
+ """
1162
+ )
1163
+
1164
+
1165
+ def init_command(args: argparse.Namespace) -> int:
1166
+ root = Path(args.name)
1167
+ project_name = root.name
1168
+ if root.exists() and any(root.iterdir()) and not args.force:
1169
+ raise ValueError(f"{root} already exists and is not empty. Pass --force to write into it.")
1170
+ root.mkdir(parents=True, exist_ok=True)
1171
+ (root / "src").mkdir(exist_ok=True)
1172
+
1173
+ config = project_template(project_name, args.template)
1174
+ write_json_file(root / "invoke.json", config)
1175
+ (root / "src" / "index.ts").write_text(sample_agent_source(project_name), encoding="utf-8")
1176
+ (root / "src" / "sdk.ts").write_text(sample_sdk_source(), encoding="utf-8")
1177
+ (root / "README.md").write_text(
1178
+ textwrap.dedent(
1179
+ f"""\
1180
+ # {project_name}
1181
+
1182
+ Invoke project scaffolded with the `{args.template}` template.
1183
+
1184
+ ```bash
1185
+ invoke deploy
1186
+ invoke call system.status '{{}}'
1187
+ ```
1188
+
1189
+ Edit `invoke.json` to add production tools or point `mcp_url` at your hosted MCP server.
1190
+ """
1191
+ ),
1192
+ encoding="utf-8",
1193
+ )
1194
+ print(f"Created {root}")
1195
+ print("Next:")
1196
+ print(f" cd {root}")
1197
+ print(" invoke deploy --dry-run")
1198
+ return 0
1199
+
1200
+
1201
+ def read_project(root: Path) -> dict[str, Any]:
1202
+ config_path = root / "invoke.json"
1203
+ if not config_path.exists():
1204
+ raise ValueError(f"No invoke.json found in {root}. Run `invoke init <name>` first.")
1205
+ config = load_json_file(config_path, {})
1206
+ if not isinstance(config, dict):
1207
+ raise ValueError("invoke.json must contain an object")
1208
+ tools = config.get("tools")
1209
+ if tools is None and (root / "invoke.register.json").exists():
1210
+ tools = load_json_file(root / "invoke.register.json", [])
1211
+ if not isinstance(tools, list) or not tools:
1212
+ raise ValueError("Project must define tools in invoke.json or invoke.register.json")
1213
+ config["tools"] = tools
1214
+ return config
1215
+
1216
+
1217
+ def project_mcp_url(root: Path, config: dict[str, Any], explicit_mcp_url: str | None = None) -> str | None:
1218
+ if explicit_mcp_url:
1219
+ return explicit_mcp_url
1220
+ configured = config.get("mcp_url")
1221
+ if not is_placeholder_mcp_url(configured):
1222
+ return configured
1223
+ dev_info = load_json_file(dev_runtime_path(root), {})
1224
+ if isinstance(dev_info, dict) and dev_info.get("mcp_url"):
1225
+ return str(dev_info["mcp_url"])
1226
+ return configured
1227
+
1228
+
1229
+ def registration_tool_name(tool: dict[str, Any]) -> str:
1230
+ return str(tool.get("mcp_tool") or tool.get("name") or "tool")
1231
+
1232
+
1233
+ def registration_tool_description(tool: dict[str, Any]) -> str:
1234
+ card = tool.get("capability_card") if isinstance(tool.get("capability_card"), dict) else {}
1235
+ return str(card.get("description") or tool.get("description") or registration_tool_name(tool))
1236
+
1237
+
1238
+ def registration_tool_schema(tool: dict[str, Any]) -> dict[str, Any]:
1239
+ card = tool.get("capability_card") if isinstance(tool.get("capability_card"), dict) else {}
1240
+ schema = card.get("input_schema") or tool.get("input_schema") or {"type": "object", "properties": {}}
1241
+ return schema if isinstance(schema, dict) else {"type": "object", "properties": {}}
1242
+
1243
+
1244
+ def jsonrpc_result(request_id: Any, result: Any) -> dict[str, Any]:
1245
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
1246
+
1247
+
1248
+ def jsonrpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
1249
+ return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
1250
+
1251
+
1252
+ def make_dev_handler(config: dict[str, Any]) -> type[BaseHTTPRequestHandler]:
1253
+ tools = {registration_tool_name(tool): tool for tool in config["tools"]}
1254
+ project_name = str(config.get("name") or "invoke-dev")
1255
+
1256
+ class InvokeDevHandler(BaseHTTPRequestHandler):
1257
+ server_version = "InvokeDev/0.1"
1258
+
1259
+ def log_message(self, fmt: str, *args: Any) -> None:
1260
+ print(f"[invoke dev] {self.address_string()} - {fmt % args}")
1261
+
1262
+ def send_json(self, status: int, body: dict[str, Any]) -> None:
1263
+ data = json.dumps(body).encode("utf-8")
1264
+ self.send_response(status)
1265
+ self.send_header("Content-Type", "application/json")
1266
+ self.send_header("Content-Length", str(len(data)))
1267
+ self.end_headers()
1268
+ self.wfile.write(data)
1269
+
1270
+ def do_GET(self) -> None:
1271
+ if self.path == "/health":
1272
+ self.send_json(200, {"status": "ok", "project": project_name})
1273
+ return
1274
+ self.send_json(404, {"error": "not_found"})
1275
+
1276
+ def do_POST(self) -> None:
1277
+ if self.path != "/mcp":
1278
+ self.send_json(404, {"error": "not_found"})
1279
+ return
1280
+ raw_length = self.headers.get("Content-Length", "0")
1281
+ try:
1282
+ length = int(raw_length)
1283
+ payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
1284
+ except Exception as exc:
1285
+ self.send_json(400, jsonrpc_error(None, -32700, f"Invalid JSON: {exc}"))
1286
+ return
1287
+
1288
+ request_id = payload.get("id")
1289
+ method = payload.get("method")
1290
+ if method == "initialize":
1291
+ self.send_json(
1292
+ 200,
1293
+ jsonrpc_result(
1294
+ request_id,
1295
+ {
1296
+ "protocolVersion": "2025-03-26",
1297
+ "capabilities": {"tools": {}},
1298
+ "serverInfo": {"name": project_name, "version": "0.1.0"},
1299
+ },
1300
+ ),
1301
+ )
1302
+ return
1303
+ if method == "notifications/initialized":
1304
+ self.send_json(202, {})
1305
+ return
1306
+ if method == "tools/list":
1307
+ self.send_json(
1308
+ 200,
1309
+ jsonrpc_result(
1310
+ request_id,
1311
+ {
1312
+ "tools": [
1313
+ {
1314
+ "name": name,
1315
+ "description": registration_tool_description(tool),
1316
+ "inputSchema": registration_tool_schema(tool),
1317
+ }
1318
+ for name, tool in tools.items()
1319
+ ]
1320
+ },
1321
+ ),
1322
+ )
1323
+ return
1324
+ if method == "tools/call":
1325
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
1326
+ name = params.get("name")
1327
+ arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
1328
+ if name not in tools:
1329
+ self.send_json(200, jsonrpc_error(request_id, -32601, f"Unknown tool {name!r}"))
1330
+ return
1331
+ self.send_json(
1332
+ 200,
1333
+ jsonrpc_result(
1334
+ request_id,
1335
+ {
1336
+ "ok": True,
1337
+ "tool": name,
1338
+ "arguments": arguments,
1339
+ "mode": "invoke_dev_mock",
1340
+ "message": f"{name} handled by local invoke dev server",
1341
+ },
1342
+ ),
1343
+ )
1344
+ return
1345
+ self.send_json(200, jsonrpc_error(request_id, -32601, f"Unsupported method {method}"))
1346
+
1347
+ return InvokeDevHandler
1348
+
1349
+
1350
+ def deployment_record(project_root: Path, provider: dict[str, Any], tools: list[dict[str, Any]], base_url: str) -> dict[str, Any]:
1351
+ return {
1352
+ "project": str(project_root.resolve()),
1353
+ "name": provider.get("name"),
1354
+ "provider_id": provider.get("id"),
1355
+ "slug": provider.get("slug"),
1356
+ "gateway_url": provider.get("gateway_url"),
1357
+ "base_url": base_url,
1358
+ "tools": [tool.get("id") or tool.get("key") or tool.get("name") for tool in tools],
1359
+ "deployed_at": dt.datetime.now(dt.timezone.utc).isoformat(),
1360
+ }
1361
+
1362
+
1363
+ def save_deployment(record: dict[str, Any]) -> None:
1364
+ deployments = load_json_file(deployments_path(), [])
1365
+ if not isinstance(deployments, list):
1366
+ deployments = []
1367
+ deployments = [item for item in deployments if item.get("project") != record["project"]]
1368
+ deployments.append(record)
1369
+ write_json_file(deployments_path(), deployments)
1370
+
1371
+
1372
+ def deploy_command(args: argparse.Namespace) -> int:
1373
+ root = Path(args.path)
1374
+ config = read_project(root)
1375
+ tools = config["tools"]
1376
+ mcp_url = project_mcp_url(root, config, args.mcp_url)
1377
+
1378
+ if args.dry_run:
1379
+ print(
1380
+ json.dumps(
1381
+ {
1382
+ "success": True,
1383
+ "dry_run": True,
1384
+ "project": str(root.resolve()),
1385
+ "name": config.get("name") or root.name,
1386
+ "tools": [tool.get("name") for tool in tools],
1387
+ "mcp_url": mcp_url,
1388
+ },
1389
+ indent=2,
1390
+ )
1391
+ )
1392
+ return 0
1393
+
1394
+ credentials = require_credentials(args)
1395
+ provider_body = {
1396
+ "name": config.get("name") or root.name,
1397
+ "owner_email": args.owner_email or config.get("owner_email") or "dev@example.com",
1398
+ "slug": args.slug or config.get("slug"),
1399
+ }
1400
+ provider_response = api_request("POST", credentials["base_url"], "/providers", credentials["api_key"], provider_body)
1401
+ provider = provider_response.get("provider") or {}
1402
+ provider_id = provider.get("id")
1403
+ if not provider_id:
1404
+ raise RuntimeError(f"Provider creation returned no provider id: {provider_response}")
1405
+
1406
+ created_tools = []
1407
+ for tool in tools:
1408
+ payload = dict(tool)
1409
+ if not payload.get("mcp_url") and mcp_url:
1410
+ payload["mcp_url"] = mcp_url
1411
+ created = api_request("POST", credentials["base_url"], f"/providers/{provider_id}/tools", credentials["api_key"], payload)
1412
+ created_tools.append(created.get("tool") or created)
1413
+
1414
+ record = deployment_record(root, provider, created_tools, credentials["base_url"])
1415
+ save_deployment(record)
1416
+ print(json.dumps({"success": True, "provider": provider, "tools": created_tools}, indent=2))
1417
+ return 0
1418
+
1419
+
1420
+ def agents_list_command(args: argparse.Namespace) -> int:
1421
+ deployments = load_json_file(deployments_path(), [])
1422
+ if not deployments:
1423
+ print("No Invoke projects deployed from this machine yet.")
1424
+ return 0
1425
+ if args.json:
1426
+ print(json.dumps({"success": True, "agents": deployments}, indent=2))
1427
+ return 0
1428
+
1429
+ print("NAME\tSLUG\tTOOLS\tBASE URL")
1430
+ for item in deployments:
1431
+ print(
1432
+ f"{item.get('name') or '-'}\t"
1433
+ f"{item.get('slug') or '-'}\t"
1434
+ f"{len(item.get('tools') or [])}\t"
1435
+ f"{item.get('base_url') or '-'}"
1436
+ )
1437
+ return 0
1438
+
1439
+
1440
+ def tools_command(args: argparse.Namespace) -> int:
1441
+ credentials = require_credentials(args)
1442
+ query = f"?q={urllib.parse.quote(args.query)}" if args.query else ""
1443
+ response = api_request("GET", credentials["base_url"], f"/v1/tools{query}", credentials["api_key"])
1444
+ tools = response.get("tools") if isinstance(response.get("tools"), list) else []
1445
+ if args.json:
1446
+ print(json.dumps(response, indent=2))
1447
+ return 0
1448
+
1449
+ if not tools:
1450
+ print("No tools available for this API key.")
1451
+ print("Try `invoke deploy` from a project with invoke.json, or check your token scopes.")
1452
+ return 0
1453
+
1454
+ print("TOOL\tRISK\tAPPROVAL\tCONFIGURED\tDESCRIPTION")
1455
+ for tool in tools:
1456
+ tool_id = tool.get("id") or tool.get("key") or tool.get("name") or "-"
1457
+ risk = tool.get("risk_level") or "-"
1458
+ approval = "yes" if tool.get("approval_required") else "no"
1459
+ configured = "yes" if tool.get("configured", True) else "no"
1460
+ description = str(tool.get("description") or "").replace("\n", " ")
1461
+ print(f"{tool_id}\t{risk}\t{approval}\t{configured}\t{description}")
1462
+ return 0
1463
+
1464
+
1465
+ def call_command(args: argparse.Namespace) -> int:
1466
+ if not args.tool:
1467
+ raise CliUsageError(
1468
+ "Missing tool.\n"
1469
+ "Usage: invoke call <tool> '<json_params>'\n"
1470
+ "Example: invoke call linear.create_issue '{\"team_id\":\"ENG\",\"title\":\"Fix retry\"}'"
1471
+ )
1472
+ credentials = require_credentials(args)
1473
+ try:
1474
+ params = json.loads(args.params)
1475
+ except json.JSONDecodeError as exc:
1476
+ raise ValueError(f"params must be valid JSON: {exc}") from exc
1477
+ response = api_request(
1478
+ "POST",
1479
+ credentials["base_url"],
1480
+ "/v1/call",
1481
+ credentials["api_key"],
1482
+ {
1483
+ "tool": args.tool,
1484
+ "params": params,
1485
+ "agent_id": args.agent_id,
1486
+ **({"idempotency_key": args.idempotency_key} if args.idempotency_key else {}),
1487
+ },
1488
+ )
1489
+ print(json.dumps(response, indent=2))
1490
+ return 0
1491
+
1492
+
1493
+ def approvals_command(args: argparse.Namespace) -> int:
1494
+ credentials = require_credentials(args)
1495
+ if args.approvals_action == "approve":
1496
+ if not args.approval_id:
1497
+ raise CliUsageError("Usage: invoke approvals approve <approval_id>")
1498
+ response = api_request(
1499
+ "POST",
1500
+ credentials["base_url"],
1501
+ f"/v1/approvals/{args.approval_id}/approve",
1502
+ credentials["api_key"],
1503
+ {"reviewed_by": args.reviewed_by},
1504
+ )
1505
+ print(json.dumps(response, indent=2))
1506
+ return 0
1507
+ if args.approvals_action == "reject":
1508
+ if not args.approval_id:
1509
+ raise CliUsageError("Usage: invoke approvals reject <approval_id>")
1510
+ response = api_request(
1511
+ "POST",
1512
+ credentials["base_url"],
1513
+ f"/v1/approvals/{args.approval_id}/reject",
1514
+ credentials["api_key"],
1515
+ {"reviewed_by": args.reviewed_by},
1516
+ )
1517
+ print(json.dumps(response, indent=2))
1518
+ return 0
1519
+
1520
+ response = api_request("GET", credentials["base_url"], "/v1/approvals", credentials["api_key"])
1521
+ approvals = response.get("approvals") if isinstance(response.get("approvals"), list) else []
1522
+ if args.json:
1523
+ print(json.dumps(response, indent=2))
1524
+ return 0
1525
+ if not approvals:
1526
+ print("No pending approvals.")
1527
+ return 0
1528
+ print("ID\tTOOL\tAGENT\tRISK\tSTATUS")
1529
+ for approval in approvals:
1530
+ print(
1531
+ f"{approval.get('id') or '-'}\t"
1532
+ f"{approval.get('tool') or '-'}\t"
1533
+ f"{approval.get('agent_id') or '-'}\t"
1534
+ f"{approval.get('risk_level') or '-'}\t"
1535
+ f"{approval.get('status') or '-'}"
1536
+ )
1537
+ return 0
1538
+
1539
+
1540
+ def search_command(args: argparse.Namespace) -> int:
1541
+ credentials = require_credentials(args)
1542
+ response = api_request(
1543
+ "POST",
1544
+ credentials["base_url"],
1545
+ "/v1/search",
1546
+ credentials["api_key"],
1547
+ {
1548
+ "query": args.query,
1549
+ "limit": args.limit,
1550
+ },
1551
+ )
1552
+ if args.json:
1553
+ print(json.dumps(response, indent=2))
1554
+ return 0
1555
+
1556
+ print(f"Invoke search: {response.get('query') or args.query}")
1557
+ print(f"Provider: {response.get('provider', 'exa')} Results: {len(response.get('results') or [])}")
1558
+ print()
1559
+ for index, result in enumerate(response.get("results") or [], start=1):
1560
+ title = result.get("title") or "Untitled result"
1561
+ summary = result.get("summary") or ""
1562
+ url = result.get("url") or ""
1563
+ print(f"{index}. {title}")
1564
+ if summary:
1565
+ print(textwrap.fill(summary, width=88, initial_indent=" ", subsequent_indent=" "))
1566
+ if url:
1567
+ print(f" {url}")
1568
+ print()
1569
+ return 0
1570
+
1571
+
1572
+ def workflow_command(args: argparse.Namespace) -> int:
1573
+ credentials = require_credentials(args)
1574
+ body: dict[str, Any] = {}
1575
+ if args.query:
1576
+ body["query"] = args.query
1577
+ if args.limit:
1578
+ body["limit"] = args.limit
1579
+ if args.params:
1580
+ try:
1581
+ body["params"] = json.loads(args.params)
1582
+ except json.JSONDecodeError as exc:
1583
+ raise ValueError(f"params must be valid JSON: {exc}") from exc
1584
+
1585
+ response = api_request(
1586
+ "POST",
1587
+ credentials["base_url"],
1588
+ f"/v1/workflows/{args.workflow}/run",
1589
+ credentials["api_key"],
1590
+ body,
1591
+ )
1592
+ if args.json:
1593
+ print(json.dumps(response, indent=2))
1594
+ return 0
1595
+
1596
+ print(response.get("summary", f"Workflow {args.workflow} completed."))
1597
+ print()
1598
+ for event in response.get("trace") or []:
1599
+ step = event.get("step", "step")
1600
+ title = event.get("title", "")
1601
+ status = event.get("status", "")
1602
+ detail = event.get("detail", "")
1603
+ print(f"- {step}: {title} [{status}]")
1604
+ if detail:
1605
+ print(textwrap.fill(detail, width=88, initial_indent=" ", subsequent_indent=" "))
1606
+ return 0
1607
+
1608
+
1609
+ def dev_command(args: argparse.Namespace) -> int:
1610
+ root = Path(args.path)
1611
+ config = read_project(root)
1612
+ host = args.host
1613
+ port = args.port
1614
+ mcp_url = f"http://{host if host not in {'0.0.0.0', '::'} else 'localhost'}:{port}/mcp"
1615
+ write_json_file(
1616
+ dev_runtime_path(root),
1617
+ {
1618
+ "mcp_url": mcp_url,
1619
+ "host": host,
1620
+ "port": port,
1621
+ "started_at": dt.datetime.now(dt.timezone.utc).isoformat(),
1622
+ },
1623
+ )
1624
+
1625
+ if args.dry_run:
1626
+ print(json.dumps({"success": True, "mcp_url": mcp_url, "tools": [registration_tool_name(tool) for tool in config["tools"]]}, indent=2))
1627
+ return 0
1628
+
1629
+ server = HTTPServer((host, port), make_dev_handler(config))
1630
+ print(f"Invoke dev MCP server running at {mcp_url}")
1631
+ print(f"Project: {root.resolve()}")
1632
+ print("In another terminal, run:")
1633
+ print(" invoke deploy")
1634
+ try:
1635
+ server.serve_forever()
1636
+ except KeyboardInterrupt:
1637
+ print("\nStopping invoke dev server.")
1638
+ finally:
1639
+ server.server_close()
1640
+ return 0
1641
+
1642
+
1643
+ def build_parser() -> argparse.ArgumentParser:
1644
+ parser = argparse.ArgumentParser(prog="invoke", description="Execution reliability infrastructure for AI agents.")
1645
+ subparsers = parser.add_subparsers(dest="command", required=True)
1646
+
1647
+ login = subparsers.add_parser("login", help="Save Invoke runtime credentials.")
1648
+ login.add_argument("--base-url", default=DEFAULT_API_URL, help="Invoke runtime URL.")
1649
+ login.add_argument("--api-key", help="Invoke API key. If omitted, prompts interactively.")
1650
+ login.set_defaults(func=login_command)
1651
+
1652
+ status = subparsers.add_parser("status", help="Show login and project context.")
1653
+ status.add_argument("--base-url", help="Override Invoke runtime URL.")
1654
+ status.add_argument("--api-key", help="Override Invoke API key.")
1655
+ status.add_argument("--check", action="store_true", help="Call /health and verify the runtime is reachable.")
1656
+ status.set_defaults(func=status_command)
1657
+
1658
+ doctor = subparsers.add_parser("doctor", help="Check credentials and runtime health.")
1659
+ doctor.add_argument("--base-url", help="Override Invoke runtime URL.")
1660
+ doctor.add_argument("--api-key", help="Override Invoke API key.")
1661
+ doctor.set_defaults(func=doctor_command)
1662
+
1663
+ config = subparsers.add_parser("config", help="Show or update CLI config.")
1664
+ config.add_argument("key", nargs="?", help="Config key to read or update: base-url or api-key.")
1665
+ config.add_argument("value", nargs="?", help="New config value.")
1666
+ config.add_argument("--json", action="store_true", help="Print config as JSON with the API key masked.")
1667
+ config.set_defaults(func=config_command)
1668
+
1669
+ init = subparsers.add_parser("init", help="Scaffold an Invoke project.")
1670
+ init.add_argument("name", help="Project directory/name.")
1671
+ init.add_argument("--template", choices=["default", "linear", "crm-guardrail"], default="default")
1672
+ init.add_argument("--force", action="store_true", help="Write into an existing non-empty directory.")
1673
+ init.set_defaults(func=init_command)
1674
+
1675
+ deploy = subparsers.add_parser("deploy", help="Register this project with an Invoke runtime.")
1676
+ deploy.add_argument("path", nargs="?", default=".", help="Project directory containing invoke.json.")
1677
+ deploy.add_argument("--base-url", help="Override Invoke runtime URL.")
1678
+ deploy.add_argument("--api-key", help="Override Invoke API key.")
1679
+ deploy.add_argument("--owner-email", help="Provider owner email.")
1680
+ deploy.add_argument("--slug", help="Provider slug.")
1681
+ deploy.add_argument("--mcp-url", help="Hosted MCP URL for registered tools.")
1682
+ deploy.add_argument("--dry-run", action="store_true", help="Validate and print the deployment plan without calling the API.")
1683
+ deploy.set_defaults(func=deploy_command)
1684
+
1685
+ dev = subparsers.add_parser("dev", help="Run a local MCP server for this Invoke project.")
1686
+ dev.add_argument("path", nargs="?", default=".", help="Project directory containing invoke.json.")
1687
+ dev.add_argument("--host", default="127.0.0.1")
1688
+ dev.add_argument("--port", type=int, default=8787)
1689
+ dev.add_argument("--dry-run", action="store_true", help="Print the local MCP URL without starting the server.")
1690
+ dev.set_defaults(func=dev_command)
1691
+
1692
+ tools = subparsers.add_parser("tools", help="List available tools.")
1693
+ tools.add_argument("query", nargs="?", help="Optional search query.")
1694
+ tools.add_argument("--json", action="store_true", help="Print the full JSON response.")
1695
+ tools.add_argument("--base-url", help="Override Invoke runtime URL.")
1696
+ tools.add_argument("--api-key", help="Override Invoke API key.")
1697
+ tools.set_defaults(func=tools_command)
1698
+
1699
+ call = subparsers.add_parser("call", help="Call a tool through Invoke.")
1700
+ call.add_argument("tool", nargs="?", help="Tool id, for example linear.create_issue.")
1701
+ call.add_argument("params", nargs="?", default="{}", help="JSON params object.")
1702
+ call.add_argument("--agent-id", default="cli_agent")
1703
+ call.add_argument("--idempotency-key")
1704
+ call.add_argument("--base-url", help="Override Invoke runtime URL.")
1705
+ call.add_argument("--api-key", help="Override Invoke API key.")
1706
+ call.set_defaults(func=call_command)
1707
+
1708
+ approvals = subparsers.add_parser("approvals", help="List or manage pending approvals.")
1709
+ approvals.add_argument(
1710
+ "approvals_action",
1711
+ nargs="?",
1712
+ choices=["list", "approve", "reject"],
1713
+ default="list",
1714
+ help="Action to run. Defaults to list.",
1715
+ )
1716
+ approvals.add_argument("approval_id", nargs="?", help="Approval id for approve/reject.")
1717
+ approvals.add_argument("--reviewed-by", default="cli", help="Reviewer name for approve/reject.")
1718
+ approvals.add_argument("--json", action="store_true", help="Print the full JSON response.")
1719
+ approvals.add_argument("--base-url", help="Override Invoke runtime URL.")
1720
+ approvals.add_argument("--api-key", help="Override Invoke API key.")
1721
+ approvals.set_defaults(func=approvals_command)
1722
+
1723
+ search = subparsers.add_parser("search", help="Search live docs/context through Invoke.")
1724
+ search.add_argument("query", help="Search query, for example 'latest MCP agent failures'.")
1725
+ search.add_argument("--limit", type=int, default=5, help="Number of Exa results to return, 1-10.")
1726
+ search.add_argument("--json", action="store_true", help="Print the full JSON response.")
1727
+ search.add_argument("--base-url", help="Override Invoke runtime URL.")
1728
+ search.add_argument("--api-key", help="Override Invoke API key.")
1729
+ search.set_defaults(func=search_command)
1730
+
1731
+ workflow = subparsers.add_parser("workflow", help="Run a packaged Invoke workflow.")
1732
+ workflow.add_argument(
1733
+ "workflow",
1734
+ choices=["safe-tool-execution", "live-context-retrieval", "failure-trace-visualization"],
1735
+ help="Workflow id to run.",
1736
+ )
1737
+ workflow.add_argument("--query", help="Live-context query for Exa-backed workflows.")
1738
+ workflow.add_argument("--limit", type=int, help="Search result limit for live-context workflows.")
1739
+ workflow.add_argument("--params", help="Optional JSON params for the workflow.")
1740
+ workflow.add_argument("--json", action="store_true", help="Print the full JSON response.")
1741
+ workflow.add_argument("--base-url", help="Override Invoke runtime URL.")
1742
+ workflow.add_argument("--api-key", help="Override Invoke API key.")
1743
+ workflow.set_defaults(func=workflow_command)
1744
+
1745
+ agents = subparsers.add_parser("agents", help="Manage locally deployed Invoke projects.")
1746
+ agents_subparsers = agents.add_subparsers(dest="agents_command", required=True)
1747
+ agents_list = agents_subparsers.add_parser("list", help="List projects deployed from this machine.")
1748
+ agents_list.add_argument("--json", action="store_true", help="Print JSON.")
1749
+ agents_list.set_defaults(func=agents_list_command)
1750
+
1751
+ wrap = subparsers.add_parser("wrap", help="Generate a wrapper project.")
1752
+ wrap.add_argument("target", help="postgresql, github, notion, linear, or a FastAPI/service name.")
1753
+ wrap.add_argument("--query", help="SQL query for postgresql wrappers.")
1754
+ wrap.add_argument("--database-url-env", default="DATABASE_URL", help="Env var used by generated PostgreSQL wrapper.")
1755
+ wrap.add_argument("--allow-write", action="store_true", help="Allow non-read-only PostgreSQL statements.")
1756
+ wrap.add_argument("--openapi", help="OpenAPI JSON file for FastAPI/service wrappers.")
1757
+ wrap.add_argument("--base-url", default="http://localhost:8000", help="Base URL for generated HTTP wrappers.")
1758
+ wrap.add_argument("--name", help="Human-friendly wrapper or tool name.")
1759
+ wrap.add_argument("--description", help="Capability description.")
1760
+ wrap.add_argument("--output", default="wrapped_tools", help="Output directory.")
1761
+ wrap.set_defaults(func=wrap_command)
1762
+
1763
+ return parser
1764
+
1765
+
1766
+ def main(argv: list[str] | None = None) -> int:
1767
+ parser = build_parser()
1768
+ raw_args = list(argv if argv is not None else sys.argv[1:])
1769
+ aliases = {
1770
+ "calls": "call",
1771
+ "tool": "tools",
1772
+ "ls": "tools",
1773
+ }
1774
+ if raw_args and raw_args[0] in aliases:
1775
+ raw_args[0] = aliases[raw_args[0]]
1776
+ args = parser.parse_args(raw_args)
1777
+ try:
1778
+ return args.func(args)
1779
+ except Exception as exc:
1780
+ print(f"invoke: error: {exc}", file=sys.stderr)
1781
+ return 2
1782
+
1783
+
1784
+ if __name__ == "__main__":
1785
+ raise SystemExit(main())