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

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion backend/__tests__/unit/routes/grants.mint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ describe('POST /api/grants on a hosted-mcp connection', () => {
});

test('write is refused on a hosted row, and write-with-confirm is not', async () => {
Integration.findById.mockResolvedValue(hostedRow());
Integration.findById.mockResolvedValue(hostedRow({ grantedScope: 'read write openid' }));
const refused = await withCatalog([ENTRY], () => hostedMint({ writeMode: 'write' }));
expect(refused.status).toBe(403);
expect(refused.body).toMatchObject({ error: 'write_requires_confirm' });
Expand All @@ -286,6 +286,16 @@ describe('POST /api/grants on a hosted-mcp connection', () => {
expect(allowed.status).toBe(201);
});

test.each(['read openid', '', undefined, 'read overwrite openid'])('Linear scope %s cannot mint confirmation mode', async (grantedScope) => {
Integration.findById.mockResolvedValue(hostedRow({ grantedScope }));
const res = await withCatalog([ENTRY], () => hostedMint({ writeMode: 'write-with-confirm' }));
expect(res.status).toBe(403);
expect(res.body.error).toBe('write_scope_required');
expect(await RoomGrant.countDocuments({})).toBe(0);
const read = await withCatalog([ENTRY], () => hostedMint({ writeMode: 'read' }));
expect(read.status).toBe(201);
});

test('a github-app write grant is refused by the CTO pod-eligibility ruling', async () => {
outsiderGrantPodRefusal.mockResolvedValue('write_mode_unconfined');
const res = await mint({ writeMode: 'write' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const intake = require('../../../services/hostedMcpIntakeService');
const dcr = require('../../../services/hostedMcpDcrRegistry');

const { HOSTED_MCP_ENTRIES: SHIPPED_ENTRIES } = jest.requireActual('../../../integrations/hostedMcp/entries');
const LINEAR_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-01.json');
const LINEAR_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-19.json');
const ATLASSIAN_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/atlassian-tools-list-2026-10-19.json');
const AIRTABLE_SCOPE_CAPTURE = require('../../fixtures/hostedMcp/airtable-tools-list-2026-10-09.json');
const GOOGLE_CALENDAR_CAPTURE = require('../../fixtures/hostedMcp/google-calendar-tools-list-2026-10-01.json');
Expand Down Expand Up @@ -337,7 +337,7 @@ describe('hosted-mcp connect: callback', () => {
});
it('the captured Linear, Atlassian and Airtable grants match their requested scope sets', () => {
const captures = [
[LINEAR_SCOPE_CAPTURE, 'scope'],
[LINEAR_SCOPE_CAPTURE, 'grantedScope'],
[ATLASSIAN_SCOPE_CAPTURE, 'grantedScope'],
[AIRTABLE_SCOPE_CAPTURE, 'grantedScope'],
];
Expand Down
74 changes: 50 additions & 24 deletions backend/__tests__/unit/services/hostedMcpEntry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ const {
} = require('../../../integrations/hostedMcp/entries');
const GOOGLE_CALENDAR_CAPTURE = require('../../fixtures/hostedMcp/google-calendar-tools-list-2026-10-01.json');
// The first Linear capture (2026-10-01) is the one the entry was built from; the
// second (2026-10-14) is the one it is pinned to now, after five schemas drifted.
// second (2026-10-14) records the earlier five-schema drift; 10-19 adds writes.
const LINEAR_FIRST_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-01.json');
const LINEAR_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-14.json');
const LINEAR_READ_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-14.json');
const LINEAR_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-19.json');
const ATLASSIAN_CAPTURE = require('../../fixtures/hostedMcp/atlassian-tools-list-2026-10-19.json');
const AIRTABLE_CAPTURE = require('../../fixtures/hostedMcp/airtable-tools-list-2026-10-09.json');
const SENTRY_CAPTURE = require('../../fixtures/hostedMcp/sentry-tools-list-2026-10-19.json');
Expand Down Expand Up @@ -590,20 +591,21 @@ describe('the shipped catalogue cannot land half-wired', () => {
expect(findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'google-calendar').title).toBe('Google Calendar');
});

test('the shipped Linear entry is read-only, fully annotated and names a revoke page', () => {
test('the shipped Linear entry pins approval-required writers and names a revoke page', () => {
const [entry] = HOSTED_MCP_ENTRIES;
// 38 tools were measured at scope `read`; two caller-chosen fetch paths are
// excluded by the v1 target-fetch rule, leaving the reviewed 36-tool pin.
expect(entry.tools).toHaveLength(36);
// 68 captured tools minus two external fetches and four excluded writers.
expect(entry.tools).toHaveLength(62);
expect(entry.tools.map((tool) => tool.name)).not.toContain('get_attachment');
expect(entry.tools.map((tool) => tool.name)).not.toContain('extract_images');
expect(new Set(entry.tools.map((tool) => tool.class))).toEqual(new Set(['read']));
expect(entry.tools.every((tool) => tool.annotations?.readOnlyHint === true)).toBe(true);
expect(entry.tools.every((tool) => tool.annotations?.destructiveHint === false)).toBe(true);
expect(entry.tools.filter((tool) => tool.class === 'read')).toHaveLength(36);
expect(entry.tools.filter((tool) => tool.class === 'write')).toHaveLength(26);
expect(entry.tools.every((tool) => tool.annotations?.readOnlyHint === (tool.class === 'read'))).toBe(true);
expect(entry.tools.filter((tool) => tool.class === 'read').every((tool) => tool.annotations?.destructiveHint === false)).toBe(true);
expect(entryToolProjections(entry).filter((tool) => tool.requiredWriteMode === 'write-with-confirm')).toHaveLength(26);
// Every name an agent sees is the upstream name, which the entry load
// requires and the broker calls with.
expect(entry.tools.every((tool) => tool.name === tool.upstreamName)).toBe(true);
expect(new Set(entry.tools.map((tool) => `${entry.id}.${tool.name}`)).size).toBe(36);
expect(new Set(entry.tools.map((tool) => `${entry.id}.${tool.name}`)).size).toBe(62);
// The person's own "Authorized applications" list, where the grant shows as
// "MCP · Read · Revoke" (fresh-account walk, 2026-10-15). `/settings/security`
// is the workspace admin page and has no per-person app list.
Expand All @@ -615,7 +617,7 @@ describe('the shipped catalogue cannot land half-wired', () => {
expect(entry.resource).toBe('https://mcp.linear.app/mcp');
expect(entry.issuer).toBe('https://mcp.linear.app');
expect(entry.client).toBe('cimd');
expect([...entry.scopes].sort()).toEqual(['openid', 'read']);
expect([...entry.scopes].sort()).toEqual(['openid', 'read', 'write']);
});

test('the Google Calendar entry is pre-registered and pins only its five read tools', () => {
Expand Down Expand Up @@ -812,39 +814,63 @@ describe('the shipped catalogue cannot land half-wired', () => {
}
});

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
test('the Linear pin is the measured list minus the six ruled exclusions', () => {
// The latest capture `connector-ops` took (2026-10-19) is committed beside
// this suite, so the pin's schemas are compared with the measurement rather
// than with themselves. Drift is checked against the LIVE list at call time; this
// is the other end, catching a hand-edit of the entry that would otherwise
// only surface as every tool refusing `tool_drift` in production.
const capture = LINEAR_CAPTURE;
const [entry] = HOSTED_MCP_ENTRIES;
expect(capture.server).toBe(entry.resource);
expect(capture.requestedScope.split(' ').sort()).toEqual([...entry.scopes].sort());
expect(capture.scopeRequested.split(' ').sort()).toEqual([...entry.scopes].sort());
const measured = new Map(capture.tools.map((tool) => [tool.name, tool]));
const excludedForCallerChosenFetch = new Set(['get_attachment', 'extract_images']);
const excludedByPinRuling = new Set(['get_attachment', 'extract_images', 'prepare_attachment_upload',
'create_attachment_from_upload', 'create_attachment', 'create_issue_label']);
expect(entry.tools.map((tool) => tool.name)).toEqual(
capture.tools.filter((tool) => !excludedForCallerChosenFetch.has(tool.name)).map((tool) => tool.name),
capture.tools.filter((tool) => !excludedByPinRuling.has(tool.name)).map((tool) => tool.name),
);
expect(capture.tools.filter((tool) => !entry.tools.some((pinned) => pinned.name === tool.name))
.map((tool) => tool.name).sort()).toEqual([...excludedForCallerChosenFetch].sort());
.map((tool) => tool.name).sort()).toEqual([...excludedByPinRuling].sort());
for (const tool of entry.tools) {
const found = measured.get(tool.name);
expect(found).toBeDefined();
expect(canonicalJson(tool.inputSchema)).toBe(canonicalJson(found.inputSchema));
expect(tool.class).toBe(found.annotations.readOnlyHint ? 'read' : 'write');
expect(tool.annotations?.readOnlyHint).toBe(found.annotations.readOnlyHint);
expect(tool.annotations?.destructiveHint).toBe(found.annotations.destructiveHint);
}
// One annotation set across the whole list, which is what the header claims
// and what makes a single `READ_ANNOTATIONS` honest. Compared canonically:
// the 10-14 list serialises one tool's identical set in a different key order.
expect(new Set(capture.tools.map((tool) => canonicalJson(tool.annotations))).size).toBe(1);
expect(capture.tools).toHaveLength(38);
// Both read and write annotations come from the consenting capture.
expect(capture.tools.filter((tool) => tool.annotations.readOnlyHint)).toHaveLength(38);
expect(capture.tools).toHaveLength(68);
expect(entry.tools.find((tool) => tool.name === 'get_diff').description).toContain('not fetch targets');
expect(entry.tools.find((tool) => tool.name === 'get_diff_threads').description).toContain('not fetch targets');
});

test('Linear writers use local summaries and the 19 reviewed irreversible flags', () => {
const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'linear');
const measured = new Map(LINEAR_CAPTURE.tools.map((tool) => [tool.name, tool]));
const irreversible = [
'save_issue', 'save_comment', 'save_document', 'save_project', 'save_status_update',
'save_release_note', 'save_diff_comment', 'submit_diff_review', 'update_diff', 'merge_diff',
'share_issue', 'delete_comment', 'delete_diff_comment', 'delete_status_update', 'delete_attachment',
'save_milestone', 'save_release', 'save_issue_label', 'save_project_label',
];
const writers = entry.tools.filter((tool) => tool.class === 'write');
expect(writers).toHaveLength(26);
expect(writers.filter((tool) => tool.irreversible).map((tool) => tool.name).sort())
.toEqual(irreversible.sort());
for (const tool of writers) {
expect(tool.description).not.toBe(measured.get(tool.name).description.split('\n')[0]);
expect(tool.irreversible).toBe(irreversible.includes(tool.name));
}
const issue = writers.find((tool) => tool.name === 'save_issue');
expect(issue.description).toContain('connection owner');
expect(issue.description).toContain('agent installed in that Linear workspace');
expect(issue.inputSchema.properties.delegate).toEqual(measured.get('save_issue').inputSchema.properties.delegate);
expect(assessEntryTools(entry, LINEAR_CAPTURE.tools).every((item) => item.verdict === 'ok')).toBe(true);
});

test('the 10-01 pins remain compatible with exactly five changes in the 10-14 capture', () => {
// Real vendor data on both sides: the schemas the entry shipped with, assessed
// against the list Linear served thirteen days later. Every change was an
Expand All @@ -854,9 +880,9 @@ describe('the shipped catalogue cannot land half-wired', () => {
const first = new Map(LINEAR_FIRST_CAPTURE.tools.map((tool) => [tool.name, tool]));
const asFirstPinned = {
...entry,
tools: entry.tools.map((tool) => ({ ...tool, inputSchema: first.get(tool.name).inputSchema })),
tools: entry.tools.filter((tool) => tool.class === 'read').map((tool) => ({ ...tool, inputSchema: first.get(tool.name).inputSchema })),
};
const assessed = assessEntryTools(asFirstPinned, LINEAR_CAPTURE.tools);
const assessed = assessEntryTools(asFirstPinned, LINEAR_READ_CAPTURE.tools);
expect(assessed.every((assessment) => assessment.verdict === 'ok')).toBe(true);
const changed = assessed.filter((assessment) => assessment.detail);
expect(changed.map((assessment) => assessment.name).sort())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ it("a hosted entry's tool row carries its connect descriptor and only its own co
expect(linear.tools).toEqual(linearEntry.tools.map((tool) => expect.objectContaining({
name: `linear.${tool.name}`,
description: tool.description,
requiredWriteMode: 'read',
requiredWriteMode: tool.class === 'write' ? 'write-with-confirm' : 'read',
irreversible: Boolean(tool.irreversible),
})));
expect(acmeRow.tools).toEqual([{ name: 'acme.list_tickets', description: 'List tickets', requiredWriteMode: 'read', irreversible: false }]);
Expand Down
Loading
Loading