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
13 changes: 11 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,14 @@ tap keg rename @acme/engineering docs
```

Namespace membership handles organization-level access. Keg grants handle
per-keg access when a domain needs a tighter boundary.
per-keg access when a domain needs a tighter boundary. Adding a member or a
grantee sends an invitation: access starts only when they accept it.

```bash
tap invitation list # invitations addressed to you
tap invitation accept 42 # or: tap invitation decline 42
tap invitation revoke 42 # withdraw one you sent
```

### Connect An Agent

Expand Down Expand Up @@ -115,7 +122,9 @@ writing KEG files directly.
- `tap keg visibility public|private` - set keg visibility.
- `tap keg rename @namespace/old new` - rename a keg alias in its namespace.
- `tap namespace create|list|members|add-member|set-role|remove-member` -
manage namespaces and membership.
manage namespaces and membership. Member management requires the owner role.
- `tap invitation list|accept|decline|revoke` - review invitations to
namespaces and kegs; access starts only when accepted.
- `tap hub list|status|add|remove|set-default` - manage hub connections.

### Safety And Operations
Expand Down
1 change: 1 addition & 0 deletions docs/ai-coding-agents/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ vocabulary to ask.
| `session_info` | Return structured credential-safe `identities[]` and exact pinned-root-context `kegs[]` |
| `namespace_list` | List the namespaces you belong to, with your role |
| `namespace_search` | Search the people and org namespaces visible on the Hub; discovery only |
| `invitation_list` | List pending invitations to namespaces and kegs addressed to you; read-only, since accepting is the user's decision |

Each identity includes only its hub locator, user ID, username, display name,
default namespace, and namespace names. Tokens, email, scopes, cookies, expiry,
Expand Down
5 changes: 5 additions & 0 deletions docs/development/feature-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ features at parity.
- Tests verify both surfaces produce equivalent results for the same input.
- Docs document features, not surfaces — one description covers both CLI and MCP
usage.
- The only exceptions are recorded, with a reason, in the exemption map in
`pkg/parity/parity_coverage_test.go`. Consent decisions are one class:
accepting or declining an invitation to a namespace or keg is the account
holder's own choice, so MCP exposes `invitation_list` read-only and never
accept or decline.

## Checklist

Expand Down
6 changes: 5 additions & 1 deletion integrations/content/tool-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ flight exactly. With a real pinned root, omission selects that root and an
explicit value selects the root or an accessible flattened descendant.
`session_info`, `session_refresh`, `flight_list`, `flight_read`,
`flight_search`, `keg_search`, `namespace_list`, `namespace_search`,
`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
authority while rendering graph-wide discovery.

`flight` is an **operational** parameter, not a discovery-only one: `node_list`,
Expand Down Expand Up @@ -174,6 +174,10 @@ and `session_refresh` are always available.
Hub, not only your own, matched by name or display name; empty query browses.
Discovery only: a match grants nothing. Follow up with `keg_search` and
`flight_search`.
- `mcp__tapper__invitation_list`: pending invitations addressed to your user,
org memberships and keg grants that take effect only if accepted. Read-only:
accepting or declining is your user's decision (Hub account page or
`tap invitation`), so tell them rather than acting.

