Skip to content

feat: expose the full DeployGate public API as MCP tools (apps, projects, workspaces)#27

Open
tnj wants to merge 48 commits into
mainfrom
feat/full-api-coverage
Open

feat: expose the full DeployGate public API as MCP tools (apps, projects, workspaces)#27
tnj wants to merge 48 commits into
mainfrom
feat/full-api-coverage

Conversation

@tnj
Copy link
Copy Markdown
Member

@tnj tnj commented May 28, 2026

Summary

Expand the MCP server from upload/distribution/basic-member management to near-complete coverage of the DeployGate public API. ~39 new tools across apps/binaries, keystores, distributions, projects (organizations), and workspaces (enterprises), plus two client robustness fixes.

Phase 1 — apps / binaries / distributions / keystores

  • apps.ts: get_app, list/get/update/delete/protect/unprotect/search app revisions (8)
  • app-members.ts: list_app_members
  • keystores.ts: get/create/update/delete/download_keystore (Android, 5)
  • distributions.ts: delete_distribution_by_name, update_distribution_revision, + IP-restriction params on update_distribution

Phase 2 — projects (organizations)

  • projects.ts: get/update/delete_project, list_project_apps, list_project_members (5)
  • app-members.ts: list/remove app teams, list/remove app shared teams (4)
  • list_members widened to accept arbitrary/custom team names

Phase 3 — workspaces (enterprises)

  • workspace-members.ts: list/get/add/remove workspace members (4)
  • workspace-projects.ts: list_workspace_projects, create_project, list_workspace_project_members, add/remove_project_member (5)
  • shared-teams.ts: list/delete shared teams, list/remove shared-team members (4)
  • workspace-saml.ts: update_saml_certificate (1)

Client fixes

  • Correct shared_teams API path (was sharedteams)
  • request() tolerates empty-body responses (204 → null, empty → {} on SyntaxError)

Testing

  • npm run build && npm test — 368 tests pass.
  • All tools live-verified against the real API (read-only with real data; mutating ops via safe error-path checks; destructive/SAML not run live).

Notes

  • This PR also stops tracking docs/superpowers/ (agent design docs) and gitignores it.
  • The branch contains feat!: commits internally; please squash-merge with this feat: title so release-please cuts a minor bump (1.5.0, since 1.4.0 already shipped on main).

🤖 Generated with Claude Code

tnj and others added 30 commits May 28, 2026 11:54
Roadmap (3 phases) + detailed Phase 1 design (apps/binaries/
distributions/keystores). Scope bounded to API-token-reachable
management operations; device-token-only and SDK/runtime routes
excluded with rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Phase 1 plan (apps/binaries/distributions/keystores/users) as
  bite-sized TDD tasks with exact paths verified against controllers.
- Note that direct app member invite only works for personal apps;
  workspace-project apps require the team-based access flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add owner-type (User / standalone Group / workspace Group) feature
matrix to the roadmap and note IP restriction + authorized_only scope
constraints on update_distribution. Verified against
application_policy.rb and ip_restriction/distribution_service.rb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A revision served by a distribution page is auto-protected (binary
protection with distribution_id), so the API rejects its deletion as
"protected"; unprotect_app_revision only clears manual pins. Document
this in delete/unprotect tool descriptions rather than pre-validating
client-side. Verified against binary.rb, distribution.rb,
binary_protection.rb, unpin_service.rb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iptions

Add preconditions, owner/plan gating, key error conditions (404/400/
403/422), and workarounds to every Phase 1 tool description, and codify
the "pre-announce constraints, pass values through, surface
DeployGateApiError" principle in the roadmap and Phase 1 spec.
Verified against distribution.rb (authorized_only/testers),
keystores_controller.rb (Android-only, idempotency), binaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add listAppMembers/inviteAppMembers/removeAppMembers client methods and
register list_app_members, invite_app_members, remove_app_members MCP tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…iction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keystore show/create/update/destroy live at the bare /keystores path
(distinguished by HTTP method); only download uses /keystores/download.
The action-suffixed paths (/show,/create,/update,/destroy) returned
HTML 404. Found via live API verification against a real app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stores)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y, out of scope)

These two tools only work for User-owned (personal) apps:
application_policy.rb member_addable?/tester_addable? return false for
non-User owners, so a direct invite/remove against a Group-owned
(workspace/project) app returns 403. Personal-app support was decided to
be out of scope, so both MCP tools, their client methods
(inviteAppMembers/removeAppMembers) and tests are removed.

