Une URL et rien d'autre : un serveur MCP distant avec Bedrock AgentCore Gateway

Par Benjamin Meudre
  • MCP
  • AgentCore Gateway
  • Amazon Bedrock
  • OAuth 2.1
  • Sécurité
  • Terraform

MCP est le protocole par lequel un assistant IA appelle des outils. Un serveur MCP distant est un serveur qu’il atteint en HTTPS, au lieu d’un processus lancé sur le poste du développeur. Le serveur vit donc dans votre compte, derrière votre propre authentification, et tout le monde parle au même.

Voici ce qu’il a fallu pour placer un serveur MCP distant devant une API REST qui existait déjà, sans écrire de serveur : l’architecture, le seul problème vraiment difficile, et les trois endroits où la documentation AWS s’arrête.

TL;DR

  • Aucun code de serveur MCP. Amazon Bedrock AgentCore Gateway assure la traduction du protocole à partir d’une spécification OpenAPI. Le backend continue de servir les mêmes routes qu’il servait à l’application web.
  • Une URL, et c’est toute la configuration côté client. Aucun jeton, aucun secret client, aucun compte de service, aucune credential AWS sur le poste du développeur.
  • Une topologie inhabituelle. La même Lambda est à la fois le fournisseur d’identité auquel la passerelle fait confiance à l’entrée, et un client OAuth confidentiel d’elle-même à la sortie. Il n’y a aucun Cognito : le serveur d’autorisation est écrit à la main dans le backend.
  • Le point difficile était de conserver l’identité de l’humain. La transporter à travers la passerelle demande deux audiences et un échange de jeton RFC 8693. En échange, l’API authentifie la personne qui s’est connectée, et non la passerelle.
  • Rien ne peut écrire. 18 routes en lecture seule exposées, 23 exclues, décidées dans une seule table qui alimente à la fois le générateur de spécification et l’autorisation à l’exécution. La spec ne peut donc pas annoncer un outil que l’API refuserait.

En mots, avant le schéma : l’IDE appelle la passerelle sans jeton et s’entend dire où se connecter. Il s’enregistre lui-même, ouvre un navigateur, et l’utilisateur se connecte comme il le fait toujours. Ensuite, chaque appel d’outil porte le jeton issu de cette connexion, et la passerelle l’échange contre un second jeton que l’API, elle, accepte.

Diagramme de séquence à quatre participants : l'IDE, le navigateur, la Lambda backend qui fait à la fois API et serveur OAuth, et AgentCore Gateway. L'IDE appelle initialize sans jeton et reçoit un 401 porteur d'un défi WWW-Authenticate. Il récupère ensuite le document de découverte, s'enregistre dynamiquement, obtient un client_id sans secret, ouvre le point d'autorisation dans le navigateur avec PKCE, et l'utilisateur effectue la connexion sans mot de passe habituelle de la plateforme. La redirection vers une adresse de boucle locale renvoie un code, que l'IDE échange contre le jeton A, d'audience platform-mcp-gateway. Le jeton A n'ouvre que la passerelle. Au premier appel d'outil, la passerelle échange le jeton A contre le jeton B, d'audience platform-api, et appelle l'API avec celui-ci. Le résultat remonte jusqu'à l'IDE.
Dix-sept messages, dont un seul est visible pour l'utilisateur : la page de connexion. Les étapes 3 à 11 n'ont lieu qu'une fois ; les étapes 12 à 17 se rejouent à chaque appel d'outil.

Ce qui existait déjà

Une plateforme B2B de supervision d’un parc d’appareils connectés déployés sur le terrain : un SPA React, un backend AWS Lambda avec une API typée décrite par schéma où toutes les routes sont des POST, DynamoDB pour le stockage, CloudFront devant. L’API existait déjà et fonctionnait déjà, authentifiée par session cookie depuis l’application web.

L’objectif était de la rendre utilisable par des agents. Deux choses que je ne voulais pas : écrire et exploiter un serveur MCP, et ajouter un second chemin d’authentification à un système dont le premier était déjà audité.

Une seule Lambda des deux côtés de la frontière de confiance

