@myvillage/cli 1.83.0 → 1.85.0
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/package.json +1 -1
- package/src/agent-runtime/context.js +21 -95
- package/src/agent-runtime/loop.js +9 -7
- package/src/commands/agent-local.js +95 -5
- package/src/commands/agent-mcp.js +59 -59
- package/src/commands/agent.js +8 -2
- package/src/index.js +24 -13
- package/src/utils/agent-recipes.js +83 -0
- package/src/utils/agent-scaffolder.js +6 -6
- package/src/utils/connectors.js +285 -0
- package/src/utils/recipes.js +18 -275
package/src/index.js
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
agentMemoryCommand,
|
|
71
71
|
agentRecallCommand,
|
|
72
72
|
agentRememberCommand,
|
|
73
|
+
agentRecipesListCommand,
|
|
73
74
|
} from './commands/agent-local.js';
|
|
74
75
|
import {
|
|
75
76
|
agentRegisterClientCommand,
|
|
@@ -80,9 +81,9 @@ import {
|
|
|
80
81
|
agentRotateClientKeyCommand,
|
|
81
82
|
} from './commands/agent-client.js';
|
|
82
83
|
import {
|
|
83
|
-
|
|
84
|
+
agentAddConnectorCommand,
|
|
84
85
|
agentListMcpCommand,
|
|
85
|
-
|
|
86
|
+
connectorsListCommand,
|
|
86
87
|
} from './commands/agent-mcp.js';
|
|
87
88
|
import {
|
|
88
89
|
agentGrantCommand,
|
|
@@ -444,7 +445,8 @@ export function run() {
|
|
|
444
445
|
agentCmd
|
|
445
446
|
.command('create')
|
|
446
447
|
.description('Create a new agent (interactive)')
|
|
447
|
-
.
|
|
448
|
+
.option('--recipe <slug>', 'Start from an Agent Recipe template (prompt + preselected MyVillageOS tools). Implies a local agent. List them with `myvillage agent recipes`.')
|
|
449
|
+
.action((options) => agentCreateCommand(options));
|
|
448
450
|
|
|
449
451
|
agentCmd
|
|
450
452
|
.command('view <handle>')
|
|
@@ -536,7 +538,7 @@ export function run() {
|
|
|
536
538
|
|
|
537
539
|
agentCmd
|
|
538
540
|
.command('add-tool <name> <tool>')
|
|
539
|
-
.description('Add a built-in tool (filesystem, browser) — for custom MCPs use `add-
|
|
541
|
+
.description('Add a built-in tool (filesystem, browser) — for custom MCPs use `add-connector`, for Google/Microsoft/Slack use `grant`')
|
|
540
542
|
.action(agentAddToolCommand);
|
|
541
543
|
|
|
542
544
|
agentCmd
|
|
@@ -544,14 +546,16 @@ export function run() {
|
|
|
544
546
|
.description('Remove an MCP server tool from a local agent')
|
|
545
547
|
.action(agentRemoveToolCommand);
|
|
546
548
|
|
|
547
|
-
// Generic add-
|
|
548
|
-
// (built-in name, local YAML file, or URL).
|
|
549
|
+
// Generic add-connector — interactive wizard, optionally pre-filled by a
|
|
550
|
+
// connector (built-in name, local YAML file, or URL). `add-mcp` stays as a
|
|
551
|
+
// hidden deprecated alias so already-documented invocations keep working.
|
|
549
552
|
agentCmd
|
|
550
|
-
.command('add-
|
|
551
|
-
.
|
|
552
|
-
.
|
|
553
|
-
.option('--
|
|
554
|
-
.
|
|
553
|
+
.command('add-connector <name> [connector]')
|
|
554
|
+
.alias('add-mcp')
|
|
555
|
+
.description('Interactively add any MCP server (connector) to an agent. Optional connector pre-fills the wizard (built-in name, ./path.yaml, or https://url/connector.yaml).')
|
|
556
|
+
.option('--from <source>', 'Connector source — same as the positional [connector] arg')
|
|
557
|
+
.option('--yes', 'Skip prompts and accept connector values verbatim (requires a connector source)')
|
|
558
|
+
.action((name, connector, options) => agentAddConnectorCommand(name, connector, options));
|
|
555
559
|
|
|
556
560
|
agentCmd
|
|
557
561
|
.command('list-mcp <name>')
|
|
@@ -577,9 +581,16 @@ export function run() {
|
|
|
577
581
|
.action(agentGrantsListCommand);
|
|
578
582
|
|
|
579
583
|
program
|
|
584
|
+
.command('connectors')
|
|
585
|
+
.alias('recipes')
|
|
586
|
+
.description('List the curated MCP connectors in the MyVillage registry')
|
|
587
|
+
.action(connectorsListCommand);
|
|
588
|
+
|
|
589
|
+
// List available Agent Recipes (starter templates for `agent create --recipe`).
|
|
590
|
+
agentCmd
|
|
580
591
|
.command('recipes')
|
|
581
|
-
.description('List
|
|
582
|
-
.action(
|
|
592
|
+
.description('List available Agent Recipes (starter templates: prompt + preselected tools)')
|
|
593
|
+
.action(agentRecipesListCommand);
|
|
583
594
|
|
|
584
595
|
// Task queue commands — assign and inspect work for a developer's agent
|
|
585
596
|
agentCmd
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ── Agent Recipes ───────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// An "Agent Recipe" is a get-started template for creating an agent: a
|
|
4
|
+
// `prompt.md` plus a pre-selected set of MyVillageOS tools (and optional
|
|
5
|
+
// schedule defaults). Recipes are DB-backed and served by the ed-platform;
|
|
6
|
+
// the CLI materializes them into the three local agent files at create time.
|
|
7
|
+
//
|
|
8
|
+
// Distinct from a "Connector" (see connectors.js), which installs an external
|
|
9
|
+
// MCP server. Recipes carry no credentials.
|
|
10
|
+
//
|
|
11
|
+
// API (auth: bearer token, any session):
|
|
12
|
+
// GET ${apiBaseUrl}/agent-recipes -> { recipes: AgentRecipeSummary[] }
|
|
13
|
+
// GET ${apiBaseUrl}/agent-recipes/{slug} -> { recipe: AgentRecipe }
|
|
14
|
+
//
|
|
15
|
+
// AgentRecipe {
|
|
16
|
+
// slug, name, description, promptMd,
|
|
17
|
+
// myvillageTools: string[],
|
|
18
|
+
// scheduleDefaults?: { check_in_interval?, active_hours? }
|
|
19
|
+
// }
|
|
20
|
+
|
|
21
|
+
import { getConfig } from './config.js';
|
|
22
|
+
import { getAccessToken } from './auth.js';
|
|
23
|
+
|
|
24
|
+
async function authedFetch(url, options = {}) {
|
|
25
|
+
const token = getAccessToken();
|
|
26
|
+
const headers = { ...(options.headers || {}) };
|
|
27
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
28
|
+
return fetch(url, { ...options, headers });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function recipeError(response, action) {
|
|
32
|
+
let detail = '';
|
|
33
|
+
try {
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
if (body?.error) detail = body.error;
|
|
36
|
+
if (body?.hint) detail += detail ? ` — ${body.hint}` : body.hint;
|
|
37
|
+
} catch {
|
|
38
|
+
// non-JSON body, ignore
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 401) {
|
|
41
|
+
return new Error(`Could not ${action}: authentication required. Run 'myvillage login' first.`);
|
|
42
|
+
}
|
|
43
|
+
if (response.status === 404) {
|
|
44
|
+
return new Error(`Could not ${action}: not found.`);
|
|
45
|
+
}
|
|
46
|
+
return new Error(`Could not ${action}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch a single Agent Recipe by slug. Returns the recipe object
|
|
51
|
+
* ({ slug, name, description, promptMd, myvillageTools, scheduleDefaults? }).
|
|
52
|
+
* Throws with a friendly message on 404 / auth / network failure.
|
|
53
|
+
*/
|
|
54
|
+
export async function loadAgentRecipe(slug) {
|
|
55
|
+
if (!slug) throw new Error('No agent recipe slug provided');
|
|
56
|
+
const url = `${getConfig().apiBaseUrl}/agent-recipes/${encodeURIComponent(slug)}`;
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = await authedFetch(url);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
throw new Error(`Could not fetch recipe "${slug}": ${err.message}`);
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw await recipeError(res, `fetch recipe "${slug}"`);
|
|
65
|
+
}
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
// Accept either { recipe: {...} } (spec shape) or a bare recipe object.
|
|
68
|
+
return body.recipe || body;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List available Agent Recipes. Returns an array of summaries
|
|
73
|
+
* ({ slug, name, description, ... }). Requires `myvillage login`.
|
|
74
|
+
*/
|
|
75
|
+
export async function listAgentRecipes() {
|
|
76
|
+
const url = `${getConfig().apiBaseUrl}/agent-recipes`;
|
|
77
|
+
const res = await authedFetch(url);
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
throw await recipeError(res, 'list agent recipes');
|
|
80
|
+
}
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
return body.recipes || body.data || [];
|
|
83
|
+
}
|
|
@@ -12,10 +12,10 @@ import { stringify as stringifyYaml } from 'yaml';
|
|
|
12
12
|
// themselves).
|
|
13
13
|
//
|
|
14
14
|
// Everything else — Twilio, custom remote MCPs, anything new the community
|
|
15
|
-
// ships — lives as a "
|
|
16
|
-
// add-
|
|
17
|
-
// Microsoft, Slack) instead connect once and are authorized per-agent
|
|
18
|
-
// `myvillage agent grant`.
|
|
15
|
+
// ships — lives as a "connector" the developer installs via `myvillage agent
|
|
16
|
+
// add-connector` after creation (see src/utils/connectors.js). OAuth providers
|
|
17
|
+
// (Google, Microsoft, Slack) instead connect once and are authorized per-agent
|
|
18
|
+
// with `myvillage agent grant`.
|
|
19
19
|
|
|
20
20
|
const TOOL_CATALOG = {
|
|
21
21
|
|
|
@@ -100,7 +100,7 @@ export function scaffoldAgent(agentDir, options) {
|
|
|
100
100
|
generateToolsYaml(tools, name),
|
|
101
101
|
);
|
|
102
102
|
|
|
103
|
-
// A starter .env file for the developer to populate. Tools/
|
|
103
|
+
// A starter .env file for the developer to populate. Tools/connectors that
|
|
104
104
|
// need credentials (Twilio SID/auth, custom headers for remote MCPs) read env
|
|
105
105
|
// vars from this file at agent start. OAuth providers (Google, Microsoft,
|
|
106
106
|
// Slack) are NOT env vars — they connect once and are authorized per-agent
|
|
@@ -123,7 +123,7 @@ function starterEnvFile() {
|
|
|
123
123
|
'# environment of every MCP subprocess it spawns. Use it for tokens, API',
|
|
124
124
|
'# keys, and any other secret your tools (or remote MCPs) need.',
|
|
125
125
|
'#',
|
|
126
|
-
'# Examples (the curated
|
|
126
|
+
'# Examples (the curated connectors — install with `myvillage agent add-connector`):',
|
|
127
127
|
'# GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxxxxxxxxx',
|
|
128
128
|
'# TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxx',
|
|
129
129
|
'# TWILIO_API_KEY=SKxxxxxxxxxxxx',
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// ── MCP Connectors ──────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A "connector" is a small declarative YAML file describing how to plug an
|
|
4
|
+
// MCP server into an agent. Connectors are NOT bundled with the CLI — they
|
|
5
|
+
// live in the MyVillage connector registry (auth-gated) and any other URL a
|
|
6
|
+
// developer points at via `--from`.
|
|
7
|
+
//
|
|
8
|
+
// (Formerly called "recipes." The name moved to "connector" so "recipe" could
|
|
9
|
+
// be reused for agent starter templates — see agent-recipes.js. The old
|
|
10
|
+
// `recipes.js` module re-exports these symbols under their previous names for
|
|
11
|
+
// back-compat with the published package's consumers, e.g. myvillage-studio.)
|
|
12
|
+
//
|
|
13
|
+
// `loadConnector(source)` accepts:
|
|
14
|
+
// - a bare name like "github" → fetches from the MyVillage registry,
|
|
15
|
+
// attaches the developer's bearer token, requires `myvillage login`
|
|
16
|
+
// - a local file path (./connector.yaml or absolute) → reads from disk
|
|
17
|
+
// - a remote URL (http(s)://...) → fetches; bearer token attached only
|
|
18
|
+
// when the host is a MyVillage host (never leaked to third parties)
|
|
19
|
+
//
|
|
20
|
+
// Connector format — same two shapes the daemon's `tools.yaml` already
|
|
21
|
+
// supports:
|
|
22
|
+
//
|
|
23
|
+
// Local subprocess MCP:
|
|
24
|
+
// name: string
|
|
25
|
+
// description: string (optional)
|
|
26
|
+
// command: string # e.g. "npx"
|
|
27
|
+
// args: [string, string, ...] # passed to command; ${VAR} ok
|
|
28
|
+
// env: # optional schema for the wizard
|
|
29
|
+
// NAME:
|
|
30
|
+
// description: string
|
|
31
|
+
// required: bool
|
|
32
|
+
// setup_help: string (optional URL)
|
|
33
|
+
//
|
|
34
|
+
// Remote URL MCP:
|
|
35
|
+
// name: string
|
|
36
|
+
// description: string (optional)
|
|
37
|
+
// url: string
|
|
38
|
+
// transport: "http" | "sse" (optional, default "http")
|
|
39
|
+
// headers: # optional; values may use ${ENV}
|
|
40
|
+
// Authorization: "Bearer ${MY_TOKEN}"
|
|
41
|
+
// env: # optional schema for the wizard
|
|
42
|
+
// MY_TOKEN:
|
|
43
|
+
// description: string
|
|
44
|
+
// required: bool
|
|
45
|
+
// setup_help: string (optional URL)
|
|
46
|
+
|
|
47
|
+
import { readFileSync, existsSync } from 'fs';
|
|
48
|
+
import { join, isAbsolute } from 'path';
|
|
49
|
+
import { parse as parseYaml } from 'yaml';
|
|
50
|
+
import { getConfig } from './config.js';
|
|
51
|
+
import { getAccessToken } from './auth.js';
|
|
52
|
+
|
|
53
|
+
// ── Deprecated registry connectors ──────────────────────
|
|
54
|
+
//
|
|
55
|
+
// Curated connectors retired in favor of one-click OAuth provider connect
|
|
56
|
+
// (`myvillage agent grant <agent> <provider>`). We hide them from the registry
|
|
57
|
+
// listing and block a direct fetch so there's a single, obvious path — even if
|
|
58
|
+
// the backend registry still serves the old entry. Keyed by registry connector
|
|
59
|
+
// name → the provider slug that replaces it. Only affects bare-name registry
|
|
60
|
+
// loads; a developer's own `--from ./slack.yaml` is untouched.
|
|
61
|
+
const DEPRECATED_REGISTRY_CONNECTORS = {
|
|
62
|
+
slack: 'slack', // bot-token Slack MCP → "Sign in with Slack" (acts as you)
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function deprecationMessage(name, providerSlug) {
|
|
66
|
+
const label = name.charAt(0).toUpperCase() + name.slice(1);
|
|
67
|
+
return (
|
|
68
|
+
`The "${name}" bot-token tool has been replaced by one-click ${label} sign-in. ` +
|
|
69
|
+
`Connect ${label} in MyVillage Studio (or at myvillageproject.ai), then run ` +
|
|
70
|
+
`\`myvillage agent grant <agent> ${providerSlug}\` — no token to copy.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Public API ──────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve any connector source (bare name | local path | URL) to a parsed,
|
|
78
|
+
* validated connector object. Throws with a friendly message if the source
|
|
79
|
+
* can't be read, the request fails, or the YAML is invalid.
|
|
80
|
+
*/
|
|
81
|
+
export async function loadConnector(source) {
|
|
82
|
+
if (!source) throw new Error('No connector source provided');
|
|
83
|
+
|
|
84
|
+
// URL — fetch (auth attached if MyVillage host)
|
|
85
|
+
if (/^https?:\/\//i.test(source)) {
|
|
86
|
+
return loadConnectorFromUrl(source);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Looks like a path — read from disk
|
|
90
|
+
if (source.includes('/') || source.includes('\\') || /\.(yaml|yml)$/i.test(source)) {
|
|
91
|
+
return loadConnectorFromFile(source);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Bare name — pull from the MyVillage curated registry
|
|
95
|
+
return loadConnectorFromRegistry(source);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List curated connectors available in the MyVillage registry. Requires
|
|
100
|
+
* `myvillage login`. Returns an array of summaries:
|
|
101
|
+
* { name, description, kind, envVars, setup_help? }
|
|
102
|
+
*/
|
|
103
|
+
export async function listRegistryConnectors() {
|
|
104
|
+
const url = `${getConfig().apiBaseUrl}/connectors`;
|
|
105
|
+
const res = await authedFetch(url);
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw await registryError(res, 'list connectors');
|
|
108
|
+
}
|
|
109
|
+
const body = await res.json();
|
|
110
|
+
// Back-compat: the endpoint returns `{ connectors: [...] }`; older/deprecated
|
|
111
|
+
// deploys may still return `{ recipes: [...] }`. Accept either.
|
|
112
|
+
const items = body.connectors || body.recipes || [];
|
|
113
|
+
// Hide retired connectors (e.g. the bot-token Slack MCP) so they never show up
|
|
114
|
+
// as an option — the OAuth provider path supersedes them.
|
|
115
|
+
return items.filter(
|
|
116
|
+
(r) => !Object.hasOwn(DEPRECATED_REGISTRY_CONNECTORS, String(r?.name || '').toLowerCase()),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Loaders ─────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async function loadConnectorFromRegistry(name) {
|
|
123
|
+
const slug = String(name || '').toLowerCase();
|
|
124
|
+
if (Object.hasOwn(DEPRECATED_REGISTRY_CONNECTORS, slug)) {
|
|
125
|
+
throw new Error(deprecationMessage(slug, DEPRECATED_REGISTRY_CONNECTORS[slug]));
|
|
126
|
+
}
|
|
127
|
+
const url = `${getConfig().apiBaseUrl}/connectors/${encodeURIComponent(name)}`;
|
|
128
|
+
const res = await authedFetch(url);
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
throw await registryError(res, `fetch connector "${name}"`);
|
|
131
|
+
}
|
|
132
|
+
const text = await res.text();
|
|
133
|
+
return validateConnector(parseYaml(text), `registry:${name}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadConnectorFromFile(path) {
|
|
137
|
+
const abs = isAbsolute(path) ? path : join(process.cwd(), path);
|
|
138
|
+
if (!existsSync(abs)) {
|
|
139
|
+
throw new Error(`Connector file not found: ${abs}`);
|
|
140
|
+
}
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = parseYaml(readFileSync(abs, 'utf-8'));
|
|
144
|
+
} catch (err) {
|
|
145
|
+
throw new Error(`Failed to parse connector at ${abs}: ${err.message}`);
|
|
146
|
+
}
|
|
147
|
+
return validateConnector(parsed, abs);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadConnectorFromUrl(url) {
|
|
151
|
+
// Attach the bearer token only if this URL is on a MyVillage host —
|
|
152
|
+
// sending it to a third-party URL would leak the developer's
|
|
153
|
+
// platform credentials.
|
|
154
|
+
const fetcher = isMyVillageHost(url) ? authedFetch : fetch;
|
|
155
|
+
let response;
|
|
156
|
+
try {
|
|
157
|
+
response = await fetcher(url, { redirect: 'follow' });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new Error(`Failed to fetch connector from ${url}: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
if (isMyVillageHost(url) && (response.status === 401 || response.status === 403)) {
|
|
163
|
+
throw new Error(`Connector at ${url} requires authentication. Run 'myvillage login' first.`);
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`Failed to fetch connector from ${url}: HTTP ${response.status}`);
|
|
166
|
+
}
|
|
167
|
+
const text = await response.text();
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = parseYaml(text);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
throw new Error(`Failed to parse connector from ${url}: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
return validateConnector(parsed, url);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Auth ────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function isMyVillageHost(urlString) {
|
|
180
|
+
try {
|
|
181
|
+
const host = new URL(urlString).hostname;
|
|
182
|
+
return host === 'myvillageproject.ai' || host.endsWith('.myvillageproject.ai');
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function authedFetch(url, options = {}) {
|
|
189
|
+
const token = getAccessToken();
|
|
190
|
+
const headers = { ...(options.headers || {}) };
|
|
191
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
192
|
+
return fetch(url, { ...options, headers });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function registryError(response, action) {
|
|
196
|
+
let detail = '';
|
|
197
|
+
try {
|
|
198
|
+
const body = await response.json();
|
|
199
|
+
if (body?.error) detail = body.error;
|
|
200
|
+
if (body?.hint) detail += detail ? ` — ${body.hint}` : body.hint;
|
|
201
|
+
} catch {
|
|
202
|
+
// non-JSON body, ignore
|
|
203
|
+
}
|
|
204
|
+
if (response.status === 401) {
|
|
205
|
+
return new Error(`Could not ${action}: authentication required. Run 'myvillage login' first.`);
|
|
206
|
+
}
|
|
207
|
+
if (response.status === 404) {
|
|
208
|
+
return new Error(`Could not ${action}: not found in the MyVillage connector registry.`);
|
|
209
|
+
}
|
|
210
|
+
return new Error(`Could not ${action}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Validation ──────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function validateConnector(connector, sourceLabel) {
|
|
216
|
+
if (!connector || typeof connector !== 'object') {
|
|
217
|
+
throw new Error(`Connector at ${sourceLabel} is empty or not a YAML object`);
|
|
218
|
+
}
|
|
219
|
+
if (!connector.name || typeof connector.name !== 'string') {
|
|
220
|
+
throw new Error(`Connector at ${sourceLabel} is missing a "name" field`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const hasCommand = typeof connector.command === 'string' && connector.command.length > 0;
|
|
224
|
+
const hasUrl = typeof connector.url === 'string' && connector.url.length > 0;
|
|
225
|
+
if (hasCommand && hasUrl) {
|
|
226
|
+
throw new Error(`Connector "${connector.name}" has both "command" and "url" — pick one`);
|
|
227
|
+
}
|
|
228
|
+
if (!hasCommand && !hasUrl) {
|
|
229
|
+
throw new Error(`Connector "${connector.name}" needs either a "command" (for local subprocess) or "url" (for remote MCP)`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (hasCommand && connector.args && !Array.isArray(connector.args)) {
|
|
233
|
+
throw new Error(`Connector "${connector.name}": "args" must be an array of strings`);
|
|
234
|
+
}
|
|
235
|
+
if (hasUrl && connector.transport && !['http', 'sse'].includes(connector.transport)) {
|
|
236
|
+
throw new Error(`Connector "${connector.name}": "transport" must be "http" or "sse"`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (connector.env != null) {
|
|
240
|
+
if (typeof connector.env !== 'object' || Array.isArray(connector.env)) {
|
|
241
|
+
throw new Error(`Connector "${connector.name}": "env" must be a mapping of env-var-name to descriptor`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return connector;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Connector → tools.yaml entry ────────────────────────
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Translate a finalized connector (with developer edits already applied)
|
|
252
|
+
* into the server entry that goes under `servers:` in tools.yaml.
|
|
253
|
+
*/
|
|
254
|
+
export function connectorToServerEntry(connector) {
|
|
255
|
+
if (connector.url) {
|
|
256
|
+
const entry = { url: connector.url };
|
|
257
|
+
if (connector.transport) entry.transport = connector.transport;
|
|
258
|
+
if (connector.headers && Object.keys(connector.headers).length > 0) {
|
|
259
|
+
entry.headers = { ...connector.headers };
|
|
260
|
+
}
|
|
261
|
+
if (connector.description) entry.description = connector.description;
|
|
262
|
+
return entry;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const entry = { command: connector.command };
|
|
266
|
+
if (connector.args && connector.args.length > 0) entry.args = [...connector.args];
|
|
267
|
+
if (connector.env_vars && Object.keys(connector.env_vars).length > 0) {
|
|
268
|
+
entry.env = { ...connector.env_vars };
|
|
269
|
+
}
|
|
270
|
+
if (connector.description) entry.description = connector.description;
|
|
271
|
+
return entry;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* List of env-var names a connector declares as needed. The wizard prints
|
|
276
|
+
* these so the developer knows what to set in their .env / shell.
|
|
277
|
+
*/
|
|
278
|
+
export function declaredEnvVars(connector) {
|
|
279
|
+
if (!connector.env || typeof connector.env !== 'object') return [];
|
|
280
|
+
return Object.entries(connector.env).map(([name, schema]) => ({
|
|
281
|
+
name,
|
|
282
|
+
description: schema?.description || '',
|
|
283
|
+
required: schema?.required !== false,
|
|
284
|
+
}));
|
|
285
|
+
}
|