Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ npm install @doist/comms-mcp
Example with [Vercel's AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#streamtext):

```js
import { fetchInbox, reply, markDone } from '@doist/comms-mcp'
import { configureBaseUrl, fetchInbox, reply, markDone } from '@doist/comms-mcp'
import { streamText } from 'ai'

// Required if your CommsApi targets staging / a custom deployment.
// `getMcpServer` calls this for you; standalone tool consumers must.
configureBaseUrl(process.env.COMMS_BASE_URL)

const result = streamText({
model: yourModel,
system: 'You are a helpful Comms assistant',
Expand Down Expand Up @@ -79,9 +83,30 @@ Add to `claude_desktop_config.json`:

#### Claude Code (CLI)

Don't pass the token via `-e KEY=VAL` — even via shell expansion, the
Comment thread
amix marked this conversation as resolved.
resolved value lands in `claude mcp add`'s argv and shows up in `ps`
output. Use a wrapper script that loads a `chmod 600` env file at
runtime:

```bash
claude mcp add comms npx @doist/comms-mcp
export COMMS_API_KEY=your-comms-api-key-here
# 1. Token in a private env file (never in shell history or argv).
install -m 600 /dev/null ~/.config/comms-mcp.env
$EDITOR ~/.config/comms-mcp.env
# Contents: COMMS_API_KEY=...

# 2. Wrapper script reads it at spawn time.
mkdir -p ~/.local/bin
cat > ~/.local/bin/comms-mcp <<'EOF'
#!/bin/sh
set -a
. ~/.config/comms-mcp.env
set +a
exec npx -y @doist/comms-mcp
EOF
chmod +x ~/.local/bin/comms-mcp

# 3. Register the wrapper — no -e flags, no secrets in argv.
claude mcp add comms -s user -- ~/.local/bin/comms-mcp
```

#### Visual Studio Code
Expand All @@ -106,15 +131,34 @@ export COMMS_API_KEY=your-comms-api-key-here
### Targeting a non-production deployment

By default the server talks to `https://comms.todoist.com`. To point at
staging or a custom deployment, also set `COMMS_BASE_URL`:
staging or a custom deployment, also set `COMMS_BASE_URL`. The staging
token and the prod token are different — a staging token will 403
against prod and vice versa.

For Claude Desktop / Cursor / VS Code:

```json
"env": {
"COMMS_API_KEY": "your-comms-api-key-here",
"COMMS_API_KEY": "your-staging-api-key-here",
"COMMS_BASE_URL": "https://comms.staging.todoist.com"
}
```

For Claude Code: extend the wrapper script from the Claude Code setup
above to also set `COMMS_BASE_URL`:

```sh
#!/bin/sh
set -a
. ~/.config/comms-mcp.env # contains COMMS_API_KEY=...
COMMS_BASE_URL=https://comms.staging.todoist.com
set +a
exec npx -y @doist/comms-mcp
```

The server logs `Comms MCP targeting <baseUrl>` to stderr on startup so
you can confirm at a glance which environment it's hitting.

### Getting a Comms API key

Generate a personal API token from the Comms app console, then export
Expand Down
16 changes: 10 additions & 6 deletions scripts/run-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import { reply } from '../src/tools/reply.js'
import { searchContent } from '../src/tools/search-content.js'
import { updateObject } from '../src/tools/update-object.js'
import { userInfo } from '../src/tools/user-info.js'
import { buildServerOptions } from '../src/utils/server-options.js'
import { configureBaseUrl } from '../src/utils/url-helpers.js'

// Define a minimal type for tool execution that works with any tool
type ExecutableTool = {
Expand Down Expand Up @@ -137,14 +139,16 @@ async function main() {
process.exit(1)
}

const apiKey = process.env.COMMS_API_KEY
if (!apiKey) {
console.error('COMMS_API_KEY not found in environment or .env file')
let commsApiKey: string
let baseUrl: string | undefined
try {
;({ commsApiKey, baseUrl } = buildServerOptions())
} catch (e) {
console.error(e instanceof Error ? e.message : String(e))
process.exit(1)
}

const baseUrl = process.env.COMMS_BASE_URL
const client = new CommsApi(apiKey, { baseUrl })
configureBaseUrl(baseUrl)
const client = new CommsApi(commsApiKey, { baseUrl })

console.log(`Running ${toolName} with args:`)
console.log(JSON.stringify(parsedArgs, null, 2))
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { reply } from './tools/reply.js'
import { searchContent } from './tools/search-content.js'
import { updateObject } from './tools/update-object.js'
import { userInfo } from './tools/user-info.js'
import { configureBaseUrl } from './utils/url-helpers.js'

const tools = {
userInfo,
Expand All @@ -33,7 +34,7 @@ const tools = {
getGroups,
}

export { tools, getMcpServer }
export { tools, getMcpServer, configureBaseUrl }

export {
userInfo,
Expand Down
22 changes: 15 additions & 7 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import dotenv from 'dotenv'
import { getMcpServer } from './mcp-server.js'
import { buildServerOptions } from './utils/server-options.js'

function main() {
const commsApiKey = process.env.COMMS_API_KEY
if (!commsApiKey) {
throw new Error('COMMS_API_KEY is not set')
}

const server = getMcpServer({ commsApiKey })
const options = buildServerOptions()
// Structured stderr log (stdout is reserved for the MCP protocol).
// Surfacing the target up-front turns "why am I getting 403s"
// debugging into one machine-parsable line — staging vs prod
// tokens aren't cross-compatible.
console.error(
JSON.stringify({
level: 'info',
event: 'startup',
base_url: options.baseUrl ?? 'https://comms.todoist.com',
base_url_source: options.baseUrl ? 'env' : 'default',
}),
)
const server = getMcpServer(options)
const transport = new StdioServerTransport()
server
.connect(transport)
.then(() => {
// We use console.error because standard I/O is being used for the MCP server communication.
console.error('Server started')
})
.catch((error) => {
Expand Down
9 changes: 8 additions & 1 deletion src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { reply } from './tools/reply.js'
import { searchContent } from './tools/search-content.js'
import { updateObject } from './tools/update-object.js'
import { userInfo } from './tools/user-info.js'
import type { ServerOptions } from './utils/server-options.js'
import { configureBaseUrl } from './utils/url-helpers.js'

const instructions = `
## Comms Communication Tools
Expand Down Expand Up @@ -58,7 +60,12 @@ Always provide clear context and maintain professional communication standards.
* @param baseUrl - Optional base URL for the Comms API.
* @returns the MCP server.
*/
function getMcpServer({ commsApiKey, baseUrl }: { commsApiKey: string; baseUrl?: string }) {
function getMcpServer({ commsApiKey, baseUrl }: ServerOptions) {
// Set up host rewriting for SDK + helper URLs before any tool runs.
// Idempotent and applies to every consumer of getMcpServer (CLI,
// importable-tools, future entry points).
configureBaseUrl(baseUrl)

const server = new McpServer(
{ name: 'comms-mcp-server', version: '0.1.0' },
{
Expand Down
68 changes: 58 additions & 10 deletions src/tools/__tests__/fetch-inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CommsApi } from '@doist/comms-sdk'
import { jest } from '@jest/globals'
import { extractTextContent, TEST_IDS } from '../../utils/test-helpers.js'
import { ToolNames } from '../../utils/tool-names.js'
import { configureBaseUrl } from '../../utils/url-helpers.js'
import { fetchInbox } from '../fetch-inbox.js'

// Mock the Comms API
Expand Down Expand Up @@ -136,7 +137,12 @@ describe(`${FETCH_INBOX} tool`, () => {
if (threads?.[0] && threads[1]) {
expect(threads[0].id).toBe(TEST_IDS.THREAD_1)
expect(threads[0].channelName).toBe('Test Channel')
expect(threads[0].threadUrl).toContain('comms.todoist.com')
// toBe (not toContain): 'comms.staging.todoist.com' contains
// 'comms.todoist.com', so toContain wouldn't catch a rewrite
// regression on a staging-targeted run.
expect(threads[0].threadUrl).toBe(
`https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`,
)
expect(threads[0].isUnread).toBe(true)
expect(threads[1].isStarred).toBe(true)
}
Expand Down Expand Up @@ -329,7 +335,9 @@ describe(`${FETCH_INBOX} tool`, () => {
expect(conversations[0].id).toBe(TEST_IDS.CONVERSATION_1)
expect(conversations[0].participantNames).toEqual(['Alice', 'Bob'])
expect(conversations[0].isUnread).toBe(true)
expect(conversations[0].conversationUrl).toContain('comms.todoist.com')
expect(conversations[0].conversationUrl).toBe(
`https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/`,
)
expect(conversations[1].title).toBe('Project Discussion')
}
})
Expand Down Expand Up @@ -540,10 +548,9 @@ describe(`${FETCH_INBOX} tool`, () => {
const { structuredContent } = result
expect(structuredContent?.threads).toHaveLength(1)
const threadUrl = structuredContent?.threads?.[0]?.threadUrl
expect(threadUrl).toBeDefined()
expect(typeof threadUrl).toBe('string')
expect(threadUrl).toContain('comms.todoist.com')
expect(threadUrl).toContain(String(TEST_IDS.THREAD_1))
expect(threadUrl).toBe(
`https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`,
)
})

it('should construct conversationUrl via getFullCommsURL when SDK omits url field', async () => {
Expand Down Expand Up @@ -607,10 +614,9 @@ describe(`${FETCH_INBOX} tool`, () => {
const { structuredContent } = result
expect(structuredContent?.conversations).toHaveLength(1)
const conversationUrl = structuredContent?.conversations?.[0]?.conversationUrl
expect(conversationUrl).toBeDefined()
expect(typeof conversationUrl).toBe('string')
expect(conversationUrl).toContain('comms.todoist.com')
expect(conversationUrl).toContain(String(TEST_IDS.CONVERSATION_1))
expect(conversationUrl).toBe(
`https://comms.todoist.com/a/${TEST_IDS.WORKSPACE_1}/msg/${TEST_IDS.CONVERSATION_1}/`,
)
})
})

Expand All @@ -627,4 +633,46 @@ describe(`${FETCH_INBOX} tool`, () => {
).rejects.toThrow('API Error: Unauthorized')
})
})

