Skip to content
Closed
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
4,206 changes: 4,206 additions & 0 deletions backend/__tests__/fixtures/hostedMcp/asana-tools-list-2026-10-19.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ describe.each(wiringTable)('%s client wiring', (entryId) => {
(envName) => {
const key = apiKeysKeyForEnv(envName);
expect(key).not.toBeNull();
const remoteKey = entryId === 'asana'
? `commonly-dev-asana-mcp-${key.slice('asana-'.length)}`
: `commonly-dev-${key}`;
expect(apiKeys).toMatch(new RegExp(
`- secretKey: ${key}\\n\\s+remoteRef:\\n\\s+key: commonly-dev-${key}\\n`,
`- secretKey: ${key}\\n\\s+remoteRef:\\n\\s+key: ${remoteKey}\\n`,
));
},
);
Expand Down
26 changes: 26 additions & 0 deletions backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const { HOSTED_MCP_ENTRIES: SHIPPED_ENTRIES } = jest.requireActual('../../../int
const LINEAR_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-01.json');
const ATLASSIAN_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/atlassian-tools-list-2026-10-09.json');
const AIRTABLE_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/airtable-tools-list-2026-10-09.json');
const ASANA_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/asana-tools-list-2026-10-19.json');
const GOOGLE_CALENDAR_CAPTURE = require('../../fixtures/hostedMcp/google-calendar-tools-list-2026-10-01.json');

const app = express();
Expand Down Expand Up @@ -360,6 +361,31 @@ describe('hosted-mcp connect: callback', () => {
expect(update.$set['config.grantedScope']).toBe('openid read');
});

it('records Asana\'s measured response-only identity scope without requesting it', async () => {
const originalScopes = FIXTURE_ENTRY.scopes;
FIXTURE_ENTRY.scopes = ['default'];
FIXTURE_ENTRY.responseOnlyScopes = ['identity'];
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
access_token: 'access-1',
refresh_token: 'refresh-1',
scope: ASANA_SCOPE_CAPTURE.grantedScope,
}),
});
try {
expect(ASANA_SCOPE_CAPTURE.scopeRequested).toBe('default');
expect(ASANA_SCOPE_CAPTURE.grantedScope).toBe('default identity');
const res = await callback();
expect(outcome(res).hostedMcp).toBe('connected');
const [, update] = Integration.findOneAndUpdate.mock.calls[1];
expect(update.$set['config.grantedScope']).toBe('default identity');
} finally {
FIXTURE_ENTRY.scopes = originalScopes;
delete FIXTURE_ENTRY.responseOnlyScopes;
}
});

it('accepts Atlassian\'s captured reordered granted-scope list', async () => {
const originalEntry = { ...FIXTURE_ENTRY, scopes: [...FIXTURE_ENTRY.scopes] };
Object.assign(FIXTURE_ENTRY, {
Expand Down
30 changes: 30 additions & 0 deletions backend/__tests__/unit/services/connectionRemoval.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,36 @@ describe('revokeTokenAtVendor (RFC 7009)', () => {
expect(revokeBody.get('client_secret')).toBeNull();
});

it('Asana revoke sends client-secret Basic auth when its metadata omits revocation methods', async () => {
const { ASANA_ENTRY } = require('../../../integrations/hostedMcp/asana');
// Live AS metadata on 2026-10-19 advertises token methods but omits the
// revocation method list. RFC 8414 defaults the omitted list to Basic.
const metadata = {
issuer: ASANA_ENTRY.issuer,
authorization_endpoint: 'https://app.asana.com/-/oauth_authorize',
token_endpoint: 'https://app.asana.com/-/oauth_token',
revocation_endpoint: 'https://app.asana.com/-/oauth_revoke',
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
};
const fetchImpl = jest.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => metadata })
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) });
await revokeTokenAtVendor({
entry: ASANA_ENTRY,
clientId: 'asana-client',
clientSecret: 'asana-secret',
token: 'held-refresh-token',
tokenTypeHint: 'refresh_token',
}, fetchImpl);
const [url, init] = fetchImpl.mock.calls[1];
expect(url).toBe(ASANA_ENTRY.revoke.endpoint);
expect(init.headers.Authorization)
.toBe(`Basic ${Buffer.from('asana-client:asana-secret').toString('base64')}`);
const revokeBody = new URLSearchParams(init.body);
expect(revokeBody.get('token')).toBe('held-refresh-token');
expect(revokeBody.get('client_secret')).toBeNull();
});

// Only a 2xx on a token we HOLD counts as revoked. `invalid_grant` used to be
// counted, and it is the one answer that must not be: RFC 6749 §5.2 also
// gives it for a token "issued to another client", and a `cimd` client id is
Expand Down
50 changes: 49 additions & 1 deletion backend/__tests__/unit/services/hostedMcpEntry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const LINEAR_FIRST_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list
const LINEAR_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-14.json');
const ATLASSIAN_CAPTURE = require('../../fixtures/hostedMcp/atlassian-tools-list-2026-10-09.json');
const AIRTABLE_CAPTURE = require('../../fixtures/hostedMcp/airtable-tools-list-2026-10-09.json');
const ASANA_CAPTURE = require('../../fixtures/hostedMcp/asana-tools-list-2026-10-19.json');

const ENTRY_TEXT = 'ENTRY TEXT the pin agreed to.';
const VENDOR_TEXT = 'VENDOR TEXT nobody reviewed.';
Expand Down Expand Up @@ -567,7 +568,7 @@ describe('the shipped catalogue cannot land half-wired', () => {
test('ships Linear, Google Calendar, Atlassian, then Airtable, and lookup names the requested entry', () => {
// The catalogue is the one place that says what an instance offers before
// any member has connected anything. Its ordering follows the build plan.
expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual(['linear', 'google-calendar', 'atlassian', 'airtable']);
expect(HOSTED_MCP_ENTRIES.map((entry) => entry.id)).toEqual(['linear', 'google-calendar', 'atlassian', 'airtable', 'asana']);
const catalogue = [linear(), linear({ id: 'notion', title: 'Notion' })];
expect(findHostedMcpEntry(catalogue, 'notion').title).toBe('Notion');
expect(findHostedMcpEntry(catalogue, 'linear').title).toBe('Linear');
Expand Down Expand Up @@ -737,6 +738,53 @@ describe('the shipped catalogue cannot land half-wired', () => {
expect(capture.tools).toHaveLength(46);
});

test('the Asana pin excludes internal widget confirmation tools and records measured safety claims', () => {
const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'asana');
expect(entry).toMatchObject({
resource: ASANA_CAPTURE.server,
issuer: 'https://app.asana.com',
client: 'pre-registered',
scopes: ['default'],
responseOnlyScopes: ['identity'],
revoke: { endpoint: 'https://app.asana.com/-/oauth_revoke' },
});
expect(ASANA_CAPTURE.scopeRequested).toBe('default');
expect(ASANA_CAPTURE.grantedScope).toBe('default identity');
expect(ASANA_CAPTURE.toolCount).toBe(39);
expect(ASANA_CAPTURE.tools).toHaveLength(39);
expect(entry.tools).toHaveLength(17);
expect(entry.tools.filter((tool) => tool.class === 'write').map((tool) => tool.name).sort())
.toEqual(['add_comment', 'create_project', 'create_tasks', 'delete_task', 'update_project', 'update_tasks']);
expect(entry.tools.find((tool) => tool.name === 'search_objects').description)
.toMatch(/teams.*tags.*goals.*portfolios.*project templates.*custom fields.*AI Teammates/);
expect(entry.tools.find((tool) => tool.name === 'delete_task').description)
.toMatch(/subtasks unless.*another project/);
const measured = new Map(ASANA_CAPTURE.tools.map((tool) => [tool.name, tool]));
for (const tool of entry.tools) {
const found = measured.get(tool.upstreamName);
expect(found).toBeDefined();
expect(tool.name).toBe(tool.upstreamName);
expect(tool.class).toBe(found.annotations.readOnlyHint ? 'read' : 'write');
expect(canonicalJson(tool.inputSchema)).toBe(canonicalJson(found.inputSchema));
expect(tool.annotations).toEqual({
readOnlyHint: found.annotations.readOnlyHint,
destructiveHint: found.annotations.destructiveHint,
});
if (found.annotations.destructiveHint) expect(tool.irreversible).toBe(true);
}
expect(entry.tools.every((tool) => !tool.name.endsWith('_confirm'))).toBe(true);
expect(entry.tools.every((tool) => !measured.get(tool.name)._meta?.ui)).toBe(true);
});

test('response-only scopes must be a distinct, explicit entry-owned allowlist', () => {
expect(() => assertHostedMcpEntries([linear({ responseOnlyScopes: ['read'] })]))
.toThrow(/response-only scopes are invalid/);
expect(() => assertHostedMcpEntries([linear({ responseOnlyScopes: ['identity', 'identity'] })]))
.toThrow(/response-only scopes are invalid/);
expect(() => assertHostedMcpEntries([linear({ responseOnlyScopes: ['identity'] })]))
.not.toThrow();
});

test('the Linear pin is the measured list minus caller-chosen fetch paths', () => {
// The latest capture `connector-ops` took (2026-10-14) is committed beside
// this suite, so the pin's schemas are compared with the measurement rather
Expand Down
Loading
Loading