- `mcp__tapper__flight_search`: literal reference/title/description search over
readable flights; at most 50 deterministic metadata results, with a truncation
Expand Down
6 changes: 5 additions & 1 deletion integrations/rendered/claude/tapper/skills/tapper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ flight exactly. With a real pinned root, omission selects that root and an
explicit value selects the root or an accessible flattened descendant.
`session_info`, `session_refresh`, `flight_list`, `flight_read`,
`flight_search`, `keg_search`, `namespace_list`, `namespace_search`,
`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
authority while rendering graph-wide discovery.

`flight` is an **operational** parameter, not a discovery-only one: `node_list`,
Expand Down Expand Up @@ -218,6 +218,10 @@ and `session_refresh` are always available.
Hub, not only your own, matched by name or display name; empty query browses.
Discovery only: a match grants nothing. Follow up with `keg_search` and
`flight_search`.
- `mcp__tapper__invitation_list`: pending invitations addressed to your user,
org memberships and keg grants that take effect only if accepted. Read-only:
accepting or declining is your user's decision (Hub account page or
`tap invitation`), so tell them rather than acting.

- `mcp__tapper__flight_search`: literal reference/title/description search over
readable flights; at most 50 deterministic metadata results, with a truncation
Expand Down
6 changes: 5 additions & 1 deletion integrations/rendered/codex/tapper/skills/tapper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ flight exactly. With a real pinned root, omission selects that root and an
explicit value selects the root or an accessible flattened descendant.
`session_info`, `session_refresh`, `flight_list`, `flight_read`,
`flight_search`, `keg_search`, `namespace_list`, `namespace_search`,
`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
authority while rendering graph-wide discovery.

`flight` is an **operational** parameter, not a discovery-only one: `node_list`,
Expand Down Expand Up @@ -218,6 +218,10 @@ and `session_refresh` are always available.
Hub, not only your own, matched by name or display name; empty query browses.
Discovery only: a match grants nothing. Follow up with `keg_search` and
`flight_search`.
- `mcp__tapper__invitation_list`: pending invitations addressed to your user,
org memberships and keg grants that take effect only if accepted. Read-only:
accepting or declining is your user's decision (Hub account page or
`tap invitation`), so tell them rather than acting.

