@softspark/ai-toolkit 4.2.5 → 4.3.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/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ Versioning follows [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## v4.3.0 - inject-mcp extension API (2026-05-12)
11
+
12
+ Minor release. Closes the asymmetry between `inject-rule` / `inject-hook` and MCP servers by adding `inject-mcp` and `remove-mcp` as first-class members of the extension API. External tools (rag-mcp, jira-mcp, custom integrations) can now register their MCP templates the same way they register rules and hooks -- from a local file or HTTPS URL, with full editor propagation and auto-refresh on `ai-toolkit update`.
13
+
14
+ ### Added
15
+
16
+ - **`ai-toolkit inject-mcp <file|url> [--name <name>] [--force]`** - Inject an external MCP server template into `~/.mcp.json` (toolkit source-of-truth) and propagate it to every editor with a `global_path` (Claude, Cursor, Codex, Gemini, Windsurf, Cline, Augment, Copilot). Servers in `~/.mcp.json` are tagged with `_source` for idempotent re-injection; native editor configs receive the same servers without `_source`.
17
+ - **`ai-toolkit remove-mcp <name>`** - Strip all servers tagged with the given source from `~/.mcp.json` and every editor config, unregister URL sources, and remove cached template files.
18
+ - **`--name <name>` flag** - Override the auto-derived source name for both local files and URLs. Required when filename stem is generic (e.g., `mcp-template.json` → `--name rag-mcp`).
19
+ - **`--force` flag** - Overwrite servers tagged with a different `_source`. Without `--force`, collisions exit with code 3. Entries tagged `"_source": "ai-toolkit"` are protected even with `--force`.
20
+ - **URL fetch + cache + auto-refresh** - HTTPS templates are cached in `~/.softspark/ai-toolkit/mcp-templates/external/<name>.json` and registered in `sources.json` with sha256 pin. On every `ai-toolkit update`, URL-sourced templates are re-fetched and re-injected; cached version is used on fetch failure.
21
+ - **`scripts/mcp_sources.py`** - Source registry for external MCP templates, mirroring `hook_sources.py`.
22
+ - **`scripts/inject_mcp_cli.py`** - CLI entry point implementing both inject and remove modes.
23
+ - **`refresh_url_mcp()` in `install_steps/markers.py`** - Update flow integration, called from `install.py` alongside `refresh_url_hooks()`.
24
+
25
+ ### Changed
26
+
27
+ - **`paths.py`** - Added `MCP_TEMPLATES_DIR` and `EXTERNAL_MCP_DIR` constants.
28
+ - **`bin/ai-toolkit.js`** - Registered `inject-mcp` / `remove-mcp` handlers and help text.
29
+ - **`kb/reference/extension-api.md`** - Documented inject-mcp / remove-mcp + flags. Version bumped to 1.5.0.
30
+ - **`kb/reference/mcp-templates.md`** - Added External Templates section pointing to inject-mcp. Version bumped to 1.2.0.
31
+
32
+ ### Tests
33
+
34
+ - **`tests/test_inject_mcp.bats`** - 15 bats cases covering local-file inject, URL fetch via fixture, `--name` override, `--force` collision override, ai-toolkit source protection, idempotent re-inject, editor propagation to Cursor (JSON) and Codex (TOML), `_source` strip from native configs, sources.json registry, `--remove` cleanup, HTTPS-only enforcement.
35
+
36
+ ### Verification
37
+
38
+ - 15/15 bats cases passing in `test_inject_mcp.bats`.
39
+ - E2E smoke test: injecting an external `mcp-template.json` writes 9 files (`~/.mcp.json` + 8 editor configs) with `_source` only in source-of-truth and stripped from native configs.
40
+ - No regressions in `test_inject_hook.bats` or `test_mcp_manager.bats`.
41
+
42
+ ---
43
+
10
44
  ## v4.2.5 - Hook safety and no-RAG compatibility (2026-05-12)
11
45
 
12
46
  Patch release. Hardens Claude Code hook enforcement while keeping the toolkit safe for users who do not have RAG/MCP search providers installed.
package/README.md CHANGED
@@ -6,16 +6,16 @@
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
  [![Skills](https://img.shields.io/badge/skills-107-brightgreen)](app/skills/)
8
8
  [![Agents](https://img.shields.io/badge/agents-44-blue)](app/agents/)
9
- [![Tests](https://img.shields.io/badge/tests-1116%20passing-success)](tests/)
9
+ [![Tests](https://img.shields.io/badge/tests-1131%20passing-success)](tests/)
10
10
 
11
- ## What's New in v4.2.5
11
+ ## What's New in v4.3.0
12
12
 
13
- Patch release hardening Claude Code hooks while keeping installs safe for users without RAG/MCP.
13
+ Minor release. Closes the asymmetry between `inject-rule` / `inject-hook` and MCP servers -- external tools (rag-mcp, jira-mcp, custom integrations) can now register MCP templates the same way they register rules and hooks.
14
14
 
15
- - **Capability-aware search hooks**: search-first enforcement now degrades to guidance when no RAG/MCP provider is installed.
16
- - **Revert safety**: destructive `git checkout`/`git restore` flows are blocked when they would discard unrelated user work.
17
- - **Test cohesion**: source edits can trigger only their mapped Bats tests, with project-level override support.
18
- - **Cross-editor hook payloads**: shared hook I/O helpers normalize Claude, Gemini, Augment, Codex, Cursor, and Windsurf payloads.
15
+ - **`ai-toolkit inject-mcp <file|url>`**: register an external MCP template into `~/.mcp.json` + every editor with a `global_path` (Claude, Cursor, Codex, Gemini, Windsurf, Cline, Augment, Copilot) in one command.
16
+ - **`ai-toolkit remove-mcp <name>`**: strip injected servers from `~/.mcp.json` and every editor config in one command.
17
+ - **URL templates + auto-refresh**: HTTPS-sourced templates are cached and re-fetched on every `ai-toolkit update`, just like URL rules and hooks.
18
+ - **`--name` and `--force` flags**: explicit source naming for both files and URLs, plus collision-safe overwrites that still protect `ai-toolkit` built-in entries.
19
19
 
20
20
  See [CHANGELOG.md](CHANGELOG.md) for full history.
21
21
 
@@ -148,7 +148,7 @@ ai-toolkit/
148
148
  │ └── ARCHITECTURE.md # Full system design
149
149
  ├── kb/ # Reference docs, procedures, plans
150
150
  ├── scripts/ # Validation, install, evaluation scripts
151
- ├── tests/ # Bats test suite (1116 tests)
151
+ ├── tests/ # Bats test suite (1131 tests)
152
152
  └── CHANGELOG.md
153
153
  ```
154
154
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-toolkit",
3
3
  "description": "Professional-grade Claude Code toolkit with persona presets, skill security auditor, expanded lifecycle hooks, experimental opt-in plugin packs, benchmark harvesting, and multi-tool support.",
4
- "version": "4.2.5",
4
+ "version": "4.3.0",
5
5
  "author": {
6
6
  "name": "SoftSpark",
7
7
  "url": "https://github.com/softspark"
package/bin/ai-toolkit.js CHANGED
@@ -69,6 +69,8 @@ const COMMANDS = {
69
69
  'remove-rule': 'Unregister a rule from ~/.softspark/ai-toolkit/rules/ and remove its block from CLAUDE.md',
70
70
  'inject-hook': 'Inject external hooks (file or URL) into ~/.claude/settings.json (URL hooks auto-refresh on update)',
71
71
  'remove-hook': 'Remove injected hooks by source name from ~/.claude/settings.json (also unregisters URL source)',
72
+ 'inject-mcp': 'Inject external MCP template (file or URL) into ~/.mcp.json + all editor MCP configs (URL templates auto-refresh on update)',
73
+ 'remove-mcp': 'Remove injected MCP servers by source name from ~/.mcp.json and all editor configs',
72
74
  validate: 'Verify toolkit integrity',
73
75
  doctor: 'Check install health, hooks, and artifact drift',
74
76
  eject: 'Export standalone config (no symlinks, no toolkit dependency)',
@@ -257,6 +259,14 @@ function showHelp() {
257
259
  console.log('\nOptions for remove-hook:');
258
260
  console.log(' <source-name> Source tag to remove (also unregisters URL source if present)');
259
261
  console.log(' [target-dir] Target dir containing .claude/settings.json (default: $HOME)');
262
+ console.log('\nOptions for inject-mcp:');
263
+ console.log(' <template-file-or-url> Path to JSON file or HTTPS URL with {"mcpServers": {...}}');
264
+ console.log(' [target-dir] Target dir for .mcp.json + editor configs (default: $HOME)');
265
+ console.log(' --name <name> Override source name (default: filename/URL stem)');
266
+ console.log(' --force Overwrite servers tagged with a different _source');
267
+ console.log('\nOptions for remove-mcp:');
268
+ console.log(' <source-name> Source tag to remove (also unregisters URL source and cleans editor configs)');
269
+ console.log(' [target-dir] Target dir containing .mcp.json (default: $HOME)');
260
270
  console.log('\nOptions for add-rule:');
261
271
  console.log(' <rule-file> Path to .md rule file or HTTPS URL to register globally');
262
272
  console.log(' [rule-name] Override rule name (default: filename/URL stem without .md)');
@@ -417,6 +427,37 @@ function handleRemoveHook(args) {
417
427
  run(scriptPath('inject_hook_cli.py'), ['--remove', sourceName, targetDir]);
418
428
  }
419
429
 
430
+ /**
431
+ * Handle `ai-toolkit inject-mcp` -- injects external MCP template (file or URL) into .mcp.json
432
+ * and propagates to all editor MCP configs.
433
+ * @param {string[]} args
434
+ */
435
+ function handleInjectMcp(args) {
436
+ const source = args[0];
437
+ if (!source) {
438
+ console.error('Usage: ai-toolkit inject-mcp <template-file-or-url> [target-dir] [--name <name>] [--force]');
439
+ process.exit(1);
440
+ }
441
+ const isUrl = source.startsWith('https://') || source.startsWith('http://');
442
+ const resolvedSource = isUrl ? source : path.resolve(CWD, source);
443
+ const remaining = args.slice(1);
444
+ run(scriptPath('inject_mcp_cli.py'), [resolvedSource, ...remaining]);
445
+ }
446
+
447
+ /**
448
+ * Handle `ai-toolkit remove-mcp` -- removes injected MCP servers by source name.
449
+ * @param {string[]} args
450
+ */
451
+ function handleRemoveMcp(args) {
452
+ const sourceName = args[0];
453
+ if (!sourceName) {
454
+ console.error('Usage: ai-toolkit remove-mcp <template-source-name> [target-dir]');
455
+ process.exit(1);
456
+ }
457
+ const targetDir = args[1] || process.env.HOME;
458
+ run(scriptPath('inject_mcp_cli.py'), ['--remove', sourceName, targetDir]);
459
+ }
460
+
420
461
  /**
421
462
  * Handle `ai-toolkit mcp` -- delegates to mcp_manager.py with subcommand.
422
463
  * @param {string[]} args
@@ -566,6 +607,8 @@ const SPECIAL_HANDLERS = {
566
607
  'add-rule': handleAddRule,
567
608
  'inject-hook': handleInjectHook,
568
609
  'remove-hook': handleRemoveHook,
610
+ 'inject-mcp': handleInjectMcp,
611
+ 'remove-mcp': handleRemoveMcp,
569
612
  'llms-txt': (_args) => generateLlmsTxt(),
570
613
  'antigravity-rules': (_args) => run(scriptPath('generate_antigravity.py'), [CWD]),
571
614
  'cursor-mdc': (_args) => run(scriptPath('generate_cursor_mdc.py'), [CWD]),
@@ -2,11 +2,11 @@
2
2
  title: "Extension API Reference"
3
3
  category: reference
4
4
  service: ai-toolkit
5
- tags: [extension-api, inject-rule, inject-hook, mcp-templates, integration, editors]
6
- version: "1.4.0"
5
+ tags: [extension-api, inject-rule, inject-hook, inject-mcp, mcp-templates, integration, editors]
6
+ version: "1.5.0"
7
7
  created: "2026-04-07"
8
- last_updated: "2026-04-15"
9
- description: "Reference for ai-toolkit's extension API: inject-rule, inject-hook, remove-rule, remove-hook, and editor-aware MCP template management."
8
+ last_updated: "2026-05-12"
9
+ description: "Reference for ai-toolkit's extension API: inject-rule, inject-hook, inject-mcp, remove-* variants, and editor-aware MCP template management."
10
10
  ---
11
11
 
12
12
  # Extension API Reference
@@ -25,8 +25,10 @@ This design is intentional: ai-toolkit is a generic toolkit. Consumers (MCP serv
25
25
  | `remove-rule <name>` | `~/.claude/CLAUDE.md` | Strip markers by block name | Yes |
26
26
  | `inject-hook <file.json\|url> [name]` | `~/.claude/settings.json` | JSON `_source` tag per entry, URL cached + registered | Yes |
27
27
  | `remove-hook <name>` | `~/.claude/settings.json` | Strip all entries with matching `_source`, unregister URL source | Yes |
28
+ | `inject-mcp <file.json\|url> [name] [--force]` | `~/.mcp.json` + every editor with `global_path` | JSON `_source` tag per server, URL cached + registered, full editor propagation | Yes |
29
+ | `remove-mcp <name>` | `~/.mcp.json` + every editor with `global_path` | Strip all servers with matching `_source`, clean editor configs, unregister URL | Yes |
28
30
  | `add-rule <file.md\|url>` | `~/.softspark/ai-toolkit/rules/` | File copy + re-inject all rules on next `update` | Yes |
29
- | `mcp add <name...>` | `.mcp.json` | Merge `mcpServers` block from template | Yes |
31
+ | `mcp add <name...>` | `.mcp.json` | Merge `mcpServers` block from built-in template | Yes |
30
32
  | `mcp install --editor <name...>` | Native editor MCP config | Render canonical template into editor format | Yes |
31
33
 
32
34
  ## inject-rule
@@ -115,6 +117,67 @@ npx @softspark/ai-toolkit remove-hook my-tool-hooks
115
117
 
116
118
  The argument is the source name (file stem used during `inject-hook`). If no entries with that source are present, the command exits 0 silently.
117
119
 
120
+ ## inject-mcp
121
+
122
+ Injects an external MCP server template into `~/.mcp.json` (toolkit source-of-truth) and propagates it to every editor that exposes a `global_path` in `EDITOR_SPECS`. Symmetric with `inject-hook` -- accepts both local file paths and HTTPS URLs, with cache + auto-refresh on `ai-toolkit update`.
123
+
124
+ ```bash
125
+ # From local file
126
+ npx @softspark/ai-toolkit inject-mcp ./rag-mcp-template.json
127
+
128
+ # From URL (cached locally, auto-refreshed on update)
129
+ npx @softspark/ai-toolkit inject-mcp https://example.com/rag-mcp-template.json
130
+
131
+ # With explicit source name (preferred when filename stem is generic)
132
+ npx @softspark/ai-toolkit inject-mcp ./mcp/mcp-template.json --name rag-mcp
133
+
134
+ # With explicit target dir
135
+ npx @softspark/ai-toolkit inject-mcp ./template.json /custom/target --name my-rag
136
+
137
+ # Force overwrite of servers with a different _source (collision resolution)
138
+ npx @softspark/ai-toolkit inject-mcp ./conflict.json --force
139
+ ```
140
+
141
+ **Flags:** `--name <name>` overrides the auto-derived source name (works for both local files and URLs). `--force` overwrites servers tagged with a different `_source`. Positional `template-name` is supported only for URL sources (legacy positional grammar inherited from `inject-hook`); for local files use `--name`.
142
+
143
+ **Implementation:** `scripts/inject_mcp_cli.py`, `scripts/mcp_sources.py`, `scripts/url_fetch.py`.
144
+
145
+ **Input format:** Same as built-in templates in `app/mcp-templates/`:
146
+ ```json
147
+ {
148
+ "name": "rag-mcp",
149
+ "description": "Multi-tenant RAG over knowledge bases",
150
+ "mcpServers": {
151
+ "rag-mcp": {
152
+ "type": "http",
153
+ "url": "http://localhost:8081/mcp/sse?secret_key=${RAG_MCP_SECRET_KEY}"
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ **Source name derivation:** `rag-mcp-template.json` → `"rag-mcp-template"`. For URLs: `https://example.com/rag-mcp-template.json` → `"rag-mcp-template"`. Every server in the `mcpServers` block is tagged with `"_source": "<source-name>"` inside `~/.mcp.json` only; native editor configs receive the same servers **without** the `_source` field (some clients reject unknown keys).
160
+
161
+ **URL support:** When an HTTPS URL is provided, the JSON is fetched, validated, cached in `~/.softspark/ai-toolkit/mcp-templates/external/<name>.json`, and registered in `sources.json`. On every `ai-toolkit update`, URL-sourced templates are re-fetched and re-injected automatically. If the fetch fails during update, the cached version is used.
162
+
163
+ **Editor propagation:** Every editor with a `global_path` in `EDITOR_SPECS` is updated -- Claude (`~/.claude.json`), Cursor (`~/.cursor/mcp.json`), GitHub Copilot (`~/.copilot/mcp-config.json`), Gemini CLI (`~/.gemini/settings.json`), Windsurf (`~/.codeium/windsurf/mcp_config.json`), Cline (`~/.cline/data/settings/cline_mcp_settings.json`), Augment (`~/.augment/settings.json`), Codex CLI (`~/.codex/config.toml`). Per-editor failures are non-fatal -- the command reports a warning and continues.
164
+
165
+ **Idempotency:** Re-running with the same source overwrites entries for that source cleanly -- no duplicates accumulate.
166
+
167
+ **Collisions:** If a server name in `~/.mcp.json` already exists under a *different* `_source` tag, the command exits with code 3 unless `--force` is passed. Entries tagged `"_source": "ai-toolkit"` are protected even with `--force` -- the built-in template namespace cannot be hijacked.
168
+
169
+ **Safety:** Only HTTPS URLs are accepted. The source name `ai-toolkit` is reserved.
170
+
171
+ ## remove-mcp
172
+
173
+ Strips all server entries from `~/.mcp.json` that carry a given `_source` tag, cleans the same server names from every editor `global_path`, and (if URL-sourced) unregisters from `sources.json` and removes the cached file.
174
+
175
+ ```bash
176
+ npx @softspark/ai-toolkit remove-mcp rag-mcp-template
177
+ ```
178
+
179
+ The argument is the source name (file stem used during `inject-mcp`). If no entries with that source are present, the command exits 0 silently. `ai-toolkit` source is reserved and cannot be removed via this command.
180
+
118
181
  ## mcp add / install
119
182
 
120
183
  Merges one or more MCP server templates from `app/mcp-templates/` into the project's `.mcp.json`.
@@ -153,6 +216,8 @@ When `install` runs with `--scope project`, ai-toolkit also updates `.mcp.json`
153
216
  │ remove-rule <name> → CLAUDE.md │
154
217
  │ inject-hook <file|url> → settings.json │
155
218
  │ remove-hook <name> → settings.json │
219
+ │ inject-mcp <file|url> → .mcp.json + editors │
220
+ │ remove-mcp <name> → .mcp.json + editors │
156
221
  │ add-rule <file|url> → rules/ registry │
157
222
  │ mcp add <template> → .mcp.json │
158
223
  │ mcp install <template> → editor-native MCP │
@@ -168,7 +233,7 @@ When `install` runs with `--scope project`, ai-toolkit also updates `.mcp.json`
168
233
  (consumer) (consumer) (consumer)
169
234
  ```
170
235
 
171
- ## Example: Registering Rules and Hooks from an External Tool
236
+ ## Example: Registering Rules, Hooks, and MCP Servers from an External Tool
172
237
 
173
238
  An external tool's install script would call:
174
239
 
@@ -176,14 +241,14 @@ An external tool's install script would call:
176
241
  # Register rules into CLAUDE.md
177
242
  npx @softspark/ai-toolkit inject-rule ./rules/my-tool-rules.md
178
243
 
179
- # Register hooks into settings.json
244
+ # Register hooks into settings.json (auto-propagates to Codex)
180
245
  npx @softspark/ai-toolkit inject-hook ./hooks/my-tool-hooks.json
181
246
 
182
- # Add an MCP server template
183
- npx @softspark/ai-toolkit mcp add github
247
+ # Register MCP server template into .mcp.json + all editor MCP configs
248
+ npx @softspark/ai-toolkit inject-mcp ./mcp-template.json
184
249
 
185
- # Render the same template into Cursor project config
186
- npx @softspark/ai-toolkit mcp install --editor cursor --scope project github --target .
250
+ # Alternative: pull MCP template from a URL (auto-refreshed on update)
251
+ npx @softspark/ai-toolkit inject-mcp https://example.com/mcp-template.json
187
252
  ```
188
253
 
189
254
  To uninstall:
@@ -191,6 +256,7 @@ To uninstall:
191
256
  ```bash
192
257
  npx @softspark/ai-toolkit remove-rule my-tool-rules
193
258
  npx @softspark/ai-toolkit remove-hook my-tool-hooks
259
+ npx @softspark/ai-toolkit remove-mcp my-tool
194
260
  ```
195
261
 
196
262
  All operations are idempotent — safe to run on every install or update.
@@ -2,11 +2,11 @@
2
2
  title: "MCP Server Templates"
3
3
  category: reference
4
4
  service: ai-toolkit
5
- tags: [mcp, templates, servers, configuration, editors]
6
- version: "1.1.0"
5
+ tags: [mcp, templates, servers, configuration, editors, inject-mcp, external-templates]
6
+ version: "1.2.0"
7
7
  created: "2026-04-07"
8
- last_updated: "2026-04-12"
9
- description: "Reference for 26 MCP server templates plus native editor MCP installation support."
8
+ last_updated: "2026-05-12"
9
+ description: "Reference for 26 built-in MCP server templates, external template injection via inject-mcp, and native editor MCP installation support."
10
10
  ---
11
11
 
12
12
  # MCP Server Templates
@@ -15,6 +15,8 @@ description: "Reference for 26 MCP server templates plus native editor MCP insta
15
15
 
16
16
  ai-toolkit ships 26 ready-to-use MCP server configuration templates in `app/mcp-templates/`. Each template is a JSON file that defines the canonical `mcpServers` block for a specific service. Templates can be merged into the project's `.mcp.json` and rendered into editor-native MCP config files via the `ai-toolkit mcp` CLI subcommand.
17
17
 
18
+ **External templates:** Tools outside the toolkit (MCP servers, plugins, custom integrations) can register their own MCP templates via `ai-toolkit inject-mcp <file|url>` -- the toolkit caches the template, tags every server with a `_source` field, and propagates the config to every editor that exposes a `global_path`. URL-sourced templates are auto-refreshed on every `ai-toolkit update`. See [PATH: kb/reference/extension-api.md] for the inject-mcp / remove-mcp reference.
19
+
18
20
  ## CLI
19
21
 
20
22
  ```bash
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.2.5",
2
+ "version": "4.3.0",
3
3
  "components": {
4
4
  "agents": {
5
5
  "description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softspark/ai-toolkit",
3
- "version": "4.2.5",
3
+ "version": "4.3.0",
4
4
  "description": "AI coding toolkit: 107 skills, 44 agents, 12-editor write-through (Claude, Cursor, Windsurf, Copilot, Gemini, Cline, Roo, Aider, Augment, Antigravity, Codex, opencode), machine-enforced safety constitution, SARIF audit, signed npm provenance.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env python3
2
+ """Inject external MCP server templates into .mcp.json and all editor configs.
3
+
4
+ Allows external tools (MCP servers, plugins) to register their own MCP server
5
+ templates alongside ai-toolkit's built-in templates. Each injected template is
6
+ tagged with ``_source`` derived from the filename stem (or URL last segment)
7
+ so that re-running is idempotent and removal is safe.
8
+
9
+ Usage:
10
+ inject_mcp_cli.py <template-file-or-url> [target-dir] [--name <name>] [--force]
11
+ inject_mcp_cli.py <url> [template-name] [target-dir] [--force] # URL-only legacy form
12
+ inject_mcp_cli.py --remove <template-source-name> [target-dir]
13
+
14
+ Arguments:
15
+ template-file-or-url Path to a JSON file or HTTPS URL with
16
+ ``{"mcpServers": {...}}`` block (toolkit template format).
17
+ target-dir Directory containing ``.mcp.json``
18
+ (default: $HOME -- writes to ~/.mcp.json and propagates
19
+ to ~/.claude.json, ~/.cursor/mcp.json, ~/.codex/config.toml, ...).
20
+
21
+ Flags:
22
+ --name <name> Explicit source name. Default: filename stem or URL last segment.
23
+ Works for both local files and URLs. Preferred over positional
24
+ template-name (which only works for URL sources for backward
25
+ compatibility with the inject-hook positional grammar).
26
+ --force Overwrite servers that already exist under a different
27
+ ``_source`` tag. Without --force, collisions are rejected.
28
+ --remove Remove all server entries tagged with the given source name
29
+ (also unregisters URL source and removes cached template file).
30
+
31
+ The source name is derived from the filename stem (e.g.,
32
+ ``rag-mcp-template.json`` becomes ``"rag-mcp-template"``). Every server in the
33
+ template is tagged with ``"_source": "<source-name>"`` inside ``.mcp.json``
34
+ (toolkit source-of-truth). Native editor configs receive the same servers
35
+ without ``_source`` (some editors reject unknown fields).
36
+
37
+ Servers tagged with ``_source: "ai-toolkit"`` are **never** modified -- those
38
+ are managed exclusively by the toolkit itself.
39
+
40
+ Propagation: every supported editor with a `global_path` in EDITOR_SPECS is
41
+ updated. Project-scoped editors are skipped (use ``ai-toolkit mcp install`` for
42
+ project scope).
43
+
44
+ Exit codes:
45
+ 0 success
46
+ 1 usage / argument error
47
+ 2 JSON parse error
48
+ 3 collision rejected (use --force)
49
+ """
50
+ from __future__ import annotations
51
+
52
+ import copy
53
+ import json
54
+ import os
55
+ import re
56
+ import sys
57
+ import urllib.parse
58
+ from pathlib import Path
59
+
60
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
61
+
62
+ PROTECTED_SOURCE = "ai-toolkit"
63
+
64
+
65
+ def load_json(path: str) -> dict:
66
+ """Load and parse a JSON file."""
67
+ with open(path) as f:
68
+ return json.load(f)
69
+
70
+
71
+ def save_json(path: str, data: dict, indent: int = 2) -> None:
72
+ """Write a dictionary to a JSON file with trailing newline."""
73
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
74
+ with open(path, "w") as f:
75
+ json.dump(data, f, indent=indent, ensure_ascii=False)
76
+ f.write("\n")
77
+
78
+
79
+ def _is_url(source: str) -> bool:
80
+ """Check if source looks like an HTTP(S) URL."""
81
+ return source.startswith("https://") or source.startswith("http://")
82
+
83
+
84
+ def _name_from_url(url: str) -> str:
85
+ """Derive a template source name from a URL's last path segment."""
86
+ parsed = urllib.parse.urlparse(url)
87
+ filename = parsed.path.rstrip("/").split("/")[-1]
88
+ stem = filename.rsplit(".", 1)[0] if "." in filename else filename
89
+ return re.sub(r"[^a-zA-Z0-9_-]", "", stem)
90
+
91
+
92
+ def _server_source(server: dict) -> str | None:
93
+ """Return the ``_source`` tag of a server entry, or None if untagged."""
94
+ if isinstance(server, dict):
95
+ return server.get("_source")
96
+ return None
97
+
98
+
99
+ def _strip_source_tag(server: dict) -> dict:
100
+ """Return a copy of *server* with ``_source`` removed (for native editors)."""
101
+ clean = copy.deepcopy(server)
102
+ clean.pop("_source", None)
103
+ return clean
104
+
105
+
106
+ def _tag_servers(servers: dict, source: str) -> dict:
107
+ """Return a copy of *servers* with every entry tagged ``_source: <source>``."""
108
+ result: dict = {}
109
+ for name, server in servers.items():
110
+ if not isinstance(server, dict):
111
+ result[name] = server
112
+ continue
113
+ tagged = copy.deepcopy(server)
114
+ tagged["_source"] = source
115
+ result[name] = tagged
116
+ return result
117
+
118
+
119
+ def _strip_servers_by_source(servers: dict, source: str) -> dict:
120
+ """Return a copy of *servers* with entries matching ``_source`` removed."""
121
+ return {
122
+ name: server for name, server in servers.items()
123
+ if _server_source(server) != source
124
+ }
125
+
126
+
127
+ def _server_names_for_source(servers: dict, source: str) -> list[str]:
128
+ """Return server names tagged with the given ``_source``."""
129
+ return [name for name, server in servers.items()
130
+ if _server_source(server) == source]
131
+
132
+
133
+ def _check_collisions(
134
+ existing: dict, new_servers: dict, source: str, force: bool
135
+ ) -> None:
136
+ """Reject when an existing server has a different ``_source``.
137
+
138
+ Same-source overwrite is always allowed (idempotent re-inject).
139
+ PROTECTED_SOURCE entries are protected even with --force.
140
+ """
141
+ collisions = []
142
+ for name in new_servers:
143
+ if name not in existing:
144
+ continue
145
+ existing_source = _server_source(existing[name])
146
+ if existing_source == source:
147
+ continue
148
+ if existing_source == PROTECTED_SOURCE:
149
+ print(
150
+ f"Error: server '{name}' is managed by '{PROTECTED_SOURCE}' "
151
+ "and cannot be overwritten.",
152
+ file=sys.stderr,
153
+ )
154
+ sys.exit(3)
155
+ collisions.append((name, existing_source))
156
+
157
+ if not collisions:
158
+ return
159
+
160
+ if not force:
161
+ print(
162
+ f"Error: server name collision(s) in .mcp.json (re-run with --force):",
163
+ file=sys.stderr,
164
+ )
165
+ for name, other_source in collisions:
166
+ src_label = other_source or "(untagged)"
167
+ print(f" - {name} already exists under _source={src_label}", file=sys.stderr)
168
+ sys.exit(3)
169
+
170
+ for name, other_source in collisions:
171
+ src_label = other_source or "(untagged)"
172
+ print(f" Overwriting '{name}' (was _source={src_label})")
173
+
174
+
175
+ def _fetch_and_cache(url: str, source: str) -> str:
176
+ """Fetch template JSON from URL, cache locally, register source.
177
+
178
+ Returns:
179
+ Path to the cached template JSON file.
180
+ """
181
+ from url_fetch import fetch_url
182
+ from mcp_sources import register_url_source
183
+ from paths import EXTERNAL_MCP_DIR
184
+
185
+ EXTERNAL_MCP_DIR.mkdir(parents=True, exist_ok=True)
186
+
187
+ try:
188
+ data = fetch_url(url)
189
+ except Exception as exc:
190
+ print(f"Error fetching URL: {exc}", file=sys.stderr)
191
+ sys.exit(1)
192
+
193
+ try:
194
+ parsed = json.loads(data)
195
+ except json.JSONDecodeError as exc:
196
+ print(f"Error: URL returned invalid JSON: {exc}", file=sys.stderr)
197
+ sys.exit(2)
198
+
199
+ if "mcpServers" not in parsed:
200
+ print("Warning: no 'mcpServers' key found in URL response", file=sys.stderr)
201
+
202
+ cached_path = EXTERNAL_MCP_DIR / f"{source}.json"
203
+ cached_path.write_bytes(data)
204
+ register_url_source(None, source, url, content=data)
205
+
206
+ return str(cached_path)
207
+
208
+
209
+ def _propagate_to_editors(
210
+ servers_clean: dict, source: str, target_dir: str, force: bool
211
+ ) -> None:
212
+ """Mirror servers to every editor that has a global_path in EDITOR_SPECS.
213
+
214
+ Servers are written without the ``_source`` tag (native editor configs do
215
+ not need it). Per-editor failures are non-fatal -- we report and continue.
216
+ """
217
+ from mcp_editors import EDITOR_SPECS, install_servers
218
+
219
+ home = Path(target_dir)
220
+ editors_with_global = [
221
+ name for name, spec in EDITOR_SPECS.items()
222
+ if spec.get("global_path")
223
+ ]
224
+
225
+ for editor in sorted(editors_with_global):
226
+ try:
227
+ updated = install_servers(
228
+ [editor], servers_clean, scope="global", home=home,
229
+ )
230
+ for path in updated:
231
+ print(f" Propagated to {editor}: {path}")
232
+ except Exception as exc:
233
+ print(
234
+ f" Warning: {editor} propagation failed: {exc}",
235
+ file=sys.stderr,
236
+ )
237
+
238
+
239
+ def _remove_from_editors(server_names: list[str], target_dir: str) -> None:
240
+ """Remove servers from every editor with a global_path."""
241
+ from mcp_editors import EDITOR_SPECS, remove_servers
242
+
243
+ home = Path(target_dir)
244
+ editors_with_global = [
245
+ name for name, spec in EDITOR_SPECS.items()
246
+ if spec.get("global_path")
247
+ ]
248
+
249
+ for editor in sorted(editors_with_global):
250
+ try:
251
+ updated = remove_servers(
252
+ [editor], server_names, scope="global", home=home,
253
+ )
254
+ for path in updated:
255
+ print(f" Cleaned {editor}: {path}")
256
+ except Exception as exc:
257
+ print(
258
+ f" Warning: {editor} cleanup failed: {exc}",
259
+ file=sys.stderr,
260
+ )
261
+
262
+
263
+ def inject(
264
+ template_file: str,
265
+ target_dir: str,
266
+ source_override: str = "",
267
+ force: bool = False,
268
+ ) -> None:
269
+ """Inject an MCP template into .mcp.json + propagate to all editors.
270
+
271
+ Args:
272
+ template_file: Path to the template JSON file, or an HTTPS URL.
273
+ target_dir: Directory used as $HOME for editor config resolution.
274
+ ``.mcp.json`` is written to ``<target_dir>/.mcp.json``.
275
+ source_override: Explicit source name (overrides filename-derived).
276
+ force: Overwrite servers tagged with a different ``_source``.
277
+ """
278
+ is_url = _is_url(template_file)
279
+
280
+ if is_url:
281
+ if template_file.startswith("http://"):
282
+ print(
283
+ "Error: only HTTPS URLs are supported. Use https:// for security.",
284
+ file=sys.stderr,
285
+ )
286
+ sys.exit(1)
287
+
288
+ source = source_override or _name_from_url(template_file)
289
+ source = re.sub(r"[^a-zA-Z0-9_-]", "", source)
290
+ if not source:
291
+ print(
292
+ "Error: could not derive template name from URL. "
293
+ "Provide one explicitly.",
294
+ file=sys.stderr,
295
+ )
296
+ sys.exit(1)
297
+
298
+ if source == PROTECTED_SOURCE:
299
+ print(
300
+ f"Error: source name '{PROTECTED_SOURCE}' is reserved.",
301
+ file=sys.stderr,
302
+ )
303
+ sys.exit(1)
304
+
305
+ template_file = _fetch_and_cache(template_file, source)
306
+ print(f"Fetched template from URL (source: '{source}')")
307
+ else:
308
+ source = source_override or Path(template_file).stem
309
+ if source == PROTECTED_SOURCE:
310
+ print(
311
+ f"Error: source name '{PROTECTED_SOURCE}' is reserved. "
312
+ "Rename your template file.",
313
+ file=sys.stderr,
314
+ )
315
+ sys.exit(1)
316
+
317
+ try:
318
+ template_data = load_json(template_file)
319
+ except json.JSONDecodeError as exc:
320
+ print(f"Error: malformed JSON in {template_file}: {exc}", file=sys.stderr)
321
+ sys.exit(2)
322
+ except OSError as exc:
323
+ print(f"Error reading template file: {exc}", file=sys.stderr)
324
+ sys.exit(1)
325
+
326
+ new_servers = template_data.get("mcpServers", {})
327
+ if not new_servers:
328
+ print(f"Warning: no 'mcpServers' key found in {template_file}", file=sys.stderr)
329
+ return
330
+
331
+ mcp_path = Path(target_dir) / ".mcp.json"
332
+ config: dict = {"mcpServers": {}}
333
+ if mcp_path.is_file():
334
+ try:
335
+ config = load_json(str(mcp_path))
336
+ except json.JSONDecodeError as exc:
337
+ print(f"Error: malformed JSON in {mcp_path}: {exc}", file=sys.stderr)
338
+ sys.exit(2)
339
+
340
+ if "mcpServers" not in config or not isinstance(config["mcpServers"], dict):
341
+ config["mcpServers"] = {}
342
+
343
+ _check_collisions(config["mcpServers"], new_servers, source, force)
344
+
345
+ tagged = _tag_servers(new_servers, source)
346
+ stripped = _strip_servers_by_source(config["mcpServers"], source)
347
+ stripped.update(tagged)
348
+ config["mcpServers"] = stripped
349
+
350
+ save_json(str(mcp_path), config)
351
+ print(f"Injected MCP template '{source}' into {mcp_path}")
352
+ print(f" Servers: {', '.join(sorted(new_servers.keys()))}")
353
+
354
+ if not is_url:
355
+ try:
356
+ from mcp_sources import register_path_source
357
+ register_path_source(
358
+ None, source, Path(template_file),
359
+ content=Path(template_file).read_bytes(),
360
+ )
361
+ except Exception as exc:
362
+ print(f"Warning: could not register local source: {exc}", file=sys.stderr)
363
+
364
+ servers_clean = {name: _strip_source_tag(s) for name, s in new_servers.items()}
365
+ _propagate_to_editors(servers_clean, source, target_dir, force)
366
+
367
+
368
+ def remove(source_name: str, target_dir: str) -> None:
369
+ """Remove all server entries tagged with *source_name*.
370
+
371
+ Also unregisters the URL source if it was URL-sourced, removes cached file,
372
+ and cleans matching server names from every global editor config.
373
+ """
374
+ if source_name == PROTECTED_SOURCE:
375
+ print(
376
+ f"Error: cannot remove '{PROTECTED_SOURCE}' entries. "
377
+ "Use 'ai-toolkit uninstall' instead.",
378
+ file=sys.stderr,
379
+ )
380
+ sys.exit(1)
381
+
382
+ mcp_path = Path(target_dir) / ".mcp.json"
383
+
384
+ server_names: list[str] = []
385
+ if mcp_path.is_file():
386
+ try:
387
+ config = load_json(str(mcp_path))
388
+ except json.JSONDecodeError as exc:
389
+ print(f"Error: malformed JSON in {mcp_path}: {exc}", file=sys.stderr)
390
+ sys.exit(2)
391
+
392
+ servers = config.get("mcpServers", {})
393
+ if isinstance(servers, dict):
394
+ server_names = _server_names_for_source(servers, source_name)
395
+ config["mcpServers"] = _strip_servers_by_source(servers, source_name)
396
+ save_json(str(mcp_path), config)
397
+ if server_names:
398
+ print(
399
+ f"Removed source '{source_name}' from {mcp_path} "
400
+ f"(servers: {', '.join(server_names)})"
401
+ )
402
+
403
+ if server_names:
404
+ _remove_from_editors(server_names, target_dir)
405
+
406
+ try:
407
+ from mcp_sources import unregister_source
408
+ from paths import EXTERNAL_MCP_DIR
409
+
410
+ if unregister_source(None, source_name):
411
+ print(f"Unregistered MCP source '{source_name}'")
412
+
413
+ cached = EXTERNAL_MCP_DIR / f"{source_name}.json"
414
+ if cached.is_file():
415
+ cached.unlink()
416
+ except ImportError:
417
+ pass
418
+
419
+ if not server_names:
420
+ print(f"No entries with source '{source_name}' found.")
421
+
422
+
423
+ def _parse_args(argv: list[str]) -> dict:
424
+ """Parse CLI arguments."""
425
+ result: dict = {
426
+ "remove_mode": False,
427
+ "remove_name": "",
428
+ "source_file": "",
429
+ "template_name": "",
430
+ "target_dir": str(Path.home()),
431
+ "force": False,
432
+ }
433
+
434
+ i = 0
435
+ positional = 0
436
+ while i < len(argv):
437
+ arg = argv[i]
438
+ if arg == "--remove":
439
+ result["remove_mode"] = True
440
+ i += 1
441
+ if i >= len(argv):
442
+ print("--remove requires a template source name", file=sys.stderr)
443
+ sys.exit(1)
444
+ result["remove_name"] = argv[i]
445
+ elif arg == "--name":
446
+ i += 1
447
+ if i >= len(argv):
448
+ print("--name requires a template source name", file=sys.stderr)
449
+ sys.exit(1)
450
+ result["template_name"] = argv[i]
451
+ elif arg == "--force":
452
+ result["force"] = True
453
+ elif arg.startswith("-"):
454
+ print(f"Unknown option: {arg}", file=sys.stderr)
455
+ sys.exit(1)
456
+ else:
457
+ if positional == 0:
458
+ if not result["remove_mode"]:
459
+ result["source_file"] = arg
460
+ else:
461
+ result["target_dir"] = arg
462
+ elif positional == 1:
463
+ if _is_url(result["source_file"]):
464
+ if arg.startswith(("/", "~", ".")):
465
+ result["target_dir"] = arg
466
+ else:
467
+ result["template_name"] = arg
468
+ else:
469
+ result["target_dir"] = arg
470
+ elif positional == 2:
471
+ result["target_dir"] = arg
472
+ positional += 1
473
+ i += 1
474
+
475
+ return result
476
+
477
+
478
+ def main() -> None:
479
+ """Inject or remove external MCP templates."""
480
+ args = _parse_args(sys.argv[1:])
481
+
482
+ if args["remove_mode"]:
483
+ remove(args["remove_name"], args["target_dir"])
484
+ return
485
+
486
+ source_file = args["source_file"]
487
+ if not source_file:
488
+ print(
489
+ "Usage: inject_mcp_cli.py <template-file-or-url> [template-name] "
490
+ "[target-dir] [--force]",
491
+ file=sys.stderr,
492
+ )
493
+ print(
494
+ " inject_mcp_cli.py --remove <template-source-name> [target-dir]",
495
+ file=sys.stderr,
496
+ )
497
+ sys.exit(1)
498
+
499
+ if not _is_url(source_file):
500
+ source_path = Path(source_file)
501
+ if not source_path.is_file():
502
+ print(f"Template file not found: {source_path}", file=sys.stderr)
503
+ sys.exit(1)
504
+
505
+ inject(
506
+ source_file,
507
+ args["target_dir"],
508
+ source_override=args["template_name"],
509
+ force=args["force"],
510
+ )
511
+
512
+
513
+ if __name__ == "__main__":
514
+ main()
@@ -52,7 +52,7 @@ from emission import agent_count as count_agents, skill_count as count_skills
52
52
  # Step modules
53
53
  from install_steps.symlinks import install_agents, install_skills, clean_legacy_commands
54
54
  from install_steps.hooks import install_hooks
55
- from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks
55
+ from install_steps.markers import install_marker_files, inject_rules, refresh_url_hooks, refresh_url_mcp
56
56
  from install_steps.ai_tools import install_ai_tools, install_local_project, run_script
57
57
  from install_steps.install_state import (
58
58
  load_state,
@@ -449,6 +449,7 @@ def install_claude_code(target_dir: Path, hooks_scripts_dir: Path,
449
449
 
450
450
  if not dry_run:
451
451
  refresh_url_hooks(str(target_dir))
452
+ refresh_url_mcp(str(target_dir))
452
453
 
453
454
  _sync_mcp_templates(dry_run)
454
455
 
@@ -141,6 +141,46 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
141
141
  inject(str(cached_file), target, source_override=hook_name)
142
142
 
143
143
 
144
+ def refresh_url_mcp(target_dir: str | None = None) -> None:
145
+ """Re-fetch all URL-sourced MCP templates and re-inject them.
146
+
147
+ Called during ``ai-toolkit update`` to keep URL-sourced MCP templates
148
+ current. On fetch failure, warns and keeps the cached version.
149
+ """
150
+ from mcp_sources import get_url_templates, register_url_source
151
+ from paths import EXTERNAL_MCP_DIR
152
+ from url_fetch import fetch_url
153
+ import json
154
+
155
+ url_templates = get_url_templates()
156
+ if not url_templates:
157
+ return
158
+
159
+ print(" Refreshing URL-sourced MCP templates...")
160
+ target = target_dir or str(Path.home())
161
+
162
+ for template_name, url in url_templates.items():
163
+ cached_file = EXTERNAL_MCP_DIR / f"{template_name}.json"
164
+ try:
165
+ data = fetch_url(url)
166
+ json.loads(data)
167
+ cached_file.write_bytes(data)
168
+ register_url_source(None, template_name, url, content=data)
169
+ print(f" Refreshed: {template_name} (from {url})")
170
+ except Exception as exc:
171
+ if cached_file.is_file():
172
+ print(f" Warning: could not refresh '{template_name}' from {url}: {exc}")
173
+ print(f" Using cached version.")
174
+ else:
175
+ print(f" Warning: could not fetch '{template_name}' from {url}: {exc}")
176
+ print(f" No cached version — template will be skipped.")
177
+ continue
178
+
179
+ if cached_file.is_file():
180
+ from inject_mcp_cli import inject
181
+ inject(str(cached_file), target, source_override=template_name, force=True)
182
+
183
+
144
184
  def _inject_rules_dry_run(rules_dir: Path) -> None:
145
185
  rules_src = app_dir / "rules"
146
186
  rule_names = " ".join(
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """URL source registry for externally-injected MCP templates.
3
+
4
+ Tracks which MCP templates were registered from a URL or local file so that
5
+ `ai-toolkit update` can re-fetch the latest version and re-inject. Mirrors
6
+ the design of hook_sources.py for parity between inject-hook and inject-mcp.
7
+
8
+ Metadata stored in ~/.softspark/ai-toolkit/mcp-templates/external/sources.json.
9
+
10
+ Stdlib-only -- no external dependencies.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ import tempfile
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+ from paths import EXTERNAL_MCP_DIR
26
+
27
+ _SOURCES_FILENAME = "sources.json"
28
+
29
+
30
+ def _sources_path(mcp_dir: Path | None = None) -> Path:
31
+ return (mcp_dir or EXTERNAL_MCP_DIR) / _SOURCES_FILENAME
32
+
33
+
34
+ def load_sources(mcp_dir: Path | None = None) -> dict[str, dict[str, Any]]:
35
+ """Load sources.json. Returns {} if missing or corrupt."""
36
+ path = _sources_path(mcp_dir)
37
+ if not path.is_file():
38
+ return {}
39
+ try:
40
+ with open(path, encoding="utf-8") as f:
41
+ data = json.load(f)
42
+ if isinstance(data, dict):
43
+ return data.get("templates", {})
44
+ return {}
45
+ except (json.JSONDecodeError, OSError):
46
+ return {}
47
+
48
+
49
+ def save_sources(mcp_dir: Path | None = None,
50
+ sources: dict[str, dict[str, Any]] | None = None) -> None:
51
+ """Write sources.json atomically."""
52
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
53
+ path = _sources_path(mcp_dir)
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ payload = json.dumps(
57
+ {"schema_version": 1, "templates": sources or {}}, indent=2
58
+ )
59
+
60
+ fd, tmp_path = tempfile.mkstemp(
61
+ dir=str(path.parent), prefix=".sources_", suffix=".tmp"
62
+ )
63
+ try:
64
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
65
+ f.write(payload)
66
+ f.write("\n")
67
+ f.flush()
68
+ os.fsync(f.fileno())
69
+ os.rename(tmp_path, str(path))
70
+ except BaseException:
71
+ try:
72
+ os.unlink(tmp_path)
73
+ except OSError:
74
+ pass
75
+ raise
76
+
77
+
78
+ def register_url_source(
79
+ mcp_dir: Path | None,
80
+ template_name: str,
81
+ url: str,
82
+ content: bytes | None = None,
83
+ ) -> None:
84
+ """Add or update a URL source entry for an MCP template.
85
+
86
+ When ``content`` is supplied, its sha256 is persisted. If a previous
87
+ sha256 exists and differs from the new one, a warning is printed
88
+ (and the process fails with exit 2 when ``AI_TOOLKIT_STRICT_PIN=1``).
89
+ """
90
+ if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
91
+ raise ValueError(f"Invalid MCP template name: {template_name!r}")
92
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
93
+ sources = load_sources(mcp_dir)
94
+ entry: dict[str, Any] = {
95
+ "url": url,
96
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
97
+ }
98
+ if content is not None:
99
+ new_hash = hashlib.sha256(content).hexdigest()
100
+ prev = sources.get(template_name) or {}
101
+ prev_hash = prev.get("sha256")
102
+ if prev_hash and prev_hash != new_hash:
103
+ msg = (
104
+ f" CHECKSUM CHANGED: mcp '{template_name}' sha256 "
105
+ f"{prev_hash[:12]}... -> {new_hash[:12]}..."
106
+ )
107
+ print(msg)
108
+ if os.environ.get("AI_TOOLKIT_STRICT_PIN") == "1":
109
+ raise SystemExit(
110
+ f"Refusing to update '{template_name}' under AI_TOOLKIT_STRICT_PIN=1."
111
+ )
112
+ entry["sha256"] = new_hash
113
+ sources[template_name] = entry
114
+ save_sources(mcp_dir, sources)
115
+
116
+
117
+ def register_path_source(
118
+ mcp_dir: Path | None,
119
+ template_name: str,
120
+ path: Path,
121
+ content: bytes | None = None,
122
+ ) -> None:
123
+ """Add or update a local-file source entry for an MCP template.
124
+
125
+ Stores the absolute origin path so subsequent ``ai-toolkit update`` runs
126
+ can detect drift, plus a sha256 of the injected content.
127
+ """
128
+ if not template_name or not re.fullmatch(r"[a-zA-Z0-9_-]+", template_name):
129
+ raise ValueError(f"Invalid MCP template name: {template_name!r}")
130
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
131
+ sources = load_sources(mcp_dir)
132
+ existing = sources.get(template_name) or {}
133
+ # Never demote a URL-tracked entry to a local-path entry. update() flows
134
+ # call inject() with the cached file path after URL fetch, which would
135
+ # otherwise overwrite the URL.
136
+ if "url" in existing:
137
+ return
138
+ entry: dict[str, Any] = {
139
+ "path": str(Path(path).resolve()),
140
+ "fetched_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
141
+ }
142
+ if content is not None:
143
+ entry["sha256"] = hashlib.sha256(content).hexdigest()
144
+ sources[template_name] = entry
145
+ save_sources(mcp_dir, sources)
146
+
147
+
148
+ def unregister_source(mcp_dir: Path | None, template_name: str) -> bool:
149
+ """Remove a source entry. Returns True if found and removed."""
150
+ mcp_dir = mcp_dir or EXTERNAL_MCP_DIR
151
+ sources = load_sources(mcp_dir)
152
+ if template_name in sources:
153
+ del sources[template_name]
154
+ save_sources(mcp_dir, sources)
155
+ return True
156
+ return False
157
+
158
+
159
+ def get_url_templates(mcp_dir: Path | None = None) -> dict[str, str]:
160
+ """Return {template_name: url} for all URL-sourced MCP templates."""
161
+ sources = load_sources(mcp_dir)
162
+ return {name: entry["url"] for name, entry in sources.items() if "url" in entry}
package/scripts/paths.py CHANGED
@@ -26,6 +26,8 @@ LEGACY_DATA_DIR = Path.home() / ".ai-toolkit"
26
26
  # Sub-directories under TOOLKIT_DATA_DIR
27
27
  HOOKS_DIR = TOOLKIT_DATA_DIR / "hooks"
28
28
  EXTERNAL_HOOKS_DIR = HOOKS_DIR / "external"
29
+ MCP_TEMPLATES_DIR = TOOLKIT_DATA_DIR / "mcp-templates"
30
+ EXTERNAL_MCP_DIR = MCP_TEMPLATES_DIR / "external"
29
31
  RULES_DIR = TOOLKIT_DATA_DIR / "rules"
30
32
  SESSIONS_DIR = TOOLKIT_DATA_DIR / "sessions"
31
33
  COMPACTIONS_DIR = TOOLKIT_DATA_DIR / "compactions"