DEV Community

Cover image for ¿Cómo encuentra un conector personalizado de Claude el servidor OAuth de osFoundry y por qué se comprueba el acceso en cada llamada?
BBT for BBT Español

Posted on

¿Cómo encuentra un conector personalizado de Claude el servidor OAuth de osFoundry y por qué se comprueba el acceso en cada llamada?

Esto es lo que recibe un cliente MCP remoto cuando llama a una herramienta de osFoundry sin token. La petición se hizo con curl desde un Mac en Tokio el 2026-11-06:

$ curl -s -D - -X POST https://api.osfoundry.io/api/v1/external-mcp \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
         "params":{"name":"list_project_resources","arguments":{}}}'

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp", error="invalid_token", error_description="Connect osFoundry to continue"

{"jsonrpc":"2.0","id":3,"result":{"isError":true,
  "content":[{"type":"text","text":"Authentication required"}],
  "_meta":{"mcp/www_authenticate":["Bearer resource_metadata=\"https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp\", ..."]}}}
Enter fullscreen mode Exit fullscreen mode

Con esa única cabecera, el conector personalizado de Claude, o la conexión MCP del modo desarrollador de ChatGPT, encuentra la página de inicio de sesión. El usuario solo pega la URL. Todo lo que sigue sale de peticiones sin autenticar al endpoint en producción, más una pequeña reconstrucción en Python, ejecutada en local, que mide lo que un curl no puede enseñar: qué pasa con el acceso después de iniciar sesión.

¿Qué ve el cliente antes de tener un token?

Tres llamadas, tres respuestas distintas:

Petición Estado Qué devuelve
initialize 200 protocolo 2025-06-18, servidor osfoundry 0.1.0, más instrucciones
tools/list 200 59 herramientas, 45,368 bytes
tools/call 401 WWW-Authenticate apuntando a los metadatos del recurso
GET sobre el endpoint 405 no hay stream SSE por GET

El handshake y el catálogo de herramientas son públicos. Solo llamar a una herramienta exige token. Así el host puede enseñar al usuario qué herramientas existen antes de que nadie inicie sesión. Nada del catálogo son datos de usuario, y el texto de instructions en initialize es genérico:

Use list_granted_resources first. Every Note edit must use the exact tab ID.
Local-only data is unavailable to cloud clients.
Enter fullscreen mode Exit fullscreen mode

Cada herramienta de tools/list declara el scope OAuth que necesita y si solo lee:

{"name": "edit_record_item",
 "annotations": {"readOnlyHint": false, "destructiveHint": true, "openWorldHint": false},
 "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Enter fullscreen mode Exit fullscreen mode

De las 59 herramientas, 30 necesitan osfoundry:read y llevan readOnlyHint: true, 26 necesitan osfoundry:write y 3 necesitan osfoundry:run (request_session_run, run_graph_session, cancel_graph_run). Un host puede usar readOnlyHint para decidir qué llamadas piden confirmación al usuario.

¿Adónde le manda el 401?

resource_metadata sigue la RFC 9728 (OAuth Protected Resource Metadata). El sufijo well-known va justo después del host, con la ruta del recurso añadida detrás. No se añade al final de la URL del recurso:

GET /.well-known/oauth-protected-resource/api/v1/external-mcp   200
GET /.well-known/oauth-protected-resource                       200 (same document)
GET /api/v1/external-mcp/.well-known/oauth-protected-resource   404
Enter fullscreen mode Exit fullscreen mode
{"resource": "https://api.osfoundry.io/api/v1/external-mcp",
 "authorization_servers": ["https://api.osfoundry.io"],
 "scopes_supported": ["osfoundry:read", "osfoundry:write", "osfoundry:run", "osfoundry:models"]}
Enter fullscreen mode Exit fullscreen mode

Si escribes un cliente a mano, ese 404 es el primer error que vas a cometer. Lee la URL de la cabecera en lugar de construirla tú.

¿Qué dice de sí mismo el servidor de autorización?

/.well-known/oauth-authorization-server (RFC 8414):

{"issuer": "https://api.osfoundry.io",
 "authorization_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/authorize",
 "token_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/token",
 "registration_endpoint": "https://api.osfoundry.io/api/v1/external-oauth/register",
 "grant_types_supported": ["authorization_code", "refresh_token"],
 "token_endpoint_auth_methods_supported": ["none"],
 "code_challenge_methods_supported": ["S256"],
 "authorization_response_iss_parameter_supported": true}
Enter fullscreen mode Exit fullscreen mode

Léelo como una cadena de decisiones. Hay un registration_endpoint, así que el host puede registrarse solo (registro dinámico de clientes) y el usuario nunca toca un client ID. La autenticación en el token es none, así que el host es un cliente público sin client secret, y lo que ata el código al cliente que lo pidió es PKCE con S256. iss en la respuesta de autorización evita confusiones cuando un host habla con varios servidores de autorización.

El paso de autorización lleva el navegador a la página de consentimiento de osFoundry, https://osfoundry.io/external-assistant-authorize. Ahí el usuario elige exactamente un workspace y marca "I approve connecting this assistant to …" (apruebo conectar este asistente a …). La aprobación nombra un workspace y nada más. Una conexión nueva no tiene proyectos. El propietario los añade después en la app (key icon → Connected → Add to project).

¿Por qué no basta con el scope?

Un scope se fija cuando se emite el token. osfoundry:write dice que la conexión puede hacer acciones de escritura. No dice qué Note, y no puede saber que esa Note se movió a otro proyecto hace diez minutos. Las notas de diseño de osFoundry enumeran lo que se vuelve a leer en cada llamada: estado de la conexión, workspace, pertenencia al proyecto, el proyecto actual y la ACL del recurso, pertenencia al canal o rol en la sesión, la herramienta y el scope OAuth. Además, la persona que aprobó la conexión tiene que seguir teniendo permisos de edición sobre el elemento. Quitar o reasignar algo "tiene efecto inmediato".

La alternativa es meter la lista de proyectos en el token, o en una caché indexada por el token, y fiarse de ella hasta que caduque. Es más barato de escribir. La reconstrucción de abajo implementa las dos cosas para poder medir la diferencia de comportamiento.

La reconstrucción

No hay ningún binario de Claude de por medio. client.py hace de host y server.py hace de servidor MCP. Los dos usan solo la biblioteca estándar, unas 280 líneas entre ambos, y se ejecutaron con Python 3.9.6 en un Apple M1. El servidor implementa initialize, tools/list y tools/call sobre JSON-RPC en HTTP plano, los dos documentos well-known, /authorize (la página de consentimiento se aprueba sola para un workspace) y /token con PKCE S256. Unas cuantas rutas /admin/* sustituyen a los clics del propietario en la pestaña Connected.

La comprobación que se ejecuta antes de cada herramienta:

def access(token_info, tool, note_id):
    if TOOL_SCOPE[tool] not in token_info["scopes"]:
        return "insufficient_scope"
    if CHECK == "token_only":                     # design B: trust issue-time state
        ok = note_id is None or NOTES[note_id]["project"] in token_info["projects_at_issue"]
        return None if ok else "not_in_project"
    conn = CONNECTIONS.get(token_info["connection"])
    if not conn or conn["status"] != "active":
        return "connection_revoked"
    if note_id is None:
        return None
    note = NOTES.get(note_id)
    if not note or note["workspace"] != conn["workspace"]:
        return "not_found"
    if (token_info["connection"], note["project"]) not in PROJECT_MEMBERS:
        return "not_in_project"
    return None
Enter fullscreen mode Exit fullscreen mode

El cliente arranca sin token. Llama a una herramienta, recibe el 401, saca resource_metadata de WWW-Authenticate, descarga los dos documentos de metadatos, hace authorize y token con un verificador PKCE nuevo y luego recorre el escenario:

$ CHECK=per_call python3 server.py &  python3 client.py 8767
discovery: first call -> 401, metadata /.well-known/oauth-protected-resource/mcp, 5 round trips, 11.3 ms
1 fresh connection, no project yet           200 OK   []
2 after owner adds project A                 200 OK   ["note-a1"]
3 read note in A                             200 OK   "Q3 launch checklist"
4 read note in B (never added)               200 DENY not_in_project
5 write with a read-only token               403 DENY insufficient_scope
6 write with read+write token                200 OK   "saved"
7 same read after note moved to B            200 DENY not_in_project
8 same read after owner removes project A    200 DENY not_in_project
9 same read after revoke                     200 DENY connection_revoked
Enter fullscreen mode Exit fullscreen mode

El mismo cliente contra CHECK=token_only. Aquí el token lleva la lista de proyectos de su último refresco:

7 same read after note moved to B            200 DENY not_in_project
8 same read after owner removes project A    200 OK   "Q3 launch checklist v2"
9 same read after revoke                     200 OK   "Q3 launch checklist v2"
Enter fullscreen mode Exit fullscreen mode

Los pasos 1–7 son idénticos. El paso 7 sigue denegado en el diseño solo-token porque el proyecto de la nota se consulta en vivo. El diseño solo-token solo se queda desfasado en los datos que viven dentro del token. Los pasos 8 y 9 son los que le importan al propietario: quitó el proyecto, luego revocó la conexión, y el chat siguió leyendo. En el diseño solo-token eso dura hasta que caduca el token, que en esta reconstrucción es expires_in: 3600.

Las denegaciones se reparten en dos capas. Un scope que falta es un fallo a nivel de protocolo: 403 con error="insufficient_scope" y el scope necesario en WWW-Authenticate, para que el host pueda pedir más. Todo lo que tiene que ver con qué elemento está implicado es un resultado normal de herramienta con isError: true. Volver a autorizar no lo arreglaría, así que no tiene sentido decirle al host que lo intente.

¿Cuánto cuesta la comprobación en cada llamada?

Medido con time.perf_counter_ns() alrededor de access() dentro del servidor, 1,000 llamadas por ejecución (500 permitidas, 500 denegadas), en un Mac que además estaba ejecutando otros trabajos:

access() mediana access() p99 llamada a herramienta en loopback, mediana
comprobación por llamada, 6 ejecuciones 1.3–1.6 µs 6.1–7.0 µs 0.43–0.78 ms
solo token, 3 ejecuciones 0.7–1.6 µs 3.5–7.7 µs 0.35–1.36 ms (2 ejecuciones)

En memoria, la recomprobación se pierde en el ruido de una llamada HTTP por loopback. Un servidor real hace consultas a base de datos en lugar de comprobar pertenencia a un conjunto, así que su coste es mayor. Esta reconstrucción no puede medirlo, así que no se da ninguna cifra. Para hacerse una idea, el tramo de red hasta el endpoint en producción desde Tokio ronda los 500 ms a 800 ms por petición con urllib y una conexión TLS nueva cada vez (n=10 cada una: initialize mediana 790 ms, tools/list 816 ms, el tools/call con 401 565 ms, cada documento de metadatos unos 520–530 ms). Un conector no pierde el tiempo en la comprobación de acceso.

El descubrimiento en sí fueron 5 idas y vueltas HTTP en local: 401, metadatos del recurso, metadatos del servidor, authorize, token. Tardó 7.5–13.4 ms en siete ejecuciones y 34 ms una vez con el Mac ocupado. Contra el servidor en producción, los tres tramos GET/POST que puedes hacer sin credenciales suman aproximadamente 1.6 s en frío. El tramo de authorize espera a una persona de todos modos.

¿Por qué Claude puede llamar a osFoundry, pero osFoundry no puede abrir tus chats de Claude?

En este montaje quien llama es el host. Abre la conexión, lista las herramientas y decide cuándo llamar a una. El servidor responde. Nada en este montaje le da al servidor un acceso a las conversaciones del host. El plan de osFoundry lo dice en su tabla de capacidades: abrir o enumerar chats personales arbitrarios de ChatGPT/Claude es "No", con "No general host API is provided for this".

Eso condiciona las herramientas. Con link_current_chat, es el propio chat el que crea un enlace desde dentro. Su etiqueta la declara el propio chat, y osFoundry la usa para enrutar, no como identidad. El trabajo asignado a un chat de Claude enlazado espera hasta que el usuario abre ese chat y le pide que lo revise. ChatGPT tiene MCP Events para chats suscritos, y el plan de osFoundry lo trata como algo específico de ese host.

El lado del servidor es dueño de todas las decisiones sobre datos, porque es el único que conoce el estado actual de un proyecto. El lado del host es dueño de todas las decisiones sobre cuándo actuar y si preguntar al usuario. La comprobación en cada llamada es lo que permite que el servidor siga siendo correcto cuando esos dos lados se desincronizan.

Si lo montas tú

  • Pon resource_metadata en el WWW-Authenticate del 401 y sirve la RFC 9728 en /.well-known/oauth-protected-resource/<resource path>. Un cliente que construye la URL a mano puede equivocarse. La cabecera se lo ahorra a todos los clientes.
  • Decide qué es público antes del inicio de sesión. osFoundry expone initialize y tools/list y protege tools/call. Si las descripciones de tus herramientas filtran algo sobre los tenants, protege también tools/list.
  • Declara un scope y readOnlyHint por herramienta. Los hosts pueden construir con ellos las peticiones de confirmación, y un 403 insufficient_scope le da al host algo concreto que volver a pedir.
  • Mantén el token pequeño: quién, qué conexión, qué scopes. Vuelve a leer en cada llamada el workspace, la pertenencia al proyecto, el proyecto del recurso y el rol actual de quien aprobó, para que revocar y "mover a otro proyecto" funcionen en la siguiente llamada.
  • Devuelve las denegaciones sobre recursos como resultados de herramienta, no como 401/403. Otro token no va a arreglar "esta Note está en el proyecto B".
  • Haz que las escrituras lleven expected_version y una clave de idempotencia. Los hosts reintentan, y los modelos repiten llamadas.

La parte MCP de la especificación está en la página de autorización de MCP. La definición breve de osFoundry está en su entrada del glosario sobre MCP.

Top comments (0)