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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,8 @@ tools:
### Connections

`gete connections` lists what ships: `freee`, `freee-mcp`, `google`, `github`,
`notion-mcp`, `zendesk`, and `slack` (retired, with the reason). Add your own or
override a catalog entry in `gete.yaml`:
`notion-mcp`, `slack-mcp`, and `zendesk`. Add your own or override a catalog
entry in `gete.yaml`:

```yaml
connections:
Expand Down Expand Up @@ -401,9 +401,9 @@ Adding a connection to the catalog is one YAML file under
### Shared credentials

A connection reads with the caller's token. Some writes have no such token
to ride on — Slack posting is one, which is why the `slack` connection is
retired — so gete also ships tools that act with a credential the agent
holds. Whoever can call the agent acts through that credential; the tools
to ride on — posting to Slack from an agent nobody has authorized is one —
so gete also ships tools that act with a credential the agent holds.
Whoever can call the agent acts through that credential; the tools
and their guardrails ship with gete, and a declaration can only name them:

```yaml
Expand Down
126 changes: 126 additions & 0 deletions src/gete/catalog/connections/slack-mcp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
id: slack-mcp
display_name: Slack MCP
docs: https://docs.slack.dev/ai/slack-mcp-server/

# The token this hands out is an ordinary Slack user token, and slack.com's
# Web API would very likely take it. slack.com still does not belong on this
# list: the face of this connection is the MCP server's tools, which an mcp:
# block can hold to an allow list. Naming slack.com beside it would make the
# whole Web API the face - every method the token's scopes reach, with
# nothing to hold it to.
hosts:
- mcp.slack.com
# Slack user tokens announce themselves, so this connection is not accepted
# by elimination and may sit beside one that is. With token rotation enabled
# the prefix becomes xoxe.xoxp-.
token_prefixes:
- xoxp-
- xoxe.xoxp-

oauth:
# The authorization server mcp.slack.com advertises (RFC 8414): its own
# issuer, with the endpoints on slack.com. This is the user pair, not the
# app pair at oauth/v2/authorize and oauth.v2.access. That one answers with
# the app's token at the top level and the user's underneath, in
# authed_user, where Gemini Enterprise never looks; oauth.v2.user.access
# answers with the user's token at the top level and nothing else to take.
authorization_url: https://slack.com/oauth/v2_user/authorize
# The metadata names client_secret_post as the only client authentication
# method. Whether Gemini Enterprise sends the secret that way is
# unconfirmed, and a connection cannot say: the Authorization resource has
# no field for it.
token_url: https://slack.com/api/oauth.v2.user.access
# scope_parameter stays at its default. The app pair needs user_scope
# because oauth/v2/authorize reads scope as the app's own permissions. This
# endpoint is reached by standard MCP clients as an RFC 8414 authorization
# server, and those send a plain scope parameter, so scope it is -
# unconfirmed; user_scope is the switch if the consent screen answers
# invalid_scope.
# pkce stays unset: S256 is supported here, not required.
# No authorization_query either. One would keep access_type and prompt off
# the URL, as notion-mcp does, but it would also fix the scopes, and the
# menu below could not be offered beside it. Slack is expected to ignore
# parameters it does not know; unconfirmed.
#
# Slack fixes a user token's scopes at consent. A scope added here later
# reaches nobody already authorized until their authorization is reset, so
# the floor is all that reading Slack takes, and the menu holds the rest.
scopes:
search:read.public: Search public channels
search:read.private: Search private channels you are a member of
search:read.im: Search your direct messages
search:read.mpim: Search your group direct messages
search:read.users: Search people in the workspace
channels:history: Read messages in public channels
groups:history: Read messages in private channels you are a member of
im:history: Read your direct messages
mpim:history: Read your group direct messages
channels:read: List public channels and their members
groups:read: List private channels you are a member of and their members
im:read: List your direct messages
mpim:read: List your group direct messages
users:read: Read people's profiles
optional_scopes:
search:read.files: Search files
files:read: Read files shared with you
chat:write: Send messages as you
reactions:write: Add reactions as you
canvases:read: Read canvases
canvases:write: Create and edit canvases as you
lists:read: Read lists
lists:write: Create and edit lists as you
# Not offered at all: users:read.email, because nothing here needs a
# person's email address; channels:write, groups:write, im:write and
# mpim:write, because no agent has a reason to open a conversation;
# files:write and emoji:read, for want of a use.

