Skip to content
Open
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
9 changes: 9 additions & 0 deletions docs/src/content/docs/consumer/install-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ dependencies:
url: https://mcp.linear.app/sse
headers:
Authorization: "Bearer ${LINEAR_TOKEN}"

# 4. Self-defined remote with harness-specific extra keys
- name: slack
registry: false
transport: http
url: https://mcp.slack.com/mcp
oauth:
clientId: "<pre-registered-client-id>"
callbackPort: 3118
```

The full grammar (overlays, `${input:...}` variables, `tools:`
Expand Down
11 changes: 11 additions & 0 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ A plain registry reference: `io.github.github/github-mcp-server`.
| `url` | `string` | Conditional | | Endpoint URL. REQUIRED when `registry: false` and `transport` is `http`, `sse`, or `streamable-http`. |
| `command` | `string` | Conditional | Single binary path; no embedded whitespace unless `args` is also present | Binary path. REQUIRED when `registry: false` and `transport` is `stdio`. |

Any additional keys not listed above are preserved as **extra passthrough fields** and round-tripped verbatim into the generated target manifest. This allows harness-specific configuration (e.g. Claude Code's `oauth` block for remote-MCP OAuth client config) to be declared in `apm.yml` and appear in the generated `.mcp.json` without modification. A warning is emitted at parse time naming each non-standard key. Extra keys never shadow the fields above; if a collision occurs the known field wins.

#### 4.2.3. Validation Rules for Self-Defined Servers

When `registry` is `false`, the following constraints apply:
Expand Down Expand Up @@ -531,6 +533,15 @@ dependencies:
args: ["--port", "3000"]
env:
API_KEY: ${{ secrets.KEY }}

# Self-defined remote server with harness-specific extra keys
- name: slack
registry: false
transport: http
url: https://mcp.slack.com/mcp
oauth:
clientId: "<pre-registered-client-id>"
callbackPort: 3118
```

#### 4.2.4. Variable References in `headers` and `env`
Expand Down
10 changes: 10 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ dependencies:
registry: false
transport: http
url: "https://mcp.internal.example.com"

