From 637305cf31dd261aab790176b487d84cec108fd9 Mon Sep 17 00:00:00 2001 From: idoubi Date: Fri, 9 Oct 2026 21:53:19 +0800 Subject: [PATCH] Add Google Search Console and Google Analytics connectors Google offers no hosted MCP and no dynamic client registration, so these use an administrator-registered OAuth client (auth: 'oauth') and REST tools Connany implements (rest): Search Console sites, search analytics, sitemaps and URL inspection; Analytics account summaries, properties, metadata and (realtime) reports. Read-only. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- README.zh-CN.md | 2 +- THIRD_PARTY_NOTICES.md | 4 +- docs/agent-integration.md | 2 +- docs/connector-setup.md | 37 ++++++++- public/admin-i18n.js | 2 +- public/admin.js | 5 ++ sdk/client.ts | 2 +- skills/connany/SKILL.md | 2 +- src/admin/pages.ts | 16 ++-- src/connector-store.ts | 19 +++-- src/connectors/catalog.ts | 60 +++++++++++++- src/connectors/google.ts | 135 ++++++++++++++++++++++++++++++++ src/connectors/hosted-mcp.ts | 4 +- src/connectors/index.ts | 51 +++++++++--- src/mcp/server.ts | 3 +- src/service.ts | 6 +- tests/github-mcp.test.ts | 2 +- tests/google-connectors.test.ts | 91 +++++++++++++++++++++ tests/hosted-connectors.test.ts | 4 +- tests/integration/admin.test.ts | 29 +++++++ tests/rest-tools.test.ts | 2 +- 22 files changed, 435 insertions(+), 45 deletions(-) create mode 100644 src/connectors/google.ts create mode 100644 tests/google-connectors.test.ts diff --git a/README.md b/README.md index 90d4788..158461e 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ To use an existing database, set `DATABASE_URL` in `.env` and skip the Docker st | Collaboration | [Notion](https://www.notion.com) · [Linear](https://linear.app) · [Atlassian](https://www.atlassian.com) (Jira, Confluence) · [ClickUp](https://clickup.com) · [monday.com](https://monday.com) · [Airtable](https://airtable.com) · [Todoist](https://todoist.com) · [Intercom](https://www.intercom.com) | | Code & deploy | [GitHub](https://github.com) · [GitLab](https://gitlab.com) · [Vercel](https://vercel.com) · [Netlify](https://www.netlify.com) · [Cloudflare](https://www.cloudflare.com) | | Databases | [Supabase](https://supabase.com) · [Neon](https://neon.com) · [Prisma](https://www.prisma.io) | -| Monitoring & analytics | [Sentry](https://sentry.io) · [PostHog](https://posthog.com) | +| Monitoring & analytics | [Sentry](https://sentry.io) · [PostHog](https://posthog.com) · [Google Search Console](https://search.google.com/search-console) · [Google Analytics](https://analytics.google.com) | | Payments | [Stripe](https://stripe.com) · [PayPal](https://www.paypal.com) · [Square](https://squareup.com) | | Design & websites | [Canva](https://www.canva.com) · [Miro](https://miro.com) · [Webflow](https://webflow.com) · [Wix](https://www.wix.com) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2cc6ea7..d25ddf8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,7 +51,7 @@ npm run dev # 或:make dev | 协作与办公 | [Notion](https://www.notion.com) · [Linear](https://linear.app) · [Atlassian](https://www.atlassian.com)(Jira、Confluence)· [ClickUp](https://clickup.com) · [monday.com](https://monday.com) · [Airtable](https://airtable.com) · [Todoist](https://todoist.com) · [Intercom](https://www.intercom.com) | | 代码与部署 | [GitHub](https://github.com) · [GitLab](https://gitlab.com) · [Vercel](https://vercel.com) · [Netlify](https://www.netlify.com) · [Cloudflare](https://www.cloudflare.com) | | 数据库 | [Supabase](https://supabase.com) · [Neon](https://neon.com) · [Prisma](https://www.prisma.io) | -| 监控与分析 | [Sentry](https://sentry.io) · [PostHog](https://posthog.com) | +| 监控与分析 | [Sentry](https://sentry.io) · [PostHog](https://posthog.com) · [Google Search Console](https://search.google.com/search-console) · [Google Analytics](https://analytics.google.com) | | 支付 | [Stripe](https://stripe.com) · [PayPal](https://www.paypal.com) · [Square](https://squareup.com) | | 设计与建站 | [Canva](https://www.canva.com) · [Miro](https://miro.com) · [Webflow](https://webflow.com) · [Wix](https://www.wix.com) | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 46c2ec4..d08ccfb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,10 +4,10 @@ Connany source code is licensed under MIT. Third-party packages retain their own ## Connector marks -All connector names and logos (Notion, GitHub, Linear, Sentry, PostHog, Atlassian, Vercel, Supabase, Neon, Netlify, GitLab, Cloudflare, Prisma, Stripe, PayPal, Square, ClickUp, monday.com, Airtable, Todoist, Miro, Canva, Intercom, Webflow and Wix) belong to their respective owners and are used to identify integrations. No affiliation or endorsement is implied. The Connany license does not grant rights to these trademarks. +All connector names and logos (Notion, GitHub, Linear, Sentry, PostHog, Atlassian, Vercel, Supabase, Neon, Netlify, GitLab, Cloudflare, Prisma, Stripe, PayPal, Square, ClickUp, monday.com, Airtable, Todoist, Miro, Canva, Intercom, Webflow, Wix, Google Search Console and Google Analytics) belong to their respective owners and are used to identify integrations. No affiliation or endorsement is implied. The Connany license does not grant rights to these trademarks. - Notion and GitHub SVG icons in `src/connectors/catalog.ts`: sourced from [LobeHub Icons](https://github.com/lobehub/lobe-icons), under the MIT license reproduced below. -- Other connector SVG icons: sourced from [Simple Icons](https://simpleicons.org) (CC0 1.0). monday.com and Canva are not available there and use plain monograms. +- Other connector SVG icons: sourced from [Simple Icons](https://simpleicons.org) (CC0 1.0). monday.com and Canva are not available there and use plain monograms. The Google Search Console mark is redrawn as a monochrome vector from Google's official logo. - Linear SVG logomark: sourced from [Linear Brand Guidelines](https://linear.app/brand), `Linear-Brand-Assets.zip?v=3`, `logo-dark.svg`. It remains subject to Linear's brand usage terms. ## LobeHub Icons license diff --git a/docs/agent-integration.md b/docs/agent-integration.md index d370854..9c991c1 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -295,7 +295,7 @@ SDK 会抛出 `ConnanyError`,带 `status`、`code`、`requestId` 和 `details` 所有连接器的工具都来自上游官方 MCP,名称格式为 `<连接器>.<官方工具名>`,参数以 `input_schema` 为准。 -接入方的代码对所有连接器都一样:用 `GET /v1/connectors` 拿到可连接的列表,授权、列出工具、调用工具的接口都不区分连接器。除下面三个之外,Sentry、PostHog、Atlassian、Vercel、Supabase、Neon、Netlify、GitLab、Cloudflare、Prisma、Stripe、PayPal、Square、ClickUp、monday.com、Airtable、Todoist、Miro、Canva、Intercom、Webflow、Wix 也都走各自的官方 MCP,工具名为 `<连接器>.<官方工具名>`。少数平台不提供用户信息,这类连接显示为「<平台> 账号」,`identity.unverified` 为 true,重新授权时无法确认是同一个账号,建议在界面上提示用户。 +接入方的代码对所有连接器都一样:用 `GET /v1/connectors` 拿到可连接的列表,授权、列出工具、调用工具的接口都不区分连接器。除下面三个之外,Sentry、PostHog、Atlassian、Vercel、Supabase、Neon、Netlify、GitLab、Cloudflare、Prisma、Stripe、PayPal、Square、ClickUp、monday.com、Airtable、Todoist、Miro、Canva、Intercom、Webflow、Wix 也都走各自的官方 MCP,工具名为 `<连接器>.<官方工具名>`。Google Search Console 和 Google Analytics 没有官方托管 MCP,工具由 Connany 内置(如 `google_search_console.query_search_analytics`、`google_analytics.run_report`),调用方式相同。少数平台不提供用户信息,这类连接显示为「<平台> 账号」,`identity.unverified` 为 true,重新授权时无法确认是同一个账号,建议在界面上提示用户。 ### Notion diff --git a/docs/connector-setup.md b/docs/connector-setup.md index f8033c6..409cfa8 100644 --- a/docs/connector-setup.md +++ b/docs/connector-setup.md @@ -72,6 +72,36 @@ GitHub App 安装授权决定仓库范围;用户 OAuth 决定操作主体。Co [官方文档](https://linear.app/docs/mcp) +## Google Search Console / Google Analytics + +Google 不提供 Search Console 和 Analytics 的托管 MCP,也不支持动态客户端注册。这两个连接器由管理员在 Google Cloud 创建一个 OAuth 客户端,Connany 用用户的 token 直接调用 Google REST API,工具目录内置在 Connany 里,无需「同步工具目录」。两个连接器可以共用同一个 OAuth 客户端。 + +1. 在 [Google Cloud 控制台](https://console.cloud.google.com/) 选择或新建项目,在「API 和服务 → 库」中启用: + - Search Console:**Google Search Console API** + - Analytics:**Google Analytics Data API** 和 **Google Analytics Admin API** +2. 配置 OAuth 同意屏幕(Google Auth Platform):填写应用名称、支持邮箱和授权域名,在「数据访问」中添加 `openid`、`email`、`https://www.googleapis.com/auth/webmasters.readonly`、`https://www.googleapis.com/auth/analytics.readonly`。 +3. 在「凭据」创建 **Web 应用** 类型的 OAuth 客户端,「已获授权的重定向 URI」加入: + - `https://connect.example.com/oauth/google_search_console/callback` + - `https://connect.example.com/oauth/google_analytics/callback` +4. 在管理后台「连接器 → Google Search Console / Google Analytics → 配置」填写 Client ID 和 Client Secret 并保存,然后在「连接测试」发起授权,试读数据会列出账号可访问的站点 / GA4 媒体资源。 + +发布状态: + +- **测试中(Testing)**:只有加入「测试用户」的 Google 账号(最多 100 个)能授权,且 refresh token **7 天后失效**,用户需要重新授权。适合自测。 +- **正式版(In production)**:两个 scope 都是 Google 的敏感范围,对外开放前需要通过 Google 的应用验证(品牌信息、隐私政策、用途说明和演示视频),不需要安全评估。未验证时用户会看到「Google 尚未验证此应用」的提示。 +- **内部(Internal)**:仅限 Google Workspace 组织内的账号,无需验证,refresh token 不会 7 天过期。 + +工具(全部只读): + +| 连接器 | 工具 | +|---|---| +| Google Search Console | `list_sites`、`query_search_analytics`(点击、展示、CTR、排名,可按查询词 / 页面 / 国家 / 设备 / 日期分组和过滤)、`list_sitemaps`、`inspect_url`(索引状态) | +| Google Analytics | `list_account_summaries`、`get_property`、`get_metadata`(可用维度与指标,含自定义)、`run_report`、`run_realtime_report` | + +工具名为 `<连接器>.<工具>`,例如 `google_analytics.run_report`。返回结果整理为按维度、指标命名的行,数值指标转为数字;单次最多 1000 行,用 `start_row` / `offset` 翻页。Google 返回的错误说明(如 API 未启用、无权访问该媒体资源、指标名错误)会放在错误的 `details.upstream_message` 中,MCP 调用会直接展示给 agent。 + +授权时请求 `access_type=offline` 和 `prompt=consent`,保证每次授权都拿到 refresh token。账号以 Google userinfo 的 `sub` 识别,显示为邮箱。断开连接会调用 `https://oauth2.googleapis.com/revoke` 撤销 refresh token。 + ## 其他官方托管 MCP 连接器 以下连接器和 Notion、Linear 一样走官方 MCP 授权:管理员在「连接器」点启用,Connany 自动完成 OAuth 客户端注册(动态客户端注册,RFC 7591),然后点「同步工具目录」用自己的账号授权一次。不需要去各平台申请应用或填写凭证。 @@ -110,7 +140,12 @@ curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp curl -s https://<授权服务器>/.well-known/oauth-authorization-server ``` -后台「连接器」、连接路由、工具校验、工具列表会自动出现新连接器,无需修改数据库。需要自定义 OAuth 的连接器(如 GitHub App)仍在 `ConnectorRuntime`(`src/connectors/index.ts`)中单独实现。 +没有托管 MCP 或不支持动态注册的服务(如 Google)使用 `auth: 'oauth'`: + +- `oauth`:`authorize`、`token`,可选 `revoke`(RFC 7009)、`userinfo`(OIDC,用 `sub` 或 `id` 识别账号)、`scope`、`authorizeParams`,以及后台配置弹框中的 `console`(创建客户端的链接和说明)。管理员在后台填写 Client ID / Secret。 +- `rest`:Connany 自己实现的工具,参考 `src/connectors/google.ts`:每个工具有 zod 参数、请求构造和可选的结果整理。工具名用 `<连接器>.<小写下划线>`,与 MCP 工具名规则一致。工具目录随代码发布,不写入 `connector_tools`。 + +后台「连接器」、连接路由、工具校验、工具列表会自动出现新连接器,无需修改数据库。GitHub App 的安装流程仍在 `ConnectorRuntime`(`src/connectors/index.ts`)中单独实现。 升级到此版本需运行一次 `pnpm db:migrate`(`005_provider_catalog.sql` 移除数据库中写死的连接器名单约束)。 diff --git a/public/admin-i18n.js b/public/admin-i18n.js index c19fb31..b0669fe 100644 --- a/public/admin-i18n.js +++ b/public/admin-i18n.js @@ -1,6 +1,6 @@ (() => { const messages = { - '总览':'Overview','协作与办公':'Collaboration','代码与部署':'Code & deploy','数据库':'Databases','监控与分析':'Monitoring & analytics','支付':'Payments','设计与建站':'Design & websites','全部':'All','全部分类':'All categories','启用状态':'Status','连接器分类':'Connector categories','搜索连接器':'Search connectors','没有匹配的连接器':'No matching connectors','连接器配置一次,工作空间里的所有项目共用。':'Configure connectors once; every project in the workspace uses them.','错误监控、Issue 与性能追踪':'Errors, issues and performance','产品分析、功能开关与会话回放':'Product analytics, feature flags and session replay','Jira 与 Confluence 的事项、页面与项目':'Jira and Confluence issues, pages and projects','项目、部署与日志':'Projects, deployments and logs','数据库、项目与 Edge Functions':'Databases, projects and Edge Functions','Serverless Postgres 数据库与分支':'Serverless Postgres databases and branches','站点、部署与环境变量':'Sites, deploys and environment variables','代码仓库、Issue 与合并请求':'Repositories, issues and merge requests','Workers、KV、R2 与 D1':'Workers, KV, R2 and D1','Prisma Postgres 数据库与迁移':'Prisma Postgres databases and migrations','支付、客户、订阅与账单':'Payments, customers, subscriptions and invoices','订单、发票与交易':'Orders, invoices and transactions','支付、订单、商品与客户':'Payments, orders, catalog and customers','任务、列表与文档':'Tasks, lists and docs','看板、项目与工作流':'Boards, projects and workflows','数据表、记录与工作区':'Bases, records and workspaces','任务、项目与提醒':'Tasks, projects and reminders','白板、便签与图表':'Boards, sticky notes and diagrams','设计、模板与素材':'Designs, templates and assets','客服会话、联系人与工单':'Conversations, contacts and tickets','站点、CMS 集合与页面':'Sites, CMS collections and pages','站点、商店与预约':'Sites, stores and bookings','注册':'Sign up','还没有账号?':'No account yet?','已有账号?':'Already have an account?','确认密码':'Confirm password','至少 8 个字符。':'At least 8 characters.','注册并进入工作台 →':'Sign up and open console →','开放注册':'Open registration','开启后,登录页显示「注册」入口,任何人都可以注册普通用户账号,各自拥有独立的工作空间。':'When on, the sign-in page shows a Sign up link and anyone can create a member account with its own workspace.','当前未开放注册,请联系管理员创建账号。':'Registration is closed. Ask an administrator to create an account.','注册请求过多,请在 15 分钟后重试。':'Too many sign-up attempts. Try again in 15 minutes.','该邮箱已注册,请直接登录。':'This email is already registered. Sign in instead.','两次输入的密码不一致。':'The passwords do not match.','MCP 接入':'MCP','登录并继续 →':'Sign in and continue →','注册并继续 →':'Sign up and continue →','授权访问':'Authorize access','请求访问你的 Connany 账号':'wants to access your Connany account','。':'.','查看可用的连接器和你已连接的账号':'See the available connectors and your connected accounts','生成连接链接,由你在浏览器中授权新账号':'Create links you open in your browser to connect new accounts','以你已连接的账号调用工具(会修改数据的操作由 agent 先向你确认)':'Call tools with your connected accounts (the agent confirms changes with you first)','授权后返回':'After you decide, you return to','。可随时在「MCP 接入」中撤销。':'. You can revoke access anytime under MCP.','拒绝':'Deny','允许':'Allow','无法授权':'Cannot authorize','授权请求缺少 client_id 或 redirect_uri。':'The authorization request is missing client_id or redirect_uri.','未知的客户端,请在 agent 中重新连接 Connany。':'Unknown client. Connect Connany again from your agent.','回调地址与客户端注册的不一致。':'The redirect URI does not match the client registration.','请回到 agent 重新发起连接。':'Go back to your agent and connect again.','授权不存在。':'Authorization not found.','在 Claude Code、Codex 等 agent 中添加 Connany,即可用你自己的账号使用所有连接器。':'Add Connany to agents such as Claude Code or Codex to use every connector with your own accounts.','1. 添加 MCP 服务':'1. Add the MCP server','首次使用时,agent 会打开浏览器让你登录 Connany 并授权。':'On first use, the agent opens your browser to sign in to Connany and approve access.','添加后在 Claude Code 中运行 /mcp,选择 connany 完成登录。':'Then run /mcp in Claude Code and choose connany to sign in.','其他客户端(Cursor 等)':'Other clients (Cursor, ...)','2. 安装 Skill(推荐)':'2. Install the skill (recommended)','Skill 告诉 agent 何时使用 Connany、如何引导你连接账号、调用前如何确认,回答更稳定。':'The skill tells the agent when to use Connany, how to guide you through connecting accounts and when to confirm before calling tools.','查看 SKILL.md ↗':'View SKILL.md ↗','3. 在对话中使用':'3. Use it in conversations','例如「看看我 Linear 里这周的 issue」「查一下 PostHog 昨天的活跃用户」。未连接的服务,agent 会给出连接链接;连接后的账号显示在「用户连接」的「个人 MCP」项目下。':'For example \\"show my Linear issues this week\\" or \\"how many active users did PostHog record yesterday\\". For services that are not connected yet, the agent gives you a link; connected accounts appear under User connections in the Personal MCP project.','当前可连接:':'Available now:','暂无可用连接器,请先在「连接器」中启用。':'No connectors available yet. Enable some under Connectors first.','已授权的客户端':'Authorized clients','客户端':'Client','授权时间(UTC+8)':'Authorized (UTC+8)','最近使用(UTC+8)':'Last used (UTC+8)','撤销':'Revoke','已撤销':'Revoked','还没有客户端授权。按上面的步骤在 agent 中添加 Connany。':'No clients authorized yet. Add Connany to an agent with the steps above.','该客户端将无法再通过 Connany 使用你的连接,需要重新登录授权。你已连接的账号不受影响。':'The client can no longer use your connections through Connany until you authorize it again. Your connected accounts are not affected.','个人 MCP':'Personal MCP','授权应用':'Authorized apps','通过 MCP 使用你的 Connany 账号的客户端,如 Claude Code、Codex。撤销后需重新登录授权,你已连接的账号不受影响。':'Clients that use your Connany account through MCP, such as Claude Code or Codex. After revoking, a client must sign in again; your connected accounts are not affected.','还没有客户端授权。在 Claude Code、Codex 等 agent 中添加 Connany 后会显示在这里。':'No clients authorized yet. They appear here after you add Connany to an agent such as Claude Code or Codex.','MCP 接入文档 ↗':'MCP setup guide ↗','登录':'Sign in','工作台':'Workbench','系统':'System','用户管理':'Users','系统管理员':'Administrator','普通用户':'Member','身份':'Role','身份已更新':'Role updated','重置密码':'Reset password','当前账号':'You','添加用户':'Add user','用户已添加':'User added','添加':'Add','初始密码':'Initial password','最近登录(UTC+8)':'Last sign-in (UTC+8)','密码已重置':'Password reset','系统管理员可以管理所有用户;普通用户可以使用工作台,看不到系统设置。':'Administrators manage all users. Members use the workbench and cannot see system settings.','8–256 个字符。保存后该用户的所有登录会话都会失效,需要用新密码重新登录。':'8–256 characters. Saving signs the user out of every session; they sign in again with the new password.','8–256 个字符。请通过安全渠道告知对方,对方可登录后在「设置 → 账户」中修改。':'8–256 characters. Share it through a secure channel; the user can change it under Settings → Account after signing in.','将同时删除该用户工作空间中的所有数据:项目、API Key、连接器配置和用户连接(会尝试撤销平台授权),无法恢复。操作记录会保留。':'This also deletes everything in the user’s workspace: projects, API keys, connector configuration and user connections (upstream grants are revoked where possible). This cannot be undone. Activity history is kept.','不能修改自己的身份。':'You cannot change your own role.','不能删除自己的账号。':'You cannot delete your own account.','至少需要保留一个系统管理员。':'At least one administrator must remain.','该邮箱已存在。':'A user with this email already exists.','用户不存在。':'User not found.','需要系统管理员权限。':'Administrator access is required.','工具目录':'Tool catalog','尚未同步':'Not synced yet','启用后可同步':'Enable to sync','文档':'Docs','Agent 接入指南':'Agent integration guide','Agent 接入指南 ↗':'Agent integration guide ↗','下载 Markdown':'Download Markdown','文档类型':'Document type','文档目录':'Contents','把「复制给开发 Agent」的内容交给负责开发的 agent,它包含服务地址、实现要求和下面的完整指南。API Key 请通过后端环境变量另行配置。':'Give the copied content to the agent doing the development. It includes the service URL, implementation requirements and the full guide below. Configure the API key separately through backend environment variables.','对话中调用':'In conversations, call',' 列出该连接可用的工具交给模型,再用 ':' to list the connection’s tools for the model, then ','列出该连接可用的工具交给模型,再用':'to list the connection’s tools for the model, then',' 执行模型选择的工具。':' to run the tool the model picks.','执行模型选择的工具。':'to run the tool the model picks.','工具目录:尚未同步':'Tool catalog: not synced yet','同步工具目录':'Sync tool catalog','重新同步':'Sync again','去授权':'Authorize','项目':'Projects','连接器凭证集中配置;每个项目独立接入,每位用户单独授权。':'Connector credentials are configured once; each project integrates separately and each user authorizes individually.','多个项目':'Multiple projects','连接器配置一次,所有项目共用;不同项目的用户与连接相互隔离。':'Connectors are configured once and shared by every project. Users and connections are isolated per project.','已启用连接器':'Enabled connectors','已启用项目':'Enabled projects','创建项目':'Create project','为每个 agent 产品创建项目,获取它的 API Key。':'Create a project for each agent product and get its API key.','提供服务地址、项目的 API Key 和文档,完成一次真实账号授权。':'Share the service URL, the project API key and the docs, then complete one real account authorization.','每个项目对应一个接入 Connany 的 agent 产品,拥有独立的 API Key 和用户连接。':'Each project is an agent product integrated with Connany, with its own API keys and user connections.','暂无项目':'No projects yet','创建后会生成第一个 API Key,仅显示一次。':'The first API key is issued on creation and shown only once.','管理':'Manage','重命名':'Rename','查看':'View','新建 API Key':'New API key','名称 / 前缀':'Name / prefix','创建时间(UTC+8)':'Created (UTC+8)','最后使用(UTC+8)':'Last used (UTC+8)','有效':'Active','吊销':'Revoke','未命名':'Unnamed','已吊销':'Revoked','轮换方式:新建 API Key,部署到 agent 后端后,再吊销旧 Key,期间两把 Key 同时有效。':'To rotate: create a new API key, deploy it to your agent backend, then revoke the old key. Both keys work in the meantime.','危险操作':'Danger zone','删除项目':'Delete project','备注名(可选)':'Label (optional)','例如 production、staging,便于区分。':'For example production or staging.','集中管理连接器凭证,为每个项目分发独立密钥,让用户授权自己的账号。':'Manage connector credentials centrally, issue separate keys to each project, and let users authorize their own accounts.','查看各个项目下用户授权的账号。断开连接会立即阻止 Connany 后续调用。':'View accounts users authorized in each project. Disconnecting immediately blocks further Connany calls.','全部连接器':'All connectors','全部项目':'All projects','项目 / 用户':'Project / user','连接器':'Connectors','暂无用户连接。把项目的 API Key 交给 agent,完成一次用户授权后会显示在这里。':'No user connections yet. Give the project API key to an agent; connections appear after a user authorizes.','使用项目的 API Key 验证授权与只读调用。建议创建测试专用项目,避免混入正式用户数据。':'Verify authorization and read-only calls with a project API key. Use a dedicated test project to keep production user data separate.','测试连接器':'Test connector','在「项目」中创建测试项目并保存 API Key。':'Create a test project under Projects and save its API key.','创建测试项目':'Create test project','吊销此 API Key?':'Revoke this API key?','这是最后一个有效 API Key,吊销后此项目将无法调用 Connany。':'This is the last active API key. After revoking it, this project can no longer call Connany.','使用此 Key 的请求会立即失败,请确认 agent 已切换到新 Key。':'Requests using this key fail immediately. Make sure the agent has switched to the new key.','停用后此项目的所有 API Key 都无法调用 Connany,用户连接保留。':'All API keys of this project stop working. User connections are retained.','在「项目」中为 agent 创建项目,安全发送生成的 API Key 给对应开发者。密钥只放在 agent 后端。':'Create a project for the agent under Projects and share the generated API key securely with its developer. Keep keys on the agent backend only.',',传入已认证用户的 external_user_id。':', passing the authenticated external_user_id.','同一套连接器可供多个项目使用。每个项目的 API Key 只能访问本项目的用户连接。':'One set of connectors serves many projects. A project API key can access only that project’s user connections.','Project 不存在。':'Project not found.','API Key 不存在。':'API key not found.','API 文档':'API Docs','连接器':'Connectors','管理工作台':'Console','管理导航':'Main navigation','设置':'Settings','退出登录':'Sign out','打开账号菜单':'Open account menu','用户':'User','账户':'Account','通用':'General','关于':'About','设置分类':'Settings sections','关闭设置':'Close settings', + '总览':'Overview','搜索表现、索引状态与站点地图':'Search performance, indexing and sitemaps','GA4 报表、实时数据与媒体资源':'GA4 reports, realtime data and properties','内置':'Built in','创建 Google OAuth 客户端 ↗':'Create Google OAuth client ↗','复制此地址到 OAuth 客户端的重定向 URI,需保持完全一致。':'Copy this exact URL into the redirect URIs of your OAuth client.','在 Google Cloud 项目中启用 Search Console API,创建「Web 应用」类型的 OAuth 客户端,并把下方回调地址加入「已获授权的重定向 URI」。':'Enable the Search Console API in a Google Cloud project, create a "Web application" OAuth client and add the callback URL below to its authorized redirect URIs.','在 Google Cloud 项目中启用 Google Analytics Data API 和 Google Analytics Admin API,创建「Web 应用」类型的 OAuth 客户端,并把下方回调地址加入「已获授权的重定向 URI」。':'Enable the Google Analytics Data API and Google Analytics Admin API in a Google Cloud project, create a "Web application" OAuth client and add the callback URL below to its authorized redirect URIs.','Google Search Console:列出可访问的站点;Google Analytics:列出账号与 GA4 媒体资源。':'Google Search Console: list accessible sites. Google Analytics: list accounts and GA4 properties.','协作与办公':'Collaboration','代码与部署':'Code & deploy','数据库':'Databases','监控与分析':'Monitoring & analytics','支付':'Payments','设计与建站':'Design & websites','全部':'All','全部分类':'All categories','启用状态':'Status','连接器分类':'Connector categories','搜索连接器':'Search connectors','没有匹配的连接器':'No matching connectors','连接器配置一次,工作空间里的所有项目共用。':'Configure connectors once; every project in the workspace uses them.','错误监控、Issue 与性能追踪':'Errors, issues and performance','产品分析、功能开关与会话回放':'Product analytics, feature flags and session replay','Jira 与 Confluence 的事项、页面与项目':'Jira and Confluence issues, pages and projects','项目、部署与日志':'Projects, deployments and logs','数据库、项目与 Edge Functions':'Databases, projects and Edge Functions','Serverless Postgres 数据库与分支':'Serverless Postgres databases and branches','站点、部署与环境变量':'Sites, deploys and environment variables','代码仓库、Issue 与合并请求':'Repositories, issues and merge requests','Workers、KV、R2 与 D1':'Workers, KV, R2 and D1','Prisma Postgres 数据库与迁移':'Prisma Postgres databases and migrations','支付、客户、订阅与账单':'Payments, customers, subscriptions and invoices','订单、发票与交易':'Orders, invoices and transactions','支付、订单、商品与客户':'Payments, orders, catalog and customers','任务、列表与文档':'Tasks, lists and docs','看板、项目与工作流':'Boards, projects and workflows','数据表、记录与工作区':'Bases, records and workspaces','任务、项目与提醒':'Tasks, projects and reminders','白板、便签与图表':'Boards, sticky notes and diagrams','设计、模板与素材':'Designs, templates and assets','客服会话、联系人与工单':'Conversations, contacts and tickets','站点、CMS 集合与页面':'Sites, CMS collections and pages','站点、商店与预约':'Sites, stores and bookings','注册':'Sign up','还没有账号?':'No account yet?','已有账号?':'Already have an account?','确认密码':'Confirm password','至少 8 个字符。':'At least 8 characters.','注册并进入工作台 →':'Sign up and open console →','开放注册':'Open registration','开启后,登录页显示「注册」入口,任何人都可以注册普通用户账号,各自拥有独立的工作空间。':'When on, the sign-in page shows a Sign up link and anyone can create a member account with its own workspace.','当前未开放注册,请联系管理员创建账号。':'Registration is closed. Ask an administrator to create an account.','注册请求过多,请在 15 分钟后重试。':'Too many sign-up attempts. Try again in 15 minutes.','该邮箱已注册,请直接登录。':'This email is already registered. Sign in instead.','两次输入的密码不一致。':'The passwords do not match.','MCP 接入':'MCP','登录并继续 →':'Sign in and continue →','注册并继续 →':'Sign up and continue →','授权访问':'Authorize access','请求访问你的 Connany 账号':'wants to access your Connany account','。':'.','查看可用的连接器和你已连接的账号':'See the available connectors and your connected accounts','生成连接链接,由你在浏览器中授权新账号':'Create links you open in your browser to connect new accounts','以你已连接的账号调用工具(会修改数据的操作由 agent 先向你确认)':'Call tools with your connected accounts (the agent confirms changes with you first)','授权后返回':'After you decide, you return to','。可随时在「MCP 接入」中撤销。':'. You can revoke access anytime under MCP.','拒绝':'Deny','允许':'Allow','无法授权':'Cannot authorize','授权请求缺少 client_id 或 redirect_uri。':'The authorization request is missing client_id or redirect_uri.','未知的客户端,请在 agent 中重新连接 Connany。':'Unknown client. Connect Connany again from your agent.','回调地址与客户端注册的不一致。':'The redirect URI does not match the client registration.','请回到 agent 重新发起连接。':'Go back to your agent and connect again.','授权不存在。':'Authorization not found.','在 Claude Code、Codex 等 agent 中添加 Connany,即可用你自己的账号使用所有连接器。':'Add Connany to agents such as Claude Code or Codex to use every connector with your own accounts.','1. 添加 MCP 服务':'1. Add the MCP server','首次使用时,agent 会打开浏览器让你登录 Connany 并授权。':'On first use, the agent opens your browser to sign in to Connany and approve access.','添加后在 Claude Code 中运行 /mcp,选择 connany 完成登录。':'Then run /mcp in Claude Code and choose connany to sign in.','其他客户端(Cursor 等)':'Other clients (Cursor, ...)','2. 安装 Skill(推荐)':'2. Install the skill (recommended)','Skill 告诉 agent 何时使用 Connany、如何引导你连接账号、调用前如何确认,回答更稳定。':'The skill tells the agent when to use Connany, how to guide you through connecting accounts and when to confirm before calling tools.','查看 SKILL.md ↗':'View SKILL.md ↗','3. 在对话中使用':'3. Use it in conversations','例如「看看我 Linear 里这周的 issue」「查一下 PostHog 昨天的活跃用户」。未连接的服务,agent 会给出连接链接;连接后的账号显示在「用户连接」的「个人 MCP」项目下。':'For example \\"show my Linear issues this week\\" or \\"how many active users did PostHog record yesterday\\". For services that are not connected yet, the agent gives you a link; connected accounts appear under User connections in the Personal MCP project.','当前可连接:':'Available now:','暂无可用连接器,请先在「连接器」中启用。':'No connectors available yet. Enable some under Connectors first.','已授权的客户端':'Authorized clients','客户端':'Client','授权时间(UTC+8)':'Authorized (UTC+8)','最近使用(UTC+8)':'Last used (UTC+8)','撤销':'Revoke','已撤销':'Revoked','还没有客户端授权。按上面的步骤在 agent 中添加 Connany。':'No clients authorized yet. Add Connany to an agent with the steps above.','该客户端将无法再通过 Connany 使用你的连接,需要重新登录授权。你已连接的账号不受影响。':'The client can no longer use your connections through Connany until you authorize it again. Your connected accounts are not affected.','个人 MCP':'Personal MCP','授权应用':'Authorized apps','通过 MCP 使用你的 Connany 账号的客户端,如 Claude Code、Codex。撤销后需重新登录授权,你已连接的账号不受影响。':'Clients that use your Connany account through MCP, such as Claude Code or Codex. After revoking, a client must sign in again; your connected accounts are not affected.','还没有客户端授权。在 Claude Code、Codex 等 agent 中添加 Connany 后会显示在这里。':'No clients authorized yet. They appear here after you add Connany to an agent such as Claude Code or Codex.','MCP 接入文档 ↗':'MCP setup guide ↗','登录':'Sign in','工作台':'Workbench','系统':'System','用户管理':'Users','系统管理员':'Administrator','普通用户':'Member','身份':'Role','身份已更新':'Role updated','重置密码':'Reset password','当前账号':'You','添加用户':'Add user','用户已添加':'User added','添加':'Add','初始密码':'Initial password','最近登录(UTC+8)':'Last sign-in (UTC+8)','密码已重置':'Password reset','系统管理员可以管理所有用户;普通用户可以使用工作台,看不到系统设置。':'Administrators manage all users. Members use the workbench and cannot see system settings.','8–256 个字符。保存后该用户的所有登录会话都会失效,需要用新密码重新登录。':'8–256 characters. Saving signs the user out of every session; they sign in again with the new password.','8–256 个字符。请通过安全渠道告知对方,对方可登录后在「设置 → 账户」中修改。':'8–256 characters. Share it through a secure channel; the user can change it under Settings → Account after signing in.','将同时删除该用户工作空间中的所有数据:项目、API Key、连接器配置和用户连接(会尝试撤销平台授权),无法恢复。操作记录会保留。':'This also deletes everything in the user’s workspace: projects, API keys, connector configuration and user connections (upstream grants are revoked where possible). This cannot be undone. Activity history is kept.','不能修改自己的身份。':'You cannot change your own role.','不能删除自己的账号。':'You cannot delete your own account.','至少需要保留一个系统管理员。':'At least one administrator must remain.','该邮箱已存在。':'A user with this email already exists.','用户不存在。':'User not found.','需要系统管理员权限。':'Administrator access is required.','工具目录':'Tool catalog','尚未同步':'Not synced yet','启用后可同步':'Enable to sync','文档':'Docs','Agent 接入指南':'Agent integration guide','Agent 接入指南 ↗':'Agent integration guide ↗','下载 Markdown':'Download Markdown','文档类型':'Document type','文档目录':'Contents','把「复制给开发 Agent」的内容交给负责开发的 agent,它包含服务地址、实现要求和下面的完整指南。API Key 请通过后端环境变量另行配置。':'Give the copied content to the agent doing the development. It includes the service URL, implementation requirements and the full guide below. Configure the API key separately through backend environment variables.','对话中调用':'In conversations, call',' 列出该连接可用的工具交给模型,再用 ':' to list the connection’s tools for the model, then ','列出该连接可用的工具交给模型,再用':'to list the connection’s tools for the model, then',' 执行模型选择的工具。':' to run the tool the model picks.','执行模型选择的工具。':'to run the tool the model picks.','工具目录:尚未同步':'Tool catalog: not synced yet','同步工具目录':'Sync tool catalog','重新同步':'Sync again','去授权':'Authorize','项目':'Projects','连接器凭证集中配置;每个项目独立接入,每位用户单独授权。':'Connector credentials are configured once; each project integrates separately and each user authorizes individually.','多个项目':'Multiple projects','连接器配置一次,所有项目共用;不同项目的用户与连接相互隔离。':'Connectors are configured once and shared by every project. Users and connections are isolated per project.','已启用连接器':'Enabled connectors','已启用项目':'Enabled projects','创建项目':'Create project','为每个 agent 产品创建项目,获取它的 API Key。':'Create a project for each agent product and get its API key.','提供服务地址、项目的 API Key 和文档,完成一次真实账号授权。':'Share the service URL, the project API key and the docs, then complete one real account authorization.','每个项目对应一个接入 Connany 的 agent 产品,拥有独立的 API Key 和用户连接。':'Each project is an agent product integrated with Connany, with its own API keys and user connections.','暂无项目':'No projects yet','创建后会生成第一个 API Key,仅显示一次。':'The first API key is issued on creation and shown only once.','管理':'Manage','重命名':'Rename','查看':'View','新建 API Key':'New API key','名称 / 前缀':'Name / prefix','创建时间(UTC+8)':'Created (UTC+8)','最后使用(UTC+8)':'Last used (UTC+8)','有效':'Active','吊销':'Revoke','未命名':'Unnamed','已吊销':'Revoked','轮换方式:新建 API Key,部署到 agent 后端后,再吊销旧 Key,期间两把 Key 同时有效。':'To rotate: create a new API key, deploy it to your agent backend, then revoke the old key. Both keys work in the meantime.','危险操作':'Danger zone','删除项目':'Delete project','备注名(可选)':'Label (optional)','例如 production、staging,便于区分。':'For example production or staging.','集中管理连接器凭证,为每个项目分发独立密钥,让用户授权自己的账号。':'Manage connector credentials centrally, issue separate keys to each project, and let users authorize their own accounts.','查看各个项目下用户授权的账号。断开连接会立即阻止 Connany 后续调用。':'View accounts users authorized in each project. Disconnecting immediately blocks further Connany calls.','全部连接器':'All connectors','全部项目':'All projects','项目 / 用户':'Project / user','连接器':'Connectors','暂无用户连接。把项目的 API Key 交给 agent,完成一次用户授权后会显示在这里。':'No user connections yet. Give the project API key to an agent; connections appear after a user authorizes.','使用项目的 API Key 验证授权与只读调用。建议创建测试专用项目,避免混入正式用户数据。':'Verify authorization and read-only calls with a project API key. Use a dedicated test project to keep production user data separate.','测试连接器':'Test connector','在「项目」中创建测试项目并保存 API Key。':'Create a test project under Projects and save its API key.','创建测试项目':'Create test project','吊销此 API Key?':'Revoke this API key?','这是最后一个有效 API Key,吊销后此项目将无法调用 Connany。':'This is the last active API key. After revoking it, this project can no longer call Connany.','使用此 Key 的请求会立即失败,请确认 agent 已切换到新 Key。':'Requests using this key fail immediately. Make sure the agent has switched to the new key.','停用后此项目的所有 API Key 都无法调用 Connany,用户连接保留。':'All API keys of this project stop working. User connections are retained.','在「项目」中为 agent 创建项目,安全发送生成的 API Key 给对应开发者。密钥只放在 agent 后端。':'Create a project for the agent under Projects and share the generated API key securely with its developer. Keep keys on the agent backend only.',',传入已认证用户的 external_user_id。':', passing the authenticated external_user_id.','同一套连接器可供多个项目使用。每个项目的 API Key 只能访问本项目的用户连接。':'One set of connectors serves many projects. A project API key can access only that project’s user connections.','Project 不存在。':'Project not found.','API Key 不存在。':'API key not found.','API 文档':'API Docs','连接器':'Connectors','管理工作台':'Console','管理导航':'Main navigation','设置':'Settings','退出登录':'Sign out','打开账号菜单':'Open account menu','用户':'User','账户':'Account','通用':'General','关于':'About','设置分类':'Settings sections','关闭设置':'Close settings', '管理你的管理员账号和登录密码。':'Manage your administrator account and password.','管理员账号':'Administrator account','修改密码':'Change password','保存后会退出所有登录会话,请使用新密码重新登录。':'Saving signs you out of all sessions. Sign in again with your new password.','当前密码':'Current password','新密码':'New password','确认新密码':'Confirm new password','8–256 个字符。':'8–256 characters.','取消':'Cancel','保存密码':'Save password', '外观与当前设备偏好设置。':'Appearance and preferences for this device.','主题':'Theme','选择控制台配色。跟随系统会使用操作系统设置。':'Choose a console theme. System follows your operating system settings.','浅色':'Light','深色':'Dark','跟随系统':'System','界面语言':'Language','选择控制台使用的界面语言。':'Choose the language used in the console.','Connany · Agent 连接器服务':'Connany · Connectors for agents','连接工具与用户数据':'Connect tools and user data','统一管理账号授权、连接与凭证,让 Agent 在用户授权范围内使用工具。':'Manage account authorization, connections and credentials so agents can use tools within each user’s permissions.','Agent 接入文档 ↗':'Agent integration guide ↗', '确定':'Confirm','确认操作':'Confirm action','API Key':'API Key','仅显示一次,请立即保存。':'Shown only once. Save it now.','复制':'Copy','我已保存':'I have saved it','已复制':'Copied','已选中文本,请手动复制':'Text selected. Copy it manually.','已保存':'Saved','保存':'Save','创建':'Create','名称':'Name','状态':'Status','用户连接':'User connections','编辑':'Edit','轮换':'Rotate','停用':'Disable','启用':'Enable','删除':'Delete','暂停':'Pause','配置':'Configure','官网 ↗':'Website ↗','未启用':'Not enabled','已启用':'Enabled','已停用':'Disabled','已暂停':'Paused','已删除':'Deleted','创建 API Key':'Create API Key','暂无 API Key':'No API keys yet','下一页 →':'Next page →','旧版密钥':'Legacy key', diff --git a/public/admin.js b/public/admin.js index e200986..04092b7 100644 --- a/public/admin.js +++ b/public/admin.js @@ -197,6 +197,8 @@ const callTool = (tool,input) => request(`${toolsPath}/${encodeURIComponent(tool)}/call`,{external_user_id:user.value,input}); const listTools = () => request(`${toolsPath}?${new URLSearchParams({external_user_id:user.value,limit:'5'})}`); let result; + // REST connectors read real data: the sites or properties the account can see. + const probes = {google_search_console:'google_search_console.list_sites',google_analytics:'google_analytics.list_account_summaries'}; if (connector.value === 'github') { githubPanel.replaceChildren(); const installations = []; @@ -228,6 +230,9 @@ const tools = await listTools(); result = {installations,tools}; status.textContent = installations.length ? '已获取组织列表,请选择组织试读。管理入口可能需要组织管理员权限。' : '账号已连接,尚未添加仓库。可以点击添加组织 / 仓库,完成后再次试读刷新列表。'; + } else if (probes[connector.value]) { + result = await callTool(probes[connector.value],{}); + status.textContent = '读取成功。空列表表示当前授权范围内没有可见数据。'; } else { result = await listTools(); status.textContent = '读取成功。空列表表示当前授权范围内没有可见数据。'; diff --git a/sdk/client.ts b/sdk/client.ts index 90a6f5e..2cce8dd 100644 --- a/sdk/client.ts +++ b/sdk/client.ts @@ -1,7 +1,7 @@ /** Server-side SDK. Do not bundle the project key into a browser, mobile app or LLM prompt. */ /** Groups connectors for display: collaboration, development, data, analytics, payments, design. */ export type ConnectorCategory = 'collaboration' | 'development' | 'data' | 'analytics' | 'payments' | 'design'; -export type ConnectorName = 'notion' | 'github' | 'linear' | 'sentry' | 'posthog' | 'atlassian' | 'vercel' | 'supabase' | 'neon' | 'netlify' | 'gitlab' | 'cloudflare' | 'prisma' | 'stripe' | 'paypal' | 'square' | 'clickup' | 'monday' | 'airtable' | 'todoist' | 'miro' | 'canva' | 'intercom' | 'webflow' | 'wix'; +export type ConnectorName = 'notion' | 'github' | 'linear' | 'sentry' | 'posthog' | 'atlassian' | 'vercel' | 'supabase' | 'neon' | 'netlify' | 'gitlab' | 'cloudflare' | 'prisma' | 'stripe' | 'paypal' | 'square' | 'clickup' | 'monday' | 'airtable' | 'todoist' | 'miro' | 'canva' | 'intercom' | 'webflow' | 'wix' | 'google_search_console' | 'google_analytics'; export interface ConnectSession { id: string; connector: ConnectorName; status: 'pending' | 'authorizing' | 'processing' | 'connected' | 'error' | 'expired'; expires_at: string; connect_url?: string; connection_id?: string | null; error_code?: string | null; diff --git a/skills/connany/SKILL.md b/skills/connany/SKILL.md index a26c598..9d81445 100644 --- a/skills/connany/SKILL.md +++ b/skills/connany/SKILL.md @@ -1,6 +1,6 @@ --- name: connany -description: Use the user's own accounts in third-party services (Notion, Linear, GitHub, GitLab, Atlassian, Stripe, PayPal, Square, PostHog, Sentry, Vercel, Supabase, Airtable, Todoist, Canva, Miro and more) through Connany. Use when the user asks to look up, summarize or change data in one of these services, e.g. "what's in my Linear this week", "how many users did PostHog record yesterday", "create a Notion page". +description: Use the user's own accounts in third-party services (Notion, Linear, GitHub, GitLab, Atlassian, Stripe, PayPal, Square, PostHog, Sentry, Vercel, Supabase, Airtable, Todoist, Canva, Miro, Google Search Console, Google Analytics and more) through Connany. Use when the user asks to look up, summarize or change data in one of these services, e.g. "what's in my Linear this week", "how many users did PostHog record yesterday", "top search queries in Search Console last week", "create a Notion page". --- # Connany diff --git a/src/admin/pages.ts b/src/admin/pages.ts index f3f26f3..a43ea65 100644 --- a/src/admin/pages.ts +++ b/src/admin/pages.ts @@ -86,7 +86,13 @@ function githubCreationUrl(callbackUrl: string) { } function credentialDialog(p: ConnectorView) { const id = `connector-${p.name}`; - return `

${names[p.name]}

填写 GitHub App 公开地址的最后一段。例如地址为 github.com/apps/my-app,则填写 my-app。用于生成安装链接,不是 App ID。
复制此地址到 GitHub App 设置中的 Callback URL,需保持完全一致。
`; + const setup = connectorSpec(p.name).oauth?.console; + // GitHub Apps also need the slug for installation links; other OAuth clients only need the credentials. + const app = connectorSpec(p.name).auth === 'github_app' + ? `
填写 GitHub App 公开地址的最后一段。例如地址为 github.com/apps/my-app,则填写 my-app。用于生成安装链接,不是 App ID。
` + : setup ? `
${esc(setup.help['zh-CN'])}
` : ''; + const callbackHelp = connectorSpec(p.name).auth === 'github_app' ? '复制此地址到 GitHub App 设置中的 Callback URL,需保持完全一致。' : '复制此地址到 OAuth 客户端的重定向 URI,需保持完全一致。'; + return `

${names[p.name]}

${app}
${callbackHelp}
`; } function connectorCard(p: ConnectorView) { const label = names[p.name]; @@ -95,9 +101,9 @@ function connectorCard(p: ConnectorView) { const sync = `
`; const synced = p.tools_synced_at ? new Date(p.tools_synced_at) : null; const when = (options: Intl.DateTimeFormatOptions) => synced!.toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',...options}); - const status = synced ? `${p.tool_count} 个工具${esc(when({month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}))} 同步` : `${p.enabled?'尚未同步':'启用后可同步'}`; - const catalog = `
工具目录${p.enabled?sync:''}
${status}
`; - return `
${icons[p.name]}${connectorStatus(p)}

${label}

MCP

${esc(connectorSpec(p.name).description['zh-CN'])}

${catalog}
${configure}${toggle}官网 ↗
`; + const status = p.built_in_tools ? `${p.tool_count} 个工具内置` : synced ? `${p.tool_count} 个工具${esc(when({month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}))} 同步` : `${p.enabled?'尚未同步':'启用后可同步'}`; + const catalog = `
工具目录${p.enabled && !p.built_in_tools?sync:''}
${status}
`; + return `
${icons[p.name]}${connectorStatus(p)}

${label}

${connectorSpec(p.name).rest?'API':'MCP'}

${esc(connectorSpec(p.name).description['zh-CN'])}

${catalog}
${configure}${toggle}官网 ↗
`; } export function connectorList(connectors: ConnectorView[]) { const enabled = connectors.filter(p=>p.enabled).length; @@ -160,5 +166,5 @@ export function usersPage(users: { id: string; email: string; role: AdminRole; c } export function testPage(connectors: ConnectorView[], selected: string) { - return pageHead('真实 API 联调','测试一个用户连接','使用项目的 API Key 验证授权与只读调用。建议创建测试专用项目,避免混入正式用户数据。') + `

等待创建测试会话。

`; + return pageHead('真实 API 联调','测试一个用户连接','使用项目的 API Key 验证授权与只读调用。建议创建测试专用项目,避免混入正式用户数据。') + `

等待创建测试会话。

`; } diff --git a/src/connector-store.ts b/src/connector-store.ts index f0efe56..86e0eb5 100644 --- a/src/connector-store.ts +++ b/src/connector-store.ts @@ -6,7 +6,7 @@ import { connector as connectorSpec } from './connectors/catalog.js'; import { Vault, id } from './crypto.js'; import { transaction } from './db.js'; import { AppError } from './errors.js'; -import { ConnectorRuntime } from './connectors/index.js'; +import { ConnectorRuntime, restToolCatalog } from './connectors/index.js'; import { defaultWorkspaceId } from './workspaces.js'; export const connectorAppInput = z.object({ @@ -18,6 +18,8 @@ export interface ConnectorView { has_secret: boolean; github_app_slug: string; callback_url: string; updated_at: Date | null; /** Cached tool catalog size and freshness; null until an administrator sync or user authorization. */ tool_count: number; tools_synced_at: Date | null; + /** The catalog ships with Connany (REST connectors) and needs no sync. */ + built_in_tools?: boolean; } /** A tool definition in the connector catalog. Never contains user data. */ export interface ToolDefinition { name: string; connector: ConnectorName; description: string; read_only: boolean; required_permissions: string[]; input_schema: Record } @@ -55,7 +57,8 @@ export class ConnectorStore { return connectorNames.map(name => { const r = rows.find(r => r.name === name); const catalog = catalogs.find(c => c.connector === name); - const tools = { tool_count: catalog?.tool_count ?? 0, tools_synced_at: catalog?.synced_at ?? null }; + const builtIn = connectorSpec(name).rest ? restToolCatalog(name).length : undefined; + const tools = { tool_count: builtIn ?? catalog?.tool_count ?? 0, tools_synced_at: catalog?.synced_at ?? null, ...(builtIn !== undefined ? { built_in_tools: true } : {}) }; if (connectorSpec(name).auth === 'mcp' && r?.settings?.transport !== 'mcp') return {name,configured:false,enabled:false,client_id:'',has_secret:false,github_app_slug:'',callback_url:this.base.callback(name),updated_at:null,...tools}; const slug = r?.settings?.github_app_slug || ''; return { name, configured: !!r?.id, enabled: !!r?.id && r.enabled, client_id: r?.client_id || '', has_secret: !!r?.id, @@ -63,6 +66,8 @@ export class ConnectorStore { }); } async saveTools(name: ConnectorName, tools: ToolDefinition[], db: pg.Pool | pg.PoolClient = this.pool) { + // Built-in REST catalogs ship with the code and are never cached. + if (connectorSpec(name).rest) return; await db.query(`INSERT INTO connector_tools(workspace_id,connector,tools,synced_at) VALUES($1,$2,$3,now()) ON CONFLICT(workspace_id,connector) DO UPDATE SET tools=EXCLUDED.tools,synced_at=now()`, [this.workspaceId, name, JSON.stringify(tools)]); } @@ -70,21 +75,23 @@ export class ConnectorStore { async tools(): Promise { const { rows } = await this.pool.query(`SELECT t.tools FROM connector_tools t JOIN connectors s ON s.workspace_id=t.workspace_id AND s.name=t.connector WHERE t.workspace_id=$1 AND s.enabled=true AND s.active_app_id IS NOT NULL ORDER BY t.connector`, [this.workspaceId]); - return rows.flatMap(row => row.tools); + const enabled = (await this.pool.query('SELECT name FROM connectors WHERE workspace_id=$1 AND enabled=true AND active_app_id IS NOT NULL', [this.workspaceId])).rows.map(r => r.name as ConnectorName); + return [...rows.flatMap(row => row.tools), ...enabled.filter(name => connectorSpec(name).rest).flatMap(name => restToolCatalog(name))]; } /** Cached catalog of one connector regardless of whether it accepts new connections; null if never synced. */ async catalog(name: ConnectorName): Promise { + if (connectorSpec(name).rest) return restToolCatalog(name); const { rows } = await this.pool.query('SELECT tools FROM connector_tools WHERE workspace_id=$1 AND connector=$2', [this.workspaceId, name]); return rows[0]?.tools ?? null; } async saveMcp(name: ConnectorName, enabled: boolean, adminId: string) { - if (connectorSpec(name).auth !== 'mcp') throw new AppError('github_app_required', 'Configure a GitHub App for OAuth.'); + if (connectorSpec(name).auth !== 'mcp') throw new AppError('oauth_client_required', 'Configure an OAuth client for this connector.'); await transaction(this.pool, async db => { await db.query('SELECT pg_advisory_xact_lock(804217332)'); const existing = (await db.query("SELECT a.* FROM connectors s JOIN connector_apps a ON a.id=s.active_app_id WHERE s.workspace_id=$1 AND s.name=$2",[this.workspaceId,name])).rows[0]; // Register again when the callback or the requested client authentication changed (e.g. a // connector now needs a confidential client). Connections keep the client they were issued by. - const requested = connectorSpec(name).mcp.clientAuth || 'none'; + const requested = connectorSpec(name).mcp!.clientAuth || 'none'; let appId = existing?.settings?.transport === 'mcp' && existing.settings.callback_url === this.base.callback(name) && (existing.settings.requested_auth ?? existing.settings.token_endpoint_auth_method ?? 'none') === requested ? existing.id : null; // Pausing never registers; it keeps whatever client is active. @@ -99,7 +106,7 @@ export class ConnectorStore { }); } async save(name: ConnectorName, input: z.infer, adminId: string) { - if (connectorSpec(name).auth !== 'github_app') throw new AppError(`use_${name}_mcp`, 'Use official MCP registration.'); + if (connectorSpec(name).auth === 'mcp') throw new AppError(`use_${name}_mcp`, 'Use official MCP registration.'); if (name === 'github' && !/^[a-zA-Z0-9-]+$/.test(input.github_app_slug)) throw new AppError('invalid_slug', '请填写 GitHub App slug。'); await transaction(this.pool, async db => { await db.query('SELECT pg_advisory_xact_lock(804217332)'); diff --git a/src/connectors/catalog.ts b/src/connectors/catalog.ts index c04f4fb..d16540b 100644 --- a/src/connectors/catalog.ts +++ b/src/connectors/catalog.ts @@ -3,11 +3,13 @@ import type { HostedMcp } from './hosted-mcp.js'; import type { ConnectorRuntime, Credentials, Identity } from './index.js'; import { githubAccess } from './github-access.js'; import { linearIdentity, notionIdentity } from './mcp-identity.js'; +import { googleAnalyticsTools, googleSearchConsoleTools, type GoogleOperation } from './google.js'; /** * Built-in connector catalog. A standard hosted MCP connector (dynamic client registration, * PKCE, `/authorize` `/token` `/register` and the MCP endpoint on one origin) needs only an - * entry here. Connectors with bespoke OAuth, like GitHub Apps, keep custom handling in ConnectorRuntime. + * entry here. Services without one use an OAuth client the administrator registers (`oauth`) and + * REST tools Connany implements (`rest`). GitHub Apps keep their installation handling in ConnectorRuntime. */ /** One resource scope a connection can reach, e.g. a GitHub organization the App is installed in. */ export interface AccessGrant { id: string; type: string; name: string; selection: 'all' | 'selected'; suspended: boolean; manage_url: string | null } @@ -45,6 +47,18 @@ export interface McpSpec { /** RFC 8707 resource indicator: true for origin + endpoint, or the exact resource from the protected resource metadata. */ resource?: boolean | string; } +/** OAuth client the administrator registers with the service, for connectors without dynamic registration. */ +export interface OAuthSpec { + authorize: string; token: string; + /** RFC 7009 revocation endpoint. */ + revoke?: string; + /** OIDC userinfo, used to identify the account. */ + userinfo?: string; + scope?: string; + authorizeParams?: Record; + /** Where administrators create the client, linked from the console. */ + console?: { url: string; label: Localized; help: Localized }; +} /** Languages for connector text returned by the API and shown in the console. */ export const locales = ['en', 'zh-CN'] as const; export type Locale = typeof locales[number]; @@ -78,8 +92,12 @@ export interface ConnectorDefinition { icon: string; /** One-line capability summary for the admin card and GET /v1/connectors. */ description: Localized; - auth: 'mcp' | 'github_app'; - mcp: McpSpec; + /** mcp: hosted MCP with dynamic client registration; github_app and oauth: an administrator-registered client. */ + auth: 'mcp' | 'github_app' | 'oauth'; + mcp?: McpSpec; + oauth?: OAuthSpec; + /** Tools Connany implements against the service's REST API, instead of an upstream MCP tool list. */ + rest?: Record; /** Resolve the account from the token response. MCP connectors without one use OIDC claims or userinfo, see genericIdentity. */ identify?: (mcp: HostedMcp, credential: Credentials, raw: any) => Promise; /** Refresh display names for existing connections without changing account identity. */ @@ -87,6 +105,15 @@ export interface ConnectorDefinition { access?: ConnectorAccess; } +const google = (scope: string, apis: string, apisEn = apis): OAuthSpec => ({ + authorize: 'https://accounts.google.com/o/oauth2/v2/auth', token: 'https://oauth2.googleapis.com/token', + revoke: 'https://oauth2.googleapis.com/revoke', userinfo: 'https://openidconnect.googleapis.com/v1/userinfo', + scope: `openid email ${scope}`, authorizeParams: { access_type: 'offline', prompt: 'consent' }, + console: { url: 'https://console.cloud.google.com/apis/credentials', + label: { 'zh-CN': '创建 Google OAuth 客户端', en: 'Create Google OAuth client' }, + help: { 'zh-CN': `在 Google Cloud 项目中启用 ${apis},创建「Web 应用」类型的 OAuth 客户端,并把下方回调地址加入「已获授权的重定向 URI」。`, + en: `Enable the ${apisEn} in a Google Cloud project, create a "Web application" OAuth client and add the callback URL below to its authorized redirect URIs.` } }, +}); const checked = (identity: T) => ({...identity, identity_checked_at: new Date().toISOString()}); export const connectorCatalog = { @@ -115,6 +142,8 @@ export const connectorCatalog = { icon: '', auth: 'github_app', mcp: { origin: 'https://api.githubcopilot.com', endpoint: '/mcp/x/all' }, + // Always offer account selection when adding or reconnecting a GitHub account. + oauth: { authorize: 'https://github.com/login/oauth/authorize', token: 'https://github.com/login/oauth/access_token', authorizeParams: { prompt: 'select_account' } }, access: githubAccess, }, linear: { @@ -335,6 +364,31 @@ export const connectorCatalog = { auth: 'mcp', mcp: { origin: 'https://mcp.wix.com', oauth: { authorize: 'https://mcp.wix.com/authorize', token: 'https://mcp.wix.com/token', register: 'https://mcp.wix.com/register', revoke: 'https://mcp.wix.com/token' }, scope: 'offline_access', resource: 'https://mcp.wix.com/mcp' }, }, + // Google offers no hosted MCP for Search Console or Analytics and no dynamic registration: + // administrators create one OAuth client (it can serve both) and Connany calls the REST APIs. + // Offline access with consent returns a refresh token on every authorization. + google_search_console: { + label: 'Google Search Console', + category: 'analytics', + website: 'https://search.google.com/search-console', + description: { 'zh-CN': '搜索表现、索引状态与站点地图', en: 'Search performance, indexing and sitemaps' }, + // Google Search Console logomark, redrawn as a monochrome vector from the official logo; gaps separate the overlapping shapes. + icon: '', + auth: 'oauth', + oauth: google('https://www.googleapis.com/auth/webmasters.readonly', 'Search Console API'), + rest: googleSearchConsoleTools, + }, + google_analytics: { + label: 'Google Analytics', + category: 'analytics', + website: 'https://analytics.google.com', + description: { 'zh-CN': 'GA4 报表、实时数据与媒体资源', en: 'GA4 reports, realtime data and properties' }, + // Simple Icons (CC0). + icon: '', + auth: 'oauth', + oauth: google('https://www.googleapis.com/auth/analytics.readonly', 'Google Analytics Data API 和 Google Analytics Admin API', 'Google Analytics Data API and Google Analytics Admin API'), + rest: googleAnalyticsTools, + }, } satisfies Record; export type ConnectorName = keyof typeof connectorCatalog; diff --git a/src/connectors/google.ts b/src/connectors/google.ts new file mode 100644 index 0000000..17dcc89 --- /dev/null +++ b/src/connectors/google.ts @@ -0,0 +1,135 @@ +import { z } from 'zod'; + +/** + * Google Search Console and Google Analytics tools. Google offers no hosted MCP server for them, + * so Connany calls the REST APIs itself with the user's OAuth token. Responses are reshaped into + * compact rows keyed by dimension and metric names, which agents read far more easily. + */ +export interface GoogleOperation { + connector: 'google_search_console' | 'google_analytics'; + description: string; + read_only: boolean; + required_permissions: string[]; + schema: z.ZodObject; + request: (input: any) => { url: string; method?: string; body?: unknown }; + response?: (raw: any, input: any) => unknown; +} +const e = encodeURIComponent; +const query = (values: Record) => new URLSearchParams(Object.entries(values).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])).toString(); + +// Search Console: https://developers.google.com/webmaster-tools/v1/api_reference_index +const webmasters = 'https://www.googleapis.com/webmasters/v3'; +const site = z.string().max(2048).regex(/^(sc-domain:[a-z0-9.-]+|https?:\/\/\S+)$/i, 'Use a URL-prefix property such as https://example.com/ or a domain property such as sc-domain:example.com.') + .describe('Property exactly as list_sites returns it, e.g. https://example.com/ or sc-domain:example.com.'); +const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.'); +const gscDimension = z.enum(['date', 'hour', 'query', 'page', 'country', 'device', 'searchAppearance']); +const gscFilter = z.object({ + dimension: z.enum(['query', 'page', 'country', 'device', 'searchAppearance']), + operator: z.enum(['equals', 'notEquals', 'contains', 'notContains', 'includingRegex', 'excludingRegex']).default('equals'), + expression: z.string().min(1).max(4096), +}).strict(); +const gsc = (description: string, shape: z.ZodRawShape, request: GoogleOperation['request'], response?: GoogleOperation['response']): GoogleOperation => + ({ connector: 'google_search_console', description, read_only: true, required_permissions: ['Search Console: read'], schema: z.object(shape).strict(), request, response }); + +export const googleSearchConsoleTools = { + 'google_search_console.list_sites': gsc('List Search Console properties (sites) the user can access, with their permission level. Call this first to get the exact site_url for other tools.', + {}, () => ({ url: `${webmasters}/sites` }), raw => ({ sites: (raw.siteEntry || []).map((s: any) => ({ site_url: s.siteUrl, permission_level: s.permissionLevel })) })), + 'google_search_console.query_search_analytics': gsc('Query Google Search performance (clicks, impressions, CTR, average position) for a property, grouped by dimensions such as query, page, country, device or date. Data is usually 2–3 days behind; dates are in Pacific time. Page through results with start_row.', + { + site_url: site, start_date: day, end_date: day, + dimensions: z.array(gscDimension).max(7).default([]).describe('Group rows by these dimensions. Empty returns one total row. "hour" requires data_state hourly_all.'), + search_type: z.enum(['web', 'image', 'video', 'news', 'discover', 'googleNews']).default('web'), + filters: z.array(gscFilter).max(20).default([]).describe('All filters must match (AND).'), + aggregation_type: z.enum(['auto', 'byPage', 'byProperty']).default('auto'), + data_state: z.enum(['final', 'all', 'hourly_all']).default('final').describe('"all" includes fresh, not yet finalized data.'), + row_limit: z.number().int().min(1).max(1000).default(100), start_row: z.number().int().min(0).max(1000000).default(0), + }, + i => ({ url: `${webmasters}/sites/${e(i.site_url)}/searchAnalytics/query`, method: 'POST', body: { + startDate: i.start_date, endDate: i.end_date, dimensions: i.dimensions, type: i.search_type, aggregationType: i.aggregation_type, dataState: i.data_state, + rowLimit: i.row_limit, startRow: i.start_row, ...(i.filters.length ? { dimensionFilterGroups: [{ groupType: 'and', filters: i.filters }] } : {}) } }), + (raw, i) => { + const rows = (raw.rows || []).map((r: any) => ({ ...Object.fromEntries(i.dimensions.map((d: string, n: number) => [d, r.keys?.[n]])), clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position })); + return { rows, row_count: rows.length, aggregation: raw.responseAggregationType, next_start_row: rows.length === i.row_limit ? i.start_row + i.row_limit : null }; + }), + 'google_search_console.list_sitemaps': gsc('List sitemaps submitted for a property, with last download time, warnings, errors and indexed content counts.', + { site_url: site }, i => ({ url: `${webmasters}/sites/${e(i.site_url)}/sitemaps` }), raw => ({ sitemaps: raw.sitemap || [] })), + 'google_search_console.inspect_url': gsc('Inspect how Google indexed one URL of a property: coverage and indexing state, last crawl, Google-selected canonical, mobile usability and rich results. The URL must belong to the property.', + { site_url: site, url: z.string().url().max(2048), language_code: z.string().max(20).default('en-US') }, + i => ({ url: 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', method: 'POST', body: { inspectionUrl: i.url, siteUrl: i.site_url, languageCode: i.language_code } }), + raw => raw.inspectionResult ?? raw), +} as const satisfies Record; + +// Google Analytics 4: https://developers.google.com/analytics/devguides/reporting/data/v1 +const admin = 'https://analyticsadmin.googleapis.com/v1beta'; +const data = 'https://analyticsdata.googleapis.com/v1beta'; +const property = z.string().regex(/^(properties\/)?\d{1,20}$/, 'Use the numeric property ID, e.g. 123456789.').describe('GA4 property ID from list_account_summaries, e.g. 123456789.'); +const propertyPath = (value: string) => `properties/${value.replace(/^properties\//, '')}`; +const gaDate = z.string().regex(/^(\d{4}-\d{2}-\d{2}|today|yesterday|\d{1,4}daysAgo)$/, 'Use YYYY-MM-DD, today, yesterday or NdaysAgo.'); +const names = (max: number, what: string) => z.array(z.string().regex(/^[A-Za-z0-9_:]{1,200}$/)).max(max).describe(`${what} API names, e.g. from get_metadata.`); +const expression = z.record(z.string(), z.unknown()).describe('A GA4 Data API FilterExpression object, e.g. {"filter":{"fieldName":"country","stringFilter":{"value":"Japan"}}}.'); +const orderBys = z.array(z.record(z.string(), z.unknown())).max(10).describe('GA4 Data API OrderBy objects, e.g. [{"metric":{"metricName":"sessions"},"desc":true}].'); +const numeric = new Set(['TYPE_INTEGER', 'TYPE_FLOAT', 'TYPE_SECONDS', 'TYPE_MILLISECONDS', 'TYPE_MINUTES', 'TYPE_HOURS', 'TYPE_STANDARD', 'TYPE_CURRENCY', 'TYPE_FEET', 'TYPE_MILES', 'TYPE_METERS', 'TYPE_KILOMETERS']); +/** Rows as objects keyed by dimension and metric names, with numeric metrics as numbers. */ +function report(raw: any, input: { limit?: number; offset?: number }) { + const dimensions: string[] = (raw.dimensionHeaders || []).map((h: any) => h.name); + const metrics: { name: string; type: string }[] = (raw.metricHeaders || []).map((h: any) => ({ name: h.name, type: h.type })); + const row = (r: any) => ({ + ...Object.fromEntries(dimensions.map((d, n) => [d, r.dimensionValues?.[n]?.value])), + ...Object.fromEntries(metrics.map((m, n) => { const value = r.metricValues?.[n]?.value; return [m.name, numeric.has(m.type) && value !== undefined ? Number(value) : value]; })), + }); + const rows = (raw.rows || []).map(row); + const total = raw.rowCount ?? rows.length; + const next = input.limit !== undefined && (input.offset ?? 0) + rows.length < total ? (input.offset ?? 0) + rows.length : null; + return { rows, row_count: total, ...(raw.totals?.length ? { totals: raw.totals.map(row) } : {}), metric_types: Object.fromEntries(metrics.map(m => [m.name, m.type])), + ...(input.offset !== undefined ? { next_offset: next } : {}), ...(raw.metadata ? { metadata: raw.metadata } : {}) }; +} +const ga = (description: string, shape: z.ZodRawShape, request: GoogleOperation['request'], response?: GoogleOperation['response']): GoogleOperation => + ({ connector: 'google_analytics', description, read_only: true, required_permissions: ['Google Analytics: read'], schema: z.object(shape).strict(), request, response }); + +export const googleAnalyticsTools = { + 'google_analytics.list_account_summaries': ga('List Google Analytics accounts and the GA4 properties under each one. Call this first to find property IDs.', + { page_size: z.number().int().min(1).max(200).default(50), page_token: z.string().max(1000).optional() }, + i => ({ url: `${admin}/accountSummaries?${query({ pageSize: i.page_size, pageToken: i.page_token })}` }), + raw => ({ accounts: (raw.accountSummaries || []).map((a: any) => ({ account: a.account, name: a.displayName, + properties: (a.propertySummaries || []).map((p: any) => ({ property_id: String(p.property).replace(/^properties\//, ''), name: p.displayName, type: p.propertyType })) })), + next_page_token: raw.nextPageToken || null })), + 'google_analytics.get_property': ga('Get a GA4 property: display name, time zone, currency, industry and creation time.', + { property_id: property }, i => ({ url: `${admin}/${propertyPath(i.property_id)}` })), + 'google_analytics.get_metadata': ga('List dimensions and metrics available for reports on a property, including its custom dimensions and metrics. Use the api_name values in run_report.', + { property_id: property, custom_only: z.boolean().default(false).describe('Only custom dimensions and metrics of this property.') }, + i => ({ url: `${data}/${propertyPath(i.property_id)}/metadata` }), + (raw, i) => { + const keep = (item: any) => !i.custom_only || item.customDefinition; + return { dimensions: (raw.dimensions || []).filter(keep).map((d: any) => ({ api_name: d.apiName, name: d.uiName, category: d.category, ...(d.customDefinition ? { custom: true } : {}) })), + metrics: (raw.metrics || []).filter(keep).map((m: any) => ({ api_name: m.apiName, name: m.uiName, type: m.type, category: m.category, ...(m.customDefinition ? { custom: true } : {}) })) }; + }), + 'google_analytics.run_report': ga('Run a GA4 report: metrics (e.g. activeUsers, sessions, screenPageViews, conversions, totalRevenue) over date ranges, grouped by dimensions (e.g. date, country, pagePath, sessionSource, deviceCategory). Use get_metadata for valid names. Page through results with offset.', + { + property_id: property, + date_ranges: z.array(z.object({ start_date: gaDate, end_date: gaDate, name: z.string().max(100).optional() }).strict()).min(1).max(4).default([{ start_date: '28daysAgo', end_date: 'yesterday' }]), + metrics: names(10, 'Metric').min(1), dimensions: names(9, 'Dimension').default([]), + dimension_filter: expression.optional(), metric_filter: expression.optional(), order_bys: orderBys.optional(), + limit: z.number().int().min(1).max(1000).default(100), offset: z.number().int().min(0).max(1000000).default(0), + keep_empty_rows: z.boolean().default(false), include_totals: z.boolean().default(false), + }, + i => ({ url: `${data}/${propertyPath(i.property_id)}:runReport`, method: 'POST', body: { + dateRanges: i.date_ranges.map((r: any) => ({ startDate: r.start_date, endDate: r.end_date, ...(r.name ? { name: r.name } : {}) })), + metrics: i.metrics.map((name: string) => ({ name })), dimensions: i.dimensions.map((name: string) => ({ name })), + dimensionFilter: i.dimension_filter, metricFilter: i.metric_filter, orderBys: i.order_bys, + limit: i.limit, offset: i.offset, keepEmptyRows: i.keep_empty_rows, ...(i.include_totals ? { metricAggregations: ['TOTAL'] } : {}) } }), + report), + 'google_analytics.run_realtime_report': ga('Run a GA4 realtime report on activity in the last 30 minutes (e.g. metric activeUsers by dimension country, unifiedScreenName or eventName).', + { + property_id: property, metrics: names(10, 'Realtime metric').min(1), dimensions: names(9, 'Realtime dimension').default([]), + minutes_ago: z.number().int().min(0).max(29).default(29).describe('Start of the window, in minutes before now.'), + dimension_filter: expression.optional(), metric_filter: expression.optional(), order_bys: orderBys.optional(), + limit: z.number().int().min(1).max(1000).default(100), + }, + i => ({ url: `${data}/${propertyPath(i.property_id)}:runRealtimeReport`, method: 'POST', body: { + metrics: i.metrics.map((name: string) => ({ name })), dimensions: i.dimensions.map((name: string) => ({ name })), + minuteRanges: [{ startMinutesAgo: i.minutes_ago, endMinutesAgo: 0 }], + dimensionFilter: i.dimension_filter, metricFilter: i.metric_filter, orderBys: i.order_bys, limit: i.limit } }), + raw => report(raw, {})), +} as const satisfies Record; + +export const googleTools = { ...googleSearchConsoleTools, ...googleAnalyticsTools }; diff --git a/src/connectors/hosted-mcp.ts b/src/connectors/hosted-mcp.ts index 4917d65..9def0c3 100644 --- a/src/connectors/hosted-mcp.ts +++ b/src/connectors/hosted-mcp.ts @@ -6,7 +6,7 @@ export interface McpClient { clientId: string; clientSecret: string; authMethod: export class HostedMcp { constructor(private fetcher: Fetcher = fetch, private connector: ConnectorName = 'notion') {} - private get spec() { return definition(this.connector).mcp; } + private get spec() { return definition(this.connector).mcp!; } private get origin() { return this.spec.origin; } private get endpoint() { return this.spec.endpoint || '/mcp'; } /** OAuth endpoint: explicit from the catalog, or the legacy origin + path layout. */ @@ -17,7 +17,7 @@ export class HostedMcp { } /** RFC 8707 resource indicator, when the connector requires one. */ get resource() { return typeof this.spec.resource === 'string' ? this.spec.resource : this.spec.resource ? this.origin + this.endpoint : undefined; } - private assertRegistrable() { if (definition(this.connector).auth !== 'mcp') throw new AppError('github_app_required','Configure a GitHub App for OAuth.'); } + private assertRegistrable() { if (definition(this.connector).auth !== 'mcp') throw new AppError('oauth_client_required','Configure an OAuth client for this connector.'); } private async request(path: string, init: RequestInit) { let response: Response; try { response = await this.fetcher(/^https:\/\//.test(path) ? path : this.origin + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(45000) }); } diff --git a/src/connectors/index.ts b/src/connectors/index.ts index f649b9c..52cd0b3 100644 --- a/src/connectors/index.ts +++ b/src/connectors/index.ts @@ -2,6 +2,7 @@ import { HostedMcp } from './hosted-mcp.js'; import { genericIdentity } from './mcp-identity.js'; import { z } from 'zod'; import { githubRestTools } from './github-rest.js'; +import { googleTools } from './google.js'; import type { Config } from '../config.js'; import { connector as definition, connectorNames, type ConnectorName } from './catalog.js'; import { challenge } from '../crypto.js'; @@ -13,13 +14,14 @@ export type Fetcher = typeof fetch; const pagination = { limit: z.number().int().min(1).max(100).default(20), cursor: z.string().max(500).optional() }; export const restTools = { ...githubRestTools, + ...googleTools, 'github.installations.list': { connector: 'github', description: 'List installations of this GitHub App accessible to the user.', schema: z.object({ page: z.number().int().min(1).max(10000).default(1), limit: pagination.limit }).strict() }, 'github.repositories.list': { connector: 'github', description: 'List repositories accessible to both the user and a GitHub App installation.', schema: z.object({ installation_id: z.number().int().positive(), page: z.number().int().min(1).max(10000).default(1), limit: pagination.limit }).strict() }, } as const; export type ToolName = keyof typeof restTools | `${ConnectorName}.${string}`; /** Dynamic MCP tool names: `.`. */ export const toolNamePattern = new RegExp(`^(${connectorNames.join('|')})\\.[a-zA-Z0-9_-]{1,128}$`); -export function restToolCatalog() { return Object.entries(restTools).map(([name, a]) => ({ name, connector: a.connector, description: a.description, read_only: 'read_only' in a ? a.read_only : true, required_permissions: 'required_permissions' in a ? a.required_permissions : ['Metadata: read'], input_schema: z.toJSONSchema(a.schema) })); } +export function restToolCatalog(connector?: ConnectorName) { return Object.entries(restTools).filter(([, a]) => !connector || a.connector === connector).map(([name, a]) => ({ name, connector: a.connector, description: a.description, read_only: 'read_only' in a ? a.read_only : true, required_permissions: 'required_permissions' in a ? a.required_permissions : ['Metadata: read'], input_schema: z.toJSONSchema(a.schema) })); } export class ConnectorRuntime { constructor(public config: Config, private fetcher: Fetcher = fetch) {} @@ -31,15 +33,15 @@ export class ConnectorRuntime { authorizeUrl(connector: ConnectorName, state: string, verifier: string) { if (!this.enabled(connector)) throw new AppError('connector_not_configured', 'This connector is not configured.', 503); const spec = definition(connector); - const url = new URL(spec.auth === 'mcp' ? this.mcp(connector).oauthUrl('authorize')! : 'https://github.com/login/oauth/authorize'); + const oauth = spec.auth === 'mcp' ? undefined : spec.oauth!; + const url = new URL(oauth ? oauth.authorize : this.mcp(connector).oauthUrl('authorize')!); url.search = new URLSearchParams({ client_id: this.config.connectors[connector].clientId, redirect_uri: this.callback(connector), response_type: 'code', state }).toString(); { url.searchParams.set('code_challenge', challenge(verifier)); url.searchParams.set('code_challenge_method', 'S256'); } - if (spec.auth === 'mcp' && spec.mcp.scope) url.searchParams.set('scope', spec.mcp.scope); - for (const [key, value] of Object.entries(spec.mcp.authorizeParams || {})) if (spec.auth === 'mcp') url.searchParams.set(key, value); - const resource = spec.auth === 'mcp' ? this.mcp(connector).resource : undefined; + const scope = oauth ? oauth.scope : spec.mcp!.scope; + if (scope) url.searchParams.set('scope', scope); + for (const [key, value] of Object.entries((oauth ?? spec.mcp!).authorizeParams || {})) url.searchParams.set(key, value); + const resource = oauth ? undefined : this.mcp(connector).resource; if (resource) url.searchParams.set('resource', resource); - // Always offer account selection when adding or reconnecting a GitHub account. - if (connector === 'github') url.searchParams.set('prompt', 'select_account'); return url.toString(); } private mcpClient(connector: ConnectorName) { const app = this.config.connectors[connector]; return { clientId: app.clientId, clientSecret: app.clientSecret, authMethod: app.authMethod || 'none' } as const; } @@ -53,14 +55,16 @@ export class ConnectorRuntime { if (!response.ok || body.error) { const authError = tokenRequest && ['invalid_grant', 'bad_refresh_token', 'expired_token', 'invalid_refresh_token'].includes(body.error); const status = response.status === 429 ? 429 : (!tokenRequest && response.status === 401) || authError ? 401 : 502; - throw new UpstreamError(authError ? 'reauth_required' : 'upstream_error', status, { upstream_status: response.status, ...(response.headers.get('retry-after') ? { retry_after: response.headers.get('retry-after') } : {}) }); + // Google API errors explain themselves (API not enabled, no access to the property, unknown metric). + const message = typeof body.error?.message === 'string' ? body.error.message.slice(0, 500) : undefined; + throw new UpstreamError(authError ? 'reauth_required' : 'upstream_error', status, { upstream_status: response.status, ...(message ? { upstream_message: message } : {}), ...(response.headers.get('retry-after') ? { retry_after: response.headers.get('retry-after') } : {}) }); } return body; } private async token(connector: ConnectorName, fields: Record, old?: Credentials) { const app = this.config.connectors[connector]; - const urls = { github: 'https://github.com/login/oauth/access_token' }; - const raw = definition(connector).auth === 'mcp' ? await this.mcp(connector).token(this.mcpClient(connector), fields) : await this.request(urls.github, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({...fields,client_id:app.clientId,client_secret:app.clientSecret}).toString() }, true); + const spec = definition(connector); + const raw = spec.auth === 'mcp' ? await this.mcp(connector).token(this.mcpClient(connector), fields) : await this.request(spec.oauth!.token, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({...fields,client_id:app.clientId,client_secret:app.clientSecret}).toString() }, true); if (typeof raw.access_token !== 'string' || !raw.access_token) throw new UpstreamError('invalid_token_response'); const expires = (seconds: unknown) => typeof seconds === 'number' && seconds > 0 ? new Date(Date.now() + seconds * 1000).toISOString() : undefined; const credential: Credentials = { accessToken: raw.access_token, refreshToken: raw.refresh_token || old?.refreshToken, @@ -83,7 +87,14 @@ export class ConnectorRuntime { const spec = definition(connector); if (spec.identify) return spec.identify(this.mcp(connector), credential, raw); if (spec.auth === 'mcp') return genericIdentity(this.mcp(connector), credential, raw, spec.label); - if (connector === 'github') { + if (spec.auth === 'oauth') { + if (!spec.oauth?.userinfo) throw new UpstreamError('invalid_upstream_identity'); + const me = await this.request(spec.oauth.userinfo, { headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json' } }); + const accountId = me.sub ?? me.id; + if ((typeof accountId !== 'string' && typeof accountId !== 'number') || accountId === '') throw new UpstreamError('invalid_upstream_identity'); + return { account_id: String(accountId), account_name: String(me.email || me.name || `${spec.label} account`), ...(typeof me.email === 'string' ? { email: me.email } : {}) }; + } + if (spec.auth === 'github_app') { const me = await this.api('github', '/user', credential); const installs = await this.api('github', '/user/installations?per_page=100', credential); if (!me.id || !me.login) throw new UpstreamError('invalid_upstream_identity'); @@ -92,15 +103,31 @@ export class ConnectorRuntime { throw new UpstreamError('invalid_upstream_identity'); } async revoke(connector: ConnectorName, credential: Credentials) { - if (definition(connector).auth === 'github_app') { + const spec = definition(connector); + if (spec.auth === 'oauth') { + // Google documents only the token parameter; revoking the refresh token also ends its access tokens. + if (spec.oauth?.revoke) await this.request(spec.oauth.revoke, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: credential.refreshToken || credential.accessToken }).toString() }); + } else if (spec.auth === 'github_app') { await this.request(`https://api.github.com/applications/${encodeURIComponent(this.config.connectors.github.clientId)}/token`, { method: 'DELETE', headers: { Authorization: this.basic(connector), Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': this.config.githubVersion, 'User-Agent': 'Connany/0.1' }, body: JSON.stringify({ access_token: credential.accessToken }) }); } else { await this.mcp(connector).revoke(this.mcpClient(connector), credential); } } + /** Tool catalog: built in for REST connectors, otherwise the upstream MCP tool list. */ + async tools(connector: ConnectorName, credential: Credentials) { + return definition(connector).rest ? restToolCatalog(connector) : this.mcp(connector).tools(credential); + } /** `known`: the tool is in the cached catalog, so the upstream list is not fetched to check it. */ async execute(name: ToolName, input: any, credential: Credentials, known = false): Promise { if (!Object.hasOwn(restTools,name)) return this.mcp(name.split('.')[0] as ConnectorName).call(name,input,credential,known); + if (Object.hasOwn(googleTools, name)) { + const operation = googleTools[name as keyof typeof googleTools]; + const parsed = operation.schema.parse(input); + const request = operation.request(parsed); + const raw = await this.request(request.url, { method: request.method || 'GET', headers: { Authorization: `Bearer ${credential.accessToken}`, Accept: 'application/json', ...(request.body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }) }); + return operation.response ? operation.response(raw, parsed) : raw; + } if (Object.hasOwn(githubRestTools, name)) { const action = githubRestTools[name as keyof typeof githubRestTools]; const request = action.request(action.schema.parse(input)); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8e277fc..48aba30 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -108,7 +108,8 @@ export class McpServer { connector_not_configured: 'This service is not available on this Connany server. Call list_connectors for the available ones.', rate_limited: 'Too many requests: wait a minute and retry.', }; - return [`${error.code}: ${error.message}`, upstream, hints[error.code]].filter(Boolean).join('\n'); + const message = typeof error.details?.upstream_message === 'string' ? `Upstream said: ${error.details.upstream_message}` : undefined; + return [`${error.code}: ${error.message}`, upstream, message, hints[error.code]].filter(Boolean).join('\n'); } /** Connectors offered to the user: their own enabled ones plus the platform's. */ private async available(user: OAuthUser) { diff --git a/src/service.ts b/src/service.ts index d8e3417..47c1357 100644 --- a/src/service.ts +++ b/src/service.ts @@ -149,7 +149,7 @@ export class Service { const { credential, raw } = await runtime.exchange(s.connector, code!, this.vault.open(s.verifier_ciphertext, s.id)); const identity = await runtime.identify(s.connector, credential, raw); // Refresh the connector's tool catalog with the user's own fresh credential; optional. - try { await this.connectorStore.in(await this.workspaceOf(s.project_id)).saveTools(s.connector, await runtime.mcp(s.connector).tools(credential)); } catch {} + try { await this.connectorStore.in(await this.workspaceOf(s.project_id)).saveTools(s.connector, await runtime.tools(s.connector, credential)); } catch {} connection = await transaction(this.pool, async db => { // Serialize identity deduplication for a project; connection-level locks serialize reconnect/disconnect. const project = (await db.query('SELECT enabled FROM projects WHERE id=$1 FOR UPDATE', [s.project_id])).rows[0]; @@ -202,7 +202,7 @@ export class Service { const runtime = await this.connectorStore.resolve(s.connector, s.connector_app_id); const { credential } = await runtime.exchange(s.connector, code!, this.vault.open(s.verifier_ciphertext, s.id)); try { - const tools = await runtime.mcp(s.connector).tools(credential); + const tools = await runtime.tools(s.connector, credential); await this.connectorStore.in(s.workspace_id).saveTools(s.connector, tools); toolCount = tools.length; } finally { // Token-level revocation: other grants of the same account are unaffected. @@ -248,7 +248,7 @@ export class Service { const catalog = definition ? null : await this.connectorStore.in(await this.workspaceOf(project, db)).catalog(c.connector); return runtime.execute(tool, parsed, credential, !!catalog?.some(t => t.name === tool)); } - const tools = await runtime.mcp(c.connector).tools(credential); + const tools = await runtime.tools(c.connector, credential); await this.connectorStore.in(await this.workspaceOf(project, db)).saveTools(c.connector, tools, db); return tools; }; diff --git a/tests/github-mcp.test.ts b/tests/github-mcp.test.ts index 89c6d29..0234120 100644 --- a/tests/github-mcp.test.ts +++ b/tests/github-mcp.test.ts @@ -21,7 +21,7 @@ test('GitHub official MCP uses existing user token and all-tool endpoint; tool e await runtime.execute('github.create_issue',{title:'Test'},{accessToken:'user-token'});assert.equal(writes,1); denied=true;await assert.rejects(()=>runtime.execute('github.create_issue',{title:'Test'},{accessToken:'user-token'}),{code:'mcp_tool_error'});assert.equal(writes,2); const auth=new URL(runtime.authorizeUrl('github','state','verifier'));assert.equal(auth.origin,'https://github.com');assert.equal(auth.searchParams.get('client_id'),config.connectors.github.clientId); - await assert.rejects(()=>runtime.mcp('github').register('https://example.com'),{code:'github_app_required'}); + await assert.rejects(()=>runtime.mcp('github').register('https://example.com'),{code:'oauth_client_required'}); }); test('catalog lists every connector, connection tools are scoped to the user, and the SDK adapter uses the connection',async()=>{ const catalog=[{name:'github.get_me',connector:'github',description:'Me',read_only:true,required_permissions:[],input_schema:{type:'object'}},{name:'github.create_issue',connector:'github',description:'Create issue',read_only:false,required_permissions:[],input_schema:{type:'object'}},{name:'notion.notion-search',connector:'notion',description:'Search',read_only:true,required_permissions:[],input_schema:{type:'object'}}]; diff --git a/tests/google-connectors.test.ts b/tests/google-connectors.test.ts new file mode 100644 index 0000000..7e5e3a0 --- /dev/null +++ b/tests/google-connectors.test.ts @@ -0,0 +1,91 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {ConnectorRuntime,restToolCatalog,toolNamePattern} from '../src/connectors/index.js'; +import {config,connectorClients} from './support.js'; + +const clients=connectorClients({google_search_console:{clientId:'gc',clientSecret:'gs'},google_analytics:{clientId:'gc',clientSecret:'gs'},github:{clientId:'g-client',clientSecret:'g-secret'}}); +const calls:{url:string;init:RequestInit}[]=[]; +const runtimeWith=(respond:(url:string,init:RequestInit)=>Response)=>{calls.length=0;return new ConnectorRuntime({...config,connectors:clients},async(url,init)=>{calls.push({url:String(url),init:init!});return respond(String(url),init!);});}; +const credential={accessToken:'at',refreshToken:'rt'}; + +test('Google connectors authorize offline with PKCE, read-only scopes and no resource indicator',()=>{ + const runtime=runtimeWith(()=>Response.json({})); + const gsc=new URL(runtime.authorizeUrl('google_search_console','state','verifier')); + assert.equal(gsc.origin+gsc.pathname,'https://accounts.google.com/o/oauth2/v2/auth'); + assert.equal(gsc.searchParams.get('scope'),'openid email https://www.googleapis.com/auth/webmasters.readonly'); + assert.equal(gsc.searchParams.get('access_type'),'offline');assert.equal(gsc.searchParams.get('prompt'),'consent'); + assert.equal(gsc.searchParams.get('code_challenge_method'),'S256');assert.equal(gsc.searchParams.get('redirect_uri'),'http://localhost:3000/oauth/google_search_console/callback'); + assert.equal(gsc.searchParams.get('resource'),null); + assert.match(new URL(runtime.authorizeUrl('google_analytics','s','v')).searchParams.get('scope')!,/analytics\.readonly$/); + // GitHub moved to the same catalog-driven path and keeps account selection. + const github=new URL(runtime.authorizeUrl('github','s','v')); + assert.equal(github.origin+github.pathname,'https://github.com/login/oauth/authorize');assert.equal(github.searchParams.get('prompt'),'select_account');assert.equal(github.searchParams.get('scope'),null); +}); + +test('Google tokens exchange with the client secret, keep the refresh token on refresh, identify by userinfo and revoke',async()=>{ + const runtime=runtimeWith(url=>url.includes('userinfo')?Response.json({sub:'1099',email:'mike@example.com'}):url.includes('/token')?Response.json({access_token:'at2',expires_in:3599,scope:'openid https://www.googleapis.com/auth/webmasters.readonly',token_type:'Bearer'}):new Response(null,{status:200})); + const {credential:fresh}=await runtime.exchange('google_search_console','code','verifier'); + const fields=new URLSearchParams(String(calls[0].init.body)); + assert.equal(calls[0].url,'https://oauth2.googleapis.com/token');assert.equal(fields.get('client_secret'),'gs');assert.equal(fields.get('code_verifier'),'verifier'); + assert.equal(fresh.accessToken,'at2');assert(fresh.expiresAt); + assert.equal((await runtime.refresh('google_search_console',credential)).refreshToken,'rt'); + assert.deepEqual(await runtime.identify('google_search_console',credential,{}),{account_id:'1099',account_name:'mike@example.com',email:'mike@example.com'}); + await runtime.revoke('google_analytics',credential); + assert.equal(calls.at(-1)!.url,'https://oauth2.googleapis.com/revoke');assert.equal(String(calls.at(-1)!.init.body),'token=rt'); + await assert.rejects(()=>runtimeWith(()=>Response.json({email:'x@example.com'})).identify('google_analytics',credential,{}),{code:'invalid_upstream_identity'}); +}); + +test('Google tool catalogs are built in, read-only and addressable by the MCP server',async()=>{ + const runtime=runtimeWith(()=>{throw new Error('no network');}); + const gsc=await runtime.tools('google_search_console',credential); + assert.deepEqual(gsc.map(t=>t.name),['google_search_console.list_sites','google_search_console.query_search_analytics','google_search_console.list_sitemaps','google_search_console.inspect_url']); + const ga=await runtime.tools('google_analytics',credential); + assert.equal(ga.length,5); + for(const tool of [...gsc,...ga]){assert(toolNamePattern.test(tool.name),tool.name);assert.equal(tool.read_only,true);assert.equal(tool.input_schema.type,'object');} + assert.equal(calls.length,0); + assert.equal(restToolCatalog('github').length,18); +}); + +test('Search Console tools encode properties, send filters and return rows keyed by dimension',async()=>{ + const runtime=runtimeWith(url=>url.endsWith('/sites')?Response.json({siteEntry:[{siteUrl:'sc-domain:example.com',permissionLevel:'siteOwner'}]}):Response.json({rows:[{keys:['connany','https://example.com/'],clicks:3,impressions:40,ctr:0.075,position:4.2}],responseAggregationType:'byPage'})); + assert.deepEqual(await runtime.execute('google_search_console.list_sites',{},credential),{sites:[{site_url:'sc-domain:example.com',permission_level:'siteOwner'}]}); + assert.equal(new Headers(calls[0].init.headers).get('Authorization'),'Bearer at'); + const result:any=await runtime.execute('google_search_console.query_search_analytics',{site_url:'sc-domain:example.com',start_date:'2026-09-01',end_date:'2026-09-30',dimensions:['query','page'],filters:[{dimension:'country',expression:'jpn'}],row_limit:1},credential); + assert.equal(calls[1].url,'https://www.googleapis.com/webmasters/v3/sites/sc-domain%3Aexample.com/searchAnalytics/query'); + const body=JSON.parse(String(calls[1].init.body)); + assert.deepEqual(body.dimensionFilterGroups,[{groupType:'and',filters:[{dimension:'country',operator:'equals',expression:'jpn'}]}]); + assert.equal(body.type,'web');assert.equal(body.rowLimit,1); + assert.deepEqual(result.rows,[{query:'connany',page:'https://example.com/',clicks:3,impressions:40,ctr:0.075,position:4.2}]); + assert.equal(result.next_start_row,1); + await runtime.execute('google_search_console.inspect_url',{site_url:'https://example.com/',url:'https://example.com/a'},credential); + assert.deepEqual(JSON.parse(String(calls[2].init.body)),{inspectionUrl:'https://example.com/a',siteUrl:'https://example.com/',languageCode:'en-US'}); + // Invalid input never reaches Google. + await assert.rejects(()=>runtime.execute('google_search_console.query_search_analytics',{site_url:'example.com',start_date:'2026-09-01',end_date:'2026-09-30'},credential)); + await assert.rejects(()=>runtime.execute('google_search_console.query_search_analytics',{site_url:'sc-domain:example.com',start_date:'last week',end_date:'2026-09-30'},credential)); + assert.equal(calls.length,3); +}); + +test('Analytics reports normalize property IDs, convert numeric metrics and page with offset',async()=>{ + const runtime=runtimeWith(url=>url.endsWith('/metadata')?Response.json({dimensions:[{apiName:'country',uiName:'Country',category:'Geography'},{apiName:'customEvent:plan',uiName:'Plan',customDefinition:true}],metrics:[{apiName:'sessions',uiName:'Sessions',type:'TYPE_INTEGER'}]}) + :Response.json({dimensionHeaders:[{name:'country'}],metricHeaders:[{name:'sessions',type:'TYPE_INTEGER'},{name:'bounceRate',type:'TYPE_FLOAT'}],rows:[{dimensionValues:[{value:'Japan'}],metricValues:[{value:'120'},{value:'0.42'}]}],rowCount:5,metadata:{currencyCode:'USD',timeZone:'Asia/Tokyo'}})); + const report:any=await runtime.execute('google_analytics.run_report',{property_id:'properties/123',metrics:['sessions','bounceRate'],dimensions:['country'],limit:1},credential); + assert.equal(calls[0].url,'https://analyticsdata.googleapis.com/v1beta/properties/123:runReport'); + const body=JSON.parse(String(calls[0].init.body)); + assert.deepEqual(body.dateRanges,[{startDate:'28daysAgo',endDate:'yesterday'}]);assert.deepEqual(body.metrics,[{name:'sessions'},{name:'bounceRate'}]);assert.equal(body.metricAggregations,undefined); + assert.deepEqual(report.rows,[{country:'Japan',sessions:120,bounceRate:0.42}]);assert.equal(report.row_count,5);assert.equal(report.next_offset,1); + const custom:any=await runtime.execute('google_analytics.get_metadata',{property_id:'123',custom_only:true},credential); + assert.deepEqual(custom,{dimensions:[{api_name:'customEvent:plan',name:'Plan',category:undefined,custom:true}],metrics:[]}); + await runtime.execute('google_analytics.run_realtime_report',{property_id:'123',metrics:['activeUsers']},credential); + assert.deepEqual(JSON.parse(String(calls[2].init.body)).minuteRanges,[{startMinutesAgo:29,endMinutesAgo:0}]); + await assert.rejects(()=>runtime.execute('google_analytics.run_report',{property_id:'abc',metrics:['sessions']},credential)); + await assert.rejects(()=>runtime.execute('google_analytics.run_report',{property_id:'123',metrics:[]},credential)); +}); + +test('Google API errors keep their explanation for the agent',async()=>{ + const runtime=runtimeWith(()=>Response.json({error:{code:403,message:'User does not have sufficient permissions for this property.',status:'PERMISSION_DENIED'}},{status:403})); + await assert.rejects(()=>runtime.execute('google_analytics.get_property',{property_id:'123'},credential),(error:any)=>{ + assert.equal(error.code,'upstream_error');assert.equal(error.details.upstream_status,403); + assert.equal(error.details.upstream_message,'User does not have sufficient permissions for this property.');return true; + }); + await assert.rejects(()=>runtimeWith(()=>Response.json({error:{code:401,message:'Invalid Credentials'}},{status:401})).execute('google_search_console.list_sites',{},credential),{status:401}); +}); diff --git a/tests/hosted-connectors.test.ts b/tests/hosted-connectors.test.ts index 4546936..7f65883 100644 --- a/tests/hosted-connectors.test.ts +++ b/tests/hosted-connectors.test.ts @@ -15,7 +15,7 @@ test('every hosted MCP connector declares HTTPS endpoints, a resource on its own if(spec.auth!=='mcp')continue; const mcp=new HostedMcp(fetch,name); for(const kind of ['authorize','token','register'] as const)assert.match(mcp.oauthUrl(kind)!,/^https:\/\//,`${name} ${kind}`); - if(typeof spec.mcp.resource==='string')assert.equal(new URL(spec.mcp.resource).origin,spec.mcp.origin,name); + if(typeof spec.mcp!.resource==='string')assert.equal(new URL(spec.mcp!.resource).origin,spec.mcp!.origin,name); } }); @@ -82,7 +82,7 @@ test('registration failures keep the upstream OAuth error for administrators',as test('the SDK names every catalog connector and every connector has a category',async()=>{ const {readFile}=await import('node:fs/promises');const sdk=await readFile('sdk/client.ts','utf8'); const union=sdk.match(/export type ConnectorName = ([^;]+);/)![1]; - assert.deepEqual([...union.matchAll(/'([a-z]+)'/g)].map(m=>m[1]).sort(),[...connectorNames].sort()); + assert.deepEqual([...union.matchAll(/'([a-z_]+)'/g)].map(m=>m[1]).sort(),[...connectorNames].sort()); const {connectorCategories}=await import('../src/connectors/catalog.js'); for(const name of connectorNames)assert((connectorCategories as readonly string[]).includes(connector(name).category),name); }); diff --git a/tests/integration/admin.test.ts b/tests/integration/admin.test.ts index dcb524a..5e72250 100644 --- a/tests/integration/admin.test.ts +++ b/tests/integration/admin.test.ts @@ -17,6 +17,10 @@ const emptyConfig={...config,connectors:connectorClients()}; const calls:{url:string;auth:string;body:string}[]=[]; const fetcher:typeof fetch=async(url,init)=>{ const u=String(url);calls.push({url:u,auth:new Headers(init?.headers).get('Authorization')||'',body:String(init?.body||'')}); + if(u==='https://oauth2.googleapis.com/token')return Response.json({access_token:'google-access',refresh_token:'google-refresh',expires_in:3599,scope:'openid email https://www.googleapis.com/auth/analytics.readonly'}); + if(u==='https://openidconnect.googleapis.com/v1/userinfo')return Response.json({sub:'1099',email:'alice@example.com'}); + if(u==='https://oauth2.googleapis.com/revoke')return new Response(null,{status:200}); + if(u.startsWith('https://analyticsadmin.googleapis.com/v1beta/accountSummaries'))return Response.json({accountSummaries:[{account:'accounts/1',displayName:'Acme',propertySummaries:[{property:'properties/123',displayName:'acme.com',propertyType:'PROPERTY_TYPE_ORDINARY'}]}]}); if(u.endsWith('/login/oauth/access_token'))return Response.json({access_token:'upstream-token',refresh_token:'upstream-refresh',expires_in:3600,workspace_id:'w',workspace_name:'Work',bot_id:'bot',owner:{user:{id:'alice',name:'Alice'}}}); if(u.endsWith('/oauth/revoke'))return Response.json({}); if(u.endsWith('/user')) return Response.json({id:42,login:'alice'}); @@ -147,6 +151,31 @@ test('deleting a project revokes upstream grants and removes only its data',asyn assert.equal((await admin(`/api/projects/${doomed.project.id}/delete`,'POST',{})).status,404); assert((await(await admin('/activity')).text()).includes('project.deleted')); }); +test('Google Analytics uses an administrator OAuth client and built-in REST tools end to end',async()=>{ + assert.equal((await admin('/api/connectors/google_analytics','POST',{client_id:'google-client',client_secret:'google-secret',enabled:true})).status,200); + const html=await(await admin('/connectors')).text(); + const dialog=html.slice(html.indexOf('id="connector-google_analytics"')); + assert(dialog.includes('创建 Google OAuth 客户端')&&dialog.includes('/oauth/google_analytics/callback'));assert(!dialog.slice(0,dialog.indexOf('')).includes('github_app_slug')); + const card=html.slice(html.indexOf('id="card-google_analytics"'));assert(card.slice(0,card.indexOf('')).includes('内置'));assert(!card.slice(0,card.indexOf('')).includes('tool-sync')); + const p=await project('Analytics'); + const catalog=await(await api(p.api_key,'/tools?connector=google_analytics')).json() as any;assert.equal(catalog.total,5); + const response=await api(p.api_key,'/connectors/google_analytics/sessions','POST',{external_user_id:'analyst'});assert.equal(response.status,201);const session=await response.json() as any; + const start=await app.request(`${session.connect_url}/start`,{method:'POST',headers:{Origin:config.publicBaseUrl}});assert.equal(start.status,200); + const authorize=new URL(start.headers.get('refresh')!.slice(6)); + assert.equal(authorize.origin,'https://accounts.google.com');assert.equal(authorize.searchParams.get('client_id'),'google-client');assert.equal(authorize.searchParams.get('access_type'),'offline'); + const result=await app.request(`http://localhost:3000/oauth/google_analytics/callback?state=${authorize.searchParams.get('state')}&code=test`,{headers:{Cookie:start.headers.get('set-cookie')!.split(';')[0]}});assert.equal(result.status,200); + const completed=await(await api(p.api_key,`/connectors/google_analytics/sessions/${session.id}?external_user_id=analyst`)).json() as any;assert.equal(completed.status,'connected'); + const token=calls.find(c=>c.url==='https://oauth2.googleapis.com/token')!;assert.equal(new URLSearchParams(token.body).get('client_secret'),'google-secret'); + const connection=await(await api(p.api_key,`/connections/${completed.connection_id}?external_user_id=analyst`)).json() as any;assert.equal(connection.identity.account_name,'alice@example.com'); + const tools=await(await api(p.api_key,`/connections/${completed.connection_id}/tools?external_user_id=analyst&query=realtime`)).json() as any; + assert.deepEqual(tools.data.map((t:any)=>t.name),['google_analytics.run_realtime_report']); + const call=await(await api(p.api_key,`/connections/${completed.connection_id}/tools/google_analytics.list_account_summaries/call`,'POST',{external_user_id:'analyst',input:{}})).json() as any; + assert.deepEqual(call.data.accounts[0].properties,[{property_id:'123',name:'acme.com',type:'PROPERTY_TYPE_ORDINARY'}]); + assert.equal(calls.at(-1)!.auth,'Bearer google-access'); + assert.equal((await pool.query("SELECT 1 FROM connector_tools WHERE connector='google_analytics'")).rowCount,0); + const disconnected=await(await api(p.api_key,`/connections/${completed.connection_id}?external_user_id=analyst`,'DELETE')).json() as any; + assert.equal(disconnected.revocation_status,'succeeded');assert.equal(calls.at(-1)!.body,'token=google-refresh'); +}); test('administrators manage console users; members use the workbench but not the System section',async()=>{ const signIn=async(email:string,password:string)=>{ const login=await app.request('http://localhost:3000/admin/api/login',{method:'POST',headers:{Origin:config.publicBaseUrl,'Content-Type':'application/json'},body:JSON.stringify({email,password})}); diff --git a/tests/rest-tools.test.ts b/tests/rest-tools.test.ts index 3b4bf87..f8d8856 100644 --- a/tests/rest-tools.test.ts +++ b/tests/rest-tools.test.ts @@ -6,7 +6,7 @@ import {config} from './support.js'; test('REST compatibility tools expose schemas, write flags and permissions under stable names',()=>{ const catalog=restToolCatalog(); - assert.equal(catalog.length,18); + assert.equal(catalog.filter(t=>t.connector==='github').length,18); const create=catalog.find(t=>t.name==='github.pull_requests.create')!; assert.equal(create.connector,'github');assert.equal(create.read_only,false);assert(create.input_schema.properties); assert(create.required_permissions.includes('Pull requests: write'));