Skip to content
Open
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
649 changes: 453 additions & 196 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
},
"dependencies": {
"@linear/sdk": "^86.0.0",
"@modelcontextprotocol/sdk": "^1.6.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"cors": "^2.8.5",
Expand Down
176 changes: 174 additions & 2 deletions scripts/mcp-smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ async function main() {
try {
await client.connect(transport);

const serverVersion = client.getServerVersion();
assert.equal(serverVersion?.name, 'linear', 'Server must identify itself as "linear".');
assert.match(
serverVersion?.version ?? '',
/^\d+\.\d+\.\d+/,
'Server must report a semver version during initialization.',
);

const serverCapabilities = client.getServerCapabilities();
assert.ok(serverCapabilities?.tools, 'Server must declare the tools capability.');
assert.ok(serverCapabilities?.resources, 'Server must declare the resources capability.');
assert.ok(serverCapabilities?.prompts, 'Server must declare the prompts capability.');

const { tools } = await client.listTools();
const actualToolNames = tools.map((tool) => tool.name).sort();
const expectedToolNames = allToolDefinitions.map((tool) => tool.name).sort();
Expand All @@ -46,6 +59,11 @@ async function main() {
expectedToolNames,
`MCP server advertised an unexpected tool set. Expected ${expectedToolNames.length} tools, got ${actualToolNames.length}.`,
);
assert.equal(
new Set(actualToolNames).size,
actualToolNames.length,
'MCP server advertised duplicate tool names.',
);

for (const tool of tools) {
assert.equal(
Expand All @@ -60,16 +78,170 @@ async function main() {
`Tool ${tool.name} exposes disallowed top-level schema key ${key}.`,
);
}

assert.ok(tool.annotations, `Tool ${tool.name} must advertise annotations.`);
for (const hint of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
assert.equal(
typeof tool.annotations[hint],
'boolean',
`Tool ${tool.name} must advertise an explicit boolean ${hint}.`,
);
}

assert.ok(tool.outputSchema, `Tool ${tool.name} must advertise an outputSchema.`);
assert.equal(
tool.outputSchema.type,
'object',
`Tool ${tool.name} must expose a top-level object output schema.`,
);
}

const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
const expectedAnnotations = [
[
'linear_getIssues',
{ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
],
[
'linear_createIssue',
{
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
],
[
'linear_deleteWebhook',
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
],
[
'linear_logoutAllSessions',
{ readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
],
[
'linear_updateIssue',
{ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
],
];
for (const [toolName, annotations] of expectedAnnotations) {
assert.deepEqual(
toolsByName.get(toolName)?.annotations,
annotations,
`Tool ${toolName} advertised unexpected annotations.`,
);
}

const openWorldToolNames = tools
.filter((tool) => tool.annotations.openWorldHint)
.map((tool) => tool.name);
assert.deepEqual(
openWorldToolNames,
[],
'Every tool talks only to the configured Linear workspace, so none may advertise openWorldHint.',
);

const arrayOutputTool = toolsByName.get('linear_getIssues');
assert.equal(
arrayOutputTool.outputSchema.properties.items.type,
'array',
'Array-producing tools must advertise their array schema wrapped under an items envelope.',
);
assert.deepEqual(
arrayOutputTool.outputSchema.required,
['items'],
'The items envelope must be required for array-producing tools.',
);

for (const toolName of criticalToolNames) {
assert.ok(actualToolNames.includes(toolName), `Expected tool ${toolName} to be registered.`);
}

const statusResult = await client.callTool({
name: 'linear_getServerStatus',
arguments: {},
});
assert.equal(statusResult.isError, false, 'Server status tool should run without Linear I/O.');
assert.ok(Array.isArray(statusResult.content), 'Tool results must carry a content array.');
assert.equal(statusResult.content[0].type, 'text', 'Tool results must be text content items.');
const statusPayload = JSON.parse(statusResult.content[0].text);
assert.equal(statusPayload.toolCount, actualToolNames.length);
assert.match(
statusPayload.version ?? '',
/^\d+\.\d+\.\d+/,
'Server status must report a semver version.',
);
// The SDK client has already validated structuredContent against the advertised
// outputSchema during callTool; here we pin that it mirrors the text payload.
assert.deepEqual(
statusResult.structuredContent,
statusPayload,
'structuredContent must mirror the JSON text payload for object-producing tools.',
);

const rateLimitResult = await client.callTool({
name: 'linear_getRateLimitStatus',
arguments: {},
});
assert.equal(
rateLimitResult.isError,
false,
'Rate-limit status should run without Linear I/O.',
);
assert.deepEqual(
rateLimitResult.structuredContent,
JSON.parse(rateLimitResult.content[0].text),
'structuredContent must mirror the JSON text payload for linear_getRateLimitStatus.',
);

const rejectedArguments = await client.callTool({
name: 'linear_getServerStatus',
arguments: { unexpectedArgument: true },
});
assert.equal(
rejectedArguments.isError,
true,
'Unknown arguments must surface an in-band error result rather than a protocol error.',
);
assert.equal(
rejectedArguments.content[0].type,
'text',
'Error results must be text content items, not protocol-level errors.',
);
assert.ok(
rejectedArguments.content[0].text.includes(
'Unknown argument(s) for linear_getServerStatus: unexpectedArgument',
),
);
assert.equal(
rejectedArguments.structuredContent,
undefined,
'Error results must stay text-only without structuredContent.',
);

const unknownToolResult = await client.callTool({
name: 'linear_toolThatDoesNotExist',
arguments: {},
});
assert.equal(
unknownToolResult.isError,
true,
'Unknown tools must surface an in-band error result rather than a protocol error.',
);
assert.ok(
unknownToolResult.content[0].text.includes('Unknown tool: linear_toolThatDoesNotExist'),
);

const { resources } = await client.listResources();
const resourceUris = resources.map((resource) => resource.uri);
assert.ok(resourceUris.includes('linear://viewer'), 'Expected linear://viewer resource to be registered.');
assert.ok(resourceUris.includes('linear://rate-limit'), 'Expected linear://rate-limit resource to be registered.');
assert.ok(
resourceUris.includes('linear://viewer'),
'Expected linear://viewer resource to be registered.',
);
assert.ok(
resourceUris.includes('linear://rate-limit'),
'Expected linear://rate-limit resource to be registered.',
);

const guideResource = await client.readResource({ uri: 'linear://resource-guide' });
assert.ok(guideResource.contents[0].text.includes('linear://project/{id}'));
Expand Down
11 changes: 9 additions & 2 deletions src/__tests__/mcp-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ describe('Linear MCP resources', () => {
lastOperation: null,
}),
});
expect(viewer.contents[0]).toMatchObject({ uri: 'linear://viewer', mimeType: 'application/json' });
expect(viewer.contents[0]).toMatchObject({
uri: 'linear://viewer',
mimeType: 'application/json',
});

await readLinearResource('linear://project/project-1', {
linearService,
Expand Down Expand Up @@ -92,6 +95,10 @@ describe('Linear MCP resources', () => {
lastOperation: 'request',
}),
});
expect(rateLimit.contents[0].text).toContain('"isBlocked": true');
const rateLimitContent = rateLimit.contents[0];
if (!('text' in rateLimitContent)) {
throw new Error('Expected linear://rate-limit to return text contents.');
}
expect(rateLimitContent.text).toContain('"isBlocked": true');
});
});
Loading