From f4f7a11cf75b32f210fca99cef53aed09f48d51f Mon Sep 17 00:00:00 2001 From: itz4blitz Date: Fri, 10 Jul 2026 19:56:01 -0400 Subject: [PATCH 1/3] Add OAuth app and webhook management --- DEVELOPMENT.md | 8 + README.md | 44 +- TOOLS.md | 52 +- package-lock.json | 12 +- package.json | 4 +- scripts/mcp-smoke-test.mjs | 48 + src/__tests__/config.test.ts | 26 +- .../future-batch-three-tools.test.ts | 2 +- src/__tests__/linear-service.test.ts | 2 +- src/__tests__/oauth-webhook-tools.test.ts | 926 ++++++++++++++++++ src/index.ts | 16 +- src/services/linear-service.ts | 543 +++++++++- src/tools/definitions/index.ts | 35 + src/tools/definitions/oauth-tools.ts | 411 ++++++++ src/tools/definitions/ops-tools.ts | 107 +- src/tools/handlers/index.ts | 44 + src/tools/handlers/oauth-handlers.ts | 172 ++++ src/tools/handlers/ops-handlers.ts | 64 +- src/tools/oauth-constants.ts | 65 ++ src/tools/type-guards.ts | 540 +++++++++- src/utils/config.ts | 37 + 21 files changed, 3118 insertions(+), 40 deletions(-) create mode 100644 src/__tests__/oauth-webhook-tools.test.ts create mode 100644 src/tools/definitions/oauth-tools.ts create mode 100644 src/tools/handlers/oauth-handlers.ts create mode 100644 src/tools/oauth-constants.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d660a9f..72a2613 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,6 +14,14 @@ npm install npm run dev -- --token YOUR_LINEAR_API_TOKEN ``` +To exercise the alpha managed OAuth application tools, use an eligible managing OAuth application's access token instead: + +```bash +npm run dev -- --oauth-token YOUR_LINEAR_OAUTH_ACCESS_TOKEN +``` + +Do not use production OAuth applications for destructive live tests. Use temporary child-app and webhook names, capture one-time secrets without logging them, and archive/delete every created resource before finishing. + ### Validation Use the following checks before merging or publishing: diff --git a/README.md b/README.md index bda69b8..c3a20e7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ MCP Linear bridges AI assistants and Linear by implementing the MCP protocol. Wi - Retrieve issues, projects, teams, cycles, milestones, roadmaps, customers, customer needs, and workspace/project/initiative/team/issue/release/cycle documents - Create and update issues, change status, assign, and comment - Manage projects, full diff-aware project and initiative update lifecycles, milestones, roadmaps, saved views, and favorites -- Work with templates, custom fields, webhooks, and attachments +- Create and manage workspace webhooks, including updates and signing-secret rotation +- Prepare OAuth app manifests and authorization URLs, issue scoped client-credentials tokens, or manage child OAuth apps when authenticated as a managing OAuth application +- Work with templates, custom fields, and attachments - Work with customer records, customer statuses/tiers, and customer needs linked to issues or projects - Read notifications, subscriptions, sessions, audits, and integrations without leaving MCP - Inspect rate-limit and server health before running heavy planning sessions @@ -50,10 +52,15 @@ Once connected, you can use prompts like: - "Get the latest project update diff and archive an outdated update" - "Show customer needs for this project and mark the important ones" - "Create an initiative update and hide the generated diff from the update body" +- "Prepare a private OAuth app for my GitHub issue pipeline with client credentials enabled" +- "Issue a narrowly scoped client-credentials token for that GitHub pipeline" +- "Create a webhook for Issue and Comment events, then rotate its signing secret" ## Installation -### Getting your Linear API token +### Authentication + +#### Personal API key (default) 1. Log in to your Linear account at [linear.app](https://linear.app) 2. Click on your organization avatar (top-left corner) @@ -63,6 +70,29 @@ Once connected, you can use prompts like: 6. Give your key a name (e.g., `MCP Linear Integration`) 7. Copy the generated API token and store it securely — you won't be able to see it again +Personal API keys support the normal Linear and workspace-webhook tools. They cannot call Linear's alpha managed-child-OAuth-application API because that API requires the caller itself to be an OAuth application. With a personal API key, `linear_generateOAuthApplicationSetup` still prepares an official manifest and pre-filled Linear setup URL for an administrator to confirm. + +#### OAuth access token (managed OAuth applications) + +To let MCP actually create and manage child OAuth applications, authenticate it with an access token belonging to a Linear OAuth application that is eligible to manage those child applications: + +```bash +export LINEAR_OAUTH_ACCESS_TOKEN=YOUR_OAUTH_ACCESS_TOKEN +mcp-linear +``` + +Or pass `--oauth-token YOUR_OAUTH_ACCESS_TOKEN`. Explicit command-line credentials take precedence over environment variables; when both environment credential types are present, OAuth authentication is selected. See Linear's [OAuth documentation](https://linear.app/developers/oauth-2-0-authentication) and [OAuth application manifests](https://linear.app/developers/oauth-app-manifests). + +Each MCP server process uses one Linear credential. If the managing app token uses `actor=app` (which cannot receive `admin`) and you also need admin-scoped workspace-webhook tools, configure two MCP server entries: one with the managing OAuth token for child-app operations and one with a workspace administrator's personal API key for workspace webhooks. A user-actor OAuth token carrying `admin` can cover the webhook side instead. + +OAuth scopes are selected when an authorization URL or client-credentials token is requested; they are not mutable fields on an OAuth application. The MCP validates current Linear scopes, prepares authorization URLs, and can issue app-actor tokens with `linear_createOAuthClientCredentialsToken`. For GitHub-hosted pipelines, enable the `client_credentials` grant and request the narrowest useful scope, such as `issues:create`. + +Client-credentials tokens normally expire after 30 days and have no refresh token. Linear permits multiple active tokens only while they use the same scope set; requesting a different scope set revokes the application's existing app-actor tokens. The token tool therefore requires both `confirmSecretExposure: true` and `confirmScopeChangeRisk: true`. + +Creating an OAuth app and rotating OAuth or webhook secrets returns one-time secret material through MCP. Those tools require `confirmSecretExposure: true`; move returned values directly into a secret manager such as GitHub Actions secrets and do not paste them into source control or logs. + +Webhook URLs must be publicly reachable HTTPS endpoints. Validation rejects credentials in URLs and obvious loopback, private-network, link-local, and local-hostname destinations. + ### Installing via [add-mcp](https://github.com/neondatabase/add-mcp) (Recommended) `add-mcp` installs the server into Claude Code, Cursor, Codex, VS Code, Claude Desktop, and many other MCP-aware agents with a single command: @@ -102,9 +132,9 @@ Add the following to your MCP settings file: Prerequisites: -- Node.js (v18+) +- Node.js (v20+) - NPM or Yarn -- Linear API token +- Linear personal API key or OAuth access token ```bash # Install globally @@ -125,6 +155,12 @@ Run the server with your Linear API token: mcp-linear --token YOUR_LINEAR_API_TOKEN ``` +Or with a managing OAuth application's access token: + +```bash +mcp-linear --oauth-token YOUR_OAUTH_ACCESS_TOKEN +``` + Or set the token in your environment and run without arguments: ```bash diff --git a/TOOLS.md b/TOOLS.md index 5fce6a1..2955a9e 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -316,13 +316,47 @@ Linear calls these "saved views" in the product UI. The GraphQL API and SDK expo ### Webhook and Attachment Tools -| Tool Name | Description | Status | -| ----------------------- | --------------------------------------- | -------------- | -| `linear_getWebhooks` | Get a list of webhooks | ✅ Implemented | -| `linear_createWebhook` | Create a webhook for integration events | ✅ Implemented | -| `linear_deleteWebhook` | Delete a webhook | ✅ Implemented | -| `linear_getAttachments` | Get attachments for an issue | ✅ Implemented | -| `linear_addAttachment` | Add an attachment to an issue | ✅ Implemented | +| Tool Name | Description | Status | +| --------------------------- | ------------------------------------------------ | -------------- | +| `linear_getWebhooks` | Get a list of workspace webhooks | ✅ Implemented | +| `linear_getWebhookById` | Get one workspace webhook | ✅ Implemented | +| `linear_createWebhook` | Create a workspace webhook | ✅ Implemented | +| `linear_updateWebhook` | Update a workspace webhook | ✅ Implemented | +| `linear_rotateWebhookSecret` | Rotate and return a new webhook signing secret | ✅ Implemented | +| `linear_deleteWebhook` | Delete a workspace webhook | ✅ Implemented | +| `linear_getAttachments` | Get attachments for an issue | ✅ Implemented | +| `linear_addAttachment` | Add an attachment to an issue | ✅ Implemented | + +Workspace webhooks are separate from the installation webhook configured on an OAuth application. Workspace webhook reads and mutations require a workspace administrator or an OAuth token with Linear's `admin` scope. Secret rotation returns the replacement secret once and invalidates the previous signing secret immediately; callers that let Linear generate a secret during creation can rotate it before enabling deliveries to capture a known value securely. + +Webhook destinations must be publicly reachable HTTPS URLs. Runtime validation rejects embedded URL credentials and obvious loopback, private-network, link-local, and local-hostname destinations. + +### OAuth Application Tools + +| Tool Name | Description | Status | +| ------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------- | +| `linear_generateOAuthApplicationSetup` | Generate an official manifest and pre-filled setup URL; a human confirms it | ✅ Implemented | +| `linear_generateOAuthAuthorizationUrl` | Generate a validated OAuth authorization URL and scope request | ✅ Implemented | +| `linear_createOAuthClientCredentialsToken` | Issue a scoped app-actor token for server-to-server pipelines | ✅ Implemented | +| `linear_getManagedOAuthApplications` | List child OAuth apps owned by the calling OAuth application | ✅ Implemented | +| `linear_getManagedOAuthApplicationById` | Get one child OAuth app owned by the calling OAuth application | ✅ Implemented | +| `linear_createManagedOAuthApplication` | Create a child OAuth app and return its one-time secrets | ✅ Implemented | +| `linear_updateManagedOAuthApplication` | Update a child OAuth app | ✅ Implemented | +| `linear_archiveManagedOAuthApplication` | Archive a child OAuth app | ✅ Implemented | +| `linear_rotateManagedOAuthApplicationSecret` | Rotate and return a child app's client secret | ✅ Implemented | +| `linear_rotateManagedOAuthApplicationWebhookSecret` | Rotate and return a child app's installation-webhook secret | ✅ Implemented | + +Linear exposes managed OAuth application lifecycle operations as an alpha GraphQL surface and does not yet include first-class methods for them in `@linear/sdk`; this server therefore uses minimal raw GraphQL for that slice. The operations only manage applications created by the calling OAuth application. Configure `LINEAR_OAUTH_ACCESS_TOKEN` or `--oauth-token` with an eligible managing application's access token; a personal API key has no calling OAuth application identity and cannot use these operations. + +One server process uses one Linear credential. When the managing app uses `actor=app` (which cannot receive `admin`), use separate MCP server entries for managed-child-app operations and admin-scoped workspace webhooks, or use an eligible user-actor OAuth token with `admin` for the webhook entry. + +For the common API-key workflow, `linear_generateOAuthApplicationSetup` does not claim to create anything. It returns a manifest plus Linear's official pre-filled creation URL, where a workspace administrator reviews and confirms the app. The manifest supports `authorization_code` and `client_credentials` grants and OAuth installation webhooks. + +Scopes are grants requested through `/oauth/authorize` or `/oauth/token`, not fields stored on an OAuth application. `linear_generateOAuthAuthorizationUrl` validates Linear's current user and app-actor scopes and rejects the unsupported `actor=app` plus `admin` combination. `linear_createOAuthClientCredentialsToken` completes the non-interactive pipeline flow by issuing an app-actor token directly from a client ID and secret. + +Client-credentials tokens normally last 30 days and do not include refresh tokens. Linear allows multiple active tokens only when they share the same scope set; requesting a different scope set revokes existing app-actor tokens. Token issuance therefore requires explicit acknowledgement of both secret exposure and scope-change risk. See Linear's [OAuth guide](https://linear.app/developers/oauth-2-0-authentication), [agent scopes](https://linear.app/developers/agents), and [manifest format](https://linear.app/developers/oauth-app-manifests). + +All operations that return a newly generated secret require `confirmSecretExposure: true`. Client secrets and signing secrets are one-time values and should be transferred directly to a secret manager. ### Notification and Session Tools @@ -437,7 +471,7 @@ Bulk import/export is feasible, but it does not map cleanly to first-class SDK b These are directly supported by the current SDK and fit the repo well. -Webhooks, attachment reads, and attachment creation are now covered. +The workspace webhook lifecycle, attachment reads, and attachment creation are now covered. ### Integrations @@ -502,7 +536,7 @@ Rich reporting support appears thin in the current SDK beyond export/report help ### Sessions and Authentication -Session and audit reads are a better fit for this repo than broader API-key or OAuth-client admin flows. +Session management and managed OAuth application workflows are covered. Personal API-key creation remains a Linear settings operation and is intentionally not exposed as an MCP tool. | Tool Name | Description | Priority | Status | | -------------------------------- | ---------------------------------------- | -------- | ---------- | diff --git a/package-lock.json b/package-lock.json index 7feadc2..5423233 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@tacticlaunch/mcp-linear", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tacticlaunch/mcp-linear", - "version": "1.3.1", + "version": "1.4.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@linear/sdk": "^86.0.0", + "@linear/sdk": "^88.1.0", "@modelcontextprotocol/sdk": "^1.6.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", @@ -1171,9 +1171,9 @@ } }, "node_modules/@linear/sdk": { - "version": "86.0.0", - "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-86.0.0.tgz", - "integrity": "sha512-Lr47874CGGPCDiPQoKYjcZDWyyu18N8zzEGvss86uzjZaeZrhNsuwKTARswG27VAJ3fPVJAvl/5dPl8QwndSlg==", + "version": "88.1.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-88.1.0.tgz", + "integrity": "sha512-l0U5O2hFcNFYbjH+YQXJyO14obFX5mb6jdSjWstAkoDaIwy6oBnkd4P9uPy/4PnUDfSf5bPWSI9STCxo5STFxw==", "license": "MIT", "dependencies": { "@graphql-typed-document-node/core": "^3.2.0" diff --git a/package.json b/package.json index be95bbe..1d56c81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tacticlaunch/mcp-linear", - "version": "1.3.1", + "version": "1.4.0", "description": "A Model Context Protocol (MCP) server implementation for the Linear GraphQL API that enables AI assistants to interact with Linear project management systems.", "main": "dist/index.js", "type": "module", @@ -43,7 +43,7 @@ "url": "git+https://github.com/tacticlaunch/mcp-linear.git" }, "dependencies": { - "@linear/sdk": "^86.0.0", + "@linear/sdk": "^88.1.0", "@modelcontextprotocol/sdk": "^1.6.0", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", diff --git a/scripts/mcp-smoke-test.mjs b/scripts/mcp-smoke-test.mjs index b2b4c92..481612b 100644 --- a/scripts/mcp-smoke-test.mjs +++ b/scripts/mcp-smoke-test.mjs @@ -9,6 +9,13 @@ const criticalToolNames = [ 'linear_updateMilestone', 'linear_updateSavedView', 'linear_removeFromFavorites', + 'linear_generateOAuthApplicationSetup', + 'linear_generateOAuthAuthorizationUrl', + 'linear_createOAuthClientCredentialsToken', + 'linear_createManagedOAuthApplication', + 'linear_getWebhookById', + 'linear_updateWebhook', + 'linear_rotateWebhookSecret', ]; const scriptPath = fileURLToPath(import.meta.url); @@ -46,6 +53,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( @@ -66,6 +78,42 @@ async function main() { assert.ok(actualToolNames.includes(toolName), `Expected tool ${toolName} to be registered.`); } + const setupResult = await client.callTool({ + name: 'linear_generateOAuthApplicationSetup', + arguments: { + name: 'MCP smoke agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + redirectUris: ['https://example.com/oauth/callback'], + grantTypes: ['authorization_code', 'client_credentials'], + }, + }); + assert.equal(setupResult.isError, false, 'OAuth application setup generator should run without Linear I/O.'); + const setupPayload = JSON.parse(setupResult.content[0].text); + assert.equal(setupPayload.requiresUserConfirmation, true); + assert.ok(setupPayload.creationUrl.startsWith('https://linear.app/settings/api/applications/new?')); + + const authorizationResult = await client.callTool({ + name: 'linear_generateOAuthAuthorizationUrl', + arguments: { + clientId: 'mcp-smoke-client', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['issues:create'], + actor: 'app', + state: 'mcp-smoke-state', + }, + }); + assert.equal(authorizationResult.isError, false, 'OAuth authorization URL generator should run without Linear I/O.'); + const authorizationPayload = JSON.parse(authorizationResult.content[0].text); + assert.deepEqual(authorizationPayload.scopes, ['read', 'issues:create']); + + const rejectedSecretRotation = await client.callTool({ + name: 'linear_rotateWebhookSecret', + arguments: { id: 'webhook-1', confirmSecretExposure: false }, + }); + assert.equal(rejectedSecretRotation.isError, true, 'Secret rotation must require explicit exposure acknowledgement.'); + assert.ok(rejectedSecretRotation.content[0].text.includes('Invalid arguments for rotateWebhookSecret')); + 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.'); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 249e4f9..eb0cf7d 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,4 +1,4 @@ -import { getLinearApiToken } from '../utils/config.js'; +import { getLinearApiToken, getLinearAuthConfig } from '../utils/config.js'; describe('getLinearApiToken', () => { const originalArgv = process.argv; @@ -10,6 +10,7 @@ describe('getLinearApiToken', () => { process.env = { ...originalEnv }; delete process.env.LINEAR_API_TOKEN; delete process.env.LINEAR_API_KEY; + delete process.env.LINEAR_OAUTH_ACCESS_TOKEN; delete process.env.LINEAR_WEBHOOK_SECRET; delete process.env.MCP_LINEAR_DEBUG; consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -46,4 +47,27 @@ describe('getLinearApiToken', () => { expect.arrayContaining(['LINEAR_WEBHOOK_SECRET']), ); }); + + it('selects an OAuth access token explicitly so managed OAuth application tools have an OAuth caller identity', () => { + process.env.LINEAR_API_TOKEN = 'api-token'; + process.env.LINEAR_OAUTH_ACCESS_TOKEN = 'oauth-access-token'; + + expect(getLinearAuthConfig()).toEqual({ type: 'oauth', token: 'oauth-access-token' }); + }); + + it('supports an explicit OAuth CLI token and otherwise preserves API-key authentication', () => { + process.argv = ['node', 'mcp-linear', '--oauth-token', 'oauth-cli-token', '--token', 'api-cli-token']; + expect(getLinearAuthConfig()).toEqual({ type: 'oauth', token: 'oauth-cli-token' }); + + process.argv = ['node', 'mcp-linear', '--token', 'api-cli-token']; + expect(getLinearAuthConfig()).toEqual({ type: 'apiKey', token: 'api-cli-token' }); + + process.env.LINEAR_OAUTH_ACCESS_TOKEN = 'oauth-env-token'; + expect(getLinearAuthConfig()).toEqual({ type: 'apiKey', token: 'api-cli-token' }); + }); + + it('does not log credential values when no supported credential is configured', () => { + expect(getLinearAuthConfig()).toBeUndefined(); + expect(consoleErrorSpy).not.toHaveBeenCalledWith(expect.stringContaining('token=')); + }); }); diff --git a/src/__tests__/future-batch-three-tools.test.ts b/src/__tests__/future-batch-three-tools.test.ts index 6c08056..c654441 100644 --- a/src/__tests__/future-batch-three-tools.test.ts +++ b/src/__tests__/future-batch-three-tools.test.ts @@ -61,7 +61,7 @@ describe('future batch three MCP tools', () => { const handlers = registerToolHandlers(service); await expect(handlers.linear_getWebhooks({ limit: 10 })).resolves.toEqual([{ id: 'webhook-1' }]); - await expect(handlers.linear_createWebhook({ url: 'https://example.com', resourceTypes: ['Issue'] })).resolves.toEqual({ id: 'webhook-1' }); + await expect(handlers.linear_createWebhook({ url: 'https://example.com', resourceTypes: ['Issue'], allPublicTeams: true })).resolves.toEqual({ id: 'webhook-1' }); await expect(handlers.linear_deleteWebhook({ id: 'webhook-1' })).resolves.toEqual({ success: true, id: 'webhook-1' }); await expect(handlers.linear_getAttachments({ issueId: 'ISS-1', limit: 10 })).resolves.toEqual([{ id: 'attachment-1' }]); await expect(handlers.linear_addAttachment({ issueId: 'ISS-1', title: 'Spec', url: 'https://example.com/spec' })).resolves.toEqual({ id: 'attachment-1' }); diff --git a/src/__tests__/linear-service.test.ts b/src/__tests__/linear-service.test.ts index 4da149c..8f367ea 100644 --- a/src/__tests__/linear-service.test.ts +++ b/src/__tests__/linear-service.test.ts @@ -2692,7 +2692,7 @@ describe('LinearService future backlog batch coverage', () => { } as never); expect((await service.getWebhooks({ limit: 10 })).length).toBe(1); - expect((await service.createWebhook({ url: 'https://example.com', resourceTypes: ['Issue'] })).id).toBe('webhook-1'); + expect((await service.createWebhook({ url: 'https://example.com', resourceTypes: ['Issue'], allPublicTeams: true })).id).toBe('webhook-1'); expect(await service.deleteWebhook('webhook-1')).toEqual({ success: true, id: 'webhook-1' }); expect((await service.getAttachments({ issueId: 'issue-1', limit: 10 })).length).toBe(1); expect((await service.addAttachment({ issueId: 'issue-1', title: 'Spec', url: 'https://example.com/spec' })).id).toBe('attachment-1'); diff --git a/src/__tests__/oauth-webhook-tools.test.ts b/src/__tests__/oauth-webhook-tools.test.ts new file mode 100644 index 0000000..7256728 --- /dev/null +++ b/src/__tests__/oauth-webhook-tools.test.ts @@ -0,0 +1,926 @@ +import { LinearService } from '../services/linear-service.js'; +import { allToolDefinitions } from '../tools/definitions/index.js'; +import { registerToolHandlers } from '../tools/handlers/index.js'; +import { + isCreateManagedOAuthApplicationArgs, + isCreateOAuthClientCredentialsTokenArgs, + isCreateWebhookArgs, + isGenerateOAuthApplicationSetupArgs, + isGenerateOAuthAuthorizationUrlArgs, + isRotateManagedOAuthApplicationSecretArgs, + isRotateWebhookSecretArgs, + isUpdateManagedOAuthApplicationArgs, + isUpdateWebhookArgs, +} from '../tools/type-guards.js'; + +const managedOAuthApplication = { + id: 'oauth-app-1', + clientId: 'client-1', + name: 'Pipeline Agent', + description: 'Creates issues from GitHub workflows', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + distribution: 'private', + grantTypes: ['authorization_code', 'client_credentials'], + imageUrl: 'https://example.com/icon.png', + redirectUris: ['https://example.com/oauth/callback'], + webhookEnabled: true, + webhookUrl: 'https://example.com/webhooks/linear', + webhookResourceTypes: ['Issue', 'Comment'], + createdAt: '2026-07-10T12:00:00.000Z', + updatedAt: '2026-07-10T12:01:00.000Z', +}; + +const webhook = { + id: 'webhook-1', + label: 'GitHub pipeline', + url: 'https://example.com/webhooks/linear', + enabled: true, + allPublicTeams: false, + resourceTypes: ['Issue', 'Comment'], + createdAt: new Date('2026-07-10T12:00:00.000Z'), + updatedAt: new Date('2026-07-10T12:01:00.000Z'), + archivedAt: null, + team: Promise.resolve({ id: 'team-1', name: 'Platform', key: 'PLAT' }), + creator: Promise.resolve({ id: 'user-1', name: 'Alex', email: 'alex@example.com' }), +}; + +function makeService(client: Record) { + return new LinearService(client as never); +} + +describe('OAuth application and complete webhook management', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers truthful OAuth setup, managed-app, and webhook lifecycle tools', () => { + const names = allToolDefinitions.map((tool) => tool.name); + + expect(names).toEqual( + expect.arrayContaining([ + 'linear_generateOAuthApplicationSetup', + 'linear_generateOAuthAuthorizationUrl', + 'linear_createOAuthClientCredentialsToken', + 'linear_getManagedOAuthApplications', + 'linear_getManagedOAuthApplicationById', + 'linear_createManagedOAuthApplication', + 'linear_updateManagedOAuthApplication', + 'linear_archiveManagedOAuthApplication', + 'linear_rotateManagedOAuthApplicationSecret', + 'linear_rotateManagedOAuthApplicationWebhookSecret', + 'linear_getWebhookById', + 'linear_updateWebhook', + 'linear_rotateWebhookSecret', + ]), + ); + + const setup = allToolDefinitions.find((tool) => tool.name === 'linear_generateOAuthApplicationSetup'); + expect(setup?.description).toContain('does not create the application'); + expect(setup?.input_schema.required).toEqual(['name', 'developer', 'redirectUris']); + expect(setup?.input_schema.properties.grantTypes).toMatchObject({ + type: 'array', + minItems: 1, + uniqueItems: true, + items: { type: 'string', enum: ['authorization_code', 'client_credentials'] }, + }); + + const authorize = allToolDefinitions.find((tool) => tool.name === 'linear_generateOAuthAuthorizationUrl'); + expect(authorize?.input_schema.properties.scopes.items.enum).toEqual( + expect.arrayContaining([ + 'read', + 'write', + 'issues:create', + 'comments:create', + 'timeSchedule:write', + 'admin', + 'app:assignable', + 'app:mentionable', + 'customer:read', + 'customer:write', + 'initiative:read', + 'initiative:write', + ]), + ); + expect(authorize?.input_schema.properties.codeChallenge).toMatchObject({ + minLength: 43, + maxLength: 43, + pattern: '^[A-Za-z0-9_-]{43}$', + }); + expect(authorize?.input_schema.properties).not.toHaveProperty('codeChallengeMethod'); + + const createManaged = allToolDefinitions.find((tool) => tool.name === 'linear_createManagedOAuthApplication'); + expect(createManaged?.description).toContain('calling OAuth application'); + expect(createManaged?.input_schema.required).toEqual( + expect.arrayContaining(['name', 'developer', 'redirectUris', 'confirmSecretExposure']), + ); + expect(createManaged?.input_schema.properties).not.toHaveProperty('distribution'); + expect(createManaged?.input_schema.properties.webhookResourceTypes.items.enum).toEqual([ + 'AgentSessionEvent', + 'AppUserNotification', + 'Attachment', + 'Comment', + 'Customer', + 'CustomerNeed', + 'Cycle', + 'Document', + 'Initiative', + 'InitiativeUpdate', + 'Issue', + 'IssueLabel', + 'IssueSLA', + 'OAuthAuthorization', + 'PermissionChange', + 'Project', + 'ProjectLabel', + 'ProjectUpdate', + 'Reaction', + 'Release', + 'ReleaseNote', + 'User', + ]); + expect(createManaged?.output_schema.properties).toMatchObject({ + application: { type: 'object' }, + clientSecret: { type: ['string', 'null'] }, + webhookSecret: { type: ['string', 'null'] }, + }); + + const rotateWebhook = allToolDefinitions.find((tool) => tool.name === 'linear_rotateWebhookSecret'); + expect(rotateWebhook?.input_schema.required).toEqual(['id', 'confirmSecretExposure']); + expect(rotateWebhook?.output_schema.properties?.secret).toEqual({ type: 'string' }); + const updateWebhook = allToolDefinitions.find((tool) => tool.name === 'linear_updateWebhook'); + expect(updateWebhook?.input_schema.properties?.label).toMatchObject({ + type: ['string', 'null'], + }); + const clientCredentials = allToolDefinitions.find( + (tool) => tool.name === 'linear_createOAuthClientCredentialsToken', + ); + expect(clientCredentials?.input_schema.required).toEqual( + expect.arrayContaining(['clientSecret', 'confirmSecretExposure', 'confirmScopeChangeRisk']), + ); + }); + + it('keeps the advertised tool registry unique and in exact parity with handlers', () => { + const definitionNames = allToolDefinitions.map((tool) => tool.name); + const handlerNames = Object.keys(registerToolHandlers({} as LinearService)); + + expect(new Set(definitionNames).size).toBe(definitionNames.length); + expect(handlerNames.sort()).toEqual([...definitionNames].sort()); + }); + + it('validates OAuth grants, webhook pairing, scope compatibility, and explicit secret exposure', () => { + const createArgs = { + name: 'Pipeline Agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + redirectUris: ['https://example.com/oauth/callback'], + grantTypes: ['authorization_code', 'client_credentials'], + webhookUrl: 'https://example.com/webhooks/linear', + webhookResourceTypes: ['Issue', 'Comment'], + confirmSecretExposure: true, + }; + + expect(isCreateManagedOAuthApplicationArgs(createArgs)).toBe(true); + expect(isCreateManagedOAuthApplicationArgs({ ...createArgs, confirmSecretExposure: false })).toBe(false); + expect(isCreateManagedOAuthApplicationArgs({ ...createArgs, redirectUris: [] })).toBe(false); + expect(isCreateManagedOAuthApplicationArgs({ ...createArgs, grantTypes: ['client_credentials'] })).toBe(false); + expect(isCreateManagedOAuthApplicationArgs({ ...createArgs, webhookResourceTypes: undefined })).toBe(false); + expect( + isCreateManagedOAuthApplicationArgs({ + ...createArgs, + redirectUris: ['https://example.com/oauth/callback', 'https://example.com/oauth/callback'], + }), + ).toBe(false); + + expect(isUpdateManagedOAuthApplicationArgs({ id: 'oauth-app-1', name: 'Renamed' })).toBe(true); + expect(isUpdateManagedOAuthApplicationArgs({ id: 'oauth-app-1' })).toBe(false); + expect( + isUpdateManagedOAuthApplicationArgs({ id: 'oauth-app-1', grantTypes: ['client_credentials'] }), + ).toBe(false); + + expect( + isGenerateOAuthApplicationSetupArgs({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + redirectUris: ['https://example.com/oauth/callback'], + grantTypes: ['authorization_code', 'client_credentials'], + }), + ).toBe(true); + expect( + isGenerateOAuthApplicationSetupArgs({ + name: 'Private Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + }), + ).toBe(true); + expect( + isGenerateOAuthApplicationSetupArgs({ + name: 'Public Agent', + developer: 'Tactic Launch', + distribution: 'public', + redirectUris: ['https://example.com/oauth/callback'], + }), + ).toBe(false); + expect( + isGenerateOAuthApplicationSetupArgs({ + name: 'Linear Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + }), + ).toBe(false); + expect( + isGenerateOAuthApplicationSetupArgs({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + redirectUris: ['https://example.com/oauth/callback'], + webhookUrl: 'https://example.com/webhooks/linear', + }), + ).toBe(false); + + expect( + isGenerateOAuthAuthorizationUrlArgs({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['issues:create', 'comments:create'], + actor: 'app', + state: 'csrf-state', + }), + ).toBe(true); + expect( + isGenerateOAuthAuthorizationUrlArgs({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['admin'], + actor: 'app', + }), + ).toBe(false); + + const clientCredentialsArgs = { + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['issues:create', 'comments:create'], + confirmSecretExposure: true, + confirmScopeChangeRisk: true, + }; + expect(isCreateOAuthClientCredentialsTokenArgs(clientCredentialsArgs)).toBe(true); + expect( + isCreateOAuthClientCredentialsTokenArgs({ + ...clientCredentialsArgs, + scopes: ['admin'], + }), + ).toBe(false); + expect( + isCreateOAuthClientCredentialsTokenArgs({ + ...clientCredentialsArgs, + confirmScopeChangeRisk: false, + }), + ).toBe(false); + expect( + isGenerateOAuthAuthorizationUrlArgs({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['repository:write'], + }), + ).toBe(false); + expect( + isGenerateOAuthAuthorizationUrlArgs({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['read'], + codeChallenge: 'challenge', + }), + ).toBe(false); + expect( + isGenerateOAuthAuthorizationUrlArgs({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['read'], + codeChallenge: `${'a'.repeat(42)}.`, + }), + ).toBe(false); + + expect(isUpdateWebhookArgs({ id: 'webhook-1', enabled: false })).toBe(true); + expect(isUpdateWebhookArgs({ id: 'webhook-1', label: null })).toBe(true); + expect(isUpdateWebhookArgs({ id: 'webhook-1' })).toBe(false); + expect(isUpdateWebhookArgs({ id: 'webhook-1', resourceTypes: [] })).toBe(false); + expect(isRotateWebhookSecretArgs({ id: 'webhook-1', confirmSecretExposure: true })).toBe(true); + expect(isRotateWebhookSecretArgs({ id: 'webhook-1', confirmSecretExposure: false })).toBe(false); + expect( + isRotateManagedOAuthApplicationSecretArgs({ id: 'oauth-app-1', confirmSecretExposure: true }), + ).toBe(true); + + expect( + isCreateWebhookArgs({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + teamId: 'team-1', + }), + ).toBe(true); + expect( + isCreateWebhookArgs({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + allPublicTeams: true, + }), + ).toBe(true); + expect( + isCreateWebhookArgs({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + }), + ).toBe(false); + expect( + isCreateWebhookArgs({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + allPublicTeams: false, + }), + ).toBe(false); + expect( + isCreateWebhookArgs({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + teamId: 'team-1', + allPublicTeams: true, + }), + ).toBe(false); + for (const url of [ + 'https://localhost/webhooks/linear', + 'https://127.0.0.1/webhooks/linear', + 'https://10.0.0.8/webhooks/linear', + 'https://[::1]/webhooks/linear', + 'https://user:password@example.com/webhooks/linear', + 'https://linear.app/webhooks/linear', + 'https://api.linear.app/webhooks/linear', + ]) { + expect( + isCreateWebhookArgs({ + url, + resourceTypes: ['Issue'], + teamId: 'team-1', + }), + ).toBe(false); + } + expect( + isUpdateWebhookArgs({ id: 'webhook-1', url: 'https://localhost/webhooks/linear' }), + ).toBe(false); + expect( + isUpdateManagedOAuthApplicationArgs({ + id: 'oauth-app-1', + webhookUrl: 'https://192.168.1.10/webhooks/linear', + }), + ).toBe(false); + expect( + isGenerateOAuthApplicationSetupArgs({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + webhookEnabled: true, + webhookUrl: 'https://localhost/webhooks/linear', + webhookResourceTypes: ['Issue'], + }), + ).toBe(false); + }); + + it('generates an official manifest setup link and an OAuth authorization URL without inventing scope CRUD', () => { + const service = makeService({}); + const setup = service.generateOAuthApplicationSetup({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + description: 'Creates Linear issues from GitHub', + imageUrl: 'https://example.com/icon.png', + distribution: 'private', + redirectUris: ['https://example.com/oauth/callback'], + grantTypes: ['authorization_code', 'client_credentials'], + webhookEnabled: true, + webhookUrl: 'https://example.com/webhooks/linear', + webhookResourceTypes: ['Issue', 'Comment', 'OAuthAuthorization'], + }); + + expect(setup.requiresUserConfirmation).toBe(true); + expect(setup.manifest).toEqual({ + $schema: 'https://linear.app/.well-known/oauth-app-manifest.schema.json', + schemaVersion: '1.0.0', + distribution: 'private', + display: { + description: 'Creates Linear issues from GitHub', + iconUrl: 'https://example.com/icon.png', + }, + developer: { name: 'Tactic Launch' }, + oauth: { + client_name: 'Pipeline Agent', + client_uri: 'https://example.com/linear', + redirect_uris: ['https://example.com/oauth/callback'], + grant_types: ['authorization_code', 'client_credentials'], + }, + webhook: { + enabled: true, + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue', 'Comment', 'OAuthAuthorization'], + }, + }); + const setupUrl = new URL(setup.creationUrl); + expect(`${setupUrl.origin}${setupUrl.pathname}`).toBe('https://linear.app/settings/api/applications/new'); + expect(JSON.parse(setupUrl.searchParams.get('manifest') as string)).toEqual(setup.manifest); + + const authorization = service.generateOAuthAuthorizationUrl({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['issues:create', 'comments:create', 'issues:create'], + actor: 'app', + state: 'csrf-state', + promptConsent: true, + codeChallenge: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + const authorizationUrl = new URL(authorization.authorizationUrl); + expect(authorization.scopes).toEqual(['read', 'issues:create', 'comments:create']); + expect(authorization.warnings).toEqual([]); + expect(authorizationUrl.origin + authorizationUrl.pathname).toBe('https://linear.app/oauth/authorize'); + expect(Object.fromEntries(authorizationUrl.searchParams)).toMatchObject({ + response_type: 'code', + client_id: 'client-1', + redirect_uri: 'https://example.com/oauth/callback', + scope: 'read,issues:create,comments:create', + actor: 'app', + state: 'csrf-state', + prompt: 'consent', + code_challenge: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + code_challenge_method: 'S256', + }); + + expect( + service.generateOAuthAuthorizationUrl({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['read'], + }).warnings, + ).toEqual([expect.stringContaining('state')]); + }); + + it('issues a scoped client-credentials token for server-to-server pipelines', async () => { + const abortTimeoutSpy = jest.spyOn(AbortSignal, 'timeout'); + const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'pipeline-access-token-once', + token_type: 'Bearer', + expires_in: 2_591_999, + scope: 'read issues:create comments:create', + }), + } as Response); + const service = makeService({}); + + await expect( + service.createOAuthClientCredentialsToken({ + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['issues:create', 'comments:create'], + }), + ).resolves.toEqual({ + accessToken: 'pipeline-access-token-once', + tokenType: 'Bearer', + expiresIn: 2_591_999, + scopes: ['read', 'issues:create', 'comments:create'], + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.linear.app/oauth/token', + expect.objectContaining({ + method: 'POST', + redirect: 'error', + signal: expect.any(AbortSignal), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }), + ); + const requestBody = fetchSpy.mock.calls[0]?.[1]?.body as URLSearchParams; + expect(abortTimeoutSpy).toHaveBeenCalledWith(15_000); + expect(requestBody.toString()).toBe( + 'grant_type=client_credentials&scope=read%2Cissues%3Acreate%2Ccomments%3Acreate&client_id=client-1&client_secret=client-secret-once', + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ access_token: null }), + } as Response); + await expect( + service.createOAuthClientCredentialsToken({ + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['read'], + }), + ).rejects.toThrow('Linear OAuth token response was incomplete'); + + for (const malformedPayload of [ + { + access_token: 'token', + token_type: 'Bearer', + expires_in: 1.5, + scope: 'read', + }, + { + access_token: 'token', + token_type: 'Bearer', + expires_in: 2_591_999, + scope: 'read unexpected:scope', + }, + ]) { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => malformedPayload, + } as Response); + await expect( + service.createOAuthClientCredentialsToken({ + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['read'], + }), + ).rejects.toThrow('Linear OAuth token response was incomplete'); + } + + fetchSpy.mockRejectedValueOnce(new DOMException('token endpoint timed out', 'TimeoutError')); + await expect( + service.createOAuthClientCredentialsToken({ + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['read'], + }), + ).rejects.toThrow('token endpoint timed out'); + }); + + it('uses narrow GraphQL for alpha managed OAuth application lifecycle and preserves one-time secrets', async () => { + const request = jest.fn(async (query: string, variables?: Record) => { + if (query.includes('query LinearGetManagedOAuthApplications')) { + return { oauthApplications: [managedOAuthApplication] }; + } + if (query.includes('query LinearGetManagedOAuthApplication')) { + return { oauthApplication: managedOAuthApplication }; + } + if (query.includes('mutation LinearCreateManagedOAuthApplication')) { + return { + oauthApplicationCreate: { + success: true, + application: managedOAuthApplication, + clientSecret: 'client-secret-once', + webhookSecret: 'webhook-secret-once', + }, + }; + } + if (query.includes('mutation LinearUpdateManagedOAuthApplication')) { + return { oauthApplicationUpdate: { success: true, application: managedOAuthApplication } }; + } + if (query.includes('mutation LinearArchiveManagedOAuthApplication')) { + return { oauthApplicationArchive: { success: true } }; + } + if (query.includes('mutation LinearRotateManagedOAuthApplicationSecret')) { + return { oauthApplicationRotateSecret: { success: true, clientSecret: 'rotated-client-secret' } }; + } + if (query.includes('mutation LinearRotateManagedOAuthApplicationWebhookSecret')) { + return { + oauthApplicationRotateWebhookSecret: { success: true, webhookSecret: 'rotated-webhook-secret' }, + }; + } + throw new Error(`Unexpected GraphQL request: ${query}`); + }); + const service = makeService({ client: { request } }); + + await expect(service.getManagedOAuthApplications()).resolves.toEqual([managedOAuthApplication]); + await expect(service.getManagedOAuthApplicationById('oauth-app-1')).resolves.toEqual(managedOAuthApplication); + await expect( + service.createManagedOAuthApplication({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + redirectUris: ['https://example.com/oauth/callback'], + grantTypes: ['authorization_code', 'client_credentials'], + idempotencyKey: 'pipeline-agent-v1', + webhookUrl: 'https://example.com/webhooks/linear', + webhookResourceTypes: ['Issue', 'Comment'], + }), + ).resolves.toEqual({ + application: managedOAuthApplication, + clientSecret: 'client-secret-once', + webhookSecret: 'webhook-secret-once', + }); + await expect( + service.updateManagedOAuthApplication({ + id: 'oauth-app-1', + name: 'Renamed Pipeline Agent', + webhookEnabled: false, + }), + ).resolves.toEqual(managedOAuthApplication); + await expect(service.archiveManagedOAuthApplication('oauth-app-1')).resolves.toEqual({ + success: true, + id: 'oauth-app-1', + }); + await expect(service.rotateManagedOAuthApplicationSecret('oauth-app-1')).resolves.toEqual({ + success: true, + id: 'oauth-app-1', + clientSecret: 'rotated-client-secret', + }); + await expect(service.rotateManagedOAuthApplicationWebhookSecret('oauth-app-1')).resolves.toEqual({ + success: true, + id: 'oauth-app-1', + webhookSecret: 'rotated-webhook-secret', + }); + + const createCall = request.mock.calls.find(([query]) => query.includes('LinearCreateManagedOAuthApplication')); + expect(createCall?.[1]).toEqual({ + input: { + name: 'Pipeline Agent', + developer: 'Tactic Launch', + developerUrl: 'https://example.com/linear', + redirectUris: ['https://example.com/oauth/callback'], + grantTypes: ['authorization_code', 'client_credentials'], + idempotencyKey: 'pipeline-agent-v1', + webhookUrl: 'https://example.com/webhooks/linear', + webhookResourceTypes: ['Issue', 'Comment'], + }, + }); + const updateCall = request.mock.calls.find(([query]) => query.includes('LinearUpdateManagedOAuthApplication')); + expect(updateCall?.[1]).toEqual({ + id: 'oauth-app-1', + input: { name: 'Renamed Pipeline Agent', webhookEnabled: false }, + }); + }); + + it('fails managed OAuth mutations when Linear does not return a successful complete payload', async () => { + const request = jest.fn(async (query: string) => { + if (query.includes('LinearGetManagedOAuthApplications')) { + return { oauthApplications: null }; + } + if (query.includes('LinearCreateManagedOAuthApplication')) { + return { oauthApplicationCreate: { success: false, application: null } }; + } + if (query.includes('LinearUpdateManagedOAuthApplication')) { + return { oauthApplicationUpdate: { success: false, application: null } }; + } + if (query.includes('LinearArchiveManagedOAuthApplication')) { + return { oauthApplicationArchive: { success: false } }; + } + if (query.includes('LinearRotateManagedOAuthApplicationSecret')) { + return { oauthApplicationRotateSecret: { success: true, clientSecret: null } }; + } + if (query.includes('LinearRotateManagedOAuthApplicationWebhookSecret')) { + return { oauthApplicationRotateWebhookSecret: { success: true, webhookSecret: null } }; + } + return { oauthApplication: null }; + }); + const service = makeService({ client: { request } }); + + await expect(service.getManagedOAuthApplications()).rejects.toThrow( + 'Failed to list managed OAuth applications', + ); + await expect(service.getManagedOAuthApplicationById('missing')).rejects.toThrow( + 'Managed OAuth application missing not found', + ); + await expect( + service.createManagedOAuthApplication({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + }), + ).rejects.toThrow('Failed to create managed OAuth application'); + await expect(service.rotateManagedOAuthApplicationSecret('oauth-app-1')).rejects.toThrow( + 'did not return a client secret', + ); + await expect( + service.updateManagedOAuthApplication({ id: 'oauth-app-1', name: 'Renamed' }), + ).rejects.toThrow('Failed to update managed OAuth application'); + await expect(service.archiveManagedOAuthApplication('oauth-app-1')).rejects.toThrow( + 'Failed to archive managed OAuth application', + ); + await expect(service.rotateManagedOAuthApplicationWebhookSecret('oauth-app-1')).rejects.toThrow( + 'did not return a webhook secret', + ); + }); + + it('completes ordinary webhook get, update, and secret-rotation lifecycle through SDK methods', async () => { + const client = { + webhook: jest.fn().mockResolvedValue(webhook), + updateWebhook: jest.fn().mockResolvedValue({ success: true, webhook: Promise.resolve(webhook) }), + rotateSecretWebhook: jest.fn().mockResolvedValue({ success: true, secret: 'rotated-webhook-secret' }), + }; + const service = makeService(client); + + await expect(service.getWebhookById('webhook-1')).resolves.toMatchObject({ + id: 'webhook-1', + team: { id: 'team-1', name: 'Platform', key: 'PLAT' }, + }); + await expect( + service.updateWebhook({ + id: 'webhook-1', + url: 'https://example.com/webhooks/linear-v2', + label: null, + enabled: false, + resourceTypes: ['Issue'], + }), + ).resolves.toMatchObject({ id: 'webhook-1' }); + await expect(service.rotateWebhookSecret('webhook-1')).resolves.toEqual({ + success: true, + id: 'webhook-1', + secret: 'rotated-webhook-secret', + }); + + expect(client.webhook).toHaveBeenCalledWith('webhook-1'); + expect(client.updateWebhook).toHaveBeenCalledWith('webhook-1', { + url: 'https://example.com/webhooks/linear-v2', + label: null, + enabled: false, + resourceTypes: ['Issue'], + }); + expect(client.rotateSecretWebhook).toHaveBeenCalledWith('webhook-1'); + }); + + it('fails ordinary webhook operations on missing or unsuccessful SDK payloads', async () => { + const service = makeService({ + webhook: jest.fn().mockResolvedValue(null), + updateWebhook: jest.fn().mockResolvedValue({ success: false, webhook: null }), + rotateSecretWebhook: jest.fn().mockResolvedValue({ success: true, secret: null }), + }); + + await expect(service.getWebhookById('missing')).rejects.toThrow( + 'Webhook with ID missing not found', + ); + await expect(service.updateWebhook({ id: 'webhook-1', enabled: false })).rejects.toThrow( + 'Failed to update webhook webhook-1', + ); + await expect(service.rotateWebhookSecret('webhook-1')).rejects.toThrow( + 'did not return a secret', + ); + }); + + it('routes handlers and strips secret-exposure acknowledgements before service calls', async () => { + const service = { + generateOAuthApplicationSetup: jest.fn().mockReturnValue({ creationUrl: 'https://linear.app/setup' }), + generateOAuthAuthorizationUrl: jest.fn().mockReturnValue({ authorizationUrl: 'https://linear.app/oauth/authorize' }), + createOAuthClientCredentialsToken: jest.fn().mockResolvedValue({ accessToken: 'once' }), + getManagedOAuthApplications: jest.fn().mockResolvedValue([managedOAuthApplication]), + getManagedOAuthApplicationById: jest.fn().mockResolvedValue(managedOAuthApplication), + createManagedOAuthApplication: jest.fn().mockResolvedValue({ application: managedOAuthApplication }), + updateManagedOAuthApplication: jest.fn().mockResolvedValue(managedOAuthApplication), + archiveManagedOAuthApplication: jest.fn().mockResolvedValue({ success: true, id: 'oauth-app-1' }), + rotateManagedOAuthApplicationSecret: jest.fn().mockResolvedValue({ clientSecret: 'once' }), + rotateManagedOAuthApplicationWebhookSecret: jest.fn().mockResolvedValue({ webhookSecret: 'once' }), + getWebhookById: jest.fn().mockResolvedValue(webhook), + updateWebhook: jest.fn().mockResolvedValue(webhook), + rotateWebhookSecret: jest.fn().mockResolvedValue({ secret: 'once' }), + } as unknown as LinearService; + const handlers = registerToolHandlers(service); + + await handlers.linear_generateOAuthApplicationSetup({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + }); + await handlers.linear_generateOAuthAuthorizationUrl({ + clientId: 'client-1', + redirectUri: 'https://example.com/oauth/callback', + scopes: ['read'], + }); + await handlers.linear_createOAuthClientCredentialsToken({ + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['issues:create'], + confirmSecretExposure: true, + confirmScopeChangeRisk: true, + }); + await handlers.linear_getManagedOAuthApplications({}); + await handlers.linear_getManagedOAuthApplicationById({ id: 'oauth-app-1' }); + await handlers.linear_createManagedOAuthApplication({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + confirmSecretExposure: true, + }); + await handlers.linear_rotateManagedOAuthApplicationSecret({ + id: 'oauth-app-1', + confirmSecretExposure: true, + }); + await handlers.linear_rotateManagedOAuthApplicationWebhookSecret({ + id: 'oauth-app-1', + confirmSecretExposure: true, + }); + await handlers.linear_updateManagedOAuthApplication({ id: 'oauth-app-1', name: 'Renamed' }); + await handlers.linear_archiveManagedOAuthApplication({ id: 'oauth-app-1' }); + await handlers.linear_getWebhookById({ id: 'webhook-1' }); + await handlers.linear_updateWebhook({ id: 'webhook-1', enabled: false }); + await handlers.linear_rotateWebhookSecret({ id: 'webhook-1', confirmSecretExposure: true }); + + expect(service.createManagedOAuthApplication).toHaveBeenCalledWith({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + }); + expect(service.generateOAuthApplicationSetup).toHaveBeenCalled(); + expect(service.generateOAuthAuthorizationUrl).toHaveBeenCalled(); + expect(service.createOAuthClientCredentialsToken).toHaveBeenCalledWith({ + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['issues:create'], + }); + expect(service.getManagedOAuthApplications).toHaveBeenCalledWith(); + expect(service.getManagedOAuthApplicationById).toHaveBeenCalledWith('oauth-app-1'); + expect(service.updateManagedOAuthApplication).toHaveBeenCalledWith({ + id: 'oauth-app-1', + name: 'Renamed', + }); + expect(service.archiveManagedOAuthApplication).toHaveBeenCalledWith('oauth-app-1'); + expect(service.rotateManagedOAuthApplicationSecret).toHaveBeenCalledWith('oauth-app-1'); + expect(service.rotateManagedOAuthApplicationWebhookSecret).toHaveBeenCalledWith('oauth-app-1'); + expect(service.getWebhookById).toHaveBeenCalledWith('webhook-1'); + expect(service.updateWebhook).toHaveBeenCalledWith({ id: 'webhook-1', enabled: false }); + expect(service.rotateWebhookSecret).toHaveBeenCalledWith('webhook-1'); + + await expect( + handlers.linear_rotateWebhookSecret({ id: 'webhook-1', confirmSecretExposure: false }), + ).rejects.toThrow('Invalid arguments for rotateWebhookSecret'); + }); + + it('does not log a caller-provided webhook signing secret when Linear rejects the request', async () => { + const leakedSecret = 'must-not-appear-in-logs'; + const linearError = Object.assign(new Error(`Invalid secret ${leakedSecret}`), { + variables: { input: { secret: leakedSecret } }, + }); + const service = { + createWebhook: jest.fn().mockRejectedValue(linearError), + } as unknown as LinearService; + const handlers = registerToolHandlers(service); + + await expect( + handlers.linear_createWebhook({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + teamId: 'team-1', + secret: leakedSecret, + }), + ).rejects.toThrow('error details were omitted'); + + expect(JSON.stringify((console.error as jest.Mock).mock.calls)).not.toContain(leakedSecret); + }); + + it('does not expose raw errors from operations whose responses may contain one-time secrets', async () => { + const leakedSecret = 'one-time-secret-that-must-not-leak'; + const secretError = Object.assign(new Error(`Partial GraphQL response: ${leakedSecret}`), { + raw: { data: { secret: leakedSecret } }, + }); + const service = { + createManagedOAuthApplication: jest.fn().mockRejectedValue(secretError), + createOAuthClientCredentialsToken: jest.fn().mockRejectedValue(secretError), + rotateManagedOAuthApplicationSecret: jest.fn().mockRejectedValue(secretError), + rotateManagedOAuthApplicationWebhookSecret: jest.fn().mockRejectedValue(secretError), + rotateWebhookSecret: jest.fn().mockRejectedValue(secretError), + } as unknown as LinearService; + const handlers = registerToolHandlers(service); + + await expect( + handlers.linear_createOAuthClientCredentialsToken({ + clientId: 'client-1', + clientSecret: leakedSecret, + scopes: ['issues:create'], + confirmSecretExposure: true, + confirmScopeChangeRisk: true, + }), + ).rejects.toThrow('error details were omitted'); + await expect( + handlers.linear_createManagedOAuthApplication({ + name: 'Pipeline Agent', + developer: 'Tactic Launch', + redirectUris: ['https://example.com/oauth/callback'], + confirmSecretExposure: true, + }), + ).rejects.toThrow('error details were omitted'); + await expect( + handlers.linear_rotateManagedOAuthApplicationSecret({ + id: 'oauth-app-1', + confirmSecretExposure: true, + }), + ).rejects.toThrow('error details were omitted'); + await expect( + handlers.linear_rotateManagedOAuthApplicationWebhookSecret({ + id: 'oauth-app-1', + confirmSecretExposure: true, + }), + ).rejects.toThrow('error details were omitted'); + await expect( + handlers.linear_rotateWebhookSecret({ + id: 'webhook-1', + confirmSecretExposure: true, + }), + ).rejects.toThrow('error details were omitted'); + + expect(JSON.stringify((console.error as jest.Mock).mock.calls)).not.toContain(leakedSecret); + }); +}); diff --git a/src/index.ts b/src/index.ts index b987251..1b9ef36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { LinearService } from './services/linear-service.js'; import { allToolDefinitions } from './tools/definitions/index.js'; import { registerToolHandlers } from './tools/handlers/index.js'; import { getLinearRateLimitSnapshot, installLinearRateLimitHandling } from './utils/linear-rate-limit.js'; -import { getLinearApiToken, logInfo, logError, isDebugLoggingEnabled } from './utils/config.js'; +import { getLinearAuthConfig, logInfo, logError, isDebugLoggingEnabled } from './utils/config.js'; import pkg from '../package.json' with { type: 'json' }; // Import package.json to access version /** @@ -22,19 +22,23 @@ async function runServer() { // Log package version logInfo(`MCP Linear version: ${pkg.version}`); - // Get Linear API token - const linearApiToken = getLinearApiToken(); + // Resolve the configured Linear authentication mode. + const linearAuth = getLinearAuthConfig(); - if (!linearApiToken) { + if (!linearAuth) { throw new Error( - 'Linear API token not found. Please provide it via --token command line argument or LINEAR_API_TOKEN environment variable.', + 'Linear credentials not found. Provide an OAuth access token via --oauth-token or LINEAR_OAUTH_ACCESS_TOKEN, or an API key via --token, LINEAR_API_TOKEN, or LINEAR_API_KEY.', ); } logInfo(`Starting MCP Linear...`); // Initialize Linear client and service - const linearClient = new LinearClient({ apiKey: linearApiToken }); + const linearClient = new LinearClient( + linearAuth.type === 'oauth' + ? { accessToken: linearAuth.token } + : { apiKey: linearAuth.token }, + ); installLinearRateLimitHandling(linearClient); const linearService = new LinearService(linearClient); const getRateLimitStatus = () => getLinearRateLimitSnapshot(linearClient); diff --git a/src/services/linear-service.ts b/src/services/linear-service.ts index 6bb0a0d..4ce8adb 100644 --- a/src/services/linear-service.ts +++ b/src/services/linear-service.ts @@ -13,6 +13,83 @@ import { type JsonObject = Record; +type OAuthApplicationGrantType = 'authorization_code' | 'client_credentials'; + +type OAuthApplicationSetupArgs = { + name: string; + developer: string; + developerUrl?: string; + description?: string; + imageUrl?: string; + distribution?: 'private' | 'public'; + redirectUris: string[]; + grantTypes?: OAuthApplicationGrantType[]; + webhookEnabled?: boolean; + webhookUrl?: string; + webhookResourceTypes?: string[]; +}; + +type OAuthAuthorizationUrlArgs = { + clientId: string; + redirectUri: string; + scopes: string[]; + actor?: 'user' | 'app'; + state?: string; + promptConsent?: boolean; + codeChallenge?: string; +}; + +type OAuthClientCredentialsTokenArgs = { + clientId: string; + clientSecret: string; + scopes: string[]; +}; + +type ManagedOAuthApplicationNode = { + id: string; + clientId: string; + name: string; + description?: string | null; + developer: string; + developerUrl?: string | null; + distribution: 'private' | 'public'; + grantTypes: OAuthApplicationGrantType[]; + imageUrl?: string | null; + redirectUris: string[]; + webhookEnabled: boolean; + webhookUrl?: string | null; + webhookResourceTypes: string[]; + createdAt: string; + updatedAt: string; +}; + +type ManagedOAuthApplicationCreateArgs = { + name: string; + developer: string; + developerUrl?: string; + description?: string; + imageUrl?: string; + redirectUris: string[]; + grantTypes?: OAuthApplicationGrantType[]; + idempotencyKey?: string; + webhookUrl?: string; + webhookResourceTypes?: string[]; +}; + +type ManagedOAuthApplicationUpdateArgs = { + id: string; + name?: string; + developer?: string; + developerUrl?: string | null; + description?: string | null; + imageUrl?: string | null; + redirectUris?: string[] | null; + grantTypes?: OAuthApplicationGrantType[] | null; + webhookEnabled?: boolean; + webhookUrl?: string | null; + webhookResourceTypes?: string[] | null; +}; + const LINEAR_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type ProjectStatusNode = { @@ -550,6 +627,93 @@ const NOTIFICATION_SUMMARY_QUERY = ` } `; +const MANAGED_OAUTH_APPLICATION_FIELDS = ` + id + clientId + name + description + developer + developerUrl + distribution + grantTypes + imageUrl + redirectUris + webhookEnabled + webhookUrl + webhookResourceTypes + createdAt + updatedAt +`; + +// Linear exposes managed OAuth application operations as an ALPHA GraphQL surface. +// They are not generated as LinearClient methods and only operate on applications +// created by the calling OAuth application, so keep these requests narrow and explicit. +const GET_MANAGED_OAUTH_APPLICATIONS_QUERY = ` + query LinearGetManagedOAuthApplications { + oauthApplications { + ${MANAGED_OAUTH_APPLICATION_FIELDS} + } + } +`; + +const GET_MANAGED_OAUTH_APPLICATION_QUERY = ` + query LinearGetManagedOAuthApplication($id: String!) { + oauthApplication(id: $id) { + ${MANAGED_OAUTH_APPLICATION_FIELDS} + } + } +`; + +const CREATE_MANAGED_OAUTH_APPLICATION_MUTATION = ` + mutation LinearCreateManagedOAuthApplication($input: OAuthApplicationCreateInput!) { + oauthApplicationCreate(input: $input) { + success + clientSecret + webhookSecret + application { + ${MANAGED_OAUTH_APPLICATION_FIELDS} + } + } + } +`; + +const UPDATE_MANAGED_OAUTH_APPLICATION_MUTATION = ` + mutation LinearUpdateManagedOAuthApplication($id: String!, $input: OAuthApplicationUpdateInput!) { + oauthApplicationUpdate(id: $id, input: $input) { + success + application { + ${MANAGED_OAUTH_APPLICATION_FIELDS} + } + } + } +`; + +const ARCHIVE_MANAGED_OAUTH_APPLICATION_MUTATION = ` + mutation LinearArchiveManagedOAuthApplication($id: String!) { + oauthApplicationArchive(id: $id) { + success + } + } +`; + +const ROTATE_MANAGED_OAUTH_APPLICATION_SECRET_MUTATION = ` + mutation LinearRotateManagedOAuthApplicationSecret($id: String!) { + oauthApplicationRotateSecret(id: $id) { + success + clientSecret + } + } +`; + +const ROTATE_MANAGED_OAUTH_APPLICATION_WEBHOOK_SECRET_MUTATION = ` + mutation LinearRotateManagedOAuthApplicationWebhookSecret($id: String!) { + oauthApplicationRotateWebhookSecret(id: $id) { + success + webhookSecret + } + } +`; + type JSONPrimitive = string | number | boolean | null; type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue }; @@ -1865,6 +2029,26 @@ export class LinearService { }; } + private normalizeManagedOAuthApplication(application: ManagedOAuthApplicationNode) { + return { + id: application.id, + clientId: application.clientId, + name: application.name, + description: application.description ?? null, + developer: application.developer, + developerUrl: application.developerUrl ?? null, + distribution: application.distribution, + grantTypes: [...application.grantTypes], + imageUrl: application.imageUrl ?? null, + redirectUris: [...application.redirectUris], + webhookEnabled: application.webhookEnabled, + webhookUrl: application.webhookUrl ?? null, + webhookResourceTypes: [...application.webhookResourceTypes], + createdAt: application.createdAt, + updatedAt: application.updatedAt, + }; + } + private async normalizeWebhook(webhook: any) { const team = webhook.team ? await webhook.team : null; const creator = webhook.creator ? await webhook.creator : null; @@ -3907,6 +4091,317 @@ export class LinearService { }; } + generateOAuthApplicationSetup(args: OAuthApplicationSetupArgs) { + const grantTypes = args.grantTypes?.length + ? [...args.grantTypes] + : ['authorization_code' as const]; + const display = this.compactObject({ + description: this.nonEmptyString(args.description), + iconUrl: this.nonEmptyString(args.imageUrl), + }); + const webhookUrl = this.nonEmptyString(args.webhookUrl); + const webhookResourceTypes = this.nonEmptyArray(args.webhookResourceTypes); + + if (Boolean(webhookUrl) !== Boolean(webhookResourceTypes)) { + throw new Error('OAuth application webhook URL and resource types must be provided together'); + } + + const manifest = { + $schema: 'https://linear.app/.well-known/oauth-app-manifest.schema.json', + schemaVersion: '1.0.0', + distribution: args.distribution ?? 'private', + ...(Object.keys(display).length > 0 ? { display } : {}), + developer: { name: args.developer }, + oauth: { + client_name: args.name, + ...(args.developerUrl ? { client_uri: args.developerUrl } : {}), + redirect_uris: [...args.redirectUris], + grant_types: grantTypes, + }, + ...(webhookUrl && webhookResourceTypes + ? { + webhook: { + enabled: args.webhookEnabled ?? true, + url: webhookUrl, + resourceTypes: [...webhookResourceTypes], + }, + } + : {}), + }; + + const creationUrl = new URL('https://linear.app/settings/api/applications/new'); + creationUrl.searchParams.set('manifest', JSON.stringify(manifest)); + + return { + manifest, + creationUrl: creationUrl.toString(), + requiresUserConfirmation: true, + }; + } + + generateOAuthAuthorizationUrl(args: OAuthAuthorizationUrlArgs) { + const scopes = Array.from( + new Set(['read', ...args.scopes.filter((scope) => scope !== 'read')]), + ); + const warnings: string[] = []; + + if (!this.nonEmptyString(args.state)) { + warnings.push( + 'No state value was provided; include one to protect the OAuth redirect from CSRF.', + ); + } + + const authorizationUrl = new URL('https://linear.app/oauth/authorize'); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', args.clientId); + authorizationUrl.searchParams.set('redirect_uri', args.redirectUri); + authorizationUrl.searchParams.set('scope', scopes.join(',')); + + if (args.actor) { + authorizationUrl.searchParams.set('actor', args.actor); + } + if (args.state) { + authorizationUrl.searchParams.set('state', args.state); + } + if (args.promptConsent) { + authorizationUrl.searchParams.set('prompt', 'consent'); + } + if (args.codeChallenge) { + authorizationUrl.searchParams.set('code_challenge', args.codeChallenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + } + + return { + authorizationUrl: authorizationUrl.toString(), + scopes, + warnings, + }; + } + + async createOAuthClientCredentialsToken(args: OAuthClientCredentialsTokenArgs) { + const scopes = Array.from( + new Set(['read', ...args.scopes.filter((scope) => scope !== 'read')]), + ); + const requestBody = new URLSearchParams({ + grant_type: 'client_credentials', + scope: scopes.join(','), + client_id: args.clientId, + client_secret: args.clientSecret, + }); + + const response = await fetch('https://api.linear.app/oauth/token', { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(15_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: requestBody, + }); + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error(`Linear OAuth token request returned invalid JSON (HTTP ${response.status})`); + } + + if (!response.ok) { + throw new Error(`Linear OAuth token request failed (HTTP ${response.status})`); + } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new Error('Linear OAuth token response was incomplete'); + } + + const tokenPayload = payload as Record; + const accessToken = tokenPayload.access_token; + const tokenType = tokenPayload.token_type; + const expiresIn = tokenPayload.expires_in; + const returnedScope = tokenPayload.scope; + const returnedScopes = Array.isArray(returnedScope) + ? returnedScope.filter((scope): scope is string => typeof scope === 'string') + : typeof returnedScope === 'string' + ? returnedScope.split(/[\s,]+/).filter(Boolean) + : []; + + if ( + typeof accessToken !== 'string' || + !this.nonEmptyString(accessToken) || + typeof tokenType !== 'string' || + !this.nonEmptyString(tokenType) || + typeof expiresIn !== 'number' || + !Number.isFinite(expiresIn) || + !Number.isInteger(expiresIn) || + expiresIn <= 0 || + returnedScopes.length === 0 || + returnedScopes.some((scope) => !scopes.includes(scope)) + ) { + throw new Error('Linear OAuth token response was incomplete'); + } + + return { + accessToken, + tokenType, + expiresIn, + scopes: Array.from(new Set(returnedScopes)), + }; + } + + async getManagedOAuthApplications() { + const response = await this.requestGraphQL<{ + oauthApplications?: ManagedOAuthApplicationNode[] | null; + }>(GET_MANAGED_OAUTH_APPLICATIONS_QUERY); + + if (!Array.isArray(response.oauthApplications)) { + throw new Error('Failed to list managed OAuth applications'); + } + + return response.oauthApplications.map((application) => + this.normalizeManagedOAuthApplication(application), + ); + } + + async getManagedOAuthApplicationById(id: string) { + const response = await this.requestGraphQL<{ + oauthApplication?: ManagedOAuthApplicationNode | null; + }>(GET_MANAGED_OAUTH_APPLICATION_QUERY, { id }); + + if (!response.oauthApplication) { + throw new Error(`Managed OAuth application ${id} not found`); + } + + return this.normalizeManagedOAuthApplication(response.oauthApplication); + } + + async createManagedOAuthApplication(args: ManagedOAuthApplicationCreateArgs) { + const input = this.compactObject({ + name: args.name, + developer: args.developer, + developerUrl: this.nonEmptyString(args.developerUrl), + description: this.nonEmptyString(args.description), + imageUrl: this.nonEmptyString(args.imageUrl), + redirectUris: [...args.redirectUris], + grantTypes: this.nonEmptyArray(args.grantTypes), + idempotencyKey: this.nonEmptyString(args.idempotencyKey), + webhookUrl: this.nonEmptyString(args.webhookUrl), + webhookResourceTypes: this.nonEmptyArray(args.webhookResourceTypes), + }); + const response = await this.requestGraphQL<{ + oauthApplicationCreate?: { + success: boolean; + application?: ManagedOAuthApplicationNode | null; + clientSecret?: string | null; + webhookSecret?: string | null; + } | null; + }>(CREATE_MANAGED_OAUTH_APPLICATION_MUTATION, { input }); + const payload = response.oauthApplicationCreate; + + if (!payload?.success || !payload.application) { + throw new Error('Failed to create managed OAuth application'); + } + + return { + application: this.normalizeManagedOAuthApplication(payload.application), + clientSecret: payload.clientSecret ?? null, + webhookSecret: payload.webhookSecret ?? null, + }; + } + + async updateManagedOAuthApplication(args: ManagedOAuthApplicationUpdateArgs) { + const input = this.compactObject({ + name: this.nonEmptyString(args.name), + developer: this.nonEmptyString(args.developer), + developerUrl: this.nullableNonEmptyString(args.developerUrl), + description: this.nullableNonEmptyString(args.description), + imageUrl: this.nullableNonEmptyString(args.imageUrl), + redirectUris: args.redirectUris === null ? null : this.nonEmptyArray(args.redirectUris), + grantTypes: args.grantTypes === null ? null : this.nonEmptyArray(args.grantTypes), + webhookEnabled: args.webhookEnabled, + webhookUrl: this.nullableNonEmptyString(args.webhookUrl), + webhookResourceTypes: + args.webhookResourceTypes === null ? null : this.nonEmptyArray(args.webhookResourceTypes), + }); + + if (Object.keys(input).length === 0) { + throw new Error('At least one managed OAuth application field must be provided'); + } + + const response = await this.requestGraphQL<{ + oauthApplicationUpdate?: { + success: boolean; + application?: ManagedOAuthApplicationNode | null; + } | null; + }>(UPDATE_MANAGED_OAUTH_APPLICATION_MUTATION, { id: args.id, input }); + const payload = response.oauthApplicationUpdate; + + if (!payload?.success || !payload.application) { + throw new Error(`Failed to update managed OAuth application ${args.id}`); + } + + return this.normalizeManagedOAuthApplication(payload.application); + } + + async archiveManagedOAuthApplication(id: string) { + const response = await this.requestGraphQL<{ + oauthApplicationArchive?: { success: boolean } | null; + }>(ARCHIVE_MANAGED_OAUTH_APPLICATION_MUTATION, { id }); + + if (!response.oauthApplicationArchive?.success) { + throw new Error(`Failed to archive managed OAuth application ${id}`); + } + + return { success: true, id }; + } + + async rotateManagedOAuthApplicationSecret(id: string) { + const response = await this.requestGraphQL<{ + oauthApplicationRotateSecret?: { success: boolean; clientSecret?: string | null } | null; + }>(ROTATE_MANAGED_OAUTH_APPLICATION_SECRET_MUTATION, { id }); + const payload = response.oauthApplicationRotateSecret; + + if (!payload?.success) { + throw new Error(`Failed to rotate managed OAuth application secret ${id}`); + } + if (!this.nonEmptyString(payload.clientSecret ?? undefined)) { + throw new Error( + `Managed OAuth application secret rotation ${id} did not return a client secret`, + ); + } + + return { success: true, id, clientSecret: payload.clientSecret as string }; + } + + async rotateManagedOAuthApplicationWebhookSecret(id: string) { + const response = await this.requestGraphQL<{ + oauthApplicationRotateWebhookSecret?: { + success: boolean; + webhookSecret?: string | null; + } | null; + }>(ROTATE_MANAGED_OAUTH_APPLICATION_WEBHOOK_SECRET_MUTATION, { id }); + const payload = response.oauthApplicationRotateWebhookSecret; + + if (!payload?.success) { + throw new Error(`Failed to rotate managed OAuth application webhook secret ${id}`); + } + if (!this.nonEmptyString(payload.webhookSecret ?? undefined)) { + throw new Error( + `Managed OAuth application webhook secret rotation ${id} did not return a webhook secret`, + ); + } + + return { success: true, id, webhookSecret: payload.webhookSecret as string }; + } + + async getWebhookById(id: string) { + const webhook = await this.client.webhook(id); + if (!webhook) { + throw new Error(`Webhook with ID ${id} not found`); + } + + return this.normalizeWebhook(webhook); + } + async getWebhooks(args: { teamId?: string; limit?: number; includeArchived?: boolean; orderBy?: string } = {}) { const source = async () => { if (!args.teamId) { @@ -3958,6 +4453,46 @@ export class LinearService { return this.normalizeWebhook(await payload.webhook); } + async updateWebhook(args: { + id: string; + url?: string; + label?: string | null; + enabled?: boolean; + resourceTypes?: string[]; + secret?: string; + }) { + const updateInput = { + url: this.nonEmptyString(args.url), + label: this.nullableNonEmptyString(args.label), + enabled: args.enabled, + resourceTypes: this.nonEmptyArray(args.resourceTypes), + secret: this.nonEmptyString(args.secret), + }; + + if (!Object.values(updateInput).some((value) => value !== undefined)) { + throw new Error('At least one webhook field must be provided'); + } + + const payload = await this.client.updateWebhook(args.id, this.compactObject(updateInput)); + if (!payload.success || !payload.webhook) { + throw new Error(`Failed to update webhook ${args.id}`); + } + + return this.normalizeWebhook(await payload.webhook); + } + + async rotateWebhookSecret(id: string) { + const payload = await this.client.rotateSecretWebhook(id); + if (!payload.success) { + throw new Error(`Failed to rotate webhook secret ${id}`); + } + if (!this.nonEmptyString(payload.secret)) { + throw new Error(`Webhook secret rotation ${id} did not return a secret`); + } + + return { success: true, id, secret: payload.secret }; + } + async deleteWebhook(id: string) { const payload = await this.client.deleteWebhook(id); if (!payload.success) { @@ -7633,7 +8168,9 @@ export class LinearService { body: args.body, health: args.health as any, isDiffHidden: args.isDiffHidden, - }); + // SDK 88 dropped this legacy input from its generated type. Preserve the + // existing v1.3 MCP contract until it can be changed in a major release. + } as any); if (updatePayload.success) { // Get the updated project update data @@ -7797,7 +8334,9 @@ export class LinearService { body: args.body, health: args.health as any, isDiffHidden: args.isDiffHidden, - }); + // SDK 88 dropped this legacy input from its generated type. Preserve the + // existing v1.3 MCP contract until it can be changed in a major release. + } as any); if (!updatePayload.success) { throw new Error(`Failed to update initiative update ${args.id}`); diff --git a/src/tools/definitions/index.ts b/src/tools/definitions/index.ts index e40a0ee..acb4c3e 100644 --- a/src/tools/definitions/index.ts +++ b/src/tools/definitions/index.ts @@ -83,8 +83,11 @@ import { } from './document-tools.js'; import { getRateLimitStatusToolDefinition, getServerStatusToolDefinition } from './server-tools.js'; import { + getWebhookByIdToolDefinition, getWebhooksToolDefinition, createWebhookToolDefinition, + updateWebhookToolDefinition, + rotateWebhookSecretToolDefinition, deleteWebhookToolDefinition, getAttachmentsToolDefinition, addAttachmentToolDefinition, @@ -101,6 +104,19 @@ import { getUserAuditEventsToolDefinition, getIntegrationsToolDefinition, } from './ops-tools.js'; +import { + archiveManagedOAuthApplicationToolDefinition, + createManagedOAuthApplicationToolDefinition, + createOAuthClientCredentialsTokenToolDefinition, + generateOAuthApplicationSetupToolDefinition, + generateOAuthAuthorizationUrlToolDefinition, + getManagedOAuthApplicationByIdToolDefinition, + getManagedOAuthApplicationsToolDefinition, + oauthToolDefinitions, + rotateManagedOAuthApplicationSecretToolDefinition, + rotateManagedOAuthApplicationWebhookSecretToolDefinition, + updateManagedOAuthApplicationToolDefinition, +} from './oauth-tools.js'; import { addToFavoritesToolDefinition, createSavedViewToolDefinition, @@ -235,9 +251,15 @@ export const allToolDefinitions: MCPToolDefinition[] = [ getRateLimitStatusToolDefinition, getServerStatusToolDefinition, + // OAuth application tools + ...oauthToolDefinitions, + // Ops tools + getWebhookByIdToolDefinition, getWebhooksToolDefinition, createWebhookToolDefinition, + updateWebhookToolDefinition, + rotateWebhookSecretToolDefinition, deleteWebhookToolDefinition, getAttachmentsToolDefinition, addAttachmentToolDefinition, @@ -423,8 +445,21 @@ export { // Server tools getRateLimitStatusToolDefinition, getServerStatusToolDefinition, + generateOAuthApplicationSetupToolDefinition, + generateOAuthAuthorizationUrlToolDefinition, + createOAuthClientCredentialsTokenToolDefinition, + getManagedOAuthApplicationsToolDefinition, + getManagedOAuthApplicationByIdToolDefinition, + createManagedOAuthApplicationToolDefinition, + updateManagedOAuthApplicationToolDefinition, + archiveManagedOAuthApplicationToolDefinition, + rotateManagedOAuthApplicationSecretToolDefinition, + rotateManagedOAuthApplicationWebhookSecretToolDefinition, + getWebhookByIdToolDefinition, getWebhooksToolDefinition, createWebhookToolDefinition, + updateWebhookToolDefinition, + rotateWebhookSecretToolDefinition, deleteWebhookToolDefinition, getAttachmentsToolDefinition, addAttachmentToolDefinition, diff --git a/src/tools/definitions/oauth-tools.ts b/src/tools/definitions/oauth-tools.ts new file mode 100644 index 0000000..8fb2825 --- /dev/null +++ b/src/tools/definitions/oauth-tools.ts @@ -0,0 +1,411 @@ +import { MCPToolDefinition } from '../../types.js'; +import { + APP_ACTOR_OAUTH_SCOPES, + OAUTH_APPLICATION_GRANT_TYPES, + OAUTH_AUTHORIZATION_SCOPES, + WEBHOOK_RESOURCE_TYPES, +} from '../oauth-constants.js'; + +const redirectUrisSchema = { + type: 'array', + minItems: 1, + maxItems: 32, + uniqueItems: true, + items: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]?://', + }, +}; + +const grantTypesSchema = { + type: 'array', + minItems: 1, + maxItems: 2, + uniqueItems: true, + items: { type: 'string', enum: [...OAUTH_APPLICATION_GRANT_TYPES] }, + contains: { const: 'authorization_code' }, +}; + +const webhookResourceTypesSchema = { + type: 'array', + minItems: 1, + maxItems: 22, + uniqueItems: true, + items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, +}; + +const manifestClientNameSchema = { + type: 'string', + minLength: 2, + maxLength: 80, + pattern: '^(?!.*[Ll][Ii][Nn][Ee][Aa][Rr])(?!.*[Hh][Tt][Tt][Pp][Ss]?://).*$', + description: 'OAuth client name. Must not contain "Linear" or an http(s) URL.', +}; + +const secretExposureConfirmationSchema = { + type: 'boolean', + const: true, + description: + 'Must be true to acknowledge that a one-time secret will be returned in the MCP tool result.', +}; + +const managedOAuthApplicationOutputSchema = { + type: 'object', + properties: { + id: { type: 'string' }, + clientId: { type: 'string' }, + name: { type: 'string' }, + description: { type: ['string', 'null'] }, + developer: { type: 'string' }, + developerUrl: { type: ['string', 'null'] }, + distribution: { type: 'string', enum: ['private', 'public'] }, + grantTypes: { type: 'array', items: { type: 'string', enum: [...OAUTH_APPLICATION_GRANT_TYPES] } }, + imageUrl: { type: ['string', 'null'] }, + redirectUris: { type: 'array', items: { type: 'string' } }, + webhookEnabled: { type: 'boolean' }, + webhookUrl: { type: ['string', 'null'] }, + webhookResourceTypes: { + type: 'array', + items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, + }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, +}; + +const managedOAuthApplicationCreateProperties = { + name: manifestClientNameSchema, + developer: { type: 'string', minLength: 2, maxLength: 80 }, + developerUrl: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]?://', + }, + description: { type: 'string', maxLength: 1000 }, + imageUrl: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]?://', + }, + redirectUris: redirectUrisSchema, + grantTypes: grantTypesSchema, + idempotencyKey: { type: 'string', minLength: 1 }, + webhookUrl: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]://', + maxLength: 1000, + }, + webhookResourceTypes: webhookResourceTypesSchema, +}; + +export const generateOAuthApplicationSetupToolDefinition: MCPToolDefinition = { + name: 'linear_generateOAuthApplicationSetup', + description: + 'Generate an official Linear OAuth application manifest and a human-confirmed setup URL. This does not create the application; an authorized workspace admin or owner must review and submit it in Linear. developerUrl is optional for private apps but required when distribution is public.', + input_schema: { + type: 'object', + properties: { + name: manifestClientNameSchema, + developer: { type: 'string', minLength: 2, maxLength: 80 }, + developerUrl: managedOAuthApplicationCreateProperties.developerUrl, + description: { type: 'string', maxLength: 1000 }, + imageUrl: managedOAuthApplicationCreateProperties.imageUrl, + distribution: { type: 'string', enum: ['private', 'public'] }, + redirectUris: redirectUrisSchema, + grantTypes: grantTypesSchema, + webhookEnabled: { type: 'boolean' }, + webhookUrl: managedOAuthApplicationCreateProperties.webhookUrl, + webhookResourceTypes: webhookResourceTypesSchema, + }, + required: ['name', 'developer', 'redirectUris'], + allOf: [ + { + if: { properties: { distribution: { const: 'public' } }, required: ['distribution'] }, + then: { required: ['developerUrl'] }, + }, + { + if: { + anyOf: [ + { required: ['webhookEnabled'] }, + { required: ['webhookUrl'] }, + { required: ['webhookResourceTypes'] }, + ], + }, + then: { required: ['webhookUrl', 'webhookResourceTypes'] }, + }, + ], + } as MCPToolDefinition['input_schema'], + output_schema: { + type: 'object', + properties: { + requiresUserConfirmation: { type: 'boolean' }, + creationUrl: { type: 'string' }, + manifest: { type: 'object', additionalProperties: true }, + }, + }, +}; + +export const generateOAuthAuthorizationUrlToolDefinition: MCPToolDefinition = { + name: 'linear_generateOAuthAuthorizationUrl', + description: + 'Generate a Linear OAuth authorization URL. Scopes are requested during authorization; this tool does not create, grant, approve, persist, or modify scopes.', + input_schema: { + type: 'object', + properties: { + clientId: { type: 'string', minLength: 1 }, + redirectUri: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]?://', + }, + scopes: { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { type: 'string', enum: [...OAUTH_AUTHORIZATION_SCOPES] }, + }, + actor: { type: 'string', enum: ['user', 'app'] }, + state: { type: 'string', minLength: 1 }, + promptConsent: { type: 'boolean' }, + codeChallenge: { + type: 'string', + minLength: 43, + maxLength: 43, + pattern: '^[A-Za-z0-9_-]{43}$', + description: + 'RFC 7636 S256 PKCE challenge: exactly 43 base64url characters. The method is set to S256 automatically.', + }, + }, + required: ['clientId', 'redirectUri', 'scopes'], + }, + output_schema: { + type: 'object', + properties: { + authorizationUrl: { type: 'string' }, + scopes: { type: 'array', items: { type: 'string', enum: [...OAUTH_AUTHORIZATION_SCOPES] } }, + warnings: { type: 'array', items: { type: 'string' } }, + }, + }, +}; + +export const createOAuthClientCredentialsTokenToolDefinition: MCPToolDefinition = { + name: 'linear_createOAuthClientCredentialsToken', + description: + 'Issue a scoped Linear app-actor access token through the client_credentials grant for server-to-server pipelines. The token is returned once, has no refresh token, and is normally valid for 30 days. If the requested scope set differs from existing app-actor tokens, Linear revokes those existing tokens.', + input_schema: { + type: 'object', + properties: { + clientId: { type: 'string', minLength: 1 }, + clientSecret: { + type: 'string', + minLength: 1, + description: 'The OAuth application client secret. It is sent only to Linear\'s token endpoint.', + }, + scopes: { + type: 'array', + minItems: 1, + uniqueItems: true, + items: { type: 'string', enum: [...APP_ACTOR_OAUTH_SCOPES] }, + description: 'Scopes for the app-actor token. The read scope is always included.', + }, + confirmSecretExposure: secretExposureConfirmationSchema, + confirmScopeChangeRisk: { + type: 'boolean', + const: true, + description: + 'Must be true to acknowledge that requesting a different scope set revokes existing app-actor tokens for this OAuth application.', + }, + }, + required: [ + 'clientId', + 'clientSecret', + 'scopes', + 'confirmSecretExposure', + 'confirmScopeChangeRisk', + ], + }, + output_schema: { + type: 'object', + properties: { + accessToken: { type: 'string' }, + tokenType: { type: 'string' }, + expiresIn: { type: 'integer', minimum: 1 }, + scopes: { + type: 'array', + items: { type: 'string', enum: [...APP_ACTOR_OAUTH_SCOPES] }, + }, + }, + }, +}; + +export const getManagedOAuthApplicationsToolDefinition: MCPToolDefinition = { + name: 'linear_getManagedOAuthApplications', + description: + 'List alpha child OAuth applications owned by the calling OAuth application. This is not a workspace-wide OAuth application admin listing and does not work as generic scope CRUD.', + input_schema: { type: 'object', properties: {} }, + output_schema: { type: 'array', items: managedOAuthApplicationOutputSchema }, +}; + +export const getManagedOAuthApplicationByIdToolDefinition: MCPToolDefinition = { + name: 'linear_getManagedOAuthApplicationById', + description: + 'Get an alpha child OAuth application owned by the calling OAuth application. It cannot retrieve an arbitrary workspace OAuth application.', + input_schema: { + type: 'object', + properties: { id: { type: 'string', minLength: 1 } }, + required: ['id'], + }, + output_schema: managedOAuthApplicationOutputSchema, +}; + +export const createManagedOAuthApplicationToolDefinition: MCPToolDefinition = { + name: 'linear_createManagedOAuthApplication', + description: + 'Create an alpha child OAuth application owned by the calling OAuth application. This is not the human setup flow and returns one-time client and optional webhook secrets in the MCP result. It cannot set distribution or pre-grant OAuth scopes.', + input_schema: { + type: 'object', + properties: { + ...managedOAuthApplicationCreateProperties, + confirmSecretExposure: secretExposureConfirmationSchema, + }, + required: ['name', 'developer', 'redirectUris', 'confirmSecretExposure'], + allOf: [ + { + if: { + anyOf: [{ required: ['webhookUrl'] }, { required: ['webhookResourceTypes'] }], + }, + then: { required: ['webhookUrl', 'webhookResourceTypes'] }, + }, + ], + } as MCPToolDefinition['input_schema'], + output_schema: { + type: 'object', + properties: { + application: managedOAuthApplicationOutputSchema, + clientSecret: { type: ['string', 'null'] }, + webhookSecret: { type: ['string', 'null'] }, + }, + }, +}; + +export const updateManagedOAuthApplicationToolDefinition: MCPToolDefinition = { + name: 'linear_updateManagedOAuthApplication', + description: + 'Update metadata, grant capabilities, or webhook configuration for an alpha child app owned by the calling OAuth application. This does not grant or approve token scopes.', + input_schema: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + name: managedOAuthApplicationCreateProperties.name, + developer: managedOAuthApplicationCreateProperties.developer, + developerUrl: { + ...managedOAuthApplicationCreateProperties.developerUrl, + type: ['string', 'null'], + }, + description: { + ...managedOAuthApplicationCreateProperties.description, + type: ['string', 'null'], + }, + imageUrl: { + ...managedOAuthApplicationCreateProperties.imageUrl, + type: ['string', 'null'], + }, + redirectUris: managedOAuthApplicationCreateProperties.redirectUris, + grantTypes: managedOAuthApplicationCreateProperties.grantTypes, + webhookEnabled: { type: 'boolean' }, + webhookUrl: { + ...managedOAuthApplicationCreateProperties.webhookUrl, + type: ['string', 'null'], + }, + webhookResourceTypes: managedOAuthApplicationCreateProperties.webhookResourceTypes, + }, + required: ['id'], + anyOf: [ + { required: ['name'] }, + { required: ['developer'] }, + { required: ['developerUrl'] }, + { required: ['description'] }, + { required: ['imageUrl'] }, + { required: ['redirectUris'] }, + { required: ['grantTypes'] }, + { required: ['webhookEnabled'] }, + { required: ['webhookUrl'] }, + { required: ['webhookResourceTypes'] }, + ], + }, + output_schema: managedOAuthApplicationOutputSchema, +}; + +export const archiveManagedOAuthApplicationToolDefinition: MCPToolDefinition = { + name: 'linear_archiveManagedOAuthApplication', + description: + 'Archive an alpha child OAuth application owned by the calling OAuth application. This does not revoke an unrelated authorized application.', + input_schema: { + type: 'object', + properties: { id: { type: 'string', minLength: 1 } }, + required: ['id'], + }, + output_schema: { + type: 'object', + properties: { success: { type: 'boolean' }, id: { type: 'string' } }, + }, +}; + +export const rotateManagedOAuthApplicationSecretToolDefinition: MCPToolDefinition = { + name: 'linear_rotateManagedOAuthApplicationSecret', + description: + 'Rotate the client secret of an alpha child app owned by the calling OAuth application. The new one-time secret is exposed in the MCP result and existing client-credentials tokens may be invalidated.', + input_schema: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + confirmSecretExposure: secretExposureConfirmationSchema, + }, + required: ['id', 'confirmSecretExposure'], + }, + output_schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + id: { type: 'string' }, + clientSecret: { type: 'string' }, + }, + }, +}; + +export const rotateManagedOAuthApplicationWebhookSecretToolDefinition: MCPToolDefinition = { + name: 'linear_rotateManagedOAuthApplicationWebhookSecret', + description: + 'Rotate the webhook signing secret of an alpha child app owned by the calling OAuth application. The new one-time secret is exposed in the MCP result.', + input_schema: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + confirmSecretExposure: secretExposureConfirmationSchema, + }, + required: ['id', 'confirmSecretExposure'], + }, + output_schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + id: { type: 'string' }, + webhookSecret: { type: 'string' }, + }, + }, +}; + +export const oauthToolDefinitions: MCPToolDefinition[] = [ + generateOAuthApplicationSetupToolDefinition, + generateOAuthAuthorizationUrlToolDefinition, + createOAuthClientCredentialsTokenToolDefinition, + getManagedOAuthApplicationsToolDefinition, + getManagedOAuthApplicationByIdToolDefinition, + createManagedOAuthApplicationToolDefinition, + updateManagedOAuthApplicationToolDefinition, + archiveManagedOAuthApplicationToolDefinition, + rotateManagedOAuthApplicationSecretToolDefinition, + rotateManagedOAuthApplicationWebhookSecretToolDefinition, +]; diff --git a/src/tools/definitions/ops-tools.ts b/src/tools/definitions/ops-tools.ts index 451c529..ab48317 100644 --- a/src/tools/definitions/ops-tools.ts +++ b/src/tools/definitions/ops-tools.ts @@ -1,4 +1,5 @@ import { MCPToolDefinition } from '../../types.js'; +import { WEBHOOK_RESOURCE_TYPES } from '../oauth-constants.js'; const positiveLimitSchema = { type: 'integer', minimum: 1 }; const paginationOrderBySchema = { type: 'string', enum: ['createdAt', 'updatedAt'] }; @@ -120,7 +121,111 @@ const integrationOutputSchema = { }; export const getWebhooksToolDefinition: MCPToolDefinition = { name: 'linear_getWebhooks', description: 'Get a list of webhooks', input_schema: { type: 'object', properties: { teamId: { type: 'string' }, limit: { ...positiveLimitSchema }, includeArchived: { type: 'boolean' }, orderBy: { ...paginationOrderBySchema } } }, output_schema: { type: 'array', items: webhookOutputSchema } }; -export const createWebhookToolDefinition: MCPToolDefinition = { name: 'linear_createWebhook', description: 'Create a webhook for integration events', input_schema: { type: 'object', properties: { url: { type: 'string' }, resourceTypes: { type: 'array', items: { type: 'string' } }, teamId: { type: 'string' }, enabled: { type: 'boolean' }, label: { type: 'string' }, secret: { type: 'string' }, allPublicTeams: { type: 'boolean' } }, required: ['url', 'resourceTypes'] }, output_schema: webhookOutputSchema }; +export const getWebhookByIdToolDefinition: MCPToolDefinition = { + name: 'linear_getWebhookById', + description: 'Get one ordinary workspace webhook by ID. Reading webhooks requires workspace-admin access or an OAuth user token with the admin scope.', + input_schema: { + type: 'object', + properties: { id: { type: 'string', minLength: 1 } }, + required: ['id'], + }, + output_schema: webhookOutputSchema, +}; +export const createWebhookToolDefinition: MCPToolDefinition = { + name: 'linear_createWebhook', + description: 'Create an ordinary workspace webhook for integration events.', + input_schema: { + type: 'object', + properties: { + url: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]://', + maxLength: 1000, + }, + resourceTypes: { + type: 'array', + minItems: 1, + maxItems: 22, + uniqueItems: true, + items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, + }, + teamId: { + type: 'string', + minLength: 1, + description: 'Target one team. Required unless allPublicTeams is true; do not combine both scopes.', + }, + enabled: { type: 'boolean' }, + label: { type: 'string', minLength: 1 }, + secret: { type: 'string', minLength: 1 }, + allPublicTeams: { + type: 'boolean', + description: 'Set to true to target all public teams instead of supplying teamId.', + }, + }, + required: ['url', 'resourceTypes'], + }, + output_schema: webhookOutputSchema, +}; +export const updateWebhookToolDefinition: MCPToolDefinition = { + name: 'linear_updateWebhook', + description: 'Update an ordinary workspace webhook URL, label, enabled state, resource subscriptions, or caller-supplied signing secret.', + input_schema: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + url: { + type: 'string', + format: 'uri', + pattern: '^[Hh][Tt][Tt][Pp][Ss]://', + maxLength: 1000, + }, + label: { type: ['string', 'null'], minLength: 1 }, + enabled: { type: 'boolean' }, + resourceTypes: { + type: 'array', + minItems: 1, + maxItems: 22, + uniqueItems: true, + items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, + }, + secret: { type: 'string', minLength: 1 }, + }, + required: ['id'], + anyOf: [ + { required: ['url'] }, + { required: ['label'] }, + { required: ['enabled'] }, + { required: ['resourceTypes'] }, + { required: ['secret'] }, + ], + }, + output_schema: webhookOutputSchema, +}; +export const rotateWebhookSecretToolDefinition: MCPToolDefinition = { + name: 'linear_rotateWebhookSecret', + description: 'Rotate an ordinary workspace webhook signing secret. The new one-time secret is exposed in the MCP tool result and the old secret is immediately invalidated.', + input_schema: { + type: 'object', + properties: { + id: { type: 'string', minLength: 1 }, + confirmSecretExposure: { + type: 'boolean', + const: true, + description: 'Must be true to acknowledge that the new signing secret will be returned in the MCP tool result.', + }, + }, + required: ['id', 'confirmSecretExposure'], + }, + output_schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + id: { type: 'string' }, + secret: { type: 'string' }, + }, + }, +}; export const deleteWebhookToolDefinition: MCPToolDefinition = { name: 'linear_deleteWebhook', description: 'Delete a webhook', input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, output_schema: { type: 'object', properties: { success: { type: 'boolean' }, id: { type: 'string' } } } }; export const getAttachmentsToolDefinition: MCPToolDefinition = { name: 'linear_getAttachments', description: 'Get attachments for an issue', input_schema: { type: 'object', properties: { issueId: { type: 'string' }, limit: { ...positiveLimitSchema }, includeArchived: { type: 'boolean' }, orderBy: { ...paginationOrderBySchema } }, required: ['issueId'] }, output_schema: { type: 'array', items: attachmentOutputSchema } }; export const addAttachmentToolDefinition: MCPToolDefinition = { name: 'linear_addAttachment', description: 'Add an attachment to an issue', input_schema: { type: 'object', properties: { issueId: { type: 'string' }, title: { type: 'string' }, url: { type: 'string' }, subtitle: { type: 'string' }, iconUrl: { type: 'string' }, metadata: { type: 'object', additionalProperties: true }, commentBody: { type: 'string' }, groupBySource: { type: 'boolean' } }, required: ['issueId', 'title', 'url'] }, output_schema: attachmentOutputSchema }; diff --git a/src/tools/handlers/index.ts b/src/tools/handlers/index.ts index a4e25ac..3a08876 100644 --- a/src/tools/handlers/index.ts +++ b/src/tools/handlers/index.ts @@ -110,8 +110,11 @@ import { } from './document-handlers.js'; import { handleGetRateLimitStatus, handleGetServerStatus } from './server-handlers.js'; import { + handleGetWebhookById, handleGetWebhooks, handleCreateWebhook, + handleUpdateWebhook, + handleRotateWebhookSecret, handleDeleteWebhook, handleGetAttachments, handleAddAttachment, @@ -128,6 +131,18 @@ import { handleGetUserAuditEvents, handleGetIntegrations, } from './ops-handlers.js'; +import { + handleArchiveManagedOAuthApplication, + handleCreateManagedOAuthApplication, + handleCreateOAuthClientCredentialsToken, + handleGenerateOAuthApplicationSetup, + handleGenerateOAuthAuthorizationUrl, + handleGetManagedOAuthApplicationById, + handleGetManagedOAuthApplications, + handleRotateManagedOAuthApplicationSecret, + handleRotateManagedOAuthApplicationWebhookSecret, + handleUpdateManagedOAuthApplication, +} from './oauth-handlers.js'; import { handleAddToFavorites, handleCreateSavedView, @@ -236,9 +251,25 @@ export function registerToolHandlers( linear_getRateLimitStatus: handleGetRateLimitStatus(options.getRateLimitStatus), linear_getServerStatus: handleGetServerStatus(options.getServerStatus), + // OAuth application tools + linear_generateOAuthApplicationSetup: handleGenerateOAuthApplicationSetup(linearService), + linear_generateOAuthAuthorizationUrl: handleGenerateOAuthAuthorizationUrl(linearService), + linear_createOAuthClientCredentialsToken: handleCreateOAuthClientCredentialsToken(linearService), + linear_getManagedOAuthApplications: handleGetManagedOAuthApplications(linearService), + linear_getManagedOAuthApplicationById: handleGetManagedOAuthApplicationById(linearService), + linear_createManagedOAuthApplication: handleCreateManagedOAuthApplication(linearService), + linear_updateManagedOAuthApplication: handleUpdateManagedOAuthApplication(linearService), + linear_archiveManagedOAuthApplication: handleArchiveManagedOAuthApplication(linearService), + linear_rotateManagedOAuthApplicationSecret: handleRotateManagedOAuthApplicationSecret(linearService), + linear_rotateManagedOAuthApplicationWebhookSecret: + handleRotateManagedOAuthApplicationWebhookSecret(linearService), + // Ops tools + linear_getWebhookById: handleGetWebhookById(linearService), linear_getWebhooks: handleGetWebhooks(linearService), linear_createWebhook: handleCreateWebhook(linearService), + linear_updateWebhook: handleUpdateWebhook(linearService), + linear_rotateWebhookSecret: handleRotateWebhookSecret(linearService), linear_deleteWebhook: handleDeleteWebhook(linearService), linear_getAttachments: handleGetAttachments(linearService), linear_addAttachment: handleAddAttachment(linearService), @@ -574,8 +605,21 @@ export { // Server handlers handleGetRateLimitStatus, handleGetServerStatus, + handleGenerateOAuthApplicationSetup, + handleGenerateOAuthAuthorizationUrl, + handleCreateOAuthClientCredentialsToken, + handleGetManagedOAuthApplications, + handleGetManagedOAuthApplicationById, + handleCreateManagedOAuthApplication, + handleUpdateManagedOAuthApplication, + handleArchiveManagedOAuthApplication, + handleRotateManagedOAuthApplicationSecret, + handleRotateManagedOAuthApplicationWebhookSecret, + handleGetWebhookById, handleGetWebhooks, handleCreateWebhook, + handleUpdateWebhook, + handleRotateWebhookSecret, handleDeleteWebhook, handleGetAttachments, handleAddAttachment, diff --git a/src/tools/handlers/oauth-handlers.ts b/src/tools/handlers/oauth-handlers.ts new file mode 100644 index 0000000..cb82e7f --- /dev/null +++ b/src/tools/handlers/oauth-handlers.ts @@ -0,0 +1,172 @@ +import { LinearService } from '../../services/linear-service.js'; +import { logError } from '../../utils/config.js'; +import { + isArchiveManagedOAuthApplicationArgs, + isCreateManagedOAuthApplicationArgs, + isCreateOAuthClientCredentialsTokenArgs, + isGenerateOAuthApplicationSetupArgs, + isGenerateOAuthAuthorizationUrlArgs, + isGetManagedOAuthApplicationByIdArgs, + isGetManagedOAuthApplicationsArgs, + isRotateManagedOAuthApplicationSecretArgs, + isRotateManagedOAuthApplicationWebhookSecretArgs, + isUpdateManagedOAuthApplicationArgs, +} from '../type-guards.js'; + +function rethrowSecretProducingOAuthError(action: string): never { + const safeError = new Error( + `Failed ${action}; Linear error details were omitted because the response may contain a one-time secret`, + ); + logError(`Error ${action}`, safeError); + throw safeError; +} + +export function handleGenerateOAuthApplicationSetup(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isGenerateOAuthApplicationSetupArgs(args)) { + throw new Error('Invalid arguments for generateOAuthApplicationSetup'); + } + return linearService.generateOAuthApplicationSetup(args); + } catch (error) { + logError('Error generating OAuth application setup', error); + throw error; + } + }; +} + +export function handleGenerateOAuthAuthorizationUrl(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isGenerateOAuthAuthorizationUrlArgs(args)) { + throw new Error('Invalid arguments for generateOAuthAuthorizationUrl'); + } + return linearService.generateOAuthAuthorizationUrl(args); + } catch (error) { + logError('Error generating OAuth authorization URL', error); + throw error; + } + }; +} + +export function handleCreateOAuthClientCredentialsToken(linearService: LinearService) { + return async (args: unknown) => { + if (!isCreateOAuthClientCredentialsTokenArgs(args)) { + const error = new Error('Invalid arguments for createOAuthClientCredentialsToken'); + logError('Error creating OAuth client-credentials token', error); + throw error; + } + + try { + return await linearService.createOAuthClientCredentialsToken({ + clientId: args.clientId, + clientSecret: args.clientSecret, + scopes: args.scopes, + }); + } catch { + rethrowSecretProducingOAuthError('creating OAuth client-credentials token'); + } + }; +} + +export function handleGetManagedOAuthApplications(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isGetManagedOAuthApplicationsArgs(args)) { + throw new Error('Invalid arguments for getManagedOAuthApplications'); + } + return await linearService.getManagedOAuthApplications(); + } catch (error) { + logError('Error getting managed OAuth applications', error); + throw error; + } + }; +} + +export function handleGetManagedOAuthApplicationById(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isGetManagedOAuthApplicationByIdArgs(args)) { + throw new Error('Invalid arguments for getManagedOAuthApplicationById'); + } + return await linearService.getManagedOAuthApplicationById(args.id); + } catch (error) { + logError('Error getting managed OAuth application', error); + throw error; + } + }; +} + +export function handleCreateManagedOAuthApplication(linearService: LinearService) { + return async (args: unknown) => { + if (!isCreateManagedOAuthApplicationArgs(args)) { + const error = new Error('Invalid arguments for createManagedOAuthApplication'); + logError('Error creating managed OAuth application', error); + throw error; + } + try { + const { confirmSecretExposure, ...serviceArgs } = args; + return await linearService.createManagedOAuthApplication(serviceArgs); + } catch { + rethrowSecretProducingOAuthError('creating managed OAuth application'); + } + }; +} + +export function handleUpdateManagedOAuthApplication(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isUpdateManagedOAuthApplicationArgs(args)) { + throw new Error('Invalid arguments for updateManagedOAuthApplication'); + } + return await linearService.updateManagedOAuthApplication(args); + } catch (error) { + logError('Error updating managed OAuth application', error); + throw error; + } + }; +} + +export function handleArchiveManagedOAuthApplication(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isArchiveManagedOAuthApplicationArgs(args)) { + throw new Error('Invalid arguments for archiveManagedOAuthApplication'); + } + return await linearService.archiveManagedOAuthApplication(args.id); + } catch (error) { + logError('Error archiving managed OAuth application', error); + throw error; + } + }; +} + +export function handleRotateManagedOAuthApplicationSecret(linearService: LinearService) { + return async (args: unknown) => { + if (!isRotateManagedOAuthApplicationSecretArgs(args)) { + const error = new Error('Invalid arguments for rotateManagedOAuthApplicationSecret'); + logError('Error rotating managed OAuth application secret', error); + throw error; + } + try { + return await linearService.rotateManagedOAuthApplicationSecret(args.id); + } catch { + rethrowSecretProducingOAuthError('rotating managed OAuth application secret'); + } + }; +} + +export function handleRotateManagedOAuthApplicationWebhookSecret(linearService: LinearService) { + return async (args: unknown) => { + if (!isRotateManagedOAuthApplicationWebhookSecretArgs(args)) { + const error = new Error('Invalid arguments for rotateManagedOAuthApplicationWebhookSecret'); + logError('Error rotating managed OAuth application webhook secret', error); + throw error; + } + try { + return await linearService.rotateManagedOAuthApplicationWebhookSecret(args.id); + } catch { + rethrowSecretProducingOAuthError('rotating managed OAuth application webhook secret'); + } + }; +} diff --git a/src/tools/handlers/ops-handlers.ts b/src/tools/handlers/ops-handlers.ts index 19bf4bb..5c44a63 100644 --- a/src/tools/handlers/ops-handlers.ts +++ b/src/tools/handlers/ops-handlers.ts @@ -11,6 +11,7 @@ import { isGetOrganizationAuditEventsArgs, isGetSubscriptionsArgs, isGetUserAuditEventsArgs, + isGetWebhookByIdArgs, isGetWebhooksArgs, isLogoutAllSessionsArgs, isLogoutOtherSessionsArgs, @@ -18,8 +19,46 @@ import { isMarkAllNotificationsAsReadArgs, isMarkNotificationAsReadArgs, isGetUnreadNotificationCountArgs, + isRotateWebhookSecretArgs, + isUpdateWebhookArgs, } from '../type-guards.js'; +function hasCallerProvidedWebhookSecret(args: unknown): boolean { + return typeof args === 'object' && args !== null && 'secret' in args; +} + +function rethrowWebhookErrorWithoutSecret(action: string, error: unknown, args: unknown): never { + if (!hasCallerProvidedWebhookSecret(args)) { + logError(`Error ${action} webhook`, error); + throw error; + } + + const safeError = new Error( + `Failed ${action} webhook; Linear error details were omitted because the request contained a signing secret`, + ); + logError(`Error ${action} webhook`, safeError); + throw safeError; +} + +function rethrowWebhookRotationError(): never { + const safeError = new Error( + 'Failed rotating webhook secret; Linear error details were omitted because the response may contain a one-time secret', + ); + logError('Error rotating webhook secret', safeError); + throw safeError; +} + +export function handleGetWebhookById(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isGetWebhookByIdArgs(args)) throw new Error('Invalid arguments for getWebhookById'); + return await linearService.getWebhookById(args.id); + } catch (error) { + logError('Error getting webhook', error); + throw error; + } + }; +} export function handleGetWebhooks(linearService: LinearService) { return async (args: unknown) => { try { @@ -37,9 +76,32 @@ export function handleCreateWebhook(linearService: LinearService) { if (!isCreateWebhookArgs(args)) throw new Error('Invalid arguments for createWebhook'); return await linearService.createWebhook(args); } catch (error) { - logError('Error creating webhook', error); + rethrowWebhookErrorWithoutSecret('creating', error, args); + } + }; +} +export function handleUpdateWebhook(linearService: LinearService) { + return async (args: unknown) => { + try { + if (!isUpdateWebhookArgs(args)) throw new Error('Invalid arguments for updateWebhook'); + return await linearService.updateWebhook(args); + } catch (error) { + rethrowWebhookErrorWithoutSecret('updating', error, args); + } + }; +} +export function handleRotateWebhookSecret(linearService: LinearService) { + return async (args: unknown) => { + if (!isRotateWebhookSecretArgs(args)) { + const error = new Error('Invalid arguments for rotateWebhookSecret'); + logError('Error rotating webhook secret', error); throw error; } + try { + return await linearService.rotateWebhookSecret(args.id); + } catch { + rethrowWebhookRotationError(); + } }; } export function handleDeleteWebhook(linearService: LinearService) { diff --git a/src/tools/oauth-constants.ts b/src/tools/oauth-constants.ts new file mode 100644 index 0000000..e397044 --- /dev/null +++ b/src/tools/oauth-constants.ts @@ -0,0 +1,65 @@ +export const OAUTH_APPLICATION_GRANT_TYPES = [ + 'authorization_code', + 'client_credentials', +] as const; + +export type OAuthApplicationGrantTypeValue = (typeof OAUTH_APPLICATION_GRANT_TYPES)[number]; + +export const OAUTH_AUTHORIZATION_SCOPES = [ + 'read', + 'write', + 'issues:create', + 'comments:create', + 'timeSchedule:write', + 'admin', + 'app:assignable', + 'app:mentionable', + 'customer:read', + 'customer:write', + 'initiative:read', + 'initiative:write', +] as const; + +export type OAuthAuthorizationScopeValue = (typeof OAUTH_AUTHORIZATION_SCOPES)[number]; + +export const APP_ACTOR_OAUTH_SCOPES: ReadonlyArray< + Exclude +> = OAUTH_AUTHORIZATION_SCOPES.filter( + (scope): scope is Exclude => scope !== 'admin', +); + +export const APP_ONLY_OAUTH_SCOPES = [ + 'app:assignable', + 'app:mentionable', + 'customer:read', + 'customer:write', + 'initiative:read', + 'initiative:write', +] as const satisfies readonly OAuthAuthorizationScopeValue[]; + +export const WEBHOOK_RESOURCE_TYPES = [ + 'AgentSessionEvent', + 'AppUserNotification', + 'Attachment', + 'Comment', + 'Customer', + 'CustomerNeed', + 'Cycle', + 'Document', + 'Initiative', + 'InitiativeUpdate', + 'Issue', + 'IssueLabel', + 'IssueSLA', + 'OAuthAuthorization', + 'PermissionChange', + 'Project', + 'ProjectLabel', + 'ProjectUpdate', + 'Reaction', + 'Release', + 'ReleaseNote', + 'User', +] as const; + +export type WebhookResourceTypeValue = (typeof WEBHOOK_RESOURCE_TYPES)[number]; diff --git a/src/tools/type-guards.ts b/src/tools/type-guards.ts index 5d65977..65fd2f3 100644 --- a/src/tools/type-guards.ts +++ b/src/tools/type-guards.ts @@ -1,3 +1,16 @@ +import { isIP } from 'node:net'; + +import { + APP_ACTOR_OAUTH_SCOPES, + APP_ONLY_OAUTH_SCOPES, + OAUTH_APPLICATION_GRANT_TYPES, + OAUTH_AUTHORIZATION_SCOPES, + WEBHOOK_RESOURCE_TYPES, + type OAuthApplicationGrantTypeValue, + type OAuthAuthorizationScopeValue, + type WebhookResourceTypeValue, +} from './oauth-constants.js'; + type JSONGuardValue = | null | string @@ -1883,22 +1896,345 @@ export function isCreateWebhookArgs(args: unknown): args is { secret?: string; allPublicTeams?: boolean; } { - return ( + const hasValidFields = ( isJsonObject(args) && - 'url' in args && typeof (args as { url: unknown }).url === 'string' && - 'resourceTypes' in args && isStringArray((args as { resourceTypes: unknown }).resourceTypes) && - (!('teamId' in args) || typeof (args as { teamId: unknown }).teamId === 'string') && + hasOnlyKeys(args, [ + 'url', + 'resourceTypes', + 'teamId', + 'enabled', + 'label', + 'secret', + 'allPublicTeams', + ]) && + 'url' in args && + isPublicWebhookUrl((args as { url: unknown }).url) && + (args as { url: string }).url.length <= 1000 && + 'resourceTypes' in args && + isAllowedUniqueStringArray( + (args as { resourceTypes: unknown }).resourceTypes, + WEBHOOK_RESOURCE_TYPE_SET, + ) && + (!('teamId' in args) || isNonEmptyString((args as { teamId: unknown }).teamId)) && (!('enabled' in args) || typeof (args as { enabled: unknown }).enabled === 'boolean') && - (!('label' in args) || typeof (args as { label: unknown }).label === 'string') && - (!('secret' in args) || typeof (args as { secret: unknown }).secret === 'string') && + (!('label' in args) || isNonEmptyString((args as { label: unknown }).label)) && + (!('secret' in args) || isNonEmptyString((args as { secret: unknown }).secret)) && (!('allPublicTeams' in args) || typeof (args as { allPublicTeams: unknown }).allPublicTeams === 'boolean') ); + + if (!hasValidFields || !isJsonObject(args)) { + return false; + } + + const hasTeam = 'teamId' in args; + const hasAllPublicTeams = args.allPublicTeams === true; + return hasTeam !== hasAllPublicTeams; } export function isDeleteWebhookArgs(args: unknown): args is { id: string } { return isGetCycleByIdArgs(args); } +export function isGenerateOAuthApplicationSetupArgs(args: unknown): args is { + name: string; + developer: string; + developerUrl?: string; + description?: string; + imageUrl?: string; + distribution?: 'private' | 'public'; + redirectUris: string[]; + grantTypes?: OAuthApplicationGrantTypeValue[]; + webhookEnabled?: boolean; + webhookUrl?: string; + webhookResourceTypes?: WebhookResourceTypeValue[]; +} { + if ( + !isJsonObject(args) || + !hasOnlyKeys(args, [ + 'name', + 'developer', + 'developerUrl', + 'description', + 'imageUrl', + 'distribution', + 'redirectUris', + 'grantTypes', + 'webhookEnabled', + 'webhookUrl', + 'webhookResourceTypes', + ]) || + !isValidOAuthApplicationName(args.name) || + !isBoundedNonEmptyString(args.developer, 2, 80) || + !isUniqueHttpUrlArray(args.redirectUris, 32) || + !isValidGrantTypes(args.grantTypes) || + !hasPairedOAuthWebhookConfiguration(args, true) + ) { + return false; + } + + return ( + (!('developerUrl' in args) || isAbsoluteHttpUrl(args.developerUrl)) && + ((args.distribution ?? 'private') !== 'public' || isAbsoluteHttpUrl(args.developerUrl)) && + (!('description' in args) || isStringWithinLimit(args.description, 1000)) && + (!('imageUrl' in args) || isAbsoluteHttpUrl(args.imageUrl)) && + (!('distribution' in args) || args.distribution === 'private' || args.distribution === 'public') && + (!('webhookEnabled' in args) || typeof args.webhookEnabled === 'boolean') + ); +} + +export function isGenerateOAuthAuthorizationUrlArgs(args: unknown): args is { + clientId: string; + redirectUri: string; + scopes: OAuthAuthorizationScopeValue[]; + actor?: 'user' | 'app'; + state?: string; + promptConsent?: boolean; + codeChallenge?: string; +} { + if ( + !isJsonObject(args) || + !hasOnlyKeys(args, [ + 'clientId', + 'redirectUri', + 'scopes', + 'actor', + 'state', + 'promptConsent', + 'codeChallenge', + ]) || + !isNonEmptyString(args.clientId) || + !isAbsoluteHttpUrl(args.redirectUri) || + !isAllowedUniqueStringArray(args.scopes, OAUTH_SCOPE_SET) || + (!('actor' in args) || args.actor === 'user' || args.actor === 'app') === false || + (!('state' in args) || isNonEmptyString(args.state)) === false || + (!('promptConsent' in args) || typeof args.promptConsent === 'boolean') === false + ) { + return false; + } + + if (args.codeChallenge !== undefined && !isValidPkceChallenge(args.codeChallenge)) { + return false; + } + + const actor = args.actor ?? 'user'; + if (actor === 'app' && args.scopes.includes('admin')) { + return false; + } + + const hasAppOnlyScope = args.scopes.some((scope) => APP_ONLY_OAUTH_SCOPE_SET.has(scope)); + return actor === 'app' || !hasAppOnlyScope; +} + +export function isCreateOAuthClientCredentialsTokenArgs(args: unknown): args is { + clientId: string; + clientSecret: string; + scopes: OAuthAuthorizationScopeValue[]; + confirmSecretExposure: true; + confirmScopeChangeRisk: true; +} { + return ( + isJsonObject(args) && + hasOnlyKeys(args, [ + 'clientId', + 'clientSecret', + 'scopes', + 'confirmSecretExposure', + 'confirmScopeChangeRisk', + ]) && + isNonEmptyString(args.clientId) && + isNonEmptyString(args.clientSecret) && + isAllowedUniqueStringArray(args.scopes, APP_ACTOR_OAUTH_SCOPE_SET) && + args.confirmSecretExposure === true && + args.confirmScopeChangeRisk === true + ); +} + +export function isGetManagedOAuthApplicationsArgs( + args: unknown, +): args is Record | null | undefined { + return args === null || args === undefined || (isJsonObject(args) && Object.keys(args).length === 0); +} + +export function isGetManagedOAuthApplicationByIdArgs( + args: unknown, +): args is { id: string } { + return isNonEmptyIdArgs(args); +} + +export function isCreateManagedOAuthApplicationArgs(args: unknown): args is { + name: string; + developer: string; + developerUrl?: string; + description?: string; + imageUrl?: string; + redirectUris: string[]; + grantTypes?: OAuthApplicationGrantTypeValue[]; + idempotencyKey?: string; + webhookUrl?: string; + webhookResourceTypes?: WebhookResourceTypeValue[]; + confirmSecretExposure: true; +} { + if ( + !isJsonObject(args) || + !hasOnlyKeys(args, [ + 'name', + 'developer', + 'developerUrl', + 'description', + 'imageUrl', + 'redirectUris', + 'grantTypes', + 'idempotencyKey', + 'webhookUrl', + 'webhookResourceTypes', + 'confirmSecretExposure', + ]) || + !isValidOAuthApplicationName(args.name) || + !isBoundedNonEmptyString(args.developer, 2, 80) || + !isUniqueHttpUrlArray(args.redirectUris, 32) || + !isValidGrantTypes(args.grantTypes) || + args.confirmSecretExposure !== true || + !hasPairedOAuthWebhookConfiguration(args) + ) { + return false; + } + + return ( + (!('developerUrl' in args) || isAbsoluteHttpUrl(args.developerUrl)) && + (!('description' in args) || isStringWithinLimit(args.description, 1000)) && + (!('imageUrl' in args) || isAbsoluteHttpUrl(args.imageUrl)) && + (!('idempotencyKey' in args) || isNonEmptyString(args.idempotencyKey)) + ); +} + +export function isUpdateManagedOAuthApplicationArgs(args: unknown): args is { + id: string; + name?: string; + developer?: string; + developerUrl?: string | null; + description?: string | null; + imageUrl?: string | null; + redirectUris?: string[]; + grantTypes?: OAuthApplicationGrantTypeValue[]; + webhookEnabled?: boolean; + webhookUrl?: string | null; + webhookResourceTypes?: WebhookResourceTypeValue[]; +} { + if ( + !isJsonObject(args) || + !hasOnlyKeys(args, [ + 'id', + 'name', + 'developer', + 'developerUrl', + 'description', + 'imageUrl', + 'redirectUris', + 'grantTypes', + 'webhookEnabled', + 'webhookUrl', + 'webhookResourceTypes', + ]) || + !isNonEmptyString(args.id) + ) { + return false; + } + + const mutableFields = [ + 'name', + 'developer', + 'developerUrl', + 'description', + 'imageUrl', + 'redirectUris', + 'grantTypes', + 'webhookEnabled', + 'webhookUrl', + 'webhookResourceTypes', + ]; + if (!mutableFields.some((field) => field in args && args[field] !== undefined)) { + return false; + } + + return ( + (!('name' in args) || isValidOAuthApplicationName(args.name)) && + (!('developer' in args) || isBoundedNonEmptyString(args.developer, 2, 80)) && + (!('developerUrl' in args) || args.developerUrl === null || isAbsoluteHttpUrl(args.developerUrl)) && + (!('description' in args) || + args.description === null || + isStringWithinLimit(args.description, 1000)) && + (!('imageUrl' in args) || args.imageUrl === null || isAbsoluteHttpUrl(args.imageUrl)) && + (!('redirectUris' in args) || isUniqueHttpUrlArray(args.redirectUris, 32)) && + (!('grantTypes' in args) || isValidGrantTypes(args.grantTypes)) && + (!('webhookEnabled' in args) || typeof args.webhookEnabled === 'boolean') && + (!('webhookUrl' in args) || + args.webhookUrl === null || + (isPublicWebhookUrl(args.webhookUrl) && args.webhookUrl.length <= 1000)) && + (!('webhookResourceTypes' in args) || + isAllowedUniqueStringArray(args.webhookResourceTypes, WEBHOOK_RESOURCE_TYPE_SET)) + ); +} + +export function isArchiveManagedOAuthApplicationArgs(args: unknown): args is { id: string } { + return isNonEmptyIdArgs(args); +} + +export function isRotateManagedOAuthApplicationSecretArgs(args: unknown): args is { + id: string; + confirmSecretExposure: true; +} { + return isSecretRotationArgs(args); +} + +export function isRotateManagedOAuthApplicationWebhookSecretArgs(args: unknown): args is { + id: string; + confirmSecretExposure: true; +} { + return isSecretRotationArgs(args); +} + +export function isGetWebhookByIdArgs(args: unknown): args is { id: string } { + return isNonEmptyIdArgs(args); +} + +export function isUpdateWebhookArgs(args: unknown): args is { + id: string; + url?: string; + label?: string | null; + enabled?: boolean; + resourceTypes?: WebhookResourceTypeValue[]; + secret?: string; +} { + if ( + !isJsonObject(args) || + !hasOnlyKeys(args, ['id', 'url', 'label', 'enabled', 'resourceTypes', 'secret']) || + !isNonEmptyString(args.id) + ) { + return false; + } + + const mutableFields = ['url', 'label', 'enabled', 'resourceTypes', 'secret']; + if (!mutableFields.some((field) => field in args && args[field] !== undefined)) { + return false; + } + + return ( + (!('url' in args) || (isPublicWebhookUrl(args.url) && args.url.length <= 1000)) && + (!('label' in args) || args.label === null || isNonEmptyString(args.label)) && + (!('enabled' in args) || typeof args.enabled === 'boolean') && + (!('resourceTypes' in args) || + isAllowedUniqueStringArray(args.resourceTypes, WEBHOOK_RESOURCE_TYPE_SET)) && + (!('secret' in args) || isNonEmptyString(args.secret)) + ); +} + +export function isRotateWebhookSecretArgs(args: unknown): args is { + id: string; + confirmSecretExposure: true; +} { + return isSecretRotationArgs(args); +} + export function isGetAttachmentsArgs(args: unknown): args is { issueId: string; limit?: number; @@ -2704,6 +3040,198 @@ export function isCustomerMetadataListArgs(args: unknown): args is { ); } +const OAUTH_APPLICATION_GRANT_TYPE_SET: ReadonlySet = + new Set(OAUTH_APPLICATION_GRANT_TYPES); + +const OAUTH_SCOPE_SET: ReadonlySet = + new Set(OAUTH_AUTHORIZATION_SCOPES); + +const APP_ONLY_OAUTH_SCOPE_SET: ReadonlySet = + new Set(APP_ONLY_OAUTH_SCOPES); + +const APP_ACTOR_OAUTH_SCOPE_SET: ReadonlySet = + new Set(APP_ACTOR_OAUTH_SCOPES); + +const WEBHOOK_RESOURCE_TYPE_SET: ReadonlySet = + new Set(WEBHOOK_RESOURCE_TYPES); + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Object.keys(value).every((key) => allowed.has(key)); +} + +function isBoundedNonEmptyString( + value: unknown, + minimumLength: number, + maximumLength: number, +): value is string { + return ( + isNonEmptyString(value) && + value.trim().length >= minimumLength && + value.length <= maximumLength + ); +} + +function isStringWithinLimit(value: unknown, maximumLength: number): value is string { + return typeof value === 'string' && value.length <= maximumLength; +} + +function isValidOAuthApplicationName(value: unknown): value is string { + return ( + isBoundedNonEmptyString(value, 2, 80) && + !/linear/i.test(value) && + !/https?:\/\//i.test(value) + ); +} + +function isAllowedUniqueStringArray( + value: unknown, + allowedValues: ReadonlySet, +): value is T[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => isNonEmptyString(item) && allowedValues.has(item as T)) && + new Set(value).size === value.length + ); +} + +function isAbsoluteHttpUrl(value: unknown, httpsOnly = false): value is string { + if (!isNonEmptyString(value)) { + return false; + } + + try { + const url = new URL(value); + return httpsOnly ? url.protocol === 'https:' : url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Linear delivers webhooks from its own infrastructure, so destinations must be + * publicly reachable HTTPS endpoints. This rejects obvious local and reserved + * destinations without pretending to replace Linear's delivery-time DNS checks. + */ +function isPublicWebhookUrl(value: unknown): value is string { + if (!isAbsoluteHttpUrl(value, true)) { + return false; + } + + const url = new URL(value); + if (url.username || url.password) { + return false; + } + + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + + if ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + hostname.endsWith('.internal') || + hostname.endsWith('.home.arpa') || + hostname === 'linear.app' || + hostname.endsWith('.linear.app') + ) { + return false; + } + + const ipVersion = isIP(hostname); + if (ipVersion === 4) { + const [first, second] = hostname.split('.').map(Number); + return !( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && (second === 0 || second === 168)) || + (first === 198 && (second === 18 || second === 19)) || + first >= 224 + ); + } + + if (ipVersion === 6) { + return !( + hostname === '::' || + hostname === '::1' || + hostname.startsWith('::ffff:') || + /^f[cd]/.test(hostname) || + /^fe[89ab]/.test(hostname) || + hostname.startsWith('ff') + ); + } + + return true; +} + +function isUniqueHttpUrlArray(value: unknown, maxItems?: number): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + (maxItems === undefined || value.length <= maxItems) && + value.every((item) => isAbsoluteHttpUrl(item)) && + new Set(value).size === value.length + ); +} + +function isValidPkceChallenge(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value); +} + +function isValidGrantTypes(value: unknown): boolean { + return ( + value === undefined || + (isAllowedUniqueStringArray(value, OAUTH_APPLICATION_GRANT_TYPE_SET) && + value.includes('authorization_code')) + ); +} + +function hasPairedOAuthWebhookConfiguration( + value: Record, + includeEnabled = false, +): boolean { + const hasUrl = value.webhookUrl !== undefined; + const hasResourceTypes = value.webhookResourceTypes !== undefined; + const hasEnabled = includeEnabled && value.webhookEnabled !== undefined; + + if (!hasUrl && !hasResourceTypes && !hasEnabled) { + return true; + } + + return ( + isPublicWebhookUrl(value.webhookUrl) && + value.webhookUrl.length <= 1000 && + isAllowedUniqueStringArray(value.webhookResourceTypes, WEBHOOK_RESOURCE_TYPE_SET) + ); +} + +function isNonEmptyIdArgs(args: unknown): args is { id: string } { + return ( + isJsonObject(args) && + hasOnlyKeys(args, ['id']) && + isNonEmptyString(args.id) + ); +} + +function isSecretRotationArgs(args: unknown): args is { + id: string; + confirmSecretExposure: true; +} { + return ( + isJsonObject(args) && + hasOnlyKeys(args, ['id', 'confirmSecretExposure']) && + isNonEmptyString(args.id) && + args.confirmSecretExposure === true + ); +} + function isJsonObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/src/utils/config.ts b/src/utils/config.ts index 45ddb91..c5c78e7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -46,6 +46,43 @@ export function getLinearApiToken(): string | undefined { return tokenFromArgs || tokenFromEnv; } +export type LinearAuthConfig = + | { type: 'oauth'; token: string } + | { type: 'apiKey'; token: string }; + +/** + * Resolve both supported Linear authentication modes. + * + * Explicit CLI credentials take precedence over environment variables. Within + * each source, OAuth is preferred because managed OAuth application operations + * must be performed by an OAuth application identity. Legacy API-key flags and + * environment variables retain their existing behavior. + */ +export function getLinearAuthConfig(): LinearAuthConfig | undefined { + const oauthTokenFromArgs = getCommandLineArg('--oauth-token'); + if (oauthTokenFromArgs) { + return { type: 'oauth', token: oauthTokenFromArgs }; + } + + const apiKeyFromArgs = getCommandLineArg('--token'); + if (apiKeyFromArgs) { + return { type: 'apiKey', token: apiKeyFromArgs }; + } + + if (process.env.LINEAR_OAUTH_ACCESS_TOKEN) { + return { type: 'oauth', token: process.env.LINEAR_OAUTH_ACCESS_TOKEN }; + } + + const apiKeyFromEnv = process.env.LINEAR_API_TOKEN || process.env.LINEAR_API_KEY; + if (apiKeyFromEnv) { + return { type: 'apiKey', token: apiKeyFromEnv }; + } + + // Preserve the existing missing-token diagnostics without printing credential values. + getLinearApiToken(); + return undefined; +} + export function isDebugLoggingEnabled(): boolean { return process.env.MCP_LINEAR_DEBUG === '1' || process.env.MCP_LINEAR_DEBUG === 'true'; } From 3856c28d97b54f6847cdbe9ab5c45564afb56c21 Mon Sep 17 00:00:00 2001 From: itz4blitz Date: Fri, 10 Jul 2026 20:20:25 -0400 Subject: [PATCH 2/3] Align OAuth and webhook schema contracts --- TOOLS.md | 2 ++ src/__tests__/oauth-webhook-tools.test.ts | 27 ++++++++++++++++++++--- src/services/linear-service.ts | 4 ++-- src/tools/definitions/oauth-tools.ts | 2 +- src/tools/definitions/ops-tools.ts | 7 ++---- src/tools/type-guards.ts | 23 ++++++++++--------- 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/TOOLS.md b/TOOLS.md index 2955a9e..d040230 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -329,6 +329,8 @@ Linear calls these "saved views" in the product UI. The GraphQL API and SDK expo Workspace webhooks are separate from the installation webhook configured on an OAuth application. Workspace webhook reads and mutations require a workspace administrator or an OAuth token with Linear's `admin` scope. Secret rotation returns the replacement secret once and invalidates the previous signing secret immediately; callers that let Linear generate a secret during creation can rotate it before enabling deliveries to capture a known value securely. +Linear types ordinary workspace-webhook `resourceTypes` as a list of strings, so those tools accept unique non-empty resource names without freezing the surface to today's OAuth installation-webhook enum. OAuth application webhooks remain validated against Linear's current 22-value manifest/GraphQL enum. + Webhook destinations must be publicly reachable HTTPS URLs. Runtime validation rejects embedded URL credentials and obvious loopback, private-network, link-local, and local-hostname destinations. ### OAuth Application Tools diff --git a/src/__tests__/oauth-webhook-tools.test.ts b/src/__tests__/oauth-webhook-tools.test.ts index 7256728..fa85188 100644 --- a/src/__tests__/oauth-webhook-tools.test.ts +++ b/src/__tests__/oauth-webhook-tools.test.ts @@ -144,7 +144,10 @@ describe('OAuth application and complete webhook management', () => { 'User', ]); expect(createManaged?.output_schema.properties).toMatchObject({ - application: { type: 'object' }, + application: { + type: 'object', + properties: { developerUrl: { type: 'string' } }, + }, clientSecret: { type: ['string', 'null'] }, webhookSecret: { type: ['string', 'null'] }, }); @@ -156,6 +159,10 @@ describe('OAuth application and complete webhook management', () => { expect(updateWebhook?.input_schema.properties?.label).toMatchObject({ type: ['string', 'null'], }); + expect(updateWebhook?.input_schema.properties?.resourceTypes.items).toEqual({ + type: 'string', + minLength: 1, + }); const clientCredentials = allToolDefinitions.find( (tool) => tool.name === 'linear_createOAuthClientCredentialsToken', ); @@ -307,6 +314,9 @@ describe('OAuth application and complete webhook management', () => { expect(isUpdateWebhookArgs({ id: 'webhook-1', enabled: false })).toBe(true); expect(isUpdateWebhookArgs({ id: 'webhook-1', label: null })).toBe(true); + expect( + isUpdateWebhookArgs({ id: 'webhook-1', resourceTypes: ['FutureWorkspaceEvent'] }), + ).toBe(true); expect(isUpdateWebhookArgs({ id: 'webhook-1' })).toBe(false); expect(isUpdateWebhookArgs({ id: 'webhook-1', resourceTypes: [] })).toBe(false); expect(isRotateWebhookSecretArgs({ id: 'webhook-1', confirmSecretExposure: true })).toBe(true); @@ -356,8 +366,6 @@ describe('OAuth application and complete webhook management', () => { 'https://10.0.0.8/webhooks/linear', 'https://[::1]/webhooks/linear', 'https://user:password@example.com/webhooks/linear', - 'https://linear.app/webhooks/linear', - 'https://api.linear.app/webhooks/linear', ]) { expect( isCreateWebhookArgs({ @@ -386,6 +394,19 @@ describe('OAuth application and complete webhook management', () => { webhookResourceTypes: ['Issue'], }), ).toBe(false); + expect( + isCreateWebhookArgs({ + url: 'https://linear.app/public-webhook-target', + resourceTypes: ['FutureWorkspaceEvent'], + teamId: 'team-1', + }), + ).toBe(true); + expect( + isCreateManagedOAuthApplicationArgs({ + ...createArgs, + webhookResourceTypes: ['FutureWorkspaceEvent'], + }), + ).toBe(false); }); it('generates an official manifest setup link and an OAuth authorization URL without inventing scope CRUD', () => { diff --git a/src/services/linear-service.ts b/src/services/linear-service.ts index 4ce8adb..7d2da2c 100644 --- a/src/services/linear-service.ts +++ b/src/services/linear-service.ts @@ -51,7 +51,7 @@ type ManagedOAuthApplicationNode = { name: string; description?: string | null; developer: string; - developerUrl?: string | null; + developerUrl: string; distribution: 'private' | 'public'; grantTypes: OAuthApplicationGrantType[]; imageUrl?: string | null; @@ -2036,7 +2036,7 @@ export class LinearService { name: application.name, description: application.description ?? null, developer: application.developer, - developerUrl: application.developerUrl ?? null, + developerUrl: application.developerUrl, distribution: application.distribution, grantTypes: [...application.grantTypes], imageUrl: application.imageUrl ?? null, diff --git a/src/tools/definitions/oauth-tools.ts b/src/tools/definitions/oauth-tools.ts index 8fb2825..4f2cc01 100644 --- a/src/tools/definitions/oauth-tools.ts +++ b/src/tools/definitions/oauth-tools.ts @@ -58,7 +58,7 @@ const managedOAuthApplicationOutputSchema = { name: { type: 'string' }, description: { type: ['string', 'null'] }, developer: { type: 'string' }, - developerUrl: { type: ['string', 'null'] }, + developerUrl: { type: 'string' }, distribution: { type: 'string', enum: ['private', 'public'] }, grantTypes: { type: 'array', items: { type: 'string', enum: [...OAUTH_APPLICATION_GRANT_TYPES] } }, imageUrl: { type: ['string', 'null'] }, diff --git a/src/tools/definitions/ops-tools.ts b/src/tools/definitions/ops-tools.ts index ab48317..6ce357d 100644 --- a/src/tools/definitions/ops-tools.ts +++ b/src/tools/definitions/ops-tools.ts @@ -1,5 +1,4 @@ import { MCPToolDefinition } from '../../types.js'; -import { WEBHOOK_RESOURCE_TYPES } from '../oauth-constants.js'; const positiveLimitSchema = { type: 'integer', minimum: 1 }; const paginationOrderBySchema = { type: 'string', enum: ['createdAt', 'updatedAt'] }; @@ -146,9 +145,8 @@ export const createWebhookToolDefinition: MCPToolDefinition = { resourceTypes: { type: 'array', minItems: 1, - maxItems: 22, uniqueItems: true, - items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, + items: { type: 'string', minLength: 1 }, }, teamId: { type: 'string', @@ -185,9 +183,8 @@ export const updateWebhookToolDefinition: MCPToolDefinition = { resourceTypes: { type: 'array', minItems: 1, - maxItems: 22, uniqueItems: true, - items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, + items: { type: 'string', minLength: 1 }, }, secret: { type: 'string', minLength: 1 }, }, diff --git a/src/tools/type-guards.ts b/src/tools/type-guards.ts index 65fd2f3..a1e2419 100644 --- a/src/tools/type-guards.ts +++ b/src/tools/type-guards.ts @@ -1911,10 +1911,7 @@ export function isCreateWebhookArgs(args: unknown): args is { isPublicWebhookUrl((args as { url: unknown }).url) && (args as { url: string }).url.length <= 1000 && 'resourceTypes' in args && - isAllowedUniqueStringArray( - (args as { resourceTypes: unknown }).resourceTypes, - WEBHOOK_RESOURCE_TYPE_SET, - ) && + isUniqueNonEmptyStringArray((args as { resourceTypes: unknown }).resourceTypes) && (!('teamId' in args) || isNonEmptyString((args as { teamId: unknown }).teamId)) && (!('enabled' in args) || typeof (args as { enabled: unknown }).enabled === 'boolean') && (!('label' in args) || isNonEmptyString((args as { label: unknown }).label)) && @@ -2202,7 +2199,7 @@ export function isUpdateWebhookArgs(args: unknown): args is { url?: string; label?: string | null; enabled?: boolean; - resourceTypes?: WebhookResourceTypeValue[]; + resourceTypes?: string[]; secret?: string; } { if ( @@ -2222,8 +2219,7 @@ export function isUpdateWebhookArgs(args: unknown): args is { (!('url' in args) || (isPublicWebhookUrl(args.url) && args.url.length <= 1000)) && (!('label' in args) || args.label === null || isNonEmptyString(args.label)) && (!('enabled' in args) || typeof args.enabled === 'boolean') && - (!('resourceTypes' in args) || - isAllowedUniqueStringArray(args.resourceTypes, WEBHOOK_RESOURCE_TYPE_SET)) && + (!('resourceTypes' in args) || isUniqueNonEmptyStringArray(args.resourceTypes)) && (!('secret' in args) || isNonEmptyString(args.secret)) ); } @@ -3096,6 +3092,15 @@ function isAllowedUniqueStringArray( ); } +function isUniqueNonEmptyStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => isNonEmptyString(item)) && + new Set(value).size === value.length + ); +} + function isAbsoluteHttpUrl(value: unknown, httpsOnly = false): value is string { if (!isNonEmptyString(value)) { return false; @@ -3134,9 +3139,7 @@ function isPublicWebhookUrl(value: unknown): value is string { hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal') || - hostname.endsWith('.home.arpa') || - hostname === 'linear.app' || - hostname.endsWith('.linear.app') + hostname.endsWith('.home.arpa') ) { return false; } From d408b70a724231991c0ee839e913e7c009762dd2 Mon Sep 17 00:00:00 2001 From: itz4blitz Date: Sun, 12 Jul 2026 10:02:43 -0400 Subject: [PATCH 3/3] Tighten review findings: guard placement, schema parity, IP deny-list precision - Validate webhook args before the sanitizing try in create/update handlers so invalid-args failures are labeled as such instead of as omitted Linear errors - State the teamId-XOR-allPublicTeams rule and public-HTTPS requirement in the createWebhook description and encode it as a source-level anyOf - Document the app-only scope and admin/actor rules on generateOAuthAuthorizationUrl - Narrow the IPv4 deny-list from 192.0.0.0/16 to the reserved /24s and add TEST-NET-2/3; derive schema maxItems from the shared constants - Cover the full public-URL deny/allow matrix, token-endpoint HTTP failure and invalid-JSON branches, and invalid-args-with-secret labeling in tests --- src/__tests__/oauth-webhook-tools.test.ts | 98 +++++++++++++++++++++++ src/tools/definitions/oauth-tools.ts | 8 +- src/tools/definitions/ops-tools.ts | 11 ++- src/tools/handlers/ops-handlers.ts | 12 ++- src/tools/type-guards.ts | 7 +- 5 files changed, 127 insertions(+), 9 deletions(-) diff --git a/src/__tests__/oauth-webhook-tools.test.ts b/src/__tests__/oauth-webhook-tools.test.ts index fa85188..ba682cc 100644 --- a/src/__tests__/oauth-webhook-tools.test.ts +++ b/src/__tests__/oauth-webhook-tools.test.ts @@ -409,6 +409,104 @@ describe('OAuth application and complete webhook management', () => { ).toBe(false); }); + it('rejects every non-public webhook destination class and accepts public ones', () => { + const rejectedUrls = [ + 'http://example.com/webhooks/linear', // plain http + 'https://0.0.0.0/webhooks/linear', // "this network" + 'https://100.64.0.1/webhooks/linear', // CGNAT + 'https://100.127.255.254/webhooks/linear', // CGNAT upper bound + 'https://169.254.169.254/webhooks/linear', // link-local / metadata + 'https://172.16.0.1/webhooks/linear', // RFC 1918 + 'https://172.31.255.254/webhooks/linear', // RFC 1918 upper bound + 'https://192.0.0.8/webhooks/linear', // IETF protocol assignments + 'https://192.0.2.1/webhooks/linear', // TEST-NET-1 + 'https://192.168.1.10/webhooks/linear', // RFC 1918 + 'https://198.18.0.1/webhooks/linear', // benchmarking + 'https://198.51.100.7/webhooks/linear', // TEST-NET-2 + 'https://203.0.113.9/webhooks/linear', // TEST-NET-3 + 'https://224.0.0.1/webhooks/linear', // multicast + 'https://255.255.255.255/webhooks/linear', // broadcast + 'https://internal.corp.local/webhooks/linear', // .local + 'https://vault.internal/webhooks/linear', // .internal + 'https://nas.home.arpa/webhooks/linear', // .home.arpa + 'https://sub.example.localhost/webhooks/linear', // .localhost + 'https://localhost./webhooks/linear', // trailing dot + 'https://[::]/webhooks/linear', // unspecified IPv6 + 'https://[fc00::1]/webhooks/linear', // unique local + 'https://[fd12:3456::1]/webhooks/linear', // unique local + 'https://[fe80::1]/webhooks/linear', // link-local IPv6 + 'https://[ff02::1]/webhooks/linear', // multicast IPv6 + 'https://[::ffff:127.0.0.1]/webhooks/linear', // IPv4-mapped + ]; + for (const url of rejectedUrls) { + expect(isCreateWebhookArgs({ url, resourceTypes: ['Issue'], teamId: 'team-1' })).toBe(false); + expect(isUpdateWebhookArgs({ id: 'webhook-1', url })).toBe(false); + } + + const acceptedUrls = [ + 'https://example.com/webhooks/linear', + 'https://192.0.32.10/webhooks/linear', // publicly routable, outside 192.0.0/24 and TEST-NET-1 + 'https://198.20.0.1/webhooks/linear', // outside the 198.18/15 benchmarking block + 'https://[2606:4700::6810:1]/webhooks/linear', // public IPv6 + ]; + for (const url of acceptedUrls) { + expect(isCreateWebhookArgs({ url, resourceTypes: ['Issue'], teamId: 'team-1' })).toBe(true); + expect(isUpdateWebhookArgs({ id: 'webhook-1', url })).toBe(true); + } + }); + + it('reports HTTP failures and invalid JSON from the token endpoint without echoing credentials', async () => { + const fetchSpy = jest.spyOn(global, 'fetch'); + const service = makeService({}); + const request = { + clientId: 'client-1', + clientSecret: 'client-secret-once', + scopes: ['read'] as string[], + }; + + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'invalid_client', error_description: 'client-secret-once' }), + } as Response); + await expect(service.createOAuthClientCredentialsToken(request)).rejects.toThrow( + /^Linear OAuth token request failed \(HTTP 401\)$/, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 502, + json: async () => { + throw new SyntaxError('Unexpected token < in JSON'); + }, + } as unknown as Response); + await expect(service.createOAuthClientCredentialsToken(request)).rejects.toThrow( + /^Linear OAuth token request returned invalid JSON \(HTTP 502\)$/, + ); + }); + + it('labels invalid webhook arguments as invalid even when the request carries a signing secret', async () => { + const service = { + createWebhook: jest.fn(), + updateWebhook: jest.fn(), + } as unknown as LinearService; + const handlers = registerToolHandlers(service); + + await expect( + handlers.linear_createWebhook({ + url: 'https://example.com/webhooks/linear', + resourceTypes: ['Issue'], + teamId: 'team-1', + secret: '', + }), + ).rejects.toThrow('Invalid arguments for createWebhook'); + await expect( + handlers.linear_updateWebhook({ id: 'webhook-1', secret: '' }), + ).rejects.toThrow('Invalid arguments for updateWebhook'); + expect((service.createWebhook as jest.Mock).mock.calls).toHaveLength(0); + expect((service.updateWebhook as jest.Mock).mock.calls).toHaveLength(0); + }); + it('generates an official manifest setup link and an OAuth authorization URL without inventing scope CRUD', () => { const service = makeService({}); const setup = service.generateOAuthApplicationSetup({ diff --git a/src/tools/definitions/oauth-tools.ts b/src/tools/definitions/oauth-tools.ts index 4f2cc01..7c7565f 100644 --- a/src/tools/definitions/oauth-tools.ts +++ b/src/tools/definitions/oauth-tools.ts @@ -21,7 +21,7 @@ const redirectUrisSchema = { const grantTypesSchema = { type: 'array', minItems: 1, - maxItems: 2, + maxItems: OAUTH_APPLICATION_GRANT_TYPES.length, uniqueItems: true, items: { type: 'string', enum: [...OAUTH_APPLICATION_GRANT_TYPES] }, contains: { const: 'authorization_code' }, @@ -30,7 +30,7 @@ const grantTypesSchema = { const webhookResourceTypesSchema = { type: 'array', minItems: 1, - maxItems: 22, + maxItems: WEBHOOK_RESOURCE_TYPES.length, uniqueItems: true, items: { type: 'string', enum: [...WEBHOOK_RESOURCE_TYPES] }, }; @@ -150,7 +150,7 @@ export const generateOAuthApplicationSetupToolDefinition: MCPToolDefinition = { export const generateOAuthAuthorizationUrlToolDefinition: MCPToolDefinition = { name: 'linear_generateOAuthAuthorizationUrl', description: - 'Generate a Linear OAuth authorization URL. Scopes are requested during authorization; this tool does not create, grant, approve, persist, or modify scopes.', + 'Generate a Linear OAuth authorization URL. Scopes are requested during authorization; this tool does not create, grant, approve, persist, or modify scopes. Actor/scope rules: with actor: "app" the admin scope is rejected, and the agent-only scopes (app:assignable, app:mentionable, customer:read, customer:write, initiative:read, initiative:write) require actor: "app".', input_schema: { type: 'object', properties: { @@ -165,6 +165,8 @@ export const generateOAuthAuthorizationUrlToolDefinition: MCPToolDefinition = { minItems: 1, uniqueItems: true, items: { type: 'string', enum: [...OAUTH_AUTHORIZATION_SCOPES] }, + description: + 'Scopes to request. The read scope is always included. app:assignable, app:mentionable, customer:*, and initiative:* scopes require actor: "app"; admin is unavailable with actor: "app".', }, actor: { type: 'string', enum: ['user', 'app'] }, state: { type: 'string', minLength: 1 }, diff --git a/src/tools/definitions/ops-tools.ts b/src/tools/definitions/ops-tools.ts index 6ce357d..c65be4c 100644 --- a/src/tools/definitions/ops-tools.ts +++ b/src/tools/definitions/ops-tools.ts @@ -132,7 +132,8 @@ export const getWebhookByIdToolDefinition: MCPToolDefinition = { }; export const createWebhookToolDefinition: MCPToolDefinition = { name: 'linear_createWebhook', - description: 'Create an ordinary workspace webhook for integration events.', + description: + 'Create an ordinary workspace webhook for integration events. Exactly one of teamId or allPublicTeams: true must be provided. The URL must be a publicly reachable HTTPS endpoint (localhost, private-network, and credential-bearing URLs are rejected).', input_schema: { type: 'object', properties: { @@ -141,6 +142,7 @@ export const createWebhookToolDefinition: MCPToolDefinition = { format: 'uri', pattern: '^[Hh][Tt][Tt][Pp][Ss]://', maxLength: 1000, + description: 'Publicly reachable HTTPS webhook destination.', }, resourceTypes: { type: 'array', @@ -162,7 +164,11 @@ export const createWebhookToolDefinition: MCPToolDefinition = { }, }, required: ['url', 'resourceTypes'], - }, + anyOf: [ + { required: ['teamId'] }, + { properties: { allPublicTeams: { const: true } }, required: ['allPublicTeams'] }, + ], + } as MCPToolDefinition['input_schema'], output_schema: webhookOutputSchema, }; export const updateWebhookToolDefinition: MCPToolDefinition = { @@ -177,6 +183,7 @@ export const updateWebhookToolDefinition: MCPToolDefinition = { format: 'uri', pattern: '^[Hh][Tt][Tt][Pp][Ss]://', maxLength: 1000, + description: 'Publicly reachable HTTPS webhook destination.', }, label: { type: ['string', 'null'], minLength: 1 }, enabled: { type: 'boolean' }, diff --git a/src/tools/handlers/ops-handlers.ts b/src/tools/handlers/ops-handlers.ts index 5c44a63..240117f 100644 --- a/src/tools/handlers/ops-handlers.ts +++ b/src/tools/handlers/ops-handlers.ts @@ -72,8 +72,12 @@ export function handleGetWebhooks(linearService: LinearService) { } export function handleCreateWebhook(linearService: LinearService) { return async (args: unknown) => { + if (!isCreateWebhookArgs(args)) { + const error = new Error('Invalid arguments for createWebhook'); + logError('Error creating webhook', error); + throw error; + } try { - if (!isCreateWebhookArgs(args)) throw new Error('Invalid arguments for createWebhook'); return await linearService.createWebhook(args); } catch (error) { rethrowWebhookErrorWithoutSecret('creating', error, args); @@ -82,8 +86,12 @@ export function handleCreateWebhook(linearService: LinearService) { } export function handleUpdateWebhook(linearService: LinearService) { return async (args: unknown) => { + if (!isUpdateWebhookArgs(args)) { + const error = new Error('Invalid arguments for updateWebhook'); + logError('Error updating webhook', error); + throw error; + } try { - if (!isUpdateWebhookArgs(args)) throw new Error('Invalid arguments for updateWebhook'); return await linearService.updateWebhook(args); } catch (error) { rethrowWebhookErrorWithoutSecret('updating', error, args); diff --git a/src/tools/type-guards.ts b/src/tools/type-guards.ts index a1e2419..3b58776 100644 --- a/src/tools/type-guards.ts +++ b/src/tools/type-guards.ts @@ -3146,7 +3146,7 @@ function isPublicWebhookUrl(value: unknown): value is string { const ipVersion = isIP(hostname); if (ipVersion === 4) { - const [first, second] = hostname.split('.').map(Number); + const [first, second, third] = hostname.split('.').map(Number); return !( first === 0 || first === 10 || @@ -3154,8 +3154,11 @@ function isPublicWebhookUrl(value: unknown): value is string { (first === 100 && second >= 64 && second <= 127) || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || - (first === 192 && (second === 0 || second === 168)) || + (first === 192 && second === 0 && (third === 0 || third === 2)) || + (first === 192 && second === 168) || (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || first >= 224 ); }