Access grant/revoke for workspace/project apps is covered by the
team-based flow (Phase 2/3 tools). list_app_members is kept: it returns
both users and teams and works regardless of owner type.

Phase 1 design/plan/roadmap docs annotated accordingly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of the full-API-coverage roadmap: 9 new MCP tools for project
(organizations) management — get/update/delete project, list project
apps/members (projects.ts), and app team/shared-team list/remove
(app-members.ts). Also extends list_members to accept arbitrary team
names, and fixes the sharedteams->shared_teams path bug in
createSharedTeam/assignSharedTeamToApp (live-verify).

Endpoints verified against webfront api/organizations controllers and
routes.rb. All callable with a Bearer API token (no device token).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bite-sized TDD plan for 9 project-management tools, list_members
widening, the shared_teams path fix, and empty-body response handling,
ending with a live-verification task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
createSharedTeam and assignSharedTeamToApp used `sharedteams` (no
underscore). The API uses `shared_teams` consistently. Live-verify in the
final task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
App-team DELETE/create endpoints return empty bodies (head :created).
request() now returns null for 204 and {} when json() fails to parse,
instead of throwing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Only suppress empty/non-JSON bodies; rethrow other json() failures so a
corrupted/truncated response is not masked as a phantom success.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
getProject, updateProject (display_name/description), deleteProject,
listProjectApps, listProjectMembers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
listAppTeams, removeAppTeam, listAppSharedTeams, removeAppSharedTeam
under /api/organizations/:project/.../apps/:app_id. Team names are
URL-encoded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
get_project, update_project, delete_project, list_project_apps,
list_project_members. Registered in index.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
list_app_teams, remove_app_team, list_app_shared_teams,
remove_app_shared_team in app-members.ts. Shared-team tools are
Enterprise-org only (pre-announced in descriptions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 4 app-team tools route through /api/organizations, so owner_name is
always a project; use an accurate schema description instead of the
generic "user or project" arg. Add a symmetric remove_app_shared_team
pass-through test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Widen the team param from the owner/developer/tester enum to a free
string so custom project teams can be listed. Folds in the roadmap's
planned list_team_members tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Final phase of the full-API-coverage roadmap: 17 MCP tools for workspace
(enterprises) management across 4 modules — workspace members + member
invitation requests (workspace-members.ts), projects + project members
(workspace-projects.ts), shared-team list/delete/members (extend
shared-teams.ts), and SAML certificate update (workspace-saml.ts).

All endpoints verified Bearer-API-token reachable (enterprises/base.rb
rejects device tokens); several are User-API-token only. 11 new client
methods + addWorkspaceMember extension + exposing 6 existing methods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bite-sized TDD plan: 5 client-method tasks (workspace members, projects,
shared-team list/delete, member invitation requests, SAML multipart) +
4 tool-module tasks (workspace-members, workspace-projects, shared-teams
extension, workspace-saml) + a live-verification task. 17 tools total.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ember

listWorkspaceMembers, getWorkspaceMember (id URL-encoded), and
addWorkspaceMember now accepts optional full_name/role (backward
compatible — add_member orchestration still calls it with user only).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tnj and others added 12 commits May 28, 2026 11:54
Consistent with getWorkspaceMember; matters for email identifiers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
listWorkspaceProjects, createProject (owner_name_or_email/name +
optional display_name/description), listWorkspaceProjectMembers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GET/DELETE /api/enterprises/:ws/shared_teams; team name URL-encoded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
getMemberInvitationRequest, approveMemberInvitationRequest,
rejectMemberInvitationRequest (optional reason).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PUT /api/enterprises/:ws/saml_settings/update_certificate; reads the
cert file and uploads it as the idp_cert form field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
list/get/add/remove workspace members and get/approve/reject member
invitation requests (workspace-members.ts). Registered in index.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
list_workspace_projects, create_project, list_workspace_project_members,
add_project_member, remove_project_member (workspace-projects.ts).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
list_shared_teams, delete_shared_team, list_shared_team_members,
remove_shared_team_member in shared-teams.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PUT a workspace SAML IdP certificate from a local PEM file
(workspace-saml.ts). Description warns about SSO-breakage risk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove the Phase 1-3 spec/plan design docs from the repo and gitignore
docs/superpowers/ so agent workflow artifacts stay local.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove the get_user tool (GET /api/users/:id user lookup), its
registerUserTools module, the getUser client method, and tests. It has
no concrete use case in the agent flows. get_user_info (token's own
user) is unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove get/approve/reject_member_invitation_request tools, their client
methods, and tests. The API has no index route for
member_invitation_requests (routes.rb only: [:show] + approve/reject) and
no other tool surfaces a request's display_id, so these tools can never
obtain an id to act on — they are unreachable in practice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tnj tnj force-pushed the feat/full-api-coverage branch from 700ddb7 to 90cf7c6 Compare May 28, 2026 02:55
@tnj tnj requested a review from Copilot May 28, 2026 03:08

This comment was marked as resolved.

@tnj
Copy link
Copy Markdown
Member Author

tnj commented May 28, 2026

/gemini review

gemini-code-assist[bot]

This comment was marked as resolved.

Encode user/team identifiers in path segments for removeProjectMember,
addTeamMember, listTeamMembers, removeTeamMember, addSharedTeamMember,
listSharedTeamMembers, removeSharedTeamMember — emails and team names
with spaces or reserved characters now work reliably. Add tests
asserting the encoding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

tnj and others added 5 commits May 28, 2026 13:21
The SyntaxError fallback in request() returned {} regardless of HTTP
status, so a non-2xx response with an empty or non-JSON body (e.g. an
HTML proxy error page) was silently treated as a successful operation.
Only treat empty/non-JSON bodies as success on response.ok; otherwise
throw a DeployGateApiError that surfaces the unexpected status.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
list_members was widened to z.string() in Phase 2 to support custom team
names, but remove_member was left as z.enum(owner/developer/tester) —
so users could list members of a custom team but not remove them. The
API endpoint accepts arbitrary team names; align the tool.

add_member's role param remains a z.enum because it drives orchestration
logic that special-cases the tester role (auto-assigns the team to the
app).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add an atomic add_team_member tool that wraps client.addTeamMember
directly (project, team, user) and accepts arbitrary team names —
closing the asymmetry where list_members/remove_member supported custom
teams but no tool could add to them.

add_member is intentionally kept role-based (owner/developer/tester) for
the deploygate:setup orchestration flow that adds to workspace + project
+ team and attaches the tester team to an app. add_team_member is the
atomic single-step counterpart for ad-hoc / custom-team additions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tools

- add_member now resolves the auto-created role team (owner/developer/
  tester) by its stable `role` keyword via getProject, so it works
  regardless of the project's display-name locale (e.g. "テスター" in
  Japanese workspaces). Step 4 (team→app attach) now fires for both
  developer and tester (only owner has project-wide app access, so it
  remains skipped). platform/app_id are now required for any non-owner
  role.