# Self-defined remote with harness-specific extra keys
# (extra keys are preserved and round-tripped into the target manifest)
- name: slack
registry: false
transport: http
url: https://mcp.slack.com/mcp
oauth:
clientId: "<pre-registered-client-id>"
callbackPort: 3118
```

## LSP dependency formats
Expand Down
14 changes: 14 additions & 0 deletions src/apm_cli/adapters/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ class MCPClientAdapter(ABC):
# (e.g. ``VSCodeClientAdapter``) that have no ``KNOWN_TARGETS`` entry.
mcp_servers_key: str = ""

@staticmethod
def _merge_extra(config: dict, server_info: dict) -> dict:
"""Merge harness-specific ``_extra`` keys from server_info into config.

Extra keys never shadow adapter-set keys; they are appended only
when absent from the config dict.
"""
extra = server_info.get("_extra")
if extra and isinstance(extra, dict):
for k, v in extra.items():
if k not in config:
config[k] = v
return config

# Whether this adapter's config path is user/global-scoped (e.g.
# ``~/.copilot/``) rather than workspace-scoped (e.g. ``.vscode/``).
# Adapters that target a global path should override this to ``True``
Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/adapters/client/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ def _process_stdio_arg(arg):
return self.normalize_project_arg(arg)

config["args"] = [_process_stdio_arg(arg) for arg in raw.get("args") or []]
self._merge_extra(config, server_info)
return config
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

# Remote MCP handling.
Expand Down Expand Up @@ -273,6 +274,7 @@ def _process_stdio_arg(arg):
if http_headers:
remote_config["http_headers"] = http_headers
self._warn_input_variables(http_headers, server_name, "Codex CLI")
self._merge_extra(remote_config, server_info)
return remote_config

if not packages:
Expand Down Expand Up @@ -361,6 +363,7 @@ def _process_stdio_arg(arg):
resolved_env,
)

self._merge_extra(config, server_info)
return config

def _process_arguments( # pylint: disable=duplicate-code # structural similarity with copilot adapter is intentional
Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/adapters/client/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
tools_override = server_info.get("_apm_tools_override")
if tools_override:
config["tools"] = tools_override
self._merge_extra(config, server_info)
return config

# Check for remote endpoints first (registry-defined priority)
Expand Down Expand Up @@ -484,6 +485,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
if tools_override:
config["tools"] = tools_override

self._merge_extra(config, server_info)
return config

# Get packages from server info
Expand All @@ -507,6 +509,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
if tools_override:
config["tools"] = tools_override

self._merge_extra(config, server_info)
return config

def _apply_auth_and_headers(
Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/adapters/client/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
else arg
for arg in args
]
self._merge_extra(config, server_info)
return config

# --- remote endpoints ---
Expand All @@ -169,6 +170,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
config["url"] = (remote.get("url") or "").strip()

self._apply_auth_and_headers(config, remote, server_info, env_overrides, "Cursor")
self._merge_extra(config, server_info)
return config

# --- local packages ---
Expand All @@ -194,6 +196,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
f"{[p.get('registry_name', 'unknown') for p in packages]}."
)

self._merge_extra(config, server_info)
return config

# ------------------------------------------------------------------ #
Expand Down
4 changes: 4 additions & 0 deletions src/apm_cli/adapters/client/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
else arg
for arg in raw.get("args") or []
]
self._merge_extra(config, server_info)
return config

# --- remote endpoints ---
Expand Down Expand Up @@ -195,6 +196,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
config["headers"], server_info.get("name", ""), "Gemini CLI"
)

self._merge_extra(config, server_info)
return config

# --- local packages ---
Expand All @@ -208,6 +210,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No

package = self._select_best_package(packages)
if not package:
self._merge_extra(config, server_info)
return config

registry_name = self._infer_registry_name(package)
Expand Down Expand Up @@ -245,6 +248,7 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
if resolved_env:
config["env"] = resolved_env

self._merge_extra(config, server_info)
return config

def configure_mcp_server(
Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/adapters/client/kiro.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def _format_server_config(
for arg in raw.get("args") or []
]
self._copy_kiro_extensions(config, server_info)
self._merge_extra(config, server_info)
return config

remotes = server_info.get("remotes", [])
Expand Down Expand Up @@ -157,6 +158,7 @@ def _format_server_config(
config["headers"] = headers
self._warn_input_variables(headers, server_info.get("name", ""), "Kiro")
self._copy_kiro_extensions(config, server_info)
self._merge_extra(config, server_info)
return config

packages = server_info.get("packages", [])
Expand All @@ -180,6 +182,7 @@ def _format_server_config(
f"Server: {server_info.get('name', 'unknown')}."
)
self._copy_kiro_extensions(config, server_info)
self._merge_extra(config, server_info)
return config

def configure_mcp_server(
Expand Down
2 changes: 2 additions & 0 deletions src/apm_cli/adapters/client/vscode.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ def _format_server_config(
input_vars.extend(
self._extract_input_variables(env_translated, server_info.get("name", ""))
)
self._merge_extra(server_config, server_info)
return server_config, input_vars

# Check for packages information
Expand Down Expand Up @@ -448,6 +449,7 @@ def _format_server_config(
f"Server: {server_info.get('name', 'unknown')}"
)

self._merge_extra(server_config, server_info)
return server_config, input_vars

@staticmethod
Expand Down
8 changes: 8 additions & 0 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,10 @@ def _build_self_defined_info(dep) -> dict:
if dep.tools:
info["_apm_tools_override"] = dep.tools

# Pass through harness-specific extra keys for adapters to merge
if hasattr(dep, "extra") and dep.extra:
info["_extra"] = dict(dep.extra)

Comment thread
sergio-sisternes-epam marked this conversation as resolved.
return info

@staticmethod
Expand Down Expand Up @@ -407,6 +411,10 @@ def _apply_overlay(server_info_cache: dict, dep) -> None:
if dep.tools:
info["_apm_tools_override"] = dep.tools

# Pass through harness-specific extra keys for adapters to merge
if hasattr(dep, "extra") and dep.extra:
info["_extra"] = dict(dep.extra)

# Warn about overlay fields not yet applied at install time
if dep.version:
warnings.warn(
Expand Down
24 changes: 22 additions & 2 deletions src/apm_cli/models/dependency/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"tools",
"url",
"command",
"extra", # explicit extra block is also a known key
}
)

Expand Down Expand Up @@ -53,6 +54,7 @@ class MCPDependency:
tools: list[str] | None = None # Restrict exposed tools (default is ["*"])
url: str | None = None # Required for self-defined http/sse transports
command: str | None = None # Required for self-defined stdio transports
extra: dict[str, Any] | None = None # Harness-specific passthrough keys (e.g. oauth)

@classmethod
def from_string(cls, s: str) -> "MCPDependency":
Expand All @@ -72,11 +74,18 @@ def from_dict(cls, d: dict) -> "MCPDependency":
raise ValueError("MCP dependency dict must contain 'name'")

unknown = sorted(str(k) for k in d if k not in _KNOWN_DICT_KEYS)
extra: dict[str, Any] | None = None
# Merge unknown top-level keys with an explicit 'extra:' block
if unknown:
extra = {str(k): d[k] for k in d if str(k) in unknown}
explicit_extra = d.get("extra")
if isinstance(explicit_extra, dict):
extra = {**(extra or {}), **explicit_extra}
if unknown:
safe_name = ascii(str(d["name"]))[1:-1]
safe_keys = ", ".join(ascii(k)[1:-1] for k in unknown)
_rich_warning(
f"MCP dependency '{safe_name}': unknown key(s) dropped: {safe_keys}",
f"MCP dependency '{safe_name}': unknown key(s) preserved in extra: {safe_keys}",
symbol="warning",
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
)

Expand All @@ -94,6 +103,7 @@ def from_dict(cls, d: dict) -> "MCPDependency":
tools=d.get("tools"),
url=d.get("url"),
command=d.get("command"),
extra=extra,
)

if instance.registry is False:
Expand All @@ -114,7 +124,11 @@ def is_self_defined(self) -> bool:
return self.registry is False

def to_dict(self) -> dict:
"""Serialize to dict, including only non-None fields."""
"""Serialize to dict, including only non-None fields.

``extra`` keys are merged at the top level but cannot shadow
known fields (known fields always win).
"""
result: dict[str, Any] = {"name": self.name}
for field_name in (
"transport",
Expand All @@ -131,6 +145,10 @@ def to_dict(self) -> dict:
value = getattr(self, field_name)
if value is not None or (field_name == "registry" and value is False):
result[field_name] = value
if self.extra:
for k, v in self.extra.items():
if k not in result:
result[k] = v
return result

_VALID_TRANSPORTS = frozenset({"stdio", "sse", "http", "streamable-http"})
Expand Down Expand Up @@ -169,6 +187,8 @@ def __repr__(self) -> str:
parts.append(f"command={preview!r}")
else:
parts.append(f"command=<{type(self.command).__name__}>")
if self.extra:
parts.append(f"extra=<{len(self.extra)} key(s)>")
return f"MCPDependency({', '.join(parts)})"

def validate(self, strict: bool = True) -> None:
Expand Down
Loading
Loading