Hi Pi! Thomas sent me this link and asked you to set up the Slack MCP integration for me.
Please do the following, step by step:
- Check that
~/.claude/.credentials.jsonexists and contains amcpOAuthsection with aplugin:slack:slack|38801a7d845718b3key. If it's missing, let me know — I need to authenticate Slack in Claude Code first (install the slack plugin at https://claude.ai/download, enable it, and sign in). - Check that my pi-config package location exists (look in
~/.pi/agent/settings.jsonfor the packages list, then find the git-cloned location under~/.pi/agent/git/).
Create the file extensions/slack-mcp/index.ts inside my pi-config package directory with the following content:
/**
* Slack MCP Extension for Pi
*
* Bridges the Slack MCP HTTP server (https://mcp.slack.com/mcp) into Pi
* as native Pi tools. Reads OAuth credentials from ~/.claude/.credentials.json
* and auto-refreshes the token when it expires.
*
* Credentials source: Claude Code's stored Slack OAuth token (same app/scope).
* Client ID matches the Slack app used by Claude Code's slack plugin.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
// ─── Constants ───────────────────────────────────────────────────────────────
const MCP_URL = "https://mcp.slack.com/mcp";
const TOKEN_ENDPOINT = "https://slack.com/api/oauth.v2.user.access";
const CLIENT_ID = "1601185624273.8899143856786";
/** Key in ~/.claude/.credentials.json under mcpOAuth */
const CRED_KEY = "plugin:slack:slack|38801a7d845718b3";
const CREDS_FILE = path.join(os.homedir(), ".claude", ".credentials.json");
// Refresh 5 minutes before actual expiry
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
// ─── Types ────────────────────────────────────────────────────────────────────
interface SlackOAuthCredentials {
accessToken: string;
refreshToken: string;
expiresAt: number;
scope?: string;
discoveryState?: unknown;
}
interface ClaudeCredentials {
mcpOAuth?: Record<string, SlackOAuthCredentials>;
}
interface McpTool {
name: string;
title?: string;
description: string;
inputSchema: {
type: string;
properties?: Record<string, unknown>;
required?: string[];
};
}
// ─── Credential Management ────────────────────────────────────────────────────
function readCredentials(): SlackOAuthCredentials | null {
try {
const raw = fs.readFileSync(CREDS_FILE, "utf8");
const parsed: ClaudeCredentials = JSON.parse(raw);
return parsed?.mcpOAuth?.[CRED_KEY] ?? null;
} catch {
return null;
}
}
function writeCredentials(creds: SlackOAuthCredentials): void {
try {
const raw = fs.readFileSync(CREDS_FILE, "utf8");
const parsed: ClaudeCredentials = JSON.parse(raw);
if (!parsed.mcpOAuth) parsed.mcpOAuth = {};
parsed.mcpOAuth[CRED_KEY] = creds;
fs.writeFileSync(CREDS_FILE, JSON.stringify(parsed), "utf8");
} catch (e) {
console.error("[slack-mcp] Failed to persist refreshed token:", e);
}
}
async function refreshToken(refreshToken: string): Promise<SlackOAuthCredentials> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
});
const resp = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) {
throw new Error(`Token refresh HTTP error: ${resp.status}`);
}
const data = (await resp.json()) as {
ok: boolean;
error?: string;
access_token?: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
};
if (!data.ok || !data.access_token) {
throw new Error(`Token refresh failed: ${data.error ?? "unknown error"}`);
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresAt: Date.now() + (data.expires_in ?? 43200) * 1000,
scope: data.scope,
};
}
// In-memory token cache
let tokenCache: SlackOAuthCredentials | null = null;
let refreshPromise: Promise<string> | null = null;
async function getAccessToken(): Promise<string> {
if (refreshPromise) return refreshPromise;
const now = Date.now();
if (tokenCache && tokenCache.expiresAt - REFRESH_BUFFER_MS > now) {
return tokenCache.accessToken;
}
const diskCreds = readCredentials();
if (!diskCreds) {
throw new Error(
"No Slack credentials found. Please ensure you've logged in to Slack in Claude Code first (slack plugin must be enabled and authenticated).",
);
}
if (diskCreds.expiresAt - REFRESH_BUFFER_MS > now) {
tokenCache = diskCreds;
return tokenCache.accessToken;
}
refreshPromise = (async () => {
try {
const newCreds = await refreshToken(diskCreds.refreshToken);
writeCredentials(newCreds);
tokenCache = newCreds;
return newCreds.accessToken;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
// ─── MCP Client ───────────────────────────────────────────────────────────────
interface McpSession {
sessionId: string;
accessToken: string;
initializedAt: number;
}
let mcpSession: McpSession | null = null;
const SESSION_TTL_MS = 22 * 60 * 60 * 1000;
async function getMcpSession(accessToken: string): Promise<McpSession> {
const now = Date.now();
if (
mcpSession &&
mcpSession.accessToken === accessToken &&
now - mcpSession.initializedAt < SESSION_TTL_MS
) {
return mcpSession;
}
const resp = await fetch(MCP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "pi-coding-agent", version: "1.0" },
},
}),
});
if (!resp.ok) {
throw new Error(`MCP initialize failed: ${resp.status}`);
}
const sessionId = resp.headers.get("mcp-session-id");
if (!sessionId) {
throw new Error("MCP server did not return a session ID");
}
mcpSession = { sessionId, accessToken, initializedAt: now };
return mcpSession;
}
async function mcpCall<T>(method: string, params: unknown = {}): Promise<T> {
const accessToken = await getAccessToken();
const session = await getMcpSession(accessToken);
const resp = await fetch(MCP_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
"mcp-session-id": session.sessionId,
Accept: "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: Math.floor(Math.random() * 100000),
method,
params,
}),
});
if (!resp.ok) {
if (resp.status === 404 || resp.status === 401) {
mcpSession = null;
tokenCache = null;
return mcpCall<T>(method, params);
}
throw new Error(`MCP call ${method} failed: ${resp.status}`);
}
const data = (await resp.json()) as { result?: T; error?: { message: string } };
if (data.error) {
throw new Error(`MCP error: ${data.error.message}`);
}
return data.result as T;
}
// ─── Tool Registration ────────────────────────────────────────────────────────
function buildParameterSchema(inputSchema: McpTool["inputSchema"]) {
const props: Record<string, unknown> = {};
for (const [key, schemaDef] of Object.entries(inputSchema.properties ?? {})) {
const def = schemaDef as Record<string, unknown>;
const description = typeof def.description === "string" ? def.description : undefined;
props[key] = description ? Type.Any({ description }) : Type.Any();
}
const required = inputSchema.required ?? [];
const finalProps: Record<string, unknown> = {};
for (const
The prompt seems to be cut off :(
Also, Claude seems to switch the credential store from file to a keychain. Now you can fetch it via