AgentCore Gateway prend une spécification OpenAPI et un fournisseur de credentials sortantes, et publie chaque opération comme un outil MCP. C’est la partie qui fait gagner le travail, et elle est bien documentée.

Des clients MCP hétérogènes atteignent des APIs existantes via une seule passerelleTrois natures de client MCP — un agent dans l’IDE sur le poste d’un développeur, un agent Strands hébergé sur Amazon Bedrock AgentCore Runtime, et un agent applicatif auto-hébergé — parlent tous MCP à une seule AgentCore Gateway. La passerelle est le seul composant qui parle MCP. En dessous, elle appelle plusieurs APIs REST existantes en HTTPS, à partir de leurs descriptions OpenAPI. Ajouter un client ou une cible ne change rien de l’autre côté.Clients MCP, où qu’ils tournentAgent IDEposte développeurAgent StrandsAgentCore Runtimeautres clients MCPMCPAgentCore Gateway — un seul point d’entrée MCPAPI RESTOpenAPI, lecture seuleAPI tiercecredentials sortantesService internemême Lambdaautres ciblesHTTPS
La passerelle est le seul composant qui parle MCP, et c’est pourquoi aucun code de serveur MCP n’a été écrit. Au-dessus, n’importe quel client MCP, où qu’il tourne ; en dessous, les APIs qui existaient déjà, appelées en simple HTTPS.

Ce qui l’est moins, c’est de savoir sur quoi la brancher. La passerelle a besoin de deux choses : un fournisseur d’identité entrant dont elle acceptera les jetons, et des credentials sortantes pour appeler la cible.

J’ai utilisé le backend pour les deux. Il est le fournisseur OpenID auquel la passerelle fait confiance à l’entrée. Il est aussi un client OAuth confidentiel de lui-même quand la passerelle revient échanger un jeton, à la sortie. Les deux sauts atterrissent sur la même Lambda, derrière la même distribution CloudFront.

C’est ce qui rend suffisants un authorizer CUSTOM_JWT et un document de découverte auto-hébergé, et c’est pourquoi il n’y a pas de Cognito dans cette architecture. Toute l’histoire de l’entrée tient dans un seul bloc Terraform :

resource "aws_bedrockagentcore_gateway" "mcp" {
  name          = local.mcp_gateway_name          # ([0-9a-zA-Z][-]?){1,48} — no underscores
  role_arn      = aws_iam_role.mcp_gateway_role.arn
  protocol_type = "MCP"

  # AWS_IAM would require the IDE to hold AWS credentials; NONE would make the gateway an open
  # proxy to the API.
  authorizer_type = "CUSTOM_JWT"

  authorizer_configuration {
    custom_jwt_authorizer {
      discovery_url = local.mcp_gateway_discovery_url
      # One half of the audience split. The backend accepts ONLY `platform-api`, so a token
      # presented here cannot be replayed there, and the exchange hop cannot be skipped.
      allowed_audience = ["platform-mcp-gateway"]
      # Also what the gateway advertises in the RFC 9728 `WWW-Authenticate` challenge: that is how
      # the IDE learns which scope to request, with nothing configured by hand.
      allowed_scopes = ["mcp:api"]
    }
  }

  # The only accepted value. The difference between "tool call failed" and "the token exchange
  # returned invalid_scope".
  exception_level = terraform.workspace == "prod" ? null : "DEBUG"
}

Pourquoi deux jetons, et pas un

Le claim aud (audience) d’un jeton nomme le seul service pour lequel ce jeton est valable. Tout ce qui suit découle du fait d’avoir donné deux audiences différentes aux deux sauts.

L’IDE détient un jeton, la passerelle en obtient un autre, et aucun des deux ne fonctionne là où l’autre a sa place.

Jeton A Jeton B
aud platform-mcp-gateway platform-api
Détenu par l’IDE la passerelle, ~15 min
scope mcp:api api
Durée de vie 1 h (+ 30 j de refresh) 15 min, sans rafraîchissement

Le jeton A est celui que produit la connexion dans le navigateur. L’IDE le conserve et l’envoie à la passerelle. Il ouvre la passerelle, et rien d’autre.

Le jeton B est celui que la passerelle obtient à chaque appel d’outil, en présentant le jeton A au serveur d’autorisation. Il ouvre l’API pendant quinze minutes et n’est jamais rafraîchi.