mcp:
url: https://mcp.slack.com/mcp

setup: |
The MCP server registers no clients of its own: an MCP client is backed by
a Slack app, and only an app from the Slack Marketplace or one internal to
the workspace will do; an unlisted app is refused. Create an internal app,
or take an existing one, and under OAuth & Permissions add every scope
named above to its User Token Scopes - the defaults and the optional ones
both, since an agent may select any of them. Leave Bot Token Scopes alone:
this connection carries the user's token and never the app's.

Register exactly one redirect URI on the app (Slack calls them Redirect
URLs): the one named above. Put the app's client id and client secret in
the secrets named above.

Where the workspace requires apps to be approved, an admin has to approve
this one before anyone can authorize. Semantic search needs a plan that
includes Slack AI search; keyword search works on any plan.

No authorization has been taken through Gemini Enterprise yet, so the
following is read off the server's metadata rather than seen to work:
- The token endpoint names client_secret_post as its only client
authentication method. Whether Gemini Enterprise sends the client secret
that way is unconfirmed.
- The scopes are sent under scope, the parameter a standard MCP client
uses. If the consent screen answers invalid_scope, set
oauth.scope_parameter to user_scope in gete.yaml.
- The authorization URL carries access_type and prompt, which are
Google's vocabulary. Whether Slack ignores them is unconfirmed.
- A standard MCP client sends resource (RFC 8707) with the code exchange;
Gemini Enterprise does not. Whether Slack requires it is unconfirmed.
- The metadata offers the refresh_token grant, so tokens may expire.
Whether Gemini Enterprise refreshes them is its own doing.

# verified is absent because no authorization has been taken through Gemini
# Enterprise yet.

examples:
accepts:
- "xoxp-1234-5678-abcdef"
- "xoxe.xoxp-1-abcdef"
rejects:
- "ya29.a0AfH6SMB" # Google access token
# Claims: {"iss": "https://accounts.google.com"}, as in an ID token.
- "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20ifQ.sig"
- "gho_16C7e42F292c6912E7710c838347Ae178B4a" # GitHub
# A bot token belongs to the app, not to a person; the connection is per
# user and refusing the shape keeps it so.
- "xoxb-1234-5678-abcdef"
32 changes: 0 additions & 32 deletions src/gete/catalog/connections/slack.yaml

This file was deleted.

106 changes: 101 additions & 5 deletions tests/conformance/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@


def test_catalog_has_the_initial_connections() -> None:
assert {"freee", "google", "github", "notion-mcp", "slack"} <= set(CATALOG)
assert {"freee", "google", "github", "notion-mcp", "slack-mcp"} <= set(CATALOG)


@pytest.mark.parametrize("connection_id", sorted(CATALOG))
Expand Down Expand Up @@ -47,10 +47,6 @@ def test_google_access_tokens_are_rejected_everywhere_but_google() -> None:
assert entry.accepts_token("ya29.a0AfH6SMB") is expected, entry.id


def test_slack_is_retired_with_a_reason() -> None:
assert CATALOG["slack"]["retired"]


def test_google_hosts_are_specific_apis_not_the_whole_domain() -> None:
"""A Workspace authorization must not be usable against GCP APIs."""
hosts = CATALOG["google"]["hosts"]
Expand Down Expand Up @@ -209,6 +205,106 @@ def test_freee_and_freee_mcp_cannot_be_held_by_one_agent() -> None:
assert elimination_problems(["freee", "freee-mcp"], registry)


def test_slack_mcp_does_not_reach_the_slack_web_api() -> None:
"""The token is an ordinary Slack user token and the Web API would very
likely take it, but the face of this connection is the MCP server's tools.
slack.com beside it would make every Web API method the token's scopes
reach part of that face."""
hosts = CATALOG["slack-mcp"]["hosts"]
assert hosts == ["mcp.slack.com"]
assert "slack.com" not in hosts