// The unit tests for url-helpers cover the rewrite mechanism in
// isolation; this case covers it end-to-end at the tool boundary,
// which is the layer that previously regressed and shipped prod
// links from a staging-targeted server.
describe('staging baseUrl rewrites tool output', () => {
afterEach(() => {
configureBaseUrl(undefined)
})

it('rewrites threadUrl and unreadThreads[*].url to the configured host', async () => {
configureBaseUrl('https://comms.staging.todoist.com')

mockCommsApi.inbox.getInbox.mockResolvedValue([makeInboxThread()])
mockCommsApi.inbox.getCount.mockResolvedValue(1)
mockCommsApi.threads.getUnread.mockResolvedValue({
data: [
{
threadId: TEST_IDS.THREAD_1,
channelId: TEST_IDS.CHANNEL_1,
objIndex: 1,
directMention: false,
},
],
version: 1,
})
mockCommsApi.conversations.getUnread.mockResolvedValue({ data: [], version: 1 })
mockCommsApi.channels.getChannel.mockResolvedValue(makeChannel())

const result = await fetchInbox.execute(
{ workspaceId: TEST_IDS.WORKSPACE_1, limit: 50, onlyUnread: false },
mockCommsApi,
)

const { structuredContent } = result
const stagingThreadUrl = `https://comms.staging.todoist.com/a/${TEST_IDS.WORKSPACE_1}/ch/${TEST_IDS.CHANNEL_1}/t/${TEST_IDS.THREAD_1}/`
expect(structuredContent?.threads?.[0]?.threadUrl).toBe(stagingThreadUrl)
// unreadThreads is the raw SDK array; its `.url` must also
// be rewritten or the structured payload is inconsistent.
expect(structuredContent?.unreadThreads?.[0]?.url).toBe(stagingThreadUrl)
})
})
})
13 changes: 9 additions & 4 deletions src/tools/build-link.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { getCommentURL, getFullCommsURL, getMessageURL } from '@doist/comms-sdk'
import { z } from 'zod'
import type { CommsTool } from '../comms-tool.js'
import { getToolOutput } from '../mcp-helpers.js'
import { BuildLinkOutputSchema } from '../utils/output-schemas.js'
import { ToolNames } from '../utils/tool-names.js'
import {
getCommentURL,
getFullCommsURL,
getMessageURL,
toRelativeCommsURL,
} from '../utils/url-helpers.js'