- Rename list_members → list_team_members and remove_member →
  remove_team_member for consistency with add_team_member and the
  {scope}_member naming convention used elsewhere
  (workspace/project/app/shared-team/team).
- Update cross-references in projects.ts/workspace-projects.ts
  descriptions, README, and the skills allow-list test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per the data model, owner/developer/tester are just the 3 teams
auto-created at project creation — not a structurally special category.
Their display names are locale-dependent and any team can be renamed,
so describing them as "built-in" was misleading and could mislead
callers into passing the English keyword to a Japanese-locale project.

Rephrase descriptions for list_team_members / add_team_member /
remove_team_member to: "team's actual display name (case-insensitive);
use `get_project` to discover team names." add_member is unchanged — its
role enum is the right interface and the team-name resolution happens
internally via the role keyword.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@tnj tnj requested a review from Copilot May 28, 2026 05:16
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@tnj
Copy link
Copy Markdown
Member Author

tnj commented May 28, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request significantly expands the DeployGate MCP server by adding a comprehensive set of new tools and client methods. These cover app revisions, app members/teams, Android keystores, project details, workspace members, workspace projects, and SAML certificate updates, along with extensive test coverage. The review feedback highlights opportunities to improve robustness and user experience by dynamically resolving home directory paths (~/) in file-reading methods and enforcing described input constraints locally within the Zod schemas and tool handlers.