def test_slack_mcp_defaults_stay_read_only_with_writes_on_the_menu() -> None:
"""A bare `connections: [slack-mcp]` searches and reads what the user can;
files, writing, canvases and lists have to be selected."""
oauth = CATALOG["slack-mcp"]["oauth"]
assert set(oauth["scopes"]) == {
"search:read.public",
"search:read.private",
"search:read.im",
"search:read.mpim",
"search:read.users",
"channels:history",
"groups:history",
"im:history",
"mpim:history",
"channels:read",
"groups:read",
"im:read",
"mpim:read",
"users:read",
}
assert set(oauth["optional_scopes"]) == {
"search:read.files",
"files:read",
"chat:write",
"reactions:write",
"canvases:read",
"canvases:write",
"lists:read",
"lists:write",
}


def test_slack_mcp_offers_no_email_addresses_and_creates_no_conversations() -> None:
"""Nothing here needs a person's email address, and no agent has a reason
to open channels or direct messages; neither is on the menu at all."""
oauth = CATALOG["slack-mcp"]["oauth"]
offered = set(oauth["scopes"]) | set(oauth["optional_scopes"])
for scope in (
"users:read.email",
"channels:write",
"groups:write",
"im:write",
"mpim:write",
"files:write",
"emoji:read",
):
assert scope not in offered, scope


def test_slack_mcp_sends_its_scopes_under_scope_with_no_verbatim_query() -> None:
"""Slack's app pair needs user_scope because oauth/v2/authorize reads scope
as the app's own permissions; the user pair behind the MCP server is
reached by standard MCP clients with a plain scope parameter. A verbatim
authorization_query would fix the scopes and leave no menu."""
oauth = CATALOG["slack-mcp"]["oauth"]
assert "scope_parameter" not in oauth
assert "authorization_query" not in oauth
assert "pkce" not in oauth


def test_slack_mcp_takes_user_tokens_and_refuses_bot_tokens() -> None:
"""The connection is per user; an app's xoxb- token must never pass as one."""
slack_mcp = Registry.from_catalog().get("slack-mcp")
assert slack_mcp.accepts_token("xoxp-1234-5678-abcdef")
assert slack_mcp.accepts_token("xoxe.xoxp-1-abcdef")
assert not slack_mcp.accepts_token("xoxb-1234-5678-abcdef")
assert "xoxb-1234-5678-abcdef" in CATALOG["slack-mcp"]["examples"]["rejects"]


def test_slack_mcp_says_what_a_person_has_to_do_before_authorizing() -> None:
"""The app's user scopes, the one redirect URI, and what has not been seen
to work yet."""
setup = CATALOG["slack-mcp"]["setup"]
assert "User Token Scopes" in setup
assert "Bot Token Scopes" in setup
assert "redirect URI" in setup
assert "unconfirmed" in setup


def test_slack_mcp_is_not_verified_until_an_authorization_has_been_taken() -> None:
assert "verified" not in CATALOG["slack-mcp"]


def test_slack_mcp_can_sit_beside_a_connection_accepted_by_elimination() -> None:
"""Its tokens announce themselves, so it takes nothing by elimination."""
registry = Registry.from_catalog()
assert elimination_problems(["slack-mcp", "freee"], registry) == []
assert elimination_problems(["slack-mcp", "notion-mcp"], registry) == []


def test_zendesk_leaves_its_root_open_until_an_installation_names_it() -> None:
"""The tenant is a subdomain; a stand-in host would be a name a stranger
could register, and then a user's token would be sent there."""
Expand Down
9 changes: 5 additions & 4 deletions tests/test_connection_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,10 +381,11 @@ def test_unknown_connection_names_the_known_ones(catalog: Registry) -> None:
catalog.get("nope")


def test_retired_connection_explains_why(catalog: Registry) -> None:
with pytest.raises(RetiredConnection, match="connector"):
catalog.get("slack")
assert catalog.get("slack", include_retired=True).retired
def test_retired_connection_explains_why() -> None:
registry = Registry([connection(id="old", retired="Declare new instead.")])
with pytest.raises(RetiredConnection, match="Declare new instead"):
registry.get("old")
assert registry.get("old", include_retired=True).retired


def test_overlapping_prefixes_between_connections_are_reported() -> None:
Expand Down
Loading