Skip to content

Commit a65f554

Browse files
author
andypalmi
committed
feat(mcp): add billing read tools
Add read-only MCP tools for team billing and the Stripe customer portal, wrapping the backing routes via app.inject(), with unit and equivalence tests. Allow-list their scopes for the expert-mcp platform token.
1 parent 913be23 commit a65f554

3 files changed

Lines changed: 177 additions & 0 deletions

File tree

forge/ee/lib/mcp/tools/billing.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const { teamId } = require('../schemas')
2+
3+
module.exports = [
4+
{
5+
name: 'platform_get_team_billing',
6+
title: 'Get Team Billing',
7+
description: `FlowFuse platform automation tool:
8+
Reads a team billing and subscription details, including the current plan items, price, and next billing date.
9+
Use this when the user asks about their team plan, subscription cost, or when the next invoice is due.
10+
Returns a 404-style error if the team has no active subscription, and a billing_unmanaged error if the team subscription is not managed through this platform (for example, an enterprise contract).`,
11+
annotations: { readOnlyHint: true, destructiveHint: false },
12+
inputSchema: {
13+
teamId
14+
},
15+
handler: async (args, { inject }) => {
16+
const response = await inject({ method: 'GET', url: `/ee/billing/teams/${args.teamId}` })
17+
return response
18+
}
19+
},
20+
{
21+
name: 'platform_get_team_customer_portal',
22+
title: 'Get Team Customer Portal',
23+
description: `FlowFuse platform automation tool:
24+
Gets a URL to the team Stripe customer portal, where the user can manage their billing details, payment methods, and subscription.
25+
Use this when the user wants to update their payment method, view invoices, or change their subscription plan.`,
26+
annotations: { readOnlyHint: true, destructiveHint: false },
27+
inputSchema: {
28+
teamId
29+
},
30+
handler: async (args, { inject }) => {
31+
const response = await inject({ method: 'GET', url: `/ee/billing/teams/${args.teamId}/customer-portal` })
32+
if (response.statusCode >= 400) {
33+
return response
34+
}
35+
return {
36+
statusCode: 200,
37+
json: () => ({ url: response.headers.location })
38+
}
39+
}
40+
}
41+
]