Comment thread src/client.ts
Comment on lines +747 to +760
async updateSamlCertificate(
workspace: string,
filePath: string,
): Promise<unknown> {
const fileBuffer = await readFile(filePath);
const fileName = basename(filePath);
const formData = new FormData();
formData.append("idp_cert", new Blob([fileBuffer]), fileName);
return this.request(
"PUT",
`/api/enterprises/${workspace}/saml_settings/update_certificate`,
{ formData },
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The filePath parameter is read directly using readFile. If the user or agent provides a path starting with ~/ (e.g., ~/certs/idp.pem), readFile will fail with ENOENT because Node.js does not automatically resolve the home directory. Resolving the home directory path dynamically makes the tool much more robust and user-friendly.

  async updateSamlCertificate(
    workspace: string,
    filePath: string,
  ): Promise<unknown> {
    const { homedir } = await import("node:os");
    const { join } = await import("node:path");
    const resolvedPath = filePath.startsWith("~/")
      ? join(homedir(), filePath.slice(2))
      : filePath;
    const fileBuffer = await readFile(resolvedPath);
    const fileName = basename(resolvedPath);
    const formData = new FormData();
    formData.append("idp_cert", new Blob([fileBuffer]), fileName);
    return this.request(
      "PUT",
      `/api/enterprises/${workspace}/saml_settings/update_certificate`,
      { formData },
    );
  }

Comment thread src/client.ts
Comment on lines +833 to +853
async updateKeystore(
owner: string,
appId: string,
params: {
filePath: string;
aliasName: string;
keystorePassword: string;
keyPassword: string;
},
): Promise<unknown> {
const fileBuffer = await readFile(params.filePath);
const fileName = basename(params.filePath);
const formData = new FormData();
formData.append("file", new Blob([fileBuffer]), fileName);
formData.append("alias_name", params.aliasName);
formData.append("keystore_password", params.keystorePassword);
formData.append("key_password", params.keyPassword);
return this.request("PUT", this.keystoreBase(owner, appId), {
formData,
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to updateSamlCertificate, the filePath parameter in updateKeystore is read directly using readFile. Resolving paths starting with ~/ dynamically prevents ENOENT errors when users or agents specify paths relative to their home directory.

  async updateKeystore(
    owner: string,
    appId: string,
    params: { 
      filePath: string;
      aliasName: string;
      keystorePassword: string;
      keyPassword: string;
    },
  ): Promise<unknown> {
    const { homedir } = await import("node:os");
    const { join } = await import("node:path");
    const resolvedPath = params.filePath.startsWith("~/")
      ? join(homedir(), params.filePath.slice(2))
      : params.filePath;
    const fileBuffer = await readFile(resolvedPath);
    const fileName = basename(resolvedPath);
    const formData = new FormData();
    formData.append("file", new Blob([fileBuffer]), fileName);
    formData.append("alias_name", params.aliasName);
    formData.append("keystore_password", params.keystorePassword);
    formData.append("key_password", params.keyPassword);
    return this.request("PUT", this.keystoreBase(owner, appId), {
      formData,
    });
  }

Comment thread src/tools/projects.ts
Comment on lines +35 to +41
async (args) => {
const results = await client.updateProject(args.project, {
display_name: args.display_name,
description: args.description,
});
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The update_project tool description states: "Provide at least one of display_name or description." However, the handler does not validate this constraint. If both are omitted, the tool will send an empty payload to the DeployGate API. Adding a local validation check prevents unnecessary API calls and provides immediate feedback to the agent.

    async (args) => {
      if (args.display_name === undefined && args.description === undefined) {
        return {
          content: [
            {
              type: "text",
              text: "Error: At least one of display_name or description must be provided.",
            },
          ],
          isError: true,
        };
      }
      const results = await client.updateProject(args.project, {
        display_name: args.display_name,
        description: args.description,
      });
      return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
    },

server.tool(
"get_workspace_member",
"Get a single workspace (enterprise) member by name or email (must be at least 3 characters). Returns 400 if no matching member is found.",
{ workspace: workspaceArg, id: z.string().describe("Member name or email") },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The get_workspace_member tool description states that the member name or email "must be at least 3 characters". Enforcing this constraint locally using Zod's .min(3) validation prevents invalid API requests and provides immediate feedback to the agent.

Suggested change
{ workspace: workspaceArg, id: z.string().describe("Member name or email") },
{ workspace: workspaceArg, id: z.string().min(3).describe("Member name or email (must be at least 3 characters)") },

{
workspace: workspaceArg,
owner_name_or_email: z.string().describe("Workspace member to set as the project owner (username or email)"),
name: z.string().describe("Project name (3-28 chars, globally unique)"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The create_project tool description states that the project name "must be 3-28 chars (letters/digits/hyphens/underscores, starting and ending with a letter or digit)". Enforcing these constraints locally using Zod's .min(3), .max(28), and .regex() validation ensures robust input validation and prevents invalid API requests.

      name: z
        .string()
        .min(3)
        .max(28)
        .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$/, "Project name must be 3-28 characters, contain only letters, digits, hyphens, or underscores, and start/end with a letter or digit")
        .describe("Project name (3-28 chars, globally unique)"),

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants