diff --git a/coworker/connectors/catalog_copy.py b/coworker/connectors/catalog_copy.py index 2e5c801e..23bcb00e 100644 --- a/coworker/connectors/catalog_copy.py +++ b/coworker/connectors/catalog_copy.py @@ -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 @@ -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.", diff --git a/coworker/connectors/descriptors.py b/coworker/connectors/descriptors.py index 9a6a34ab..3c62d26d 100644 --- a/coworker/connectors/descriptors.py +++ b/coworker/connectors/descriptors.py @@ -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____`), - # 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____`), 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. @@ -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", @@ -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, diff --git a/coworker/connectors/setup.py b/coworker/connectors/setup.py index 57d5476d..1901d980 100644 --- a/coworker/connectors/setup.py +++ b/coworker/connectors/setup.py @@ -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, @@ -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]: @@ -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} diff --git a/coworker/connectors/tool_defs.py b/coworker/connectors/tool_defs.py index d8b365eb..692a3d2f 100644 --- a/coworker/connectors/tool_defs.py +++ b/coworker/connectors/tool_defs.py @@ -492,6 +492,200 @@ class ConnectorToolDef: "write", "Add a comment to a task.", ), + # -- New Relic via their hosted MCP server (API-key connect; no REST tool set — + # NerdGraph is large enough that duplicating it in Python isn't worth it). Pinned + # to every tool across New Relic's documented tags (discovery, data-access, + # alerting, incident-response, performance-analytics, advanced-analysis) — this + # allowlist IS the scope — no include-tags header (descriptors.py) narrows it. + ConnectorToolDef( + "newrelic", + "mcp__newrelic__get_entity", + "Find entity", + "read", + "Fetch a New Relic entity by GUID or name pattern.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_related_entities", + "Related entities", + "read", + "List entities one hop away from a given entity — upstream/downstream dependencies.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__execute_nrql_query", + "Run NRQL", + "read", + "Execute an NRQL query against NRDB.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__natural_language_to_nrql_query", + "Query in plain English", + "read", + "Convert a natural-language request into NRQL, run it, and return the results.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_alert_policies", + "List alert policies", + "read", + "List alert policies for an account.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__search_incident", + "Search alert events", + "read", + "List open and closed alert events with flexible filtering.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_recent_issues", + "List open issues", + "read", + "List all open issues for an account.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_change_events", + "List change events", + "read", + "List an entity's change/deploy history — correlate incidents with what shipped.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__analyze_deployment_impact", + "Analyze deployment impact", + "read", + "Analyze the performance impact of a deployment on an entity.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_entity_error_groups", + "List error groups", + "read", + "Fetch Errors Inbox error groups for an entity within a time window.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__generate_alert_insights_report", + "Alert insights report", + "read", + "Generate an AI alert-intelligence analysis report for an issue.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__generate_user_impact_report", + "User impact report", + "read", + "Generate an end-user impact analysis report for an issue.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_alert_conditions", + "List alert conditions", + "read", + "List alert condition details for a specific alert policy.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_synthetic_monitors", + "List synthetic monitors", + "read", + "List synthetic monitors — automated checks of a service's availability and performance.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__search_entity_with_tag", + "Search entities by tag", + "read", + "Search for entities using a specific tag key and value.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__get_dashboard", + "Read dashboard", + "read", + "Fetch details about a specific dashboard.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_dashboards", + "List dashboards", + "read", + "List all dashboards for an account.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_available_new_relic_accounts", + "List accounts", + "read", + "List all account IDs available to the user.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__convert_time_period_to_epoch_ms", + "Convert time period", + "read", + "Convert a time period (e.g. \"last 30 minutes\") to epoch milliseconds.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__analyze_entity_logs", + "Analyze logs", + "read", + "Analyze application logs to identify error patterns, anomalies, and recurring issues.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__analyze_golden_metrics", + "Analyze golden metrics", + "read", + "Analyze golden metrics — throughput, response time, error rate, and saturation.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__analyze_transactions", + "Analyze transactions", + "read", + "Analyze transactions for an entity within a time window, identifying slow and error-prone ones.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__analyze_threads", + "Analyze threads", + "read", + "Analyze thread metric data for an entity — thread state, CPU usage, and memory.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__analyze_kafka_metrics", + "Analyze Kafka metrics", + "read", + "Analyze Kafka metrics — consumer lag, producer throughput, message latency, partition balance.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_garbage_collection_metrics", + "GC metrics", + "read", + "List garbage collection and memory metrics for an account and entity.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_recent_logs", + "Recent logs", + "read", + "List recent logs for an account and entity.", + ), + ConnectorToolDef( + "newrelic", + "mcp__newrelic__list_entity_performance_risk_groups", + "Performance risk groups", + "read", + "Fetch performance-risk groups for an entity from the Performance Risks Inbox.", + ), ConnectorToolDef( "confluence", "confluence_search", diff --git a/coworker/mcp/client.py b/coworker/mcp/client.py index 04f8fcc0..f67894da 100644 --- a/coworker/mcp/client.py +++ b/coworker/mcp/client.py @@ -96,6 +96,7 @@ async def _serve( f"MCP server '{server.name}' is http but has no url" ) auth = None + headers = dict(server.headers or {}) if server.auth == "oauth": from ..secrets import SecretStore from .oauth import build_auth @@ -108,9 +109,21 @@ async def _serve( self._secrets, interactive=interactive, ) + elif server.auth == "connector": + # Non-oauth MCP-backed connector (New Relic): the API key + # lives in that connector's SecretStore profile, never in + # this plain-text, paste-shareable config — fetched fresh + # on every connect instead of persisted to server.headers. + from ..secrets import SecretStore + + if self._secrets is None: + self._secrets = SecretStore() + profile = self._secrets.get(f"{server.name}:default") or {} + if profile.get("api_key"): + headers["Api-Key"] = profile["api_key"] read, write, *_ = await stack.enter_async_context( streamablehttp_client( - server.url, headers=server.headers or None, auth=auth + server.url, headers=headers or None, auth=auth ) ) else: diff --git a/coworker/mcp/config.py b/coworker/mcp/config.py index ae04a0ad..ab9e7386 100644 --- a/coworker/mcp/config.py +++ b/coworker/mcp/config.py @@ -35,7 +35,10 @@ class MCPServerDef: exclude_tools: Optional[list[str]] = None requires_approval: bool = True # "oauth" → browser OAuth 2.1 + PKCE with Dynamic Client Registration (mcp/oauth.py). - # HTTP transport only; tokens live in the SecretStore, never in this file. + # "connector" → non-oauth MCP-backed connector (e.g. New Relic): the API key + # lives in that connector's SecretStore profile, fetched fresh per connect + # (mcp/client.py) rather than kept here. Both HTTP transport only; secrets + # live in the SecretStore, never in this file. auth: Optional[str] = None diff --git a/coworker/personas/builtin/ops.md b/coworker/personas/builtin/ops.md index 2926aa56..b72bd09b 100644 --- a/coworker/personas/builtin/ops.md +++ b/coworker/personas/builtin/ops.md @@ -17,9 +17,12 @@ recommends: - connector: slack reason: receive alerts and reply to the team in-channel tier: core + - connector: newrelic + reason: pull firing alerts, entities, and NRQL to triage an incident + tier: core - connector: datadog reason: pull the firing alerts and the incident timeline - tier: core + tier: optional - connector: pagerduty reason: see who's on-call before paging tier: optional diff --git a/coworker/server/manager.py b/coworker/server/manager.py index b2d9be0c..3777899c 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1022,6 +1022,11 @@ async def mcp_connect_connector(self, name: str) -> dict[str, Any]: d = get_descriptor(name) if d is None or not d.mcp_url: return {"ok": False, "error": f"{name} has no MCP connect path"} + if d.mcp_auth != "oauth": + # Not one-click (New Relic: API key only) — the GUI gates this on + # `mcp_auth`, but fail closed here too rather than seed a broken + # oauth entry if this is ever reached some other way. + return {"ok": False, "error": f"{name} has no one-click MCP connect path"} put_global_server( name, { diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 7df802f5..efa149cc 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -153,8 +153,8 @@ const CONNECTORS = { // MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset. // monday is one-click ONLY (no manual fields); jira also has a manual token path // (two-mode modal). Neither needs cloud sign-in. - { name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this Mac."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false }, - { name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false }, + { name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, mcp_auth: "oauth", fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this Mac."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false }, + { name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, mcp_auth: "oauth", fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false }, ], }; diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index c700cdd0..aa484481 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -442,7 +442,11 @@ export interface Connector { brand_color: string; // hex brand color, e.g. "#611f69" (fallback gray "#6b7280") logo: string; // stable logo id keyed into the frontend registry (empty → fallback glyph) aliases?: string[]; // extra typeahead terms ("calendar" surfaces Outlook) - mcp?: boolean; // MCP-backed one-click (vendor-hosted MCP + local OAuth — no cloud sign-in) + mcp?: boolean; // MCP-backed (vendor-hosted MCP server — no cloud sign-in either way) + // How the MCP-backed connect authenticates: "oauth" = one-click browser OAuth + // (jira, monday); "connector" = manual-only, the field form's own connect seeds + // the MCP entry (New Relic — its OAuth needs a redirect we can't provide). + mcp_auth?: string | null; allowed_users: string[]; // the allow-list (managed inline in the Connectors tab) allowed_user_names?: Record; // id → display name (people directory) approval_owner_ids?: string[]; // Manual Slack: humans allowed to resolve approvals diff --git a/surfaces/gui/src/components/ManageTabs.tsx b/surfaces/gui/src/components/ManageTabs.tsx index f9ab2992..08e0611c 100644 --- a/surfaces/gui/src/components/ManageTabs.tsx +++ b/surfaces/gui/src/components/ManageTabs.tsx @@ -811,8 +811,9 @@ export function ConnectSetup({ return (
- {c.mcp && !manualOnly && ( - /* MCP-backed one-click needs no cloud sign-in — the OAuth flow is local. */ + {c.mcp && c.mcp_auth === "oauth" && !manualOnly && ( + /* MCP-backed one-click needs no cloud sign-in — the OAuth flow is local. + mcp_auth:"connector" connectors (New Relic) have no one-click path. */