const ArgsSchema = {
workspaceId: z.number().describe('The workspace ID.'),
Expand All @@ -26,7 +31,7 @@ const ArgsSchema = {
.optional()
.default(true)
.describe(
'Whether to return a full URL (with https://comms.todoist.com) or relative path.',
'Whether to return a full URL (including the configured host) or a relative path.',
),
}

Expand Down Expand Up @@ -71,7 +76,7 @@ const buildLink = {
const params = { workspaceId, conversationId }
url = fullUrl
? getFullCommsURL(params)
: getFullCommsURL(params).replace('https://comms.todoist.com', '')
: toRelativeCommsURL(getFullCommsURL(params))
}
} else if (threadId !== undefined) {
if (commentId !== undefined) {
Expand All @@ -90,7 +95,7 @@ const buildLink = {
: { workspaceId, threadId }
url = fullUrl
? getFullCommsURL(params)
: getFullCommsURL(params).replace('https://comms.todoist.com', '')
: toRelativeCommsURL(getFullCommsURL(params))
}
} else {
throw new Error('Must provide either conversationId OR threadId to build a link')
Expand Down
15 changes: 8 additions & 7 deletions src/tools/create-thread.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getFullCommsURL } from '@doist/comms-sdk'
import { z } from 'zod'
import type { CommsTool } from '../comms-tool.js'
import { getToolOutput } from '../mcp-helpers.js'
import { type CreateThreadOutput, CreateThreadOutputSchema } from '../utils/output-schemas.js'
import { ToolNames } from '../utils/tool-names.js'
import { getFullCommsURL, rewriteToConfiguredHost } from '../utils/url-helpers.js'

const ArgsSchema = {
channelId: z.string().describe('The ID of the channel to create the thread in.'),
Expand Down Expand Up @@ -48,13 +48,14 @@ const createThread = {
: postedValue
: new Date()

const threadUrl =
const threadUrl = rewriteToConfiguredHost(
thread.url ??
getFullCommsURL({
workspaceId: thread.workspaceId,
channelId: thread.channelId,
threadId: thread.id,
})
getFullCommsURL({
workspaceId: thread.workspaceId,
channelId: thread.channelId,
threadId: thread.id,
}),
)

const lines: string[] = [
`# Thread Created`,
Expand Down
Loading
Loading