Cette séparation existe pour qu’aucun des deux sauts ne puisse être contourné, et elle coupe dans trois directions :

  • Le serveur d’autorisation n’émet pas de jeton d’audience platform-api par le parcours navigateur. L’IDE ne peut donc pas en obtenir un et appeler l’API directement.
  • L’API n’accepte pas de jeton d’audience platform-mcp-gateway. Ce que détient l’IDE est donc inutilisable contre l’API, même en cas de fuite.
  • Le jeton B, s’il fuit, ouvre l’API quinze minutes et ne s’échange contre rien d’autre.

Le claim qui fait le vrai travail, c’est sub, le sujet : la personne dont ce jeton parle. Il est recopié tel quel du jeton A vers le jeton B, la passerelle n’a donc pas voix au chapitre sur l’identité qu’elle relaie. C’est ce qui fait que l’API authentifie l’humain qui s’est connecté, et non la passerelle qui l’appelle.

Le gestionnaire d’échange exécute cinq étapes, dans un ordre choisi délibérément :

// 1. Authentifier la passerelle AVANT de regarder le jeton sujet, pour qu'un appelant
//    non authentifié n'apprenne rien.
authenticateConfidentialClient({
  presented: extractClientCredentials({headers: req.headers, form: req.form}),
  expected: await getGatewayClientCredentials(),
});

// 2. Seule l'audience passerelle est acceptée ici : un jeton d'audience API est rejeté.
subjectClaims = verifyJwt({
  token: subjectToken,
  publicKey: await getOauthPublicKey(),
  issuer: getIssuer(),
  audience: MCP_GATEWAY_AUDIENCE,
});

// 3. L'échange peut restreindre la portée, jamais l'élargir.
const exchange = validateTokenExchange({
  form: req.form,
  subjectClaims,
  apiResourceUris: [getIssuer()],
});

// 4. Un compte vivant est toujours exigé au moment de l'échange.
const email = await store.getEmailBySubject({sub: exchange.subject});
if (email === undefined) {
  throw new OauthError({code: 'invalid_grant'});
}

// 5. `sub` est recopié tel quel : c'est ce qui fait que l'API authentifie l'humain
//    et non la passerelle.
const accessToken = await signJwt({
  claims: buildAccessTokenClaims({
    issuer: getIssuer(),
    subject: exchange.subject,
    audience: exchange.audience, // platform-api
    scope: exchange.scope, // api
    ttlSeconds: exchange.ttlSeconds, // 15 min, no refresh
  }),
  signer,
});

L’étape 1 est en premier pour une raison. Authentifier la passerelle avant de regarder le jeton sujet fait qu’un appelant non authentifié n’apprend rien sur la validité d’un jeton qu’il détiendrait.

Deux contraintes protègent ce grant. C’est le seul grant à client confidentiel du serveur — le seul dont l’appelant se prouve avec un secret — et ses credentials sont provisionnées hors bande. Aucun client enregistré dynamiquement ne peut l’atteindre.

Tout autre client s’enregistre lui-même, et s’enregistre comme public : sans secret, parce qu’un IDE sur un poste n’a nulle part où en garder un. Les clients publics reçoivent PKCE à la place, qui prouve que celui qui termine la connexion est bien celui qui l’a commencée. Les URI de redirection sont restreintes à la boucle locale, le port n’étant volontairement pas comparé, conformément à la RFC 8252 §7.3.

Cette dernière règle n’est pas cosmétique. L’enregistrement est ouvert par conception : autoriser un callback https:// arbitraire permettrait donc à un attaquant d’enregistrer le sien et d’attirer un utilisateur déjà connecté vers un /oauth/authorize forgé. PKCE n’y changerait rien, puisque c’est alors l’attaquant qui détient le verifier.

Deux décisions plus petites dans le même registre, assez peu coûteuses pour qu’il n’y ait pas de raison de s’en priver :

  • Les JWT sont signés par KMS, avec ECC_NIST_P256, soit ES256 au sens JOSE. La Lambda reçoit kms:Sign et kms:GetPublicKey, mais délibérément pas kms:Verify. La vérification est locale, contre une clé publique mise en cache : authentifier une requête coûte donc zéro appel KMS, et seule l’émission coûte quelque chose.
  • Le claim sub est opaque, associé à une adresse e-mail par une table. Un e-mail ne circule jamais dans un jeton, et supprimer un compte tue instantanément tous les jetons vivants en supprimant l’association.