forge/routes/auth/permissions.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const IMPLICIT_TOKEN_SCOPES = {
4848
// teams
4949
'user:team:list', // list teams
5050
'team:read', // get team details
51+
// billing
52+
'team:edit', // get team billing details, get team customer-portal link
5153
// platform
5254
'stack:list',
5355
'flow-blueprint:list',
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
const should = require('should') // eslint-disable-line no-unused-vars
2+
3+
const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence')
4+
5+
const FF_UTIL = require('flowforge-test-utils')
6+
7+
const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/billing')
8+
const findTool = toolFinder(tools)
9+
10+
describe('MCP Billing Tools', function () {
11+
describe('platform_get_team_billing', function () {
12+
const tool = findTool('platform_get_team_billing')
13+
14+
it('has read-only, non-destructive annotations', function () {
15+
tool.annotations.readOnlyHint.should.equal(true)
16+
tool.annotations.destructiveHint.should.equal(false)
17+
})
18+
19+
it('exposes the expected input schema keys', function () {
20+
Object.keys(tool.inputSchema).should.eql(['teamId'])
21+
})
22+
23+
it('requests the team billing endpoint', async function () {
24+
const { calls, inject } = recordingInject()
25+
await tool.handler({ teamId: 'team123' }, { inject })
26+
calls.should.have.length(1)
27+
calls[0].method.should.equal('GET')
28+
calls[0].url.should.equal('/ee/billing/teams/team123')
29+
})
30+
})
31+
32+
describe('platform_get_team_customer_portal', function () {
33+
const tool = findTool('platform_get_team_customer_portal')
34+
35+
it('has read-only, non-destructive annotations', function () {
36+
tool.annotations.readOnlyHint.should.equal(true)
37+
tool.annotations.destructiveHint.should.equal(false)
38+
})
39+
40+
it('exposes the expected input schema keys', function () {
41+
Object.keys(tool.inputSchema).should.eql(['teamId'])
42+
})
43+
44+
it('requests the team customer-portal endpoint', async function () {
45+
const { calls, inject } = recordingInject()
46+
await tool.handler({ teamId: 'team123' }, { inject })
47+
calls.should.have.length(1)
48+
calls[0].method.should.equal('GET')
49+
calls[0].url.should.equal('/ee/billing/teams/team123/customer-portal')
50+
})
51+
52+
it('surfaces the portal URL from the response location header', async function () {
53+
const inject = async () => ({ statusCode: 200, headers: { location: 'https://billing.stripe.com/session/abc' } })
54+
const response = await tool.handler({ teamId: 'team123' }, { inject })
55+
response.statusCode.should.equal(200)
56+
response.json().should.eql({ url: 'https://billing.stripe.com/session/abc' })
57+
})
58+
59+
it('returns the error response unchanged when the route fails', async function () {
60+
const errorResponse = { statusCode: 404, json: () => ({ error: 'not found' }) }
61+
const inject = async () => errorResponse
62+
const response = await tool.handler({ teamId: 'team123' }, { inject })
63+
response.should.equal(errorResponse)
64+
})
65+
})
66+
67+
describe('Integration smoke', function () {
68+
const setup = require('../../../setup')
69+
70+
let app
71+
let stripe
72+
let token
73+
74+
before(async function () {
75+
stripe = setup.setupStripe({
76+
billingPortal: {
77+
sessions: {
78+
create: async () => ({ url: 'https://billing.stripe.com/p/session/test_123' })
79+
}
80+
}
81+
})
82+
app = await setup({
83+
ai: { enabled: true },
84+
expert: { enabled: true }
85+
})
86+
token = await createExpertMcpToken(app)
87+
})
88+
89+
after(async function () {
90+
await app.close()
91+
setup.resetStripe()
92+
})
93+
94+
it('platform_get_team_billing returns the team subscription details', async function () {
95+
const inject = (o) => app.inject({ ...o, headers: { ...(o.headers || {}), authorization: `Bearer ${token}` } })
96+
const tool = findTool('platform_get_team_billing')
97+
98+
const response = await tool.handler({ teamId: app.team.hashid }, { inject })
99+
100+
// app.team has an active subscription via the Stripe mock
101+
response.statusCode.should.equal(200)
102+
const body = response.json()
103+
body.should.have.property('items').which.is.an.Array()
104+
body.should.have.property('customer')
105+
should.equal(stripe.subscriptions.retrieve.called, true)
106+
})
107+
108+
it('platform_get_team_billing matches the team billing route response exactly', async function () {
109+
const inject = (o) => app.inject({ ...o, headers: { ...(o.headers || {}), authorization: `Bearer ${token}` } })
110+
const tool = findTool('platform_get_team_billing')
111+
112+
await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, {
113+
method: 'GET',
114+
url: `/ee/billing/teams/${app.team.hashid}`
115+
})
116+
})
117+
118+
it('platform_get_team_customer_portal matches the customer-portal route redirect location', async function () {
119+
const inject = (o) => app.inject({ ...o, headers: { ...(o.headers || {}), authorization: `Bearer ${token}` } })
120+
const tool = findTool('platform_get_team_customer_portal')
121+
122+
const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, {
123+
method: 'GET',
124+
url: `/ee/billing/teams/${app.team.hashid}/customer-portal`,
125+
transform: (r) => (r.statusCode >= 400
126+
? { statusCode: r.statusCode, body: r.json() }
127+
: { statusCode: 200, body: { url: r.headers.location } })
128+
})
129+
130+
// Stripe mock's billing portal session is wired to a fixed URL
131+
routeResponse.headers.location.should.equal('https://billing.stripe.com/p/session/test_123')
132+
})
133+
})
134+
})

0 commit comments

Comments
 (0)