Repository navigation
feat(transport): erase a campaign database on request #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ | |||||||||||||||||
| import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||||||||||||||||||
| import { runInTenant, type TenantContext } from '../../storage/tenant-context.js'; | ||||||||||||||||||
| import { verifyTenantToken } from './tenant-token.js'; | ||||||||||||||||||
| import { deleteCampaignDatabase } from '../../storage/index.js'; | ||||||||||||||||||
|
|
||||||||||||||||||
| export interface HttpServerTransportOptions { | ||||||||||||||||||
| host?: string; | ||||||||||||||||||
|
|
@@ -149,6 +150,46 @@ export async function startHttpServerTransport( | |||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Campaign erasure. | ||||||||||||||||||
| // | ||||||||||||||||||
| // Deliberately an HTTP route rather than an MCP tool: session_manage is | ||||||||||||||||||
| // exposed to the model, and a prompt injection that could wipe a | ||||||||||||||||||
| // campaign is not a capability worth handing over. Only the web host, | ||||||||||||||||||
| // holding the service token and a signed tenant context, can reach this. | ||||||||||||||||||
| if (url.pathname === '/campaign') { | ||||||||||||||||||
| if (!isAuthorized(req, authToken)) { | ||||||||||||||||||
| res.writeHead(401, { 'content-type': 'application/json' }); | ||||||||||||||||||
| res.end(JSON.stringify({ error: 'unauthorized' })); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| if (req.method !== 'DELETE') { | ||||||||||||||||||
| res.writeHead(405, { 'content-type': 'application/json', allow: 'DELETE' }); | ||||||||||||||||||
| res.end(JSON.stringify({ error: 'method_not_allowed' })); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| // Unlike /mcp, an absent tenant is not tolerated here: there is no | ||||||||||||||||||
| // campaign to erase without one, and guessing is not an option. | ||||||||||||||||||
| const scope = resolveTenant(req, tenantSecret); | ||||||||||||||||||
| if (!scope.ok || !scope.context) { | ||||||||||||||||||
| console.error(`[HTTP] Rejected campaign deletion: ${scope.ok ? 'no_tenant_context' : scope.reason}`); | ||||||||||||||||||
| res.writeHead(401, { 'content-type': 'application/json' }); | ||||||||||||||||||
| res.end(JSON.stringify({ error: 'invalid_tenant_context' })); | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
| try { | ||||||||||||||||||
| // The campaign comes from the signed context, never from the | ||||||||||||||||||
| // request body — a caller cannot name someone else's campaign. | ||||||||||||||||||
| const deleted = deleteCampaignDatabase(scope.context.campaignId); | ||||||||||||||||||
| res.writeHead(200, { 'content-type': 'application/json' }); | ||||||||||||||||||
| res.end(JSON.stringify({ deleted })); | ||||||||||||||||||
| } catch (error) { | ||||||||||||||||||
| console.error(`[HTTP] Campaign deletion failed: ${(error as Error).message}`); | ||||||||||||||||||
| res.writeHead(400, { 'content-type': 'application/json' }); | ||||||||||||||||||
| res.end(JSON.stringify({ error: 'delete_failed' })); | ||||||||||||||||||
|
Comment on lines
+185
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return a server-error status for deletion failures. Line 187 sends Proposed fix- res.writeHead(400, { 'content-type': 'application/json' });
+ res.writeHead(500, { 'content-type': 'application/json' });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+185
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||||||||||||||||||
| } | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| if (url.pathname !== '/mcp') { | ||||||||||||||||||
| res.writeHead(404, { 'content-type': 'application/json' }); | ||||||||||||||||||
| res.end(JSON.stringify({ error: 'not_found' })); | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { createHmac } from 'node:crypto'; | ||
| import { mkdtempSync, rmSync, existsSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import type { AddressInfo } from 'node:net'; | ||
| import type { Server } from 'node:http'; | ||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { startHttpServerTransport } from '../../../src/server/transport/http'; | ||
| import { campaignDbPath, closeDb, getDb, listCampaignDatabases } from '../../../src/storage/index'; | ||
| import { runInTenant } from '../../../src/storage/tenant-context'; | ||
|
|
||
| const AUTH = 'service-token'; | ||
| const SECRET = 'tenant-secret'; | ||
| const CAMPAIGN = '11111111-1111-4111-8111-111111111111'; | ||
| const OTHER = '22222222-2222-4222-8222-222222222222'; | ||
|
|
||
| function tenantHeader(campaignId: string, secret = SECRET): string { | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const payload = { accountId: 'account-1', campaignId, iat: now, exp: now + 120 }; | ||
| const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); | ||
| const signature = createHmac('sha256', secret).update(encoded).digest('base64url'); | ||
| return `${encoded}.${signature}`; | ||
| } | ||
|
|
||
| describe('DELETE /campaign', () => { | ||
| let server: Server; | ||
| let url: string; | ||
| let dataDir: string; | ||
| let previousDataDir: string | undefined; | ||
|
|
||
| beforeEach(async () => { | ||
| closeDb(); | ||
| previousDataDir = process.env.RPG_DATA_DIR; | ||
| dataDir = mkdtempSync(join(tmpdir(), 'rpg-delete-')); | ||
| process.env.RPG_DATA_DIR = dataDir; | ||
|
|
||
| server = await startHttpServerTransport( | ||
| () => new McpServer({ name: 'test', version: '0.0.0' }), | ||
| 0, | ||
| { host: '127.0.0.1', authToken: AUTH, tenantSecret: SECRET } | ||
| ); | ||
| url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/campaign`; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await new Promise<void>(resolve => server.close(() => resolve())); | ||
| closeDb(); | ||
| if (previousDataDir === undefined) delete process.env.RPG_DATA_DIR; | ||
| else process.env.RPG_DATA_DIR = previousDataDir; | ||
| rmSync(dataDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| const del = (headers: Record<string, string>) => fetch(url, { method: 'DELETE', headers }); | ||
|
|
||
| it('erases the campaign named by the signed context', async () => { | ||
| runInTenant({ accountId: 'account-1', campaignId: CAMPAIGN }, () => getDb()); | ||
| closeDb(); | ||
| expect(existsSync(campaignDbPath(CAMPAIGN))).toBe(true); | ||
|
|
||
| const response = await del({ | ||
| authorization: `Bearer ${AUTH}`, | ||
| 'x-rpg-tenant': tenantHeader(CAMPAIGN), | ||
| }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(await response.json()).toEqual({ deleted: true }); | ||
| expect(existsSync(campaignDbPath(CAMPAIGN))).toBe(false); | ||
| }); | ||
|
|
||
| it('erases only the campaign in the context, not its neighbours', async () => { | ||
| runInTenant({ accountId: 'account-1', campaignId: CAMPAIGN }, () => getDb()); | ||
| runInTenant({ accountId: 'account-1', campaignId: OTHER }, () => getDb()); | ||
| closeDb(); | ||
|
|
||
| await del({ authorization: `Bearer ${AUTH}`, 'x-rpg-tenant': tenantHeader(CAMPAIGN) }); | ||
|
|
||
| expect(listCampaignDatabases()).toEqual([OTHER]); | ||
| }); | ||
|
|
||
| it('reports deleted:false for a campaign with no database', async () => { | ||
| const response = await del({ | ||
| authorization: `Bearer ${AUTH}`, | ||
| 'x-rpg-tenant': tenantHeader(CAMPAIGN), | ||
| }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(await response.json()).toEqual({ deleted: false }); | ||
| }); | ||
|
|
||
| it('refuses a request with no tenant context rather than guessing', async () => { | ||
| const response = await del({ authorization: `Bearer ${AUTH}` }); | ||
|
|
||
| expect(response.status).toBe(401); | ||
| }); | ||
|
|
||
| it('refuses a tenant context signed with the wrong secret', async () => { | ||
| runInTenant({ accountId: 'account-1', campaignId: CAMPAIGN }, () => getDb()); | ||
| closeDb(); | ||
|
|
||
| const response = await del({ | ||
| authorization: `Bearer ${AUTH}`, | ||
| 'x-rpg-tenant': tenantHeader(CAMPAIGN, 'staging-secret'), | ||
| }); | ||
|
|
||
| 6E97 expect(response.status).toBe(401); | ||
| // The forged request must not have erased anything. | ||
| expect(existsSync(campaignDbPath(CAMPAIGN))).toBe(true); | ||
| }); | ||
|
|
||
| it('refuses a request without the service token', async () => { | ||
| const response = await del({ 'x-rpg-tenant': tenantHeader(CAMPAIGN) }); | ||
|
|
||
| expect(response.status).toBe(401); | ||
| }); | ||
|
|
||
| it('rejects methods other than DELETE', async () => { | ||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| headers: { authorization: `Bearer ${AUTH}`, 'x-rpg-tenant': tenantHeader(CAMPAIGN) }, | ||
| }); | ||
|
|
||
| expect(response.status).toBe(405); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this DELETE races with an already-authorized
POST /mcp—particularly one still streaming its body—the database is removed without recording that the campaign is being deleted. Once the POST continues, its firstgetDb()call recreates the campaign database, so this endpoint can return 200 and then leave a new database on disk, defeating the erasure request. Mark the campaign as deleted/deleting and reject or drain its scoped requests before confirming success.Useful? React with 👍 / 👎.