Lecture seule, décidé à un seul endroit

Aucun outil MCP ne peut écrire. C’est garanti par une seule table, qui alimente mécaniquement le générateur OpenAPI et l’autorisation à l’exécution : la spec ne peut donc jamais annoncer un outil que l’API refuserait. Les motifs d’exclusion sont des données et non des commentaires, ce qui permet à un test d’en exiger un :

export const EXCLUDED_FROM_MCP: Readonly<Record<string, string>> = {
  '/update-account': CHANGES_STATE,
  // ...
  // Writes the device status and its message history through `refreshDeviceStatus`, so it changes
  // state even though it reads like a query.
  '/resolve-device-anomaly-category': `${CHANGES_STATE} (persists a new device status)`,
  // Irreversible destruction of the account, and it takes no parameters, so a single confused or
  // injected instruction would be enough.
  '/delete-account': `${CHANGES_STATE}, irreversibly, and takes no parameters`,
  // Sends a login code by email, so an agent could email arbitrary addresses on demand.
  '/code': 'sends email to an arbitrary address',
};

/** Returns the scope required to call a route with a JWT, or undefined if it is not exposed. */
export function requiredScopeForJwtRoute(path: string): ApiScope | undefined {
  return MCP_ROUTES.has(path) ? API_ACCESS_SCOPE : undefined;
}

18 routes exposées, 23 exclues. Lire cette table est l’heure la plus utile du projet, parce que les noms mentent. refreshDeviceStatus appelle putAlertDeviceMessage : toute route qui rafraîchit le statut d’un appareil écrit donc en base, malgré un nom de getter.

J’ai donc audité chaque route exposée en lisant le corps des handlers à la recherche de primitives d’écriture, plutôt qu’en faisant confiance au verbe dans le chemin. Le risque résiduel mérite d’être dit clairement : les tests de garde raisonnent sur des noms, donc un handler qui gagnera une écriture le mois prochain conservera l’outil en lecture seule qu’il a aujourd’hui.

Trois choses que la documentation ne dit pas

GetResourceOauth2Token est autorisée sur trois ressources successives, et la documentation en couvre une. La ressource documentée est token-vault/<id>/oauth2credentialprovider/<name>. L’action exige aussi workload-identity-directory/default/workload-identity/<gateway-id> et la racine du coffre, token-vault/default. Chacune manquante produit exactement le même message opaque dans le client MCP — insufficient permissions for token exchange — qui ne nomme aucune ressource.

Ce qui a rendu la chose réellement coûteuse : aws iam simulate-principal-policy répond allowed quand on l’interroge sur l’ARN du fournisseur, parce que ce n’est pas la ressource évaluée. CloudTrail est le seul endroit qui nomme la vraie ressource, avec quelques minutes de latence d’indexation. J’ai trouvé l’ensemble complet en corrigeant une ressource, en retestant, et en lisant le refus suivant :

{
  # GetResourceOauth2Token is evaluated against THREE resources in sequence and denies on the
  # first one missing:
  #   1. workload-identity-directory/default/workload-identity/<gateway-id>
  #   2. token-vault/<vault-id>                      <- the vault root
  #   3. token-vault/<vault-id>/oauth2credentialprovider/<provider-name>
  # The AWS documentation lists only (3). Each missing one produces the same opaque
  # "insufficient permissions for token exchange", and CloudTrail is the only place that names
  # the resource actually being evaluated.
  Effect = "Allow"
  Action = ["bedrock-agentcore:GetResourceOauth2Token"]
  # NOT `token-vault/*`: an IAM `*` also matches `/`, which would grant this on every credential
  # provider in the vault.
  Resource = [
    "${local.arn_prefix}:token-vault/default",
    "${local.arn_prefix}:token-vault/*/oauth2credentialprovider/${local.provider_name}",
  ]
}

