Skip to content

Commit 0131c39

Browse files
authored
feat(tool/conversational-analytics-list-accessible-data-agents): return all data agents and support manual paging (#4066)
## Description list_data_agents issued a single dataAgents:listAccessible request and returned the raw response, so projects with more agents than one page silently received a truncated list plus a nextPageToken the caller had no parameter to act on. - Add optional page_size and page_token parameters; when either is set the tool returns that single page unchanged. - When both are omitted, drain every page and return the merged list, bounded by 1000 agents / 100 pages / 5 minutes. A nextPageToken is returned only when a bound stopped the drain early. - Preserve extra top-level fields (e.g. unreachable) by concatenating their JSON arrays across pages, and stop when the API repeats a page token. - Propagate ctx into every request and surface a paging hint on drain timeout. - Update the prebuilt config description and tool docs. ## PR Checklist > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [ ] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/mcp-toolbox/blob/main/CONTRIBUTING.md) - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/mcp-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure you have manually reviewed the entire diff before requesting a review - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes #
1 parent 4b7a44d commit 0131c39

5 files changed

Lines changed: 1481 additions & 90 deletions

File tree

‎docs/en/integrations/cloudgda/tools/conversational-analytics-list-accessible-data-agents.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,19 @@ aliases:
1313
A `conversational-analytics-list-accessible-data-agents` tool allows you to list
1414
data agents that are accessible.
1515

16+
The Conversational Analytics API returns accessible data agents one page at a
17+
time. By default this tool hides that: it follows every page itself and returns
18+
all accessible data agents in a single response. Set `page_size` or `page_token`
19+
to take over pagination and receive one page at a time instead.
20+
1621
It's compatible with the following sources:
1722

1823
- cloud-gemini-data-analytics
1924

20-
`conversational-analytics-list-accessible-data-agents` does not accept any parameters.
25+
`conversational-analytics-list-accessible-data-agents` accepts the following parameters:
26+
27+
- **`page_size`** (optional): The maximum number of data agents to return in this call. Must be a positive integer. Only set this to page through the results manually; when both `page_size` and `page_token` are omitted, every page is fetched automatically.
28+
- **`page_token`** (optional): A `nextPageToken` returned by a previous call, used to fetch the next page. Only set this to page through the results manually.
2129

2230
## Example
2331

@@ -31,6 +39,34 @@ description: |
3139
Use this tool to list available data agents.
3240
```
3341
42+
## Output Format
43+
44+
```json
45+
{
46+
"dataAgents": [
47+
{
48+
"name": "projects/my-project/locations/global/dataAgents/my-agent",
49+
"displayName": "My Agent",
50+
"createTime": "2026-01-01T00:00:00.000000Z",
51+
"updateTime": "2026-01-01T00:00:00.000000Z"
52+
}
53+
]
54+
}
55+
```
56+
57+
`nextPageToken` is only included when more data agents remain:
58+
59+
- **Without pagination parameters**, the response has no `nextPageToken`,
60+
because every page was already fetched. If the automatic fetch reaches its
61+
safety limit (100 pages or around 1000 data agents), it returns everything
62+
collected so far along with the `nextPageToken` it stopped at, so you can pass
63+
that token back as `page_token` to step through the rest. If a page fails or
64+
the automatic fetch exceeds its 5 minute budget, the call returns an error
65+
rather than a partial list.
66+
- **With `page_size` or `page_token`**, the response is the API's own page and
67+
carries a `nextPageToken` whenever more data agents are available. Pass it
68+
back as `page_token` to fetch the next page.
69+
3470
## Reference
3571

3672
| **field** | **type** | **required** | **description** |

‎internal/prebuiltconfigs/tools/conversational-analytics-with-data-agent.yaml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ source: conversational-analytics-source
2525
location: ${CLOUD_GDA_LOCATION:global}
2626
description: |
2727
List all available Data Agents that can be used for
28-
conversational analytics in the current project.
28+
conversational analytics in the current project. Every page is fetched
29+
automatically, so a single call returns all of them. Only if the response
30+
contains a `nextPageToken` are there more: pass that value back as
31+
`page_token` to step through the remaining pages one page at a time.
2932
---
3033
kind: tool
3134
name: get_data_agent_info

‎internal/tools/conversationalanalytics/conversationalanalyticslistaccessibledataagents/conversationalanalyticslistaccessibledataagents.go‎

Lines changed: 222 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ package conversationalanalyticslistaccessibledataagents
1717
import (
1818
"context"
1919
"encoding/json"
20+
"errors"
2021
"fmt"
2122
"io"
2223
"net/http"
24+
"net/url"
25+
"strconv"
2326
"time"
2427

2528
yaml "github.com/goccy/go-yaml"
@@ -34,6 +37,14 @@ import (
3437

3538
const resourceType string = "conversational-analytics-list-accessible-data-agents"
3639

40+
const (
41+
minPageSize = 1
42+
maxAutoDataAgents = 1000
43+
maxAutoPages = 100
44+
// maxAutoDuration bounds the total time an automatic drain may spend.
45+
maxAutoDuration = 5 * time.Minute
46+
)
47+
3748
func init() {
3849
if !tools.Register(resourceType, newConfig) {
3950
panic(fmt.Sprintf("tool type %q already registered", resourceType))
@@ -80,7 +91,20 @@ func (cfg Config) Initialize(context.Context) (tools.Tool, error) {
8091
cfg.Location = "global"
8192
}
8293

83-
params := parameters.Parameters{}
94+
minPageSizeValue := minPageSize
95+
params := parameters.Parameters{
96+
parameters.NewIntParameter(
97+
"page_size",
98+
"Optional. The maximum number of data agents to return in this call. Must be a positive integer. Only set this to page through the results manually: when both `page_size` and `page_token` are omitted, every page is fetched automatically and all accessible data agents are returned.",
99+
parameters.WithIntRequired(false),
100+
parameters.WithIntMinValue(&minPageSizeValue),
101+
),
102+
parameters.NewStringParameter(
103+
"page_token",
104+
"Optional. A page token returned as `nextPageToken` by a previous call, used to fetch the next page. Only set this to page through the results manually; omit it to fetch all accessible data agents at once.",
105+
parameters.WithStringRequired(false),
106+
),
107+
}
84108

85109
// finish tool setup
86110
return Tool{
@@ -121,6 +145,12 @@ func (t Tool) Invoke(ctx context.Context, s sources.Source, params parameters.Pa
121145
if !ok {
122146
return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, nil)
123147
}
148+
149+
pageSize, pageToken, tErr := parsePaginationParams(params)
150+
if tErr != nil {
151+
return nil, tErr
152+
}
153+
124154
var tokenSource oauth2.TokenSource
125155
var err error
126156
// Get credentials for the API call
@@ -147,38 +177,212 @@ func (t Tool) Invoke(ctx context.Context, s sources.Source, params parameters.Pa
147177
}
148178
}
149179

150-
// Construct URL
151-
projectID := source.GetProjectID()
152-
caURL := fmt.Sprintf("%s/v1/projects/%s/locations/%s/dataAgents:listAccessible", util.GetGDAEndpoint(), projectID, t.Cfg.Location)
180+
client, err := util.NewGDAClient(ctx, option.WithTokenSource(tokenSource))
181+
if err != nil {
182+
return nil, util.NewClientServerError("failed to create GDA client", http.StatusInternalServerError, err)
183+
}
184+
client.Timeout = 30 * time.Second
185+
186+
return listAccessibleDataAgents(ctx, client, util.GetGDAEndpoint(), source.GetProjectID(), t.Cfg.Location, pageSize, pageToken)
187+
}
188+
189+
// parsePaginationParams extracts optional page_size and page_token parameters.
190+
func parsePaginationParams(params parameters.ParamValues) (*int, string, util.ToolboxError) {
191+
paramsMap := params.AsMap()
192+
193+
var pageSize *int
194+
if ps, ok := paramsMap["page_size"]; ok && ps != nil {
195+
size, ok := ps.(int)
196+
if !ok {
197+
return nil, "", util.NewAgentError(fmt.Sprintf("error casting 'page_size' parameter: %v", ps), nil)
198+
}
199+
if size < minPageSize {
200+
return nil, "", util.NewAgentError(fmt.Sprintf("'page_size' must be positive, got %d", size), nil)
201+
}
202+
pageSize = &size
203+
}
204+
205+
var pageToken string
206+
if pt, ok := paramsMap["page_token"]; ok && pt != nil {
207+
s, ok := pt.(string)
208+
if !ok {
209+
return nil, "", util.NewAgentError(fmt.Sprintf("error casting 'page_token' parameter: %v", pt), nil)
210+
}
211+
pageToken = s
212+
}
213+
return pageSize, pageToken, nil
214+
}
215+
216+
// listAccessibleDataAgents returns a single page when pagination parameters are provided, or all pages by default.
217+
func listAccessibleDataAgents(ctx context.Context, client *http.Client, endpoint, projectID, location string, pageSize *int, pageToken string) (any, util.ToolboxError) {
218+
if pageSize != nil || pageToken != "" {
219+
body, tErr := listAccessibleDataAgentsPage(ctx, client, endpoint, projectID, location, pageSize, pageToken)
220+
if tErr != nil {
221+
return nil, tErr
222+
}
223+
var result any
224+
if err := json.Unmarshal(body, &result); err != nil {
225+
return nil, util.NewClientServerError("failed to decode response", http.StatusInternalServerError, err)
226+
}
227+
return result, nil
228+
}
229+
return listAllAccessibleDataAgents(ctx, client, endpoint, projectID, location, maxAutoDuration)
230+
}
231+
232+
// parseDataAgentsPage splits a page into its data agents, its page token, and
233+
// any other top-level fields, which are kept as raw JSON.
234+
func parseDataAgentsPage(body []byte) ([]json.RawMessage, string, map[string]json.RawMessage, error) {
235+
var page map[string]json.RawMessage
236+
if err := json.Unmarshal(body, &page); err != nil {
237+
return nil, "", nil, err
238+
}
239+
if page == nil {
240+
return nil, "", nil, fmt.Errorf("response is not a JSON object")
241+
}
242+
243+
var dataAgents []json.RawMessage
244+
if raw, ok := page["dataAgents"]; ok {
245+
if err := json.Unmarshal(raw, &dataAgents); err != nil {
246+
return nil, "", nil, fmt.Errorf("invalid 'dataAgents' field: %w", err)
247+
}
248+
}
249+
250+
var nextPageToken string
251+
if raw, ok := page["nextPageToken"]; ok {
252+
if err := json.Unmarshal(raw, &nextPageToken); err != nil {
253+
return nil, "", nil, fmt.Errorf("invalid 'nextPageToken' field: %w", err)
254+
}
255+
}
256+
257+
delete(page, "dataAgents")
258+
delete(page, "nextPageToken")
259+
return dataAgents, nextPageToken, page, nil
260+
}
261+
262+
// mergeExtras folds a page's extra top-level fields into the ones collected so
263+
// far, concatenating JSON arrays so lists such as `unreachable` keep every page.
264+
func mergeExtras(extras, pageExtras map[string]json.RawMessage) {
265+
for field, value := range pageExtras {
266+
extras[field] = concatJSONArrays(extras[field], value)
267+
}
268+
}
153269

154-
req, err := http.NewRequest("GET", caURL, nil)
270+
// concatJSONArrays joins two JSON arrays. When the values cannot be merged it
271+
// keeps whichever side holds real data: a non-array `prev` is overwritten by
272+
// `next` (last page wins for scalars), while an unmergeable `next` leaves the
273+
// already aggregated `prev` untouched so earlier pages are never dropped.
274+
func concatJSONArrays(prev, next json.RawMessage) json.RawMessage {
275+
if len(prev) == 0 {
276+
return next
277+
}
278+
if len(next) == 0 {
279+
return prev
280+
}
281+
var prevItems, nextItems []json.RawMessage
282+
if json.Unmarshal(prev, &prevItems) != nil || prevItems == nil {
283+
return next
284+
}
285+
if json.Unmarshal(next, &nextItems) != nil || nextItems == nil {
286+
// Keep what was aggregated so far instead of discarding earlier pages.
287+
return prev
288+
}
289+
merged, err := json.Marshal(append(prevItems, nextItems...))
155290
if err != nil {
156-
return nil, util.NewClientServerError("failed to create request", http.StatusInternalServerError, err)
291+
return prev
157292
}
158-
req.Header.Set("X-Goog-API-Client", util.GDAClientID)
293+
return merged
294+
}
159295

160-
client, err := util.NewGDAClient(ctx, option.WithTokenSource(tokenSource))
296+
// drainResult assembles a drained response. `nextPageToken` is omitted once the
297+
// drain reached the end of the list.
298+
func drainResult(extras map[string]json.RawMessage, dataAgents []json.RawMessage, pageToken string) map[string]any {
299+
result := make(map[string]any, len(extras)+2)
300+
for field, value := range extras {
301+
result[field] = value
302+
}
303+
result["dataAgents"] = dataAgents
304+
if pageToken != "" {
305+
result["nextPageToken"] = pageToken
306+
}
307+
return result
308+
}
309+
310+
// listAllAccessibleDataAgents fetches pages until completion or safety limits.
311+
// budget caps the total time spent draining pages; callers pass maxAutoDuration.
312+
func listAllAccessibleDataAgents(ctx context.Context, client *http.Client, endpoint, projectID, location string, budget time.Duration) (any, util.ToolboxError) {
313+
drainCtx, cancel := context.WithTimeout(ctx, budget)
314+
defer cancel()
315+
316+
dataAgents := []json.RawMessage{}
317+
pageToken := ""
318+
extras := map[string]json.RawMessage{}
319+
320+
for page := 0; page < maxAutoPages; page++ {
321+
body, tErr := listAccessibleDataAgentsPage(drainCtx, client, endpoint, projectID, location, nil, pageToken)
322+
if tErr != nil {
323+
if ctx.Err() == nil && errors.Is(tErr, context.DeadlineExceeded) {
324+
return nil, util.NewAgentError("timed out while fetching accessible data agents; set 'page_size' and 'page_token' to page through them instead", tErr)
325+
}
326+
return nil, tErr
327+
}
328+
329+
pageAgents, nextPageToken, pageExtras, err := parseDataAgentsPage(body)
330+
if err != nil {
331+
return nil, util.NewClientServerError("failed to decode response", http.StatusInternalServerError, err)
332+
}
333+
334+
dataAgents = append(dataAgents, pageAgents...)
335+
mergeExtras(extras, pageExtras)
336+
337+
// A repeated token means the API is not advancing; stop rather than loop.
338+
if nextPageToken == pageToken {
339+
nextPageToken = ""
340+
}
341+
pageToken = nextPageToken
342+
343+
if pageToken == "" || len(dataAgents) >= maxAutoDataAgents {
344+
break
345+
}
346+
}
347+
348+
return drainResult(extras, dataAgents, pageToken), nil
349+
}
350+
351+
// listAccessibleDataAgentsPage sends a GET request for a single page of data agents.
352+
func listAccessibleDataAgentsPage(ctx context.Context, client *http.Client, endpoint, projectID, location string, pageSize *int, pageToken string) ([]byte, util.ToolboxError) {
353+
caURL := fmt.Sprintf("%s/v1/projects/%s/locations/%s/dataAgents:listAccessible", endpoint, projectID, location)
354+
355+
query := url.Values{}
356+
if pageSize != nil {
357+
query.Set("pageSize", strconv.Itoa(*pageSize))
358+
}
359+
if pageToken != "" {
360+
query.Set("pageToken", pageToken)
361+
}
362+
if len(query) > 0 {
363+
caURL += "?" + query.Encode()
364+
}
365+
366+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil)
161367
if err != nil {
162-
return nil, util.NewClientServerError("failed to create GDA client", http.StatusInternalServerError, err)
368+
return nil, util.NewClientServerError("failed to create request", http.StatusInternalServerError, err)
163369
}
164-
client.Timeout = 30 * time.Second
370+
req.Header.Set("X-Goog-API-Client", util.GDAClientID)
371+
165372
resp, err := client.Do(req)
166373
if err != nil {
167374
return nil, util.NewClientServerError("failed to send request", http.StatusInternalServerError, err)
168375
}
169376
defer resp.Body.Close()
170377

378+
body, err := io.ReadAll(resp.Body)
379+
if err != nil {
380+
return nil, util.NewClientServerError("failed to read response", http.StatusInternalServerError, err)
381+
}
171382
if resp.StatusCode != http.StatusOK {
172-
body, _ := io.ReadAll(resp.Body)
173383
return nil, util.NewAgentError(fmt.Sprintf("API returned non-200 status: %d %s", resp.StatusCode, string(body)), nil)
174384
}
175-
176-
var result any
177-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
178-
return nil, util.NewClientServerError("failed to decode response", http.StatusInternalServerError, err)
179-
}
180-
181-
return result, nil
385+
return body, nil
182386
}
183387

184388
func (t Tool) RequiresClientAuthorization(source sources.Source) (bool, error) {

0 commit comments

Comments
 (0)