- `mcp__tapper__flight_search`: literal reference/title/description search over
readable flights; at most 50 deterministic metadata results, with a truncation
Expand Down
6 changes: 5 additions & 1 deletion integrations/rendered/opencode/tapper/skills/tapper/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ flight exactly. With a real pinned root, omission selects that root and an
explicit value selects the root or an accessible flattened descendant.
`session_info`, `session_refresh`, `flight_list`, `flight_read`,
`flight_search`, `keg_search`, `namespace_list`, `namespace_search`,
`agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
`invitation_list`, `agent_list`, and `agent_read` do not accept `flight`. MCP resources use root
authority while rendering graph-wide discovery.

`flight` is an **operational** parameter, not a discovery-only one: `node_list`,
Expand Down Expand Up @@ -218,6 +218,10 @@ and `session_refresh` are always available.
Hub, not only your own, matched by name or display name; empty query browses.
Discovery only: a match grants nothing. Follow up with `keg_search` and
`flight_search`.
- `mcp__tapper__invitation_list`: pending invitations addressed to your user,
org memberships and keg grants that take effect only if accepted. Read-only:
accepting or declining is your user's decision (Hub account page or
`tap invitation`), so tell them rather than acting.

- `mcp__tapper__flight_search`: literal reference/title/description search over
readable flights; at most 50 deterministic metadata results, with a truncation
Expand Down
135 changes: 135 additions & 0 deletions pkg/cli/cmd_invitation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package cli

import (
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"

"github.com/jlrickert/tapper/pkg/tapper"
)

// printAccessChange reports the outcome of adding a member or grant: either an
// immediate role change or an invitation the user must still accept.
func printAccessChange(cmd *cobra.Command, change tapper.AccessChange, user, role string) {
user = "@" + strings.TrimPrefix(strings.TrimSpace(user), "@")
if change.Invited() {
fmt.Fprintf(cmd.OutOrStdout(), "invited %s as %s (invitation %d); access starts when they accept\n", user, role, change.InvitationID)
return
}
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", user, role)
}

// NewInvitationCmd manages pending invitations to org namespaces and kegs.
func NewInvitationCmd(deps *Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "invitation",
Aliases: []string{"invitations"},
Short: "list, accept, decline, or withdraw invitations to namespaces and kegs",
Long: "Org membership and keg grants are offered, not imposed: access starts only when the invitee\n" +
"accepts. `tap invitation list` shows invitations addressed to you on the selected hub (--hub).",
}
cmd.AddCommand(newInvitationListCmd(deps), newInvitationDecideCmd(deps, "accept"), newInvitationDecideCmd(deps, "decline"), newInvitationRevokeCmd(deps))
return cmd
}

func newInvitationListCmd(deps *Deps) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "list invitations addressed to you",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
rows, err := deps.Tap.InvitationList(cmd.Context(), tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub})
if err != nil {
return err
}
for _, inv := range rows {
fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\t%s\t%s\t@%s\t%s\n", inv.ID, inv.Kind, inv.Target, inv.Role, inv.InvitedBy, inv.ExpiresAt.Format("2006-01-02"))
}
return nil
},
}
}

func newInvitationDecideCmd(deps *Deps, action string) *cobra.Command {
cmd := &cobra.Command{
Use: action + " <id>",
Short: action + " an invitation addressed to you",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := parseInvitationID(args[0])
if err != nil {
return err
}
opts := tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub, ID: id}
var inv *tapper.HubInvitation
if action == "accept" {
inv, err = deps.Tap.InvitationAccept(cmd.Context(), opts)
} else {
inv, err = deps.Tap.InvitationDecline(cmd.Context(), opts)
}
if err != nil {
return err
}
if action == "accept" {
fmt.Fprintf(cmd.OutOrStdout(), "joined %s as %s\n", inv.Target, inv.Role)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "declined the invitation to %s\n", inv.Target)
}
return nil
},
}
cmd.ValidArgsFunction = invitationIDCompletion(deps)
return cmd
}

func newInvitationRevokeCmd(deps *Deps) *cobra.Command {
return &cobra.Command{
Use: "revoke <id>",
Short: "withdraw a pending invitation you can manage",
Long: "Withdraw a pending invitation. Requires the namespace owner role (memberships) or keg admin role (grants).",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := parseInvitationID(args[0])
if err != nil {
return err
}
if err := deps.Tap.InvitationRevoke(cmd.Context(), tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub, ID: id}); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "withdrew invitation %d\n", id)
return nil
},
}
}

func parseInvitationID(raw string) (int64, error) {
id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || id <= 0 {
return 0, fmt.Errorf("invalid invitation id %q", raw)
}
return id, nil
}

// invitationIDCompletion completes the ids of invitations addressed to the
// caller, described by their target.
func invitationIDCompletion(deps *Deps) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 || deps.Tap == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
rows, err := deps.Tap.InvitationList(cmd.Context(), tapper.InvitationOptions{Hub: globalKegTarget(deps).Hub})
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var out []string
for _, inv := range rows {
id := strconv.FormatInt(inv.ID, 10)
if strings.HasPrefix(id, toComplete) {
out = append(out, id+"\t"+inv.Target+" as "+inv.Role)
}
}
return out, cobra.ShellCompDirectiveNoFileComp
}
}
11 changes: 9 additions & 2 deletions pkg/cli/cmd_keg.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,24 @@ func newKegGrantCmd(deps *Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "grant <@user> <role>",
Short: "grant a user a role on a keg (viewer|editor|admin)",
Long: "Grant a user a role on the keg selected by --keg/--namespace/--hub (default: the resolved keg).",
Long: "Grant a user a role on the keg selected by --keg/--namespace/--hub (default: the resolved keg).\n\n" +
"An existing grantee's role changes immediately. Anyone else is sent an invitation and gains access\n" +
"only after accepting it (tap invitation accept); withdraw a pending one with tap invitation revoke.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
kt := globalKegTarget(deps)
return deps.Tap.KegGrant(cmd.Context(), tapper.KegGrantOptions{
change, err := deps.Tap.KegGrant(cmd.Context(), tapper.KegGrantOptions{
Keg: kt.Keg,
Namespace: kt.Namespace,
Hub: kt.Hub,
User: args[0],
Role: args[1],
})
if err != nil {
return err
}
printAccessChange(cmd, change, args[0], args[1])
return nil
},
}
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
Expand Down
13 changes: 10 additions & 3 deletions pkg/cli/cmd_namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,24 @@ func newNamespaceMembersCmd(deps *Deps) *cobra.Command {
func newNamespaceAddMemberCmd(deps *Deps) *cobra.Command {
cmd := &cobra.Command{
Use: "add-member <@user> <role>",
Short: "add a member to a namespace (owner|admin|member)",
Long: "Add a member to the namespace selected by --namespace/--hub (default: the resolved namespace).",
Short: "invite a member to a namespace (owner|admin|member)",
Long: "Invite a member to the namespace selected by --namespace/--hub (default: the resolved namespace).\n\n" +
"They join only after accepting the invitation (tap invitation accept). For an existing member the\n" +
"role changes immediately. Member management requires the owner role.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
kt := globalKegTarget(deps)
return deps.Tap.NamespaceAddMember(cmd.Context(), tapper.NamespaceAddMemberOptions{
change, err := deps.Tap.NamespaceAddMember(cmd.Context(), tapper.NamespaceAddMemberOptions{
Namespace: kt.Namespace,
Hub: kt.Hub,
User: args[0],
Role: args[1],
})
if err != nil {
return err
}
printAccessChange(cmd, change, args[0], args[1])
return nil
},
}
cmd.ValidArgsFunction = namespaceMemberRoleArgCompletion(deps, 1)
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/cmd_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ func NewRootCmd(deps *Deps) *cobra.Command {
subcommands = append(subcommands,
NewKegCmd(deps),
NewNamespaceCmd(deps),
NewInvitationCmd(deps),
NewUseCmd(deps),
NewBootstrapCmd(deps),
configCmd,
Expand Down
3 changes: 3 additions & 0 deletions pkg/mcp/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,9 @@ func (p localNamespaceProvider) ListNamespaces(ctx context.Context) ([]tapper.Hu
func (p localNamespaceProvider) SearchNamespaces(ctx context.Context, query string) (tapper.NamespaceSearchResult, error) {
return p.tap.NamespaceSearch(ctx, tapper.NamespaceSearchOptions{Query: query})
}
func (p localNamespaceProvider) ListInvitations(ctx context.Context) ([]tapper.HubInvitation, error) {
return p.tap.InvitationList(ctx, tapper.InvitationOptions{})
}

type localIdentityProvider struct{ tap *tapper.Tap }

Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp/session_flight.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ var ungovernedToolNames = map[string]bool{
"namespace_list": true, "namespace_create": true, "namespace_members": true,
"namespace_add_member": true, "namespace_set_role": true, "namespace_remove_member": true,
"license": true, "flight_list": true, "flight_read": true,
"namespace_search": true, "agent_list": true, "agent_read": true,
"namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true,
}

// sessionMode is the authority state of one MCP session.
Expand Down
2 changes: 1 addition & 1 deletion pkg/mcp/session_transition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ func TestMCP_AuthorityBearingSchemasExposeOptionalFlightAndRejectKegListAll(t *t
session, ctx := newPerCallFlightSession(t, backend)
result, err := session.ListTools(ctx, nil)
require.NoError(t, err)
ungoverned := map[string]bool{"guide": true, "flight_search": true, "session_info": true, "keg_search": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "namespace_search": true, "agent_list": true, "agent_read": true}
ungoverned := map[string]bool{"guide": true, "flight_search": true, "session_info": true, "keg_search": true, "flight_list": true, "flight_read": true, "session_refresh": true, "namespace_list": true, "namespace_search": true, "invitation_list": true, "agent_list": true, "agent_read": true}
seen := map[string]bool{}
for _, tool := range result.Tools {
seen[tool.Name] = true
Expand Down
Loading
Loading