La racine du coffre est écrite littéralement token-vault/default et non token-vault/*, et ce n’est pas un détail : en IAM, un * couvre aussi le /, donc la forme avec joker accorderait l’action sur tous les fournisseurs de credentials du coffre.

CreateGateway va chercher et analyse votre URL de découverte, donc sur un environnement neuf le premier terraform apply doit échouer par construction. Avant que le backend ne soit déployé, ce chemin renvoie l’index.html du SPA — un 200 en text/html — et la création échoue sur Failed to create gateway dependencies: Invalid discovery document. L’ordre est donc : apply, provisionner les secrets, déployer, apply à nouveau, puis créer la cible.

Le piège est ce que l’échec laisse derrière lui. La passerelle est absente de l’état Terraform mais existe côté AWS en statut FAILED, il faut donc la supprimer à la main sous peine de les accumuler.

Deux des cinq ressources AWS ne s’expriment pas en Terraform. Le provider AWS 6.58.0 n’expose que client_id, client_secret et oauth_discovery sous custom_oauth2_provider_config. Aucun moyen de définir onBehalfOfTokenExchangeConfig ni clientAuthenticationMethod, sans quoi la passerelle ne peut pas échanger le jeton de l’utilisateur — c’est-à-dire tout le mécanisme. awscc fournit awscc_bedrockagentcore_gateway, mais ni _gateway_target ni _oauth2_credential_provider.

Le fournisseur de credentials et la cible de la passerelle sont donc créés par un script shell idempotent qui appelle bedrock-agentcore-control, et le prix est écrit noir sur blanc dans le code : aucune détection de dérive sur ces deux-là.

Ce que le client a à configurer

Voici l’intégralité :

{
  "mcpServers": {
    "platform": {
      "url": "https://<gateway-id>.gateway.bedrock-agentcore.<region>.amazonaws.com/mcp"
    }
  }
}

Aucun jeton, aucun secret, aucun compte de service. Le client découvre le serveur d’autorisation à partir de cette URL, s’enregistre lui-même, ouvre le navigateur, et chaque appel d’outil suivant s’exécute au nom de l’humain qui s’est connecté.

Ce que je ferais autrement

Les tests de garde raisonnent sur des noms de routes. C’était le choix pragmatique et c’est le point le plus faible de la conception. Une route figure sur la liste en lecture seule à cause de ce que faisait son handler le jour où je l’ai lu, et rien dans la suite de tests ne remarquerait que cela a changé. Ce que je veux vraiment, c’est une vérification sur le graphe d’appels du handler : si une route atteignable depuis la table MCP peut atteindre une primitive d’écriture, le build échoue. C’est un vrai chantier, pas une après-midi, et c’est pourquoi il n’existe pas encore.

Les deux ressources créées par script shell sont une dette assumée. Elles sont idempotentes et versionnées, mais rien ne détecte de dérive dessus, et je ne saurai pas si quelqu’un modifie le fournisseur de credentials depuis la console. J’aurais préféré attendre le support côté provider plutôt qu’écrire le script — sauf que la fonctionnalité ne marche pas sans ces champs, donc il n’y avait rien à attendre.

L’amorçage en plusieurs étapes est laid. « Apply, encaisser un échec, déployer, apply à nouveau, supprimer à la main la passerelle en FAILED » n’est pas une procédure que j’ai envie de transmettre. C’est exactement le genre de chose qu’on oublie entre deux environnements neufs à six mois d’intervalle. Cela demande à devenir un script unique qui porte toute la séquence.

La lecture seule est une contrainte que j’ai choisie, pas une contrainte imposée par l’architecture. Autoriser l’écriture par MCP demanderait ce que cette conception n’a pas : une étape de consentement réellement visible par l’utilisateur, outil par outil, au moment de l’appel. En attendant, un agent qui ne peut que lire est une limite que je défends volontiers, et que je serais mal à l’aise de retirer en silence.

Tous les articles

Parlez-nous directement

Écrivez-nous directement. Pas de commercial, pas d'appel de qualification. C'est l'un de nous deux qui répond, sous 24h.

contact@onescale.io

Nous répondons sous 24h, et c'est l'un de nous deux qui répond.

Basés à Lyon, nous travaillons en France, en Europe et à l’international.