Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions forge/ee/routes/mcp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @param {import('../../../forge').ForgeApplication} app
*/
module.exports = async function (app) {
await app.register(require('./wellKnown'), { prefix: '/.well-known', logLevel: app.config.logging.http })
await app.register(require('./registrations'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http })
await app.register(require('./server'), { prefix: '/mcp', logLevel: app.config.logging.http })
}
4 changes: 3 additions & 1 deletion forge/ee/routes/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ module.exports = async function (app) {
// Resolves the caller's identity and scope, or sends an error reply and returns null.
async function resolveCaller (request, reply) {
if (!request.session?.User) {
// RFC 9728 §5.1: point unauthenticated callers at the resource metadata.
reply.header('WWW-Authenticate', `Bearer resource_metadata="${app.config.base_url}/.well-known/oauth-protected-resource/mcp"`)
reply.code(401).send({ code: 'unauthorized', error: 'Unauthorized' })
return null
}
Expand All @@ -55,7 +57,7 @@ module.exports = async function (app) {
// POST serves the MCP Streamable HTTP protocol in JSON mode: each request is
// forwarded to the gateway and its response returned. Notifications (no id)
// are acknowledged; the gateway populates its session on the first request.
app.post('/', async (request, reply) => {
app.post('/', { config: { allowAnonymous: true } }, async (request, reply) => {
const caller = await resolveCaller(request, reply)
if (!caller) {
return
Expand Down
34 changes: 34 additions & 0 deletions forge/ee/routes/mcp/wellKnown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module.exports = async function (app) {
// RFC 9728: OAuth 2.0 Protected Resource Metadata for the /mcp resource.
// Lives in the EE mcp plugin so it is only advertised where /mcp exists.
function protectedResourceMetadata () {
const baseUrl = app.config.base_url
return {
resource: `${baseUrl}/mcp`,
authorization_servers: [baseUrl]
}
}

const schema = {
tags: ['Authentication', 'X-HIDDEN'],
response: {
200: {
type: 'object',
properties: {
resource: { type: 'string' },
authorization_servers: { type: 'array', items: { type: 'string' } }
}
}
}
}

// RFC 9728 §3.1: clients derive the metadata URL by inserting the resource
// path, so the /mcp resource is served at oauth-protected-resource/mcp. The
// bare path is kept as an alias for clients that omit path insertion.
app.get('/oauth-protected-resource/mcp', { config: { allowAnonymous: true }, schema }, async (request, reply) => {
reply.send(protectedResourceMetadata())
})
app.get('/oauth-protected-resource', { config: { allowAnonymous: true }, schema }, async (request, reply) => {
reply.send(protectedResourceMetadata())
})
}
1 change: 1 addition & 0 deletions forge/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ module.exports = fp(async function (app, opts) {
await app.register(require('@fastify/websocket'))
await app.register(require('./auth'), { logLevel: app.config.logging.http })
await app.register(require('./api'), { prefix: '/api/v1', logLevel: app.config.logging.http })
await app.register(require('./wellKnown'), { prefix: '/.well-known', logLevel: app.config.logging.http })
await app.register(require('./ui'), { logLevel: app.config.logging.http })
await app.register(require('./setup'), { logLevel: app.config.logging.http })
await app.register(require('./storage'), { prefix: '/storage', logLevel: app.config.logging.http })
Expand Down
36 changes: 36 additions & 0 deletions forge/routes/wellKnown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module.exports = async function (app) {
// RFC 8414: OAuth 2.0 Authorization Server Metadata
app.get('/oauth-authorization-server', {
config: { allowAnonymous: true },
schema: {
tags: ['Authentication', 'X-HIDDEN'],
response: {
200: {
type: 'object',
properties: {
issuer: { type: 'string' },
authorization_endpoint: { type: 'string' },
token_endpoint: { type: 'string' },
response_types_supported: { type: 'array', items: { type: 'string' } },
grant_types_supported: { type: 'array', items: { type: 'string' } },
code_challenge_methods_supported: { type: 'array', items: { type: 'string' } },
token_endpoint_auth_methods_supported: { type: 'array', items: { type: 'string' } },
registration_endpoint: { type: 'string' }
}
}
}
}
}, async (request, reply) => {
const baseUrl = app.config.base_url
reply.send({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/account/authorize`,
token_endpoint: `${baseUrl}/account/token`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
registration_endpoint: `${baseUrl}/account/client`
})
})
}
33 changes: 33 additions & 0 deletions test/unit/forge/ee/routes/mcp/server_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ describe('MCP Platform Tools Server', function () {
})
})

describe('GET /.well-known/oauth-protected-resource (RFC 9728)', function () {
it('serves the path-inserted resource metadata anonymously', async function () {
const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource/mcp' })
response.statusCode.should.equal(200)
response.json().should.deepEqual({
resource: `${app.config.base_url}/mcp`,
authorization_servers: [app.config.base_url]
})
})

it('serves the bare alias anonymously', async function () {
const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource' })
response.statusCode.should.equal(200)
response.json().should.deepEqual({
resource: `${app.config.base_url}/mcp`,
authorization_servers: [app.config.base_url]
})
})
})

describe('POST proxies to the MCP gateway', function () {
let proxyRequest

Expand All @@ -63,6 +83,19 @@ describe('MCP Platform Tools Server', function () {
proxyRequest.called.should.be.false()
})

it('should challenge with the protected resource metadata URL', async function () {
const response = await app.inject({
method: 'POST',
url: '/mcp',
payload: { jsonrpc: '2.0', method: 'initialize', id: 1 }
})
response.statusCode.should.equal(401)
response.headers.should.have.property(
'www-authenticate',
`Bearer resource_metadata="${app.config.base_url}/.well-known/oauth-protected-resource/mcp"`
)
})

it('should forward the request and return the gateway response', async function () {
const response = await app.inject({
method: 'POST',
Expand Down
48 changes: 48 additions & 0 deletions test/unit/forge/routes/wellKnown_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const should = require('should') // eslint-disable-line no-unused-vars

const setup = require('./setup')

describe('.well-known OAuth discovery', function () {
let app
const baseUrl = 'http://localhost:3000'

before(async function () {
app = await setup({ base_url: baseUrl })
})

after(async function () {
await app.close()
})

describe('GET /.well-known/oauth-authorization-server (RFC 8414)', function () {
let body

before(async function () {
const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-authorization-server' })
response.statusCode.should.equal(200)
body = response.json()
})

it('advertises the issuer and endpoints derived from base_url', function () {
body.should.have.property('issuer', baseUrl)
body.should.have.property('authorization_endpoint', `${baseUrl}/account/authorize`)
body.should.have.property('token_endpoint', `${baseUrl}/account/token`)
body.should.have.property('registration_endpoint', `${baseUrl}/account/client`)
})

it('advertises the authorization code and refresh token grants', function () {
body.response_types_supported.should.containEql('code')
body.grant_types_supported.should.containDeep(['authorization_code', 'refresh_token'])
})

it('advertises PKCE S256 and public clients (no secret)', function () {
body.code_challenge_methods_supported.should.eql(['S256'])
body.token_endpoint_auth_methods_supported.should.eql(['none'])
})
})

it('serves the authorization server document anonymously, without a session', async function () {
const authServer = await app.inject({ method: 'GET', url: '/.well-known/oauth-authorization-server' })
authServer.statusCode.should.equal(200)
})
})
Loading