Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- VS Code adapter now defaults to `http` transport when `transport_type` is missing from remote registry data, matching Copilot adapter behavior (#654)
- `apm install` no longer silently drops skills, agents, and commands when a Claude Code plugin also ships `hooks/*.json`. The package-type detection cascade now classifies plugin-shaped packages as `MARKETPLACE_PLUGIN` (which already maps hooks via the plugin synthesizer) before falling back to the hook-only classification, and emits a default-visibility `[!]` warning when a hook-only classification disagrees with the package's directory contents (#780)
- Preserve custom git ports across protocols: non-default ports on `ssh://` and `https://` dependency URLs (e.g. Bitbucket Datacenter on SSH port 7999, self-hosted GitLab on HTTPS port 8443) are now captured as a first-class `port` field on `DependencyReference` and threaded through all clone URL builders. When the SSH clone fails, the HTTPS fallback reuses the same port instead of silently dropping it (#661, #731)
- Detect port-like first path segment in SCP shorthand (`git@host:7999/path`) and raise an actionable error suggesting the `ssh://` URL form, instead of silently misparsing the port as part of the repository path (#784)
Expand All @@ -36,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644)
- Deploy primitives from the project root's own `.apm/` directory alongside declared dependencies, so single-package projects no longer need a sub-package stub to install their own content (#715)
- Add `temp-dir` configuration key (`apm config set temp-dir PATH`) to override the system temporary directory, resolving `[WinError 5] Access is denied` in corporate Windows environments (#629)

### Changed

- Refactor `apm install` into a modular engine package (`apm_cli/install/`) with discrete phases (resolve, targets, download, integrate, cleanup, lockfile, finalize, post-deps local) and apply design patterns -- introduce a `DependencySource` Strategy hierarchy with shared `run_integration_template()` Template Method (kills ~300 LOC duplication across local/cached/fresh dep handlers), add `services.py` DI seam to eliminate `_install_mod` indirection, and wrap the pipeline in a typed `InstallService` Application Service consuming a frozen `InstallRequest`. `install/phases/integrate.py` shrinks from 1013 to ~400 LOC; the public `apm install` behaviour and CLI surface are unchanged. Preserves the `#762` cleanup chokepoint and remains backward-compatible (`_install_apm_dependencies` re-export and 55 healthy test patches keep working) (#764)
Expand Down
31 changes: 26 additions & 5 deletions src/apm_cli/adapters/client/vscode.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,17 +320,25 @@ def _format_server_config(self, server_info):
}
# Check for remotes (similar to Copilot adapter)
elif "remotes" in server_info and server_info["remotes"]:
remotes = server_info["remotes"]
remote = remotes[0] # Take the first remote
transport = remote.get("transport_type", "")
if transport in ("sse", "http", "streamable-http"):
remote = self._select_remote_with_url(server_info["remotes"])
if remote:
transport = (remote.get("transport_type") or "").strip()
# Default to "http" when transport_type is missing/empty,
# matching the Copilot adapter behavior (copilot.py:190-192).
if not transport:
transport = "http"
elif transport not in ("sse", "http", "streamable-http"):
raise ValueError(
f"Unsupported remote transport '{transport}' for VS Code. "
f"Server: {server_info.get('name', 'unknown')}. "
f"Supported transports: http, sse, streamable-http.")
headers = remote.get("headers", {})
# Normalize header list format to dict
if isinstance(headers, list):
headers = {h["name"]: h["value"] for h in headers if "name" in h and "value" in h}
server_config = {
"type": transport,
"url": remote.get("url", ""),
"url": remote["url"].strip(),
"headers": headers,
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
}
input_vars.extend(
Expand Down Expand Up @@ -424,6 +432,19 @@ def _extract_package_args(package):

return []

@staticmethod
def _select_remote_with_url(remotes):
"""Return the first remote entry that has a non-empty URL.

Returns:
dict or None: The first usable remote, or None if none found.
"""
for remote in remotes:
url = (remote.get("url") or "").strip()
if url:
return remote
return None

def _select_best_package(self, packages):
"""Select the best package for VS Code installation from available packages.

Expand Down
117 changes: 117 additions & 0 deletions tests/unit/test_vscode_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,123 @@ def test_configure_self_defined_http_via_cache(self, mock_get_path):
config["servers"]["my-private-srv"]["url"], "http://localhost:8787/"
)

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_missing_transport_type(self, mock_get_path):
"""Remote with no transport_type defaults to http (issue #654)."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "atlassian-mcp-server",
"remotes": [{"url": "https://mcp.atlassian.com/v1/mcp"}],
}
config, inputs = adapter._format_server_config(server_info)

self.assertEqual(config["type"], "http")
self.assertEqual(config["url"], "https://mcp.atlassian.com/v1/mcp")
self.assertEqual(config["headers"], {})

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_empty_transport_type(self, mock_get_path):
"""Remote with empty transport_type defaults to http."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "remote-srv",
"remotes": [{"transport_type": "", "url": "https://example.com/mcp"}],
}
config, inputs = adapter._format_server_config(server_info)

self.assertEqual(config["type"], "http")
self.assertEqual(config["url"], "https://example.com/mcp")

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_none_transport_type(self, mock_get_path):
"""Remote with transport_type=None defaults to http."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "remote-srv",
"remotes": [{"transport_type": None, "url": "https://example.com/mcp"}],
}
config, inputs = adapter._format_server_config(server_info)

self.assertEqual(config["type"], "http")

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_whitespace_transport_type(self, mock_get_path):
"""Remote with whitespace-only transport_type defaults to http."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "remote-srv",
"remotes": [{"transport_type": " ", "url": "https://example.com/mcp"}],
}
config, inputs = adapter._format_server_config(server_info)

self.assertEqual(config["type"], "http")

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_unsupported_transport_raises(self, mock_get_path):
"""Remote with an unrecognized transport_type raises ValueError."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "future-srv",
"remotes": [{"transport_type": "grpc", "url": "https://example.com/mcp"}],
}
with self.assertRaises(ValueError) as ctx:
adapter._format_server_config(server_info)

self.assertIn("Unsupported remote transport", str(ctx.exception))
self.assertIn("grpc", str(ctx.exception))

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_skips_entries_without_url(self, mock_get_path):
"""Remotes with empty URLs are skipped; first with a valid URL is used."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "multi-remote",
"remotes": [
{"transport_type": "http", "url": ""},
{"transport_type": "sse", "url": "https://good.example.com/sse"},
],
}
config, inputs = adapter._format_server_config(server_info)

self.assertEqual(config["type"], "sse")
self.assertEqual(config["url"], "https://good.example.com/sse")

@patch("apm_cli.adapters.client.vscode.VSCodeClientAdapter.get_config_path")
def test_format_server_config_remote_default_http_preserves_headers(self, mock_get_path):
"""Defaulting to http still normalizes headers and extracts input vars."""
mock_get_path.return_value = self.temp_path
adapter = VSCodeClientAdapter()

server_info = {
"name": "header-srv",
"remotes": [
{
"url": "https://example.com/mcp",
"headers": [
{"name": "Authorization", "value": "${input:auth-token}"},
],
}
],
}
config, inputs = adapter._format_server_config(server_info)

self.assertEqual(config["type"], "http")
self.assertEqual(config["headers"], {"Authorization": "${input:auth-token}"})
self.assertTrue(len(inputs) > 0)
self.assertEqual(inputs[0]["id"], "auth-token")


class TestVSCodeSelectBestPackage(unittest.TestCase):
"""Test cases for _select_best_package logic."""
Expand Down
Loading