Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 20 additions & 19 deletions docs/enclave.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `.

Expand All @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<base32 seed>"}` |
Expand Down
20 changes: 20 additions & 0 deletions enclave/src/providers/definitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
]
}
}
32 changes: 30 additions & 2 deletions enclave/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ mod tests {
.collect()
}

const ROWS: [Row; 7] = [
const ROWS: [Row; 8] = [
Row {
name: "google_workspace",
client: ClientKind::Static,
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1199,6 +1222,7 @@ mod tests {
&["/userinfo", "/payment-details", "/shipping_addresses"],
),
("granola", "mcp.granola.ai", &[], &["/mcp"]),
("mercury", "mcp.mercury.com", &[], &["/mcp"]),
];

#[test]
Expand Down Expand Up @@ -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() {
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion enclave/src/tests/canary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +179,7 @@ const PUBLIC: [(&str, Leaves, Leaves); 7] = [
("x", OAUTH_LEAVES, &[]),
("link", OAUTH_LEAVES, &[]),
("granola", OAUTH_LEAVES, &[]),
("mercury", OAUTH_LEAVES, &[]),
];

impl Leaf {
Expand Down
11 changes: 9 additions & 2 deletions host/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ mod tests {
"notion",
"x",
"link",
"granola"
"granola",
"mercury"
])
);
assert!(provider_names("[]").is_err());
Expand Down Expand Up @@ -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::<Value>(&body).unwrap(), expected);
Expand Down Expand Up @@ -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(&[])),
Expand Down