mcp-keycloak-admin 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # mcp-keycloak-admin
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/mcp-keycloak-admin.svg)](https://www.npmjs.com/package/mcp-keycloak-admin)
4
+ [![CI](https://github.com/mrz1880/mcp-keycloak-admin/actions/workflows/ci.yml/badge.svg)](https://github.com/mrz1880/mcp-keycloak-admin/actions/workflows/ci.yml)
5
+ [![CodeQL](https://github.com/mrz1880/mcp-keycloak-admin/actions/workflows/codeql.yml/badge.svg)](https://github.com/mrz1880/mcp-keycloak-admin/actions/workflows/codeql.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+
3
8
  A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server to
4
9
  administer a [Keycloak](https://www.keycloak.org) instance through its Admin
5
10
  REST API. Safe by default, configurable, and built with a clean, test-driven
@@ -7,9 +12,18 @@ architecture.
7
12
 
8
13
  Compatible with **Keycloak 26.x** (validated against 26.0.5).
9
14
 
10
- > **Project status:** early. The architecture, tooling and safety model are in
11
- > place and exercised by unit and integration tests. The exposed tool surface is
12
- > being grown incrementally see [Tools](#tools) and [Roadmap](#roadmap).
15
+ ## Install
16
+
17
+ No install neededrun it straight from npm with `npx`:
18
+
19
+ ```bash
20
+ npx -y mcp-keycloak-admin
21
+ ```
22
+
23
+ The server speaks MCP over stdio, so you normally wire it into an MCP client
24
+ rather than running it by hand — see [Usage with an MCP client](#usage-with-an-mcp-client).
25
+ New to it? The [Quickstart](docs/quickstart.md) spins up a local Keycloak and a
26
+ client config in a couple of minutes.
13
27
 
14
28
  ## Why
15
29
 
@@ -64,6 +78,49 @@ Add the server to your MCP client configuration:
64
78
  See [docs/setup-keycloak.md](docs/setup-keycloak.md) to create the `mcp-admin`
65
79
  client and grant it the least-privilege roles it needs.
66
80
 
81
+ ### Multiple Keycloak instances
82
+
83
+ Each server entry targets **one** Keycloak (one base URL + realm + auth). To
84
+ manage several environments, add **one entry per instance** — each fully
85
+ isolated, with its own credentials and guardrails:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "kc-preprod": {
91
+ "command": "npx",
92
+ "args": ["-y", "mcp-keycloak-admin"],
93
+ "env": {
94
+ "KEYCLOAK_BASE_URL": "https://preprod.example.com",
95
+ "KEYCLOAK_REALM": "Pandi-Panda-Preprod",
96
+ "AUTH_MODE": "service_account",
97
+ "KC_CLIENT_ID": "mcp-admin",
98
+ "KC_CLIENT_SECRET": "…",
99
+ "ALLOWED_REALMS": "Pandi-Panda-Preprod"
100
+ }
101
+ },
102
+ "kc-prod": {
103
+ "command": "npx",
104
+ "args": ["-y", "mcp-keycloak-admin"],
105
+ "env": {
106
+ "KEYCLOAK_BASE_URL": "https://auth.example.com",
107
+ "KEYCLOAK_REALM": "Pandi-Panda",
108
+ "AUTH_MODE": "service_account",
109
+ "KC_CLIENT_ID": "mcp-admin",
110
+ "KC_CLIENT_SECRET": "…",
111
+ "READ_ONLY": "true",
112
+ "ALLOWED_REALMS": "Pandi-Panda"
113
+ }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ The client namespaces the tools per server (e.g. `kc-prod:keycloak_user_delete`),
120
+ so there's no risk of running an operation against the wrong environment. This
121
+ is the recommended pattern: you can, for example, keep production `READ_ONLY`
122
+ while preprod stays writable.
123
+
67
124
  ## Configuration
68
125
 
69
126
  | Variable | Required | Description |
@@ -89,6 +146,11 @@ confirmation). Every tool carries the matching MCP annotations
89
146
 
90
147
  Currently implemented:
91
148
 
149
+ > **Note:** this table tracks the `main` branch, which can run ahead of the
150
+ > latest npm release shown by the version badge above. To confirm what's
151
+ > available in your install, check `npm view mcp-keycloak-admin version` and pin
152
+ > `mcp-keycloak-admin@latest`.
153
+
92
154
  | Tool | Level | Description |
93
155
  | ------------------------------------------- | ----- | ----------------------------------------------------- |
94
156
  | `keycloak_user_search` | R | Search realm users by email, username or free text. |
@@ -105,7 +167,14 @@ Currently implemented:
105
167
  | `keycloak_user_roles_get` | R | List a user's realm roles. |
106
168
  | `keycloak_user_role_assign` | W | Grant a realm role to a user. |
107
169
  | `keycloak_user_role_unassign` | D | Revoke a realm role from a user. |
170
+ | `keycloak_client_roles_list` | R | List the roles defined on a client. |
171
+ | `keycloak_user_client_roles_get` | R | List a user's client roles. |
172
+ | `keycloak_user_client_role_assign` | W | Grant a client role to a user. |
173
+ | `keycloak_user_client_role_unassign` | D | Revoke a client role from a user. |
108
174
  | `keycloak_client_list` | R | List the realm clients. |
175
+ | `keycloak_client_create` | W | Create a realm client. |
176
+ | `keycloak_client_update` | W | Update a client (enabled, public, redirect URIs). |
177
+ | `keycloak_client_delete` | D | Delete a client. |
109
178
  | `keycloak_client_get` | R | Fetch a client by its clientId. |
110
179
  | `keycloak_client_get_secret` | R | Read a client secret (masked unless `reveal`). |
111
180
  | `keycloak_client_scopes_list` | R | List the realm's client scopes. |
@@ -164,6 +233,8 @@ npm run check # typecheck + lint + format check + unit tests
164
233
  npm run build # bundle to dist/
165
234
  ```
166
235
 
236
+ Releases are automated — see [docs/releasing.md](docs/releasing.md).
237
+
167
238
  ## Contributing
168
239
 
169
240
  Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md).
package/dist/index.js CHANGED
@@ -451,7 +451,7 @@ var KeycloakClientRepository = class {
451
451
  }
452
452
  client;
453
453
  async list() {
454
- const raw = await this.client.getJson("/clients");
454
+ const raw = await this.client.list("/clients");
455
455
  return raw.map(toSummary);
456
456
  }
457
457
  async findByClientId(clientId) {
@@ -473,6 +473,30 @@ var KeycloakClientRepository = class {
473
473
  );
474
474
  return ClientSecret.fromString(raw.value);
475
475
  }
476
+ create(client) {
477
+ return this.client.post("/clients", {
478
+ clientId: client.clientId.toString(),
479
+ enabled: client.enabled,
480
+ publicClient: client.publicClient,
481
+ redirectUris: client.redirectUris
482
+ });
483
+ }
484
+ update(uuid, changes) {
485
+ const body = {};
486
+ if (changes.enabled !== void 0) {
487
+ body.enabled = changes.enabled;
488
+ }
489
+ if (changes.publicClient !== void 0) {
490
+ body.publicClient = changes.publicClient;
491
+ }
492
+ if (changes.redirectUris !== void 0) {
493
+ body.redirectUris = changes.redirectUris;
494
+ }
495
+ return this.client.put(`/clients/${uuid.toString()}`, body);
496
+ }
497
+ delete(uuid) {
498
+ return this.client.delete(`/clients/${uuid.toString()}`);
499
+ }
476
500
  };
477
501
 
478
502
  // src/infrastructure/keycloak/authentication-repository.ts
@@ -963,7 +987,7 @@ var KeycloakGroupRepository = class {
963
987
  }
964
988
  client;
965
989
  async list() {
966
- const raw = await this.client.getJson("/groups");
990
+ const raw = await this.client.list("/groups");
967
991
  return raw.map(toGroup);
968
992
  }
969
993
  create(name) {
@@ -1121,7 +1145,7 @@ var KeycloakRoleRepository = class {
1121
1145
  }
1122
1146
  client;
1123
1147
  async listRealmRoles() {
1124
- const raw = await this.client.getJson("/roles");
1148
+ const raw = await this.client.list("/roles");
1125
1149
  return raw.map(toRole);
1126
1150
  }
1127
1151
  async findRealmRole(name) {
@@ -1154,6 +1178,43 @@ var KeycloakRoleRepository = class {
1154
1178
  [toRepresentation(role)]
1155
1179
  );
1156
1180
  }
1181
+ async listClientRoles(clientUuid) {
1182
+ const raw = await this.client.list(
1183
+ `/clients/${clientUuid.toString()}/roles`
1184
+ );
1185
+ return raw.map(toRole);
1186
+ }
1187
+ async findClientRole(clientUuid, name) {
1188
+ try {
1189
+ const raw = await this.client.getJson(
1190
+ `/clients/${clientUuid.toString()}/roles/${encodeURIComponent(name.toString())}`
1191
+ );
1192
+ return toRole(raw);
1193
+ } catch (error) {
1194
+ if (error instanceof KeycloakError && error.status === 404) {
1195
+ return null;
1196
+ }
1197
+ throw error;
1198
+ }
1199
+ }
1200
+ async listUserClientRoles(userId, clientUuid) {
1201
+ const raw = await this.client.getJson(
1202
+ `/users/${userId.toString()}/role-mappings/clients/${clientUuid.toString()}`
1203
+ );
1204
+ return raw.map(toRole);
1205
+ }
1206
+ assignClientRole(userId, clientUuid, role) {
1207
+ return this.client.post(
1208
+ `/users/${userId.toString()}/role-mappings/clients/${clientUuid.toString()}`,
1209
+ [toRepresentation(role)]
1210
+ );
1211
+ }
1212
+ removeClientRole(userId, clientUuid, role) {
1213
+ return this.client.delete(
1214
+ `/users/${userId.toString()}/role-mappings/clients/${clientUuid.toString()}`,
1215
+ [toRepresentation(role)]
1216
+ );
1217
+ }
1157
1218
  };
1158
1219
 
1159
1220
  // src/infrastructure/mcp/auth-tools.ts
@@ -1646,6 +1707,42 @@ function buildClientScopeTools(deps) {
1646
1707
  // src/infrastructure/mcp/client-tools.ts
1647
1708
  import { z as z5 } from "zod";
1648
1709
 
1710
+ // src/application/clients/create-client.use-case.ts
1711
+ var CreateClientUseCase = class {
1712
+ constructor(clients) {
1713
+ this.clients = clients;
1714
+ }
1715
+ clients;
1716
+ execute(client) {
1717
+ return this.clients.create(client);
1718
+ }
1719
+ };
1720
+
1721
+ // src/application/clients/delete-client.use-case.ts
1722
+ var DeleteClientUseCase = class {
1723
+ constructor(clients, confirmer) {
1724
+ this.clients = clients;
1725
+ this.confirmer = confirmer;
1726
+ }
1727
+ clients;
1728
+ confirmer;
1729
+ async execute(clientId) {
1730
+ const client = await this.clients.findByClientId(clientId);
1731
+ if (client === null) {
1732
+ return { deleted: false, reason: "Client not found" };
1733
+ }
1734
+ const operation = DestructiveOperation.of(
1735
+ `Delete client ${clientId.toString()}`,
1736
+ "Removes the client and everything attached to it (roles, scopes, service account). Applications using it stop working. Irreversible."
1737
+ );
1738
+ if (!await this.confirmer.confirm(operation)) {
1739
+ return { deleted: false, reason: "Operation not confirmed" };
1740
+ }
1741
+ await this.clients.delete(client.uuid);
1742
+ return { deleted: true };
1743
+ }
1744
+ };
1745
+
1649
1746
  // src/application/clients/get-client-secret.use-case.ts
1650
1747
  var GetClientSecretUseCase = class {
1651
1748
  constructor(clients) {
@@ -1708,6 +1805,22 @@ var RegenerateClientSecretUseCase = class {
1708
1805
  }
1709
1806
  };
1710
1807
 
1808
+ // src/application/clients/update-client.use-case.ts
1809
+ var UpdateClientUseCase = class {
1810
+ constructor(clients) {
1811
+ this.clients = clients;
1812
+ }
1813
+ clients;
1814
+ async execute(input) {
1815
+ const client = await this.clients.findByClientId(input.clientId);
1816
+ if (client === null) {
1817
+ return { updated: false, reason: "Client not found" };
1818
+ }
1819
+ await this.clients.update(client.uuid, input.changes);
1820
+ return { updated: true };
1821
+ }
1822
+ };
1823
+
1711
1824
  // src/infrastructure/mcp/client-tools.ts
1712
1825
  function serializeClient(client) {
1713
1826
  return {
@@ -1811,12 +1924,110 @@ function regenerateClientSecretTool(deps) {
1811
1924
  }
1812
1925
  };
1813
1926
  }
1927
+ function readStringArray(value) {
1928
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1929
+ }
1930
+ function createClientTool(deps) {
1931
+ return {
1932
+ name: "keycloak_client_create",
1933
+ title: "Create client",
1934
+ description: "Create a realm client.",
1935
+ level: "write" /* Write */,
1936
+ inputSchema: {
1937
+ clientId: z5.string(),
1938
+ enabled: z5.boolean().optional(),
1939
+ publicClient: z5.boolean().optional(),
1940
+ redirectUris: z5.array(z5.string()).optional()
1941
+ },
1942
+ annotations: {
1943
+ readOnlyHint: false,
1944
+ destructiveHint: false,
1945
+ idempotentHint: false
1946
+ },
1947
+ async handler(args) {
1948
+ await new CreateClientUseCase(deps.clientRepository).execute({
1949
+ clientId: ClientId.fromString(String(args.clientId)),
1950
+ enabled: args.enabled !== false,
1951
+ publicClient: args.publicClient === true,
1952
+ redirectUris: readStringArray(args.redirectUris)
1953
+ });
1954
+ return textResult(`Client "${String(args.clientId)}" created.`);
1955
+ }
1956
+ };
1957
+ }
1958
+ function updateClientTool(deps) {
1959
+ return {
1960
+ name: "keycloak_client_update",
1961
+ title: "Update client",
1962
+ description: "Update a client's enabled, public flag or redirect URIs.",
1963
+ level: "write" /* Write */,
1964
+ inputSchema: {
1965
+ clientId: z5.string(),
1966
+ enabled: z5.boolean().optional(),
1967
+ publicClient: z5.boolean().optional(),
1968
+ redirectUris: z5.array(z5.string()).optional()
1969
+ },
1970
+ annotations: {
1971
+ readOnlyHint: false,
1972
+ destructiveHint: false,
1973
+ idempotentHint: true
1974
+ },
1975
+ async handler(args) {
1976
+ const changes = {};
1977
+ if (typeof args.enabled === "boolean") {
1978
+ changes.enabled = args.enabled;
1979
+ }
1980
+ if (typeof args.publicClient === "boolean") {
1981
+ changes.publicClient = args.publicClient;
1982
+ }
1983
+ if (Array.isArray(args.redirectUris)) {
1984
+ changes.redirectUris = readStringArray(args.redirectUris);
1985
+ }
1986
+ const result = await new UpdateClientUseCase(
1987
+ deps.clientRepository
1988
+ ).execute({
1989
+ clientId: ClientId.fromString(String(args.clientId)),
1990
+ changes
1991
+ });
1992
+ return textResult(
1993
+ result.updated ? `Client "${String(args.clientId)}" updated.` : `Not updated: ${result.reason ?? "unknown reason"}`
1994
+ );
1995
+ }
1996
+ };
1997
+ }
1998
+ function deleteClientTool(deps) {
1999
+ return {
2000
+ name: "keycloak_client_delete",
2001
+ title: "Delete client",
2002
+ description: "Delete a client. Requires confirmation.",
2003
+ level: "destructive" /* Destructive */,
2004
+ inputSchema: { clientId: z5.string(), confirm: z5.boolean().optional() },
2005
+ annotations: {
2006
+ readOnlyHint: false,
2007
+ destructiveHint: true,
2008
+ idempotentHint: false
2009
+ },
2010
+ async handler(args) {
2011
+ const confirmer = deps.confirmers.create(args.confirm === true);
2012
+ const result = await new DeleteClientUseCase(
2013
+ deps.clientRepository,
2014
+ confirmer
2015
+ ).execute(ClientId.fromString(String(args.clientId)));
2016
+ return textResult(
2017
+ result.deleted ? `Client "${String(args.clientId)}" deleted.` : `Not deleted: ${result.reason ?? "unknown reason"}`
2018
+ );
2019
+ }
2020
+ };
2021
+ }
1814
2022
  function buildClientTools(deps) {
1815
2023
  return [
1816
2024
  listClientsTool(deps),
1817
2025
  getClientTool(deps),
1818
2026
  getClientSecretTool(deps),
1819
- regenerateClientSecretTool(deps)
2027
+ createClientTool(deps),
2028
+ updateClientTool(deps),
2029
+ regenerateClientSecretTool(deps),
2030
+ deleteClientTool(deps)
1820
2031
  ];
1821
2032
  }
1822
2033
 
@@ -2708,6 +2919,28 @@ function buildIdpTools(deps) {
2708
2919
  // src/infrastructure/mcp/role-tools.ts
2709
2920
  import { z as z10 } from "zod";
2710
2921
 
2922
+ // src/application/roles/assign-client-role.use-case.ts
2923
+ var AssignClientRoleUseCase = class {
2924
+ constructor(clients, roles) {
2925
+ this.clients = clients;
2926
+ this.roles = roles;
2927
+ }
2928
+ clients;
2929
+ roles;
2930
+ async execute(input) {
2931
+ const client = await this.clients.findByClientId(input.clientId);
2932
+ if (client === null) {
2933
+ return { assigned: false, reason: "Client not found" };
2934
+ }
2935
+ const role = await this.roles.findClientRole(client.uuid, input.role);
2936
+ if (role === null) {
2937
+ return { assigned: false, reason: "Role not found" };
2938
+ }
2939
+ await this.roles.assignClientRole(input.userId, client.uuid, role);
2940
+ return { assigned: true };
2941
+ }
2942
+ };
2943
+
2711
2944
  // src/application/roles/assign-user-role.use-case.ts
2712
2945
  var AssignUserRoleUseCase = class {
2713
2946
  constructor(roles) {
@@ -2724,6 +2957,23 @@ var AssignUserRoleUseCase = class {
2724
2957
  }
2725
2958
  };
2726
2959
 
2960
+ // src/application/roles/get-user-client-roles.use-case.ts
2961
+ var GetUserClientRolesUseCase = class {
2962
+ constructor(clients, roles) {
2963
+ this.clients = clients;
2964
+ this.roles = roles;
2965
+ }
2966
+ clients;
2967
+ roles;
2968
+ async execute(input) {
2969
+ const client = await this.clients.findByClientId(input.clientId);
2970
+ if (client === null) {
2971
+ return null;
2972
+ }
2973
+ return this.roles.listUserClientRoles(input.userId, client.uuid);
2974
+ }
2975
+ };
2976
+
2727
2977
  // src/application/roles/get-user-roles.use-case.ts
2728
2978
  var GetUserRolesUseCase = class {
2729
2979
  constructor(roles) {
@@ -2735,6 +2985,23 @@ var GetUserRolesUseCase = class {
2735
2985
  }
2736
2986
  };
2737
2987
 
2988
+ // src/application/roles/list-client-roles.use-case.ts
2989
+ var ListClientRolesUseCase = class {
2990
+ constructor(clients, roles) {
2991
+ this.clients = clients;
2992
+ this.roles = roles;
2993
+ }
2994
+ clients;
2995
+ roles;
2996
+ async execute(clientId) {
2997
+ const client = await this.clients.findByClientId(clientId);
2998
+ if (client === null) {
2999
+ return null;
3000
+ }
3001
+ return this.roles.listClientRoles(client.uuid);
3002
+ }
3003
+ };
3004
+
2738
3005
  // src/application/roles/list-realm-roles.use-case.ts
2739
3006
  var ListRealmRolesUseCase = class {
2740
3007
  constructor(roles) {
@@ -2746,6 +3013,37 @@ var ListRealmRolesUseCase = class {
2746
3013
  }
2747
3014
  };
2748
3015
 
3016
+ // src/application/roles/unassign-client-role.use-case.ts
3017
+ var UnassignClientRoleUseCase = class {
3018
+ constructor(clients, roles, confirmer) {
3019
+ this.clients = clients;
3020
+ this.roles = roles;
3021
+ this.confirmer = confirmer;
3022
+ }
3023
+ clients;
3024
+ roles;
3025
+ confirmer;
3026
+ async execute(input) {
3027
+ const client = await this.clients.findByClientId(input.clientId);
3028
+ if (client === null) {
3029
+ return { removed: false, reason: "Client not found" };
3030
+ }
3031
+ const role = await this.roles.findClientRole(client.uuid, input.role);
3032
+ if (role === null) {
3033
+ return { removed: false, reason: "Role not found" };
3034
+ }
3035
+ const operation = DestructiveOperation.of(
3036
+ `Remove client role ${input.role.toString()} (client ${input.clientId.toString()}) from user ${input.userId.toString()}`,
3037
+ "The user immediately loses the permissions granted by this client role."
3038
+ );
3039
+ if (!await this.confirmer.confirm(operation)) {
3040
+ return { removed: false, reason: "Operation not confirmed" };
3041
+ }
3042
+ await this.roles.removeClientRole(input.userId, client.uuid, role);
3043
+ return { removed: true };
3044
+ }
3045
+ };
3046
+
2749
3047
  // src/application/roles/unassign-user-role.use-case.ts
2750
3048
  var UnassignUserRoleUseCase = class {
2751
3049
  constructor(roles, confirmer) {
@@ -2875,12 +3173,130 @@ function unassignUserRoleTool(deps) {
2875
3173
  }
2876
3174
  };
2877
3175
  }
3176
+ function listClientRolesTool(deps) {
3177
+ return {
3178
+ name: "keycloak_client_roles_list",
3179
+ title: "List client roles",
3180
+ description: "List the roles defined on a client.",
3181
+ level: "read" /* Read */,
3182
+ inputSchema: { clientId: z10.string() },
3183
+ annotations: {
3184
+ readOnlyHint: true,
3185
+ destructiveHint: false,
3186
+ idempotentHint: true
3187
+ },
3188
+ async handler(args) {
3189
+ const roles = await new ListClientRolesUseCase(
3190
+ deps.clientRepository,
3191
+ deps.roleRepository
3192
+ ).execute(ClientId.fromString(String(args.clientId)));
3193
+ return textResult(
3194
+ roles === null ? "Client not found." : JSON.stringify(roles.map(serializeRole), null, 2)
3195
+ );
3196
+ }
3197
+ };
3198
+ }
3199
+ function getUserClientRolesTool(deps) {
3200
+ return {
3201
+ name: "keycloak_user_client_roles_get",
3202
+ title: "Get a user's client roles",
3203
+ description: "List the client roles assigned to a user for a client.",
3204
+ level: "read" /* Read */,
3205
+ inputSchema: { userId: z10.string(), clientId: z10.string() },
3206
+ annotations: {
3207
+ readOnlyHint: true,
3208
+ destructiveHint: false,
3209
+ idempotentHint: true
3210
+ },
3211
+ async handler(args) {
3212
+ const roles = await new GetUserClientRolesUseCase(
3213
+ deps.clientRepository,
3214
+ deps.roleRepository
3215
+ ).execute({
3216
+ userId: UserId.fromString(String(args.userId)),
3217
+ clientId: ClientId.fromString(String(args.clientId))
3218
+ });
3219
+ return textResult(
3220
+ roles === null ? "Client not found." : JSON.stringify(roles.map(serializeRole), null, 2)
3221
+ );
3222
+ }
3223
+ };
3224
+ }
3225
+ function assignClientRoleTool(deps) {
3226
+ return {
3227
+ name: "keycloak_user_client_role_assign",
3228
+ title: "Assign a client role to a user",
3229
+ description: "Grant a client role to a user.",
3230
+ level: "write" /* Write */,
3231
+ inputSchema: {
3232
+ userId: z10.string(),
3233
+ clientId: z10.string(),
3234
+ role: z10.string()
3235
+ },
3236
+ annotations: {
3237
+ readOnlyHint: false,
3238
+ destructiveHint: false,
3239
+ idempotentHint: true
3240
+ },
3241
+ async handler(args) {
3242
+ const result = await new AssignClientRoleUseCase(
3243
+ deps.clientRepository,
3244
+ deps.roleRepository
3245
+ ).execute({
3246
+ userId: UserId.fromString(String(args.userId)),
3247
+ clientId: ClientId.fromString(String(args.clientId)),
3248
+ role: RoleName.fromString(String(args.role))
3249
+ });
3250
+ return textResult(
3251
+ result.assigned ? `Client role "${String(args.role)}" assigned.` : `Not assigned: ${result.reason ?? "unknown reason"}`
3252
+ );
3253
+ }
3254
+ };
3255
+ }
3256
+ function unassignClientRoleTool(deps) {
3257
+ return {
3258
+ name: "keycloak_user_client_role_unassign",
3259
+ title: "Remove a client role from a user",
3260
+ description: "Revoke a client role from a user. Requires confirmation.",
3261
+ level: "destructive" /* Destructive */,
3262
+ inputSchema: {
3263
+ userId: z10.string(),
3264
+ clientId: z10.string(),
3265
+ role: z10.string(),
3266
+ confirm: z10.boolean().optional()
3267
+ },
3268
+ annotations: {
3269
+ readOnlyHint: false,
3270
+ destructiveHint: true,
3271
+ idempotentHint: false
3272
+ },
3273
+ async handler(args) {
3274
+ const confirmer = deps.confirmers.create(args.confirm === true);
3275
+ const result = await new UnassignClientRoleUseCase(
3276
+ deps.clientRepository,
3277
+ deps.roleRepository,
3278
+ confirmer
3279
+ ).execute({
3280
+ userId: UserId.fromString(String(args.userId)),
3281
+ clientId: ClientId.fromString(String(args.clientId)),
3282
+ role: RoleName.fromString(String(args.role))
3283
+ });
3284
+ return textResult(
3285
+ result.removed ? `Client role "${String(args.role)}" removed.` : `Not removed: ${result.reason ?? "unknown reason"}`
3286
+ );
3287
+ }
3288
+ };
3289
+ }
2878
3290
  function buildRoleTools(deps) {
2879
3291
  return [
2880
3292
  listRealmRolesTool(deps),
2881
3293
  getUserRolesTool(deps),
3294
+ listClientRolesTool(deps),
3295
+ getUserClientRolesTool(deps),
2882
3296
  assignUserRoleTool(deps),
2883
- unassignUserRoleTool(deps)
3297
+ assignClientRoleTool(deps),
3298
+ unassignUserRoleTool(deps),
3299
+ unassignClientRoleTool(deps)
2884
3300
  ];
2885
3301
  }
2886
3302
 
@@ -3486,7 +3902,7 @@ function createServer(config) {
3486
3902
  const tools = filterTools(
3487
3903
  [
3488
3904
  ...buildUserTools({ userRepository, confirmers }),
3489
- ...buildRoleTools({ roleRepository, confirmers }),
3905
+ ...buildRoleTools({ roleRepository, clientRepository, confirmers }),
3490
3906
  ...buildClientTools({ clientRepository, confirmers }),
3491
3907
  ...buildClientScopeTools({
3492
3908
  clientRepository,