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
10 changes: 10 additions & 0 deletions coworker/connectors/catalog_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
"asana": "Keep up with your Asana work — search and read tasks and "
"projects, create tasks, and comment. Connects with a personal access "
"token from the Asana developer console.",
"newrelic": "Triage incidents with New Relic — find entities, check firing "
"alerts and open issues, run NRQL (or ask in plain English), correlate a "
"deploy or change with the impact it had, and dig into logs, dashboards, and "
"performance metrics. Connects with a New Relic user API key; read-only, no "
"way to change alerts or data.",
}

# What connecting actually grants, as short honest bullets. Write powers always
Expand Down Expand Up @@ -112,6 +117,11 @@
"Reads and searches tasks your account can see.",
"Creates tasks as you.",
],
"newrelic": [
"Reads entities, alerts, issues, dashboards, logs, NRQL query results, "
"change/deploy history, and performance metrics your API key can see. "
"Read-only — no tool can change an alert, a policy, or your data.",
],
"confluence": [
"Reads and searches spaces and pages your account can see.",
"Creates pages as you.",
Expand Down
97 changes: 91 additions & 6 deletions coworker/connectors/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,34 @@ class ConnectorDescriptor:
# Extra search terms for the catalog typeahead — capability words the title
# doesn't carry (e.g. "calendar" must surface Outlook, not just Google Calendar).
aliases: tuple = ()
# Vendor-hosted MCP server URL → this connector is MCP-BACKED: one-click connect
# runs the local MCP OAuth flow (DCR, tokens on this Mac — no broker), and the
# tool surface is the PINNED subset in tool_defs (names `mcp__<name>__<tool>`),
# never the vendor's full catalog (drift can only shrink capability, not grow it).
# A connector may carry BOTH mcp_url and manual fields (jira): the profile's
# mode decides which tool set is live.
# Vendor-hosted MCP server URL → this connector is MCP-BACKED: connect
# runs the MCP flow named by `mcp_auth` below, and the tool surface is the
# PINNED subset in tool_defs (names `mcp__<name>__<tool>`), never the vendor's
# full catalog (drift can only shrink capability, not grow it). A connector may
# carry BOTH mcp_url and manual fields (jira): the profile's mode decides which
# tool set is live.
mcp_url: str = ""
# How the MCP-backed connect authenticates — independent of `auth` above, which
# describes the MANUAL field form only (jira's `auth` is "api_token" for its
# manual REST tools, but its one-click MCP path is full browser OAuth, because
# Atlassian's MCP server supports Dynamic Client Registration):
# "oauth" → one-click browser OAuth 2.1 + PKCE + DCR (jira, monday, default).
# "connector" → NOT one-click: the manual field form's connect flow seeds the
# MCP entry itself, and mcp/client.py injects the key from the SecretStore
# at connect time. For a connector whose OAuth needs a vendor-registered
# redirect our dynamic sidecar port can't provide (New Relic's fixed OAuth
# client id; the same DCR wall Asana hit — Asana just has no mcp_url at all,
# since it has no non-oauth path to fall back to).
mcp_auth: str = "oauth"
# For an mcp_auth="connector" descriptor: given the resolved field values,
# returns the URL to seed instead of `mcp_url` as-is (e.g. New Relic's
# per-region endpoint). None means always use `mcp_url`.
mcp_url_for: Optional[Callable[[dict], str]] = None
# Extra STATIC (non-secret) headers to seed alongside an mcp_auth="connector"
# connector's config, e.g. New Relic's `include-tags` tool-corpus filter. Never
# put a secret here — the seeded config is plain text and paste-shareable; the
# API key itself is injected at connect time from the SecretStore (mcp/client.py).
mcp_headers: dict = field(default_factory=dict)
# Experimental connectors are hidden unless the user enables them in settings, require an
# explicit risk acknowledgment to connect, and ship in a separate package
# (connectors/experimental/) that release builds exclude entirely.
Expand Down Expand Up @@ -409,6 +430,27 @@ def _validate_outlook(creds: dict) -> ValidationResult:
)


def _newrelic_region_prefix(region: str) -> str:
"""us (default) has no prefix; eu/jp prefix both the MCP and NerdGraph hostnames."""
r = str(region or "").strip().lower()
return f"{r}." if r in ("eu", "jp") else ""


def _newrelic_mcp_url(region: str) -> str:
return f"https://mcp.{_newrelic_region_prefix(region)}newrelic.com/mcp/"


def _validate_newrelic(creds: dict) -> ValidationResult:
region = _newrelic_region_prefix(creds.get("region", ""))
return _validate_whoami(
"POST",
f"https://api.{region}newrelic.com/graphql",
headers={"Api-Key": creds.get("api_key", ""), "Content-Type": "application/json"},
json={"query": "{ actor { user { email } } }"},
identity=lambda d: d["data"]["actor"]["user"]["email"],
)


_ALLOWED_FIELD = Field(
key="allowed_users",
label="Allowed user IDs",
Expand Down Expand Up @@ -1027,6 +1069,49 @@ def _validate_outlook(creds: dict) -> ValidationResult:
],
validate=_validate_quickbooks,
),
ConnectorDescriptor(
name="newrelic",
title="New Relic",
icon="◔",
blurb="Query alerts, entities, and NRQL for incident triage.",
auth="api_token",
two_way=False,
brand_color="#008c99",
logo="newrelic",
aliases=("observability", "apm", "on-call", "on call", "incidents", "monitoring", "alerts"),
# New Relic's OAuth needs a vendor-registered redirect our dynamic sidecar port
# can't provide (the DCR wall Asana also hit) — API key is the supported path
# here. mcp_url is the US default; mcp_url_for picks the region the user set.
mcp_url=_newrelic_mcp_url(""),
mcp_auth="connector",
mcp_url_for=lambda raw: _newrelic_mcp_url(raw.get("region", "")),
# No include-tags header (docs.newrelic.com/docs/agentic-ai/mcp/setup): we pin
# every tool (tool_defs.py) including convert_time_period_to_epoch_ms, which
# exists but carries none of the 6 documented tags — a tag filter here would
# silently drop it. The pin is the only allowlist that matters.
fields=[
Field(
"api_key",
"User API key",
secret=True,
help="New Relic → user menu → API keys → create (or copy) a User key.",
placeholder="NRAK-…",
),
Field(
"region",
"Region",
required=False,
help="us (default), eu, or jp — matches your account's data center.",
placeholder="us",
),
],
instructions=[
"In New Relic, enable the preview: user menu (bottom left) → Administration → Previews & Trials → New Relic AI MCP server.",
"User menu → API keys → create a User key (format NRAK-…) and paste it below.",
"Only set a region if your account is EU or JP — leave it blank for US.",
],
validate=_validate_newrelic,
),
# -- placeholders (available=False) --------------------------------------------
# Not yet shipped, but referenced by persona `recommends` (e.g. Ops → datadog/pagerduty) so
# the GUI can render a brand badge + a "connect to enable" state. A placeholder has no fields,
Expand Down
41 changes: 39 additions & 2 deletions coworker/connectors/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
"brand_color": d.brand_color,
"logo": d.logo,
"aliases": list(d.aliases),
# MCP-backed one-click (vendor-hosted MCP server + local OAuth) —
# distinct from `managed` (broker OAuth): no cloud sign-in needed.
# MCP-backed (vendor-hosted MCP server) — distinct from `managed` (broker
# OAuth): no cloud sign-in needed either way. `mcp_auth` tells the GUI
# whether that's a one-click browser OAuth ("oauth") or manual-only,
# the field form's own connect seeding the MCP entry ("connector").
"mcp": bool(d.mcp_url),
"mcp_auth": d.mcp_auth if d.mcp_url else None,
"fields": [f.to_dict() for f in d.fields],
"instructions": d.instructions,
"connected": connected,
Expand Down Expand Up @@ -382,9 +385,35 @@ def _resolved(f) -> str:
return result
return {"ok": True, "account": identity or account_id, "account_id": account_id}
secrets.put(f"{name}:default", profile)
if d.mcp_url and d.mcp_auth != "oauth":
_seed_header_mcp_server(d, raw)
return {"ok": True, "account": identity}


def _seed_header_mcp_server(d, raw: dict[str, Any]) -> None:
"""Seed the global MCP config for a non-oauth MCP-backed connector (New Relic):
pinned tools, the descriptor's static (non-secret) headers, and the connector's
OWN url for these creds (region, etc). The API key itself is never written here —
mcp.json is plain text and paste-shareable; mcp/client.py injects it fresh from
the SecretStore at connect time (`auth: "connector"`)."""
from ..mcp.config import put_global_server
from .tool_defs import mcp_pinned_tools

put_global_server(
d.name,
{
"url": d.mcp_url_for(raw) if d.mcp_url_for else d.mcp_url,
"auth": "connector",
"headers": dict(d.mcp_headers),
# Server-level approval off: writes (none, today) would gate per-tool via
# the pinned read/write classification, same as the oauth-backed connectors.
"requires_approval": False,
"include_tools": mcp_pinned_tools(d.name),
"enabled": True,
},
)


def managed_connect_connector(
secrets: SecretStore, name: str, profile: dict[str, Any]
) -> dict[str, Any]:
Expand Down Expand Up @@ -520,5 +549,13 @@ def disconnect_connector(secrets: SecretStore, name: str) -> dict[str, Any]:
from ..mcp import oauth as mcp_oauth

dropped_accounts = mcp_oauth.sign_out(name, secrets) or dropped_accounts
mcp_config.delete_global_server(name)
d = get_descriptor(name)
if d is not None and d.mcp_url and d.mcp_auth != "oauth":
# Non-oauth MCP-backed connect (New Relic): the manual flow seeded the
# global server entry itself (no profile `mode` marker) — take it with us,
# same "no dangling config" invariant as the oauth teardown above.
from ..mcp import config as mcp_config

mcp_config.delete_global_server(name)
return {"ok": secrets.delete(f"{name}:default") or dropped_accounts}
Loading