diff --git a/README.md b/README.md index f776ca6..c304c09 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ attest, and read here what a node does with a credential. An account is one user. The app is the iOS app of that account. The operator is the company that runs the service, and the operator domain is everything it runs outside a node: the backend, its storage, the host program, the network. A provider is a service whose credentials a node holds -(Google, Microsoft, Slack, Notion, X, Link, Granola). +(Google, Microsoft, Slack, Notion, X, Link, Granola, Mercury). ```text device of the account (the app) diff --git a/docs/enclave.md b/docs/enclave.md index 4e5b694..5ee1931 100644 --- a/docs/enclave.md +++ b/docs/enclave.md @@ -814,24 +814,24 @@ and `id_token_claims` are in 6.4, the rules for `api` in section 7. ### 6.2 The values of the definitions -| | google_workspace | microsoft | slack | notion | x | link | granola | -| --- | --- | --- | --- | --- | --- | --- | --- | -| client | static | static | static | static | static | static | dynamic | -| authorize url | `https://accounts.google.com/o/oauth2/v2/auth` | `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` | `https://slack.com/oauth/v2/authorize` | `https://api.notion.com/v1/oauth/authorize` | `https://x.com/i/oauth2/authorize` | `https://login.link.com/auth` | `https://mcp-auth.granola.ai/oauth2/authorize` | -| authorize fixed | None | None | None | `owner=user` | None | None | `resource=https://mcp.granola.ai/mcp` | -| authorize allowed | `scope`, `access_type`, `prompt`, `include_granted_scopes`, `login_hint` | `scope`, `prompt`, `login_hint` | `user_scope` | None | `scope` | `scope` | `scope` | -| key_param | None | None | None | None | None | `key` | None | -| scope_param, delimiter | `scope`, space | `scope`, space | `user_scope`, `,` | None | `scope`, space | `scope`, space | `scope`, space | -| pkce | S256 | S256 | none | none | S256 | S256 | S256 | -| token url | `https://oauth2.googleapis.com/token` | `https://login.microsoftonline.com/common/oauth2/v2.0/token` | `https://slack.com/api/oauth.v2.access` | `https://api.notion.com/v1/oauth/token` | `https://api.x.com/2/oauth2/token` | `https://login.link.com/auth/token` | `https://mcp-auth.granola.ai/oauth2/token` | -| token client_auth, body | body, form | body, form | body, form | basic, json | basic, form | body, form | none, form | -| token headers | None | None | None | `Notion-Version: 2025-09-03` | None | None | None | -| token bearer | None | None | None | None | None | `publishable_key` | None | -| token fixed | None | None | None | None | None | None | `resource=https://mcp.granola.ai/mcp` | -| ok_field | None | None | `ok` | None | None | None | None | -| token_container | None | None | `authed_user` | None | None | None | None | -| revoke | `https://oauth2.googleapis.com/revoke`, client_auth none | None | None | None | `https://api.x.com/2/oauth2/revoke`, client_auth basic | `https://login.link.com/auth/revoke`, client_auth body, bearer `publishable_key` | None | -| inject paths | `access_token` | `access_token` | `authed_user.access_token`, `access_token` | `access_token` | `access_token` | `access_token` | `access_token` | +| | google_workspace | microsoft | slack | notion | x | link | granola | mercury | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| client | static | static | static | static | static | static | dynamic | dynamic | +| authorize url | `https://accounts.google.com/o/oauth2/v2/auth` | `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` | `https://slack.com/oauth/v2/authorize` | `https://api.notion.com/v1/oauth/authorize` | `https://x.com/i/oauth2/authorize` | `https://login.link.com/auth` | `https://mcp-auth.granola.ai/oauth2/authorize` | `https://mcp.mercury.com/authorize` | +| authorize fixed | None | None | None | `owner=user` | None | None | `resource=https://mcp.granola.ai/mcp` | `resource=https://mcp.mercury.com/mcp` | +| authorize allowed | `scope`, `access_type`, `prompt`, `include_granted_scopes`, `login_hint` | `scope`, `prompt`, `login_hint` | `user_scope` | None | `scope` | `scope` | `scope` | `scope` | +| key_param | None | None | None | None | None | `key` | None | None | +| scope_param, delimiter | `scope`, space | `scope`, space | `user_scope`, `,` | None | `scope`, space | `scope`, space | `scope`, space | `scope`, space | +| pkce | S256 | S256 | none | none | S256 | S256 | S256 | S256 | +| token url | `https://oauth2.googleapis.com/token` | `https://login.microsoftonline.com/common/oauth2/v2.0/token` | `https://slack.com/api/oauth.v2.access` | `https://api.notion.com/v1/oauth/token` | `https://api.x.com/2/oauth2/token` | `https://login.link.com/auth/token` | `https://mcp-auth.granola.ai/oauth2/token` | `https://mcp.mercury.com/token` | +| token client_auth, body | body, form | body, form | body, form | basic, json | basic, form | body, form | none, form | none, form | +| token headers | None | None | None | `Notion-Version: 2025-09-03` | None | None | None | None | +| token bearer | None | None | None | None | None | `publishable_key` | None | None | +| token fixed | None | None | None | None | None | None | `resource=https://mcp.granola.ai/mcp` | `resource=https://mcp.mercury.com/mcp` | +| ok_field | None | None | `ok` | None | None | None | None | None | +| token_container | None | None | `authed_user` | None | None | None | None | None | +| revoke | `https://oauth2.googleapis.com/revoke`, client_auth none | None | None | None | `https://api.x.com/2/oauth2/revoke`, client_auth basic | `https://login.link.com/auth/revoke`, client_auth body, bearer `publishable_key` | None | None | +| inject paths | `access_token` | `access_token` | `authed_user.access_token`, `access_token` | `access_token` | `access_token` | `access_token` | `access_token` | `access_token` | Every definition injects the header `Authorization` with the prefix `Bearer `. @@ -843,7 +843,7 @@ The public fields of the definitions (path: type, and the limit of a string in b | microsoft | `scope`: string 8192. `expires_in`: integer. `ext_expires_in`: integer. `token_type`: string 32 | `tid`: string 64. `oid`: string 64. `preferred_username`: string 320 | | slack | `team.id`: string 32. `team.name`: string 256. `enterprise.id`: string 32. `enterprise.name`: string 256. `app_id`: string 32. `is_enterprise_install`: boolean. `authed_user.id`: string 32. `authed_user.scope`: string 8192. `authed_user.expires_in`: integer. `authed_user.token_type`: string 32 | None | | notion | `workspace_id`: string 64. `workspace_name`: string 256. `workspace_icon`: string 2048. `bot_id`: string 64. `duplicated_template_id`: string 64. `owner.type`: string 32. `owner.user.id`: string 64. `owner.user.name`: string 256. `owner.user.avatar_url`: string 2048. `owner.user.type`: string 32. `owner.user.person.email`: string 320 | None | -| x, link, granola | `scope`: string 8192. `expires_in`: integer. `token_type`: string 32 | None | +| x, link, granola, mercury | `scope`: string 8192. `expires_in`: integer. `token_type`: string 32 | None | No definition has a list of allowed scopes (`scopes`): the consent page of the provider shows the requested scopes to the user, and the `api` list bounds the addresses that a token can reach. @@ -921,6 +921,7 @@ value that the definition does not list does not leave the node. | x | `api.x.com` | Prefixes `/2/users`, `/2/tweets`, `/2/dm_events` | | link | `api.link.com` | Prefix `/spend_requests`. Exact `/userinfo`, `/payment-details`, `/shipping_addresses` | | granola | `mcp.granola.ai` | Exact `/mcp` | +| mercury | `mcp.mercury.com` | Exact `/mcp` | The rules: diff --git a/docs/protocol.md b/docs/protocol.md index 2b2eec6..d6333fb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -435,7 +435,7 @@ caller, after the log entry of that release is on the chain. | kind | provider | Created by | Use mode | Plaintext (UTF-8 JSON) | | --- | --- | --- | --- | --- | -| `oauth` | The name of a provider (enclave.md section 6) | A node (`oauth/complete`: the node receives the token from the provider itself). `refresh` and `oauth/merge` write the record again | enclave-use: the token is used through `forward` only. No call hands the plaintext out | `{"token":{the token response of the provider, merged by the rules of section 8},"obtained_ms":n}`. A dynamically registered client (granola) also carries `"client_id"` | +| `oauth` | The name of a provider (enclave.md section 6) | A node (`oauth/complete`: the node receives the token from the provider itself). `refresh` and `oauth/merge` write the record again | enclave-use: the token is used through `forward` only. No call hands the plaintext out | `{"token":{the token response of the provider, merged by the rules of section 8},"obtained_ms":n}`. A dynamically registered client (granola, mercury) also carries `"client_id"` | | `oauth_imported` | The name of a provider | The device of the account: the app encrypted a token that the operator domain held before it used a node. No call of a node creates a record of this kind | enclave-use: `forward`, `refresh` and `revoke-token` take the record by the same rules as kind `oauth`, and `refresh` writes the record again under the same kind. `oauth/merge` and `release` refuse it (`not_allowed`) | The form of kind `oauth` | | `vault_password` | `vault` | The app | release: `release` hands the value out | `{"value":"..."}` | | `vault_totp` | `vault` | The app | enclave-use: the seed does not leave. `release` hands out only the code that the node computed | `{"value":""}` | diff --git a/enclave/src/providers/definitions.json b/enclave/src/providers/definitions.json index 2dd7436..7064188 100644 --- a/enclave/src/providers/definitions.json +++ b/enclave/src/providers/definitions.json @@ -173,5 +173,25 @@ "api": [ {"host": "mcp.granola.ai", "prefixes": [], "exact": ["/mcp"]} ] + }, + "mercury": { + "client": "dynamic", + "authorize": {"url": "https://mcp.mercury.com/authorize", "fixed": [["resource", "https://mcp.mercury.com/mcp"]], "allowed": ["scope"], "key_param": ""}, + "scope_param": "scope", + "scope_delimiter": " ", + "pkce": "S256", + "token": {"url": "https://mcp.mercury.com/token", "client_auth": "none", "body": "form", "headers": [], "bearer": "", "fixed": [["resource", "https://mcp.mercury.com/mcp"]], "ok_field": ""}, + "token_container": "", + "revoke": null, + "inject": {"header": "Authorization", "prefix": "Bearer ", "paths": ["access_token"]}, + "public": [ + {"path": "scope", "type": "string", "max": 8192}, + {"path": "expires_in", "type": "integer"}, + {"path": "token_type", "type": "string", "max": 32} + ], + "id_token_claims": [], + "api": [ + {"host": "mcp.mercury.com", "prefixes": [], "exact": ["/mcp"]} + ] } } diff --git a/enclave/src/providers/mod.rs b/enclave/src/providers/mod.rs index 51d8534..98efe3a 100644 --- a/enclave/src/providers/mod.rs +++ b/enclave/src/providers/mod.rs @@ -841,7 +841,7 @@ mod tests { .collect() } - const ROWS: [Row; 7] = [ + const ROWS: [Row; 8] = [ Row { name: "google_workspace", client: ClientKind::Static, @@ -1052,6 +1052,29 @@ mod tests { public: COMMON, id_token_claims: &[], }, + Row { + name: "mercury", + client: ClientKind::Dynamic, + authorize_url: "https://mcp.mercury.com/authorize", + authorize_fixed: &[("resource", "https://mcp.mercury.com/mcp")], + allowed: &["scope"], + key_param: "", + scope_param: "scope", + scope_delimiter: " ", + pkce: Pkce::S256, + token_url: "https://mcp.mercury.com/token", + client_auth: ClientAuth::None, + body: BodyFormat::Form, + token_headers: &[], + token_bearer: "", + token_fixed: &[("resource", "https://mcp.mercury.com/mcp")], + ok_field: "", + token_container: "", + revoke: None, + inject_paths: &["access_token"], + public: COMMON, + id_token_claims: &[], + }, ]; #[test] @@ -1105,7 +1128,7 @@ mod tests { } /// The table of enclave.md section 7: provider, host, prefixes, exact paths. - const ADDRESSES: [(&str, &str, &[&str], &[&str]); 15] = [ + const ADDRESSES: [(&str, &str, &[&str], &[&str]); 16] = [ ( "google_workspace", "gmail.googleapis.com", @@ -1199,6 +1222,7 @@ mod tests { &["/userinfo", "/payment-details", "/shipping_addresses"], ), ("granola", "mcp.granola.ai", &[], &["/mcp"]), + ("mercury", "mcp.mercury.com", &[], &["/mcp"]), ]; #[test] @@ -1547,6 +1571,7 @@ mod tests { ("link", "https://api.link.com/spend_requests/lsrq_1"), ("link", "https://api.link.com/userinfo"), ("granola", "https://mcp.granola.ai/mcp"), + ("mercury", "https://mcp.mercury.com/mcp"), ]; for (provider, address) in samples { for name in definitions.names() { @@ -1567,6 +1592,9 @@ mod tests { ("link", "https://api.link.com/userinfo2"), ("granola", "https://mcp.granola.ai/mcp/other"), ("granola", "https://mcp-auth.granola.ai/oauth2/token"), + ("mercury", "https://mcp.mercury.com/mcp/other"), + ("mercury", "https://mcp.mercury.com/token"), + ("mercury", "https://mcp.mercury.com/register"), ("microsoft", "https://graph.microsoft.com/beta/me"), ( "microsoft", diff --git a/enclave/src/tests/canary.rs b/enclave/src/tests/canary.rs index 61b2ee2..f503dbd 100644 --- a/enclave/src/tests/canary.rs +++ b/enclave/src/tests/canary.rs @@ -116,7 +116,7 @@ const OAUTH_LEAVES: Leaves = &[ /// The public fields of every provider (egress-policy.md section 4): the leaves of the token /// object by their paths, then the claims of the `id_token`. Nothing else of a token response /// leaves a node. -const PUBLIC: [(&str, Leaves, Leaves); 7] = [ +const PUBLIC: [(&str, Leaves, Leaves); 8] = [ ( "google_workspace", OAUTH_LEAVES, @@ -179,6 +179,7 @@ const PUBLIC: [(&str, Leaves, Leaves); 7] = [ ("x", OAUTH_LEAVES, &[]), ("link", OAUTH_LEAVES, &[]), ("granola", OAUTH_LEAVES, &[]), + ("mercury", OAUTH_LEAVES, &[]), ]; impl Leaf { diff --git a/host/src/config.rs b/host/src/config.rs index 27900da..527fa4b 100644 --- a/host/src/config.rs +++ b/host/src/config.rs @@ -335,7 +335,8 @@ mod tests { "notion", "x", "link", - "granola" + "granola", + "mercury" ]) ); assert!(provider_names("[]").is_err()); @@ -411,6 +412,12 @@ mod tests { "redirect_uri": "https://api.example.test/api/integrations/granola/oauth/callback", "publishable_key": "", }, + "mercury": { + "client_id": "", + "client_secret": "", + "redirect_uri": "https://api.example.test/api/integrations/mercury/oauth/callback", + "publishable_key": "", + }, } }); assert_eq!(serde_json::from_slice::(&body).unwrap(), expected); @@ -561,7 +568,7 @@ mod tests { assert_eq!( line, "operator configuration: client id set for google_workspace, link; \ - not set for microsoft, slack, notion, x, granola" + not set for microsoft, slack, notion, x, granola, mercury" ); assert_eq!( summary(&names(&["slack"]), &environment(&[])),