Setting the file. One moment.
Get Catalog Inputs · Microsoft Foundry · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page 36.9
Azd Setup
(opens in a new tab)
foundry-agent/toolbox/scripts/ get-catalog-inputs.sh
Shell · 253 lines · 13 KB
14
# Usage: ./get-catalog-inputs.sh [connector-name-substring] [--managed-oauth|--user-entra-token]
15 # The name substring is OPTIONAL — omit it to list every MCP tile.
16 # Example: ./get-catalog-inputs.sh # list ALL MCP tiles
17 # ./get-catalog-inputs.sh --managed-oauth # list all managed-OAuth tiles
18 # ./get-catalog-inputs.sh --user-entra-token # list all user-entra-token tiles
19 # ./get-catalog-inputs.sh github # narrow by name substring
20 #
21 # Requires: az (logged in), curl, python. The MCP catalog lives only in eastus,
22 # in the registry-prod-bl container (the public connectors-registry-prod-bl holds
23 # Logic Apps connectors, not MCP tools, so it is not queried).
24 #
25 # HOW AUTH TYPES ARE DETECTED (mirrors the portal, verified against
26 # app/src/components/tools/Config/ToolConfigFields.tsx + transformTools.tsx):
27 # The asset-gallery search entity's `properties` carry the auth signals the portal
28 # keys off — no separate managedApis call is needed:
29 # * host = "remotes" when kind == "mcp" and properties.remotes is non-empty.
30 # * x-ms-connector-name the Foundry-brokered OAuth app (e.g. "foundrygithubmcp").
31 # * x-ms-auth-schemas case-insensitive scheme keys (oauth2 / managedidentity /
32 # agentidentity); often EMPTY even for OAuth tiles.
33 # * x-ms-security-schemes the OpenAPI-style scheme block (declares oauth2, scopes).
34 # * x-ms-audience the resource the forwarded user token is scoped to.
35 # MANAGED OAUTH (portal supportsManagedOAuth) = has x-ms-connector-name OR
36 # x-ms-auth-schemas contains "oauth2". Foundry brokers the app; the config dialog
37 # needs connector-name + toolEntityId and does NOT ask for an audience.
38 # (github/vercel qualify via connector-name; sentinel/foundry-mcp via oauth2.)
39 # work_iq / fabric_iq are hard-coded managed in the portal.
40 # USER ENTRA TOKEN (Microsoft Entra -> Agent User Impersonation) = NO brokered
41 # connector-name, but the tile declares an oauth2 scheme AND an x-ms-audience, so
42 # the caller identity is forwarded. The config dialog REQUIRES that audience.
43 # (work_iq, foundry-mcp, sentinel qualify.) A tile can support BOTH.
44 #
45 # NOTE on serverUrl: remotes[].url is often null in the index (verified for
46 # github). When empty, supply the connector's documented MCP endpoint as
47 # --target (e.g. github Copilot -> https://api.githubcopilot.com/mcp).
48 set -euo pipefail
49
50 NAME = ""
51 FILTER = "all" # all | managed | entra
52 for arg in " $@ " ; do
53 case " $arg " in
54 --managed-oauth ) FILTER = "managed" ;;
55 --user-entra-token ) FILTER = "entra" ;;
56 -h | --help ) echo "usage: get-catalog-inputs.sh [connector-name-substring] [--managed-oauth|--user-entra-token]" >&2 ; exit 0 ;;
57 *) NAME = " $arg " ;;
58 esac
59 done
60 # NAME is OPTIONAL — with no substring the script lists every MCP tile in the registry.
61
62 GALLERY = "https://eastus.api.azureml.ms/asset-gallery/v1.0/tools"
63 # Prefer real `python`; the bare `python3` on Windows is often a broken Store alias.
64 PY = "$( command -v python || command -v python3 || true )"
65 [ -n " $PY " ] || { echo "error: python (or python3) is required" >&2 ; exit 2 ; }
66
67 TOKEN = $( az account get-access-token --resource "https://management.azure.com" --query accessToken -o tsv )
68
69 # Page through the MCP registry with NO name filter. This matters: adding an
70 # `annotations/name contains` filter makes the index return a THIN projection
71 # (only timestamps) that omits x-ms-auth-schemas / kind / remotes — the fields we
72 # classify on. Fetching unfiltered returns the full properties; we match $NAME
73 # client-side in the classifier. pageSize max is 100, so we page via continuationToken.
74 fetch_page () {
75 local ct = " $1 " body
76 if [ -n " $ct " ]; then
77 body = "{ \" freeTextSearch \" : \" * \" , \" filters \" :[{ \" field \" : \" entityContainerId \" , \" operator \" : \" eq \" , \" values \" :[ \" registry-prod-bl \" ]},{ \" field \" : \" type \" , \" operator \" : \" eq \" , \" values \" :[ \" tools \" ]}], \" pageSize \" :100, \" continuationToken \" : $ct }"
78 else
79 body = "{ \" freeTextSearch \" : \" * \" , \" filters \" :[{ \" field \" : \" entityContainerId \" , \" operator \" : \" eq \" , \" values \" :[ \" registry-prod-bl \" ]},{ \" field \" : \" type \" , \" operator \" : \" eq \" , \" values \" :[ \" tools \" ]}], \" pageSize \" :100}"
80 fi
81 curl -sS -X POST " $GALLERY " -H "Authorization: Bearer $TOKEN " -H "Content-Type: application/json" -d " $body "
82 }
83
84 echo "# Querying the MCP registry (registry-prod-bl) for ' $NAME '..." >&2
85 # Collect all pages into one NDJSON-ish stream separated by \x1e for the classifier.
86 PAGES = ""
87 CT = ""
88 for _ in $( seq 1 10 ); do # safety cap: 10 pages * 100 = 1000 entities
89 PAGE = $( fetch_page " $CT " )
90 PAGES = "${ PAGES }${ PAGE }"$'\x1e'
91 CT = $( printf '%s' " $PAGE " | " $PY " -c 'import sys,json;d=json.load(sys.stdin);ct=d.get("continuationToken");print(json.dumps(ct) if ct else "")' 2> /dev/null )
92 [ -n " $CT " ] || break
93 done
94
95 # Classify each entity from the index `properties` (the same fields the portal
96 # reads) and print a per-connector report, matched by $NAME and filtered by $FILTER.
97 printf '%s' " $PAGES " | FILTER = " $FILTER " NAME = " $NAME " " $PY " -c '
98 import sys, os, json, re
99
100 filt = os.environ.get("FILTER", "all")
101 HARDCODED_MANAGED = {"work_iq", "fabric_iq"}
102
103 def first_oauth_scope_key(props):
104 # x-ms-security-schemes: { <name>: { type: "oauth2", flows: { authorizationCode: { scopes: {<aud>: <desc>} } } } }
105 ss = props.get("x-ms-security-schemes") or {}
106 if isinstance(ss, str):
107 try: ss = json.loads(ss)
108 except Exception: ss = {}
109 for v in (ss.values() if isinstance(ss, dict) else []):
110 if isinstance(v, dict) and v.get("type") == "oauth2":
111 scopes = (((v.get("flows") or {}).get("authorizationCode") or {}).get("scopes")) or {}
112 if isinstance(scopes, dict) and scopes:
113 return next(iter(scopes.keys()))
114 return ""
115
116 def all_scopes(props):
117 ss = props.get("x-ms-security-schemes") or {}
118 if isinstance(ss, str):
119 try: ss = json.loads(ss)
120 except Exception: ss = {}
121 out = []
122 for v in (ss.values() if isinstance(ss, dict) else []):
123 if isinstance(v, dict) and v.get("type") == "oauth2":
124 scopes = (((v.get("flows") or {}).get("authorizationCode") or {}).get("scopes")) or {}
125 if isinstance(scopes, dict):
126 out.extend(scopes.keys())
127 return ",".join(out)
128
129 def has_oauth2_scheme(props):
130 # True if x-ms-security-schemes declares an oauth2 scheme (the tile can do OAuth).
131 ss = props.get("x-ms-security-schemes") or {}
132 if isinstance(ss, str):
133 try: ss = json.loads(ss)
134 except Exception: ss = {}
135 return any(isinstance(v, dict) and v.get("type") == "oauth2"
136 for v in (ss.values() if isinstance(ss, dict) else []))
137
138 seen = {}
139 for blob in sys.stdin.read().split("\x1e"):
140 blob = blob.strip()
141 if not blob:
142 continue
143 try:
144 doc = json.loads(blob)
145 except json.JSONDecodeError:
146 continue
147 for r in doc.get("value", []) or []:
148 eid = r.get("entityId", "")
149 props = r.get("properties", {}) or {}
150 m = re.search(r"objectId/([^/]+)", eid)
151 conn = m.group(1) if m else ""
152 tool_id = props.get("customProperties", {}).get("id") or r.get("name") or conn
153 title = props.get("title") or ""
154 name = (r.get("annotations", {}) or {}).get("name") or title or r.get("name") or conn
155
156 # Client-side name match (the request carries no name filter — see script header).
157 needle = os.environ.get("NAME", "").lower()
158 if needle and needle not in f"{name} {conn} {title} {tool_id}".lower():
159 continue
160
161 remotes = props.get("remotes") or []
162 url = (remotes[0].get("url") if remotes and isinstance(remotes[0], dict) else "") or ""
163 host = "remotes" if (props.get("kind") == "mcp" and remotes) else (props.get("customProperties", {}).get("type") or "")
164
165 schemes = props.get("x-ms-auth-schemas") or []
166 if isinstance(schemes, str):
167 try: schemes = json.loads(schemes)
168 except Exception: schemes = [schemes]
169 low = [str(s).lower() for s in schemes] if isinstance(schemes, list) else []
170
171 connector_name = props.get("x-ms-connector-name") or "" # Foundry-brokered app, e.g. "foundrygithubmcp"
172 audience = props.get("x-ms-audience") or ""
173 oauth2_scheme = has_oauth2_scheme(props)
174
175 # Auth classification (mutually exclusive — one type per tile):
176 # user-entra-token = "oauth2" listed in x-ms-auth-schemas (foundry-mcp, M365
177 # frontier, dataverse, sentinel, fabric) — the portal commits UserEntraToken.
178 # managed OAuth = has an x-ms-connector-name (github/vercel) OR an oauth2
179 # security-scheme with NO managedidentity/agentidentity declared in
180 # x-ms-auth-schemas (work_iq, yutori, oracle...). work_iq / fabric_iq hard-coded.
181 # AGENT IDENTITY (excluded here) = x-ms-auth-schemas lists managedidentity or
182 # agentidentity (Azure Language, Azure Managed Grafana, Azure AI Search) —
183 # see tool-mcp-agent-identity.md. Key-auth / no-auth tiles are also excluded.
184 is_remote = (host == "remotes")
185 has_mi_agent = ("managedidentity" in low) or ("agentidentity" in low)
186 entra = is_remote and ("oauth2" in low)
187 managed = (is_remote and (bool(connector_name) or (oauth2_scheme and not has_mi_agent)) or (str(tool_id).lower() in HARDCODED_MANAGED)) and not entra
188
189 if not audience:
190 audience = first_oauth_scope_key(props)
191 scopes = all_scopes(props)
192
193 # Dedup on objectId across versions/pages; prefer the record that actually
194 # classified (managed/entra) or carries auth signals — a thin projection has none.
195 key = conn or eid
196 new_rich = managed or entra or bool(low) or bool(connector_name) or bool(audience)
197 prev = seen.get(key)
198 if prev is not None:
199 old_rich = prev["managed"] or prev["entra"] or bool(prev["schemes"]) or bool(prev["connector_name"]) or bool(prev["audience"])
200 if old_rich and not new_rich:
201 continue
202 seen[key] = dict(name=name, title=title, conn=conn, eid=eid, url=url, host=host,
203 schemes=",".join(low), connector_name=connector_name,
204 managed=managed, entra=entra,
205 audience=audience, scopes=scopes)
206
207 count = 0
208 for r in seen.values():
209 if not r["conn"]:
210 continue
211 if filt == "managed" and not r["managed"]:
212 continue
213 if filt == "entra" and not r["entra"]:
214 continue
215 count += 1
216 print()
217 print(" name : " + str(r["name"]))
218 print(" displayName : " + (r["title"] or "<none>"))
219 print(" toolEntityId : " + str(r["eid"]))
220 print(" serverUrl : " + (r["url"] or "<empty — use documented MCP URL>"))
221 print(" authSchemes : " + (r["schemes"] or "<none>"))
222 print(" managedOAuth : " + str(r["managed"]).lower())
223 if r["managed"]:
224 # --connector-name for the managed-OAuth connection = the Foundry-brokered app name
225 # (x-ms-connector-name, e.g. "foundrygithubmcp"), NOT the toolEntityId objectId.
226 print(" connectorName : " + (r["connector_name"] or "<none — this tile has no brokered connector; use user-entra-token>"))
227 print(" userEntraToken : " + str(r["entra"]).lower())
228 if r["entra"]:
229 print(" audience : " + (r["audience"] or "<derive from OAuth scope key>"))
230 if r["managed"] and r["scopes"]:
231 print(" scopes : " + r["scopes"])
232 print()
233 label = {"managed": " supporting managed OAuth", "entra": " supporting user-entra-token", "all": ""}[filt]
234 print("# " + str(count) + " connector(s)" + label + ".")
235 '
236
237 cat >&2 << 'HINT'
238 # Managed OAuth (config dialog does NOT ask for an audience):
239 # azd ai connection create <name> \
240 # --kind remote-tool --auth-type oauth2 \
241 # --target <serverUrl-or-documented-MCP-URL> \
242 # --connector-name <connectorName> \
243 # --metadata type=catalog_MCP \
244 # --metadata toolEntityId=<toolEntityId> \
245 # --project-endpoint "$FOUNDRY_PROJECT_ENDPOINT"
246 #
247 # User Entra token (config dialog REQUIRES an audience):
248 # azd ai connection create <name> \
249 # --kind remote-tool --auth-type user-entra-token \
250 # --target <serverUrl-or-documented-MCP-URL> \
251 # --audience <audience-from-report> \
252 # --project-endpoint "$FOUNDRY_PROJECT_ENDPOINT"
253 HINT