Skip to content

Commit 86d7fef

Browse files
dcramercodex
andcommitted
feat(mcp): Add plugin-scoped MCP tool support
Add plugin manifest support for HTTP MCP servers and a turn-local MCP tool manager that progressively exposes plugin tools when matching skills load. Pause turns on MCP auth challenges and resume the same session after the callback instead of failing the turn. Persist loaded skills and active MCP providers across resume, and align the resumed callback path with normal reply delivery and thread-state persistence so Slack state stays consistent. Co-Authored-By: Codex <noreply@openai.com>
1 parent c19f9ac commit 86d7fef

25 files changed

Lines changed: 3710 additions & 162 deletions

packages/junior/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@chat-adapter/state-redis": "4.17.0",
4343
"@mariozechner/pi-agent-core": "^0.56.3",
4444
"@mariozechner/pi-ai": "^0.56.3",
45+
"@modelcontextprotocol/sdk": "1.27.1",
4546
"@sinclair/typebox": "^0.34.48",
4647
"@slack/web-api": "^7.14.1",
4748
"@vercel/queue": "^0.1.3",
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import type {
2+
OAuthClientInformationMixed,
3+
OAuthTokens,
4+
} from "@modelcontextprotocol/sdk/shared/auth.js";
5+
import type { OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
6+
import type { ThreadArtifactsState } from "@/chat/slack-actions/types";
7+
import { getStateAdapter } from "@/chat/state";
8+
9+
const MCP_AUTH_SESSION_PREFIX = "junior:mcp_auth_session";
10+
const MCP_AUTH_CREDENTIALS_PREFIX = "junior:mcp_auth_credentials";
11+
const MCP_AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
12+
const MCP_AUTH_CREDENTIALS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13+
14+
export interface McpAuthSessionState {
15+
authSessionId: string;
16+
provider: string;
17+
userId: string;
18+
conversationId: string;
19+
sessionId: string;
20+
userMessage: string;
21+
channelId?: string;
22+
threadTs?: string;
23+
toolChannelId?: string;
24+
configuration?: Record<string, unknown>;
25+
artifactState?: ThreadArtifactsState;
26+
authorizationUrl?: string;
27+
codeVerifier?: string;
28+
createdAtMs: number;
29+
updatedAtMs: number;
30+
}
31+
32+
export interface McpStoredOAuthCredentials {
33+
clientInformation?: OAuthClientInformationMixed;
34+
discoveryState?: OAuthDiscoveryState;
35+
tokens?: OAuthTokens;
36+
}
37+
38+
function sessionKey(authSessionId: string): string {
39+
return `${MCP_AUTH_SESSION_PREFIX}:${authSessionId}`;
40+
}
41+
42+
function credentialsKey(userId: string, provider: string): string {
43+
return `${MCP_AUTH_CREDENTIALS_PREFIX}:${userId}:${provider}`;
44+
}
45+
46+
function isRecord(value: unknown): value is Record<string, unknown> {
47+
return typeof value === "object" && value !== null;
48+
}
49+
50+
function parseMcpAuthSession(value: unknown): McpAuthSessionState | undefined {
51+
if (typeof value !== "string") {
52+
return undefined;
53+
}
54+
55+
try {
56+
const parsed = JSON.parse(value) as Record<string, unknown>;
57+
if (!isRecord(parsed)) {
58+
return undefined;
59+
}
60+
61+
if (
62+
typeof parsed.authSessionId !== "string" ||
63+
typeof parsed.provider !== "string" ||
64+
typeof parsed.userId !== "string" ||
65+
typeof parsed.conversationId !== "string" ||
66+
typeof parsed.sessionId !== "string" ||
67+
typeof parsed.userMessage !== "string" ||
68+
typeof parsed.createdAtMs !== "number" ||
69+
typeof parsed.updatedAtMs !== "number"
70+
) {
71+
return undefined;
72+
}
73+
74+
return {
75+
authSessionId: parsed.authSessionId,
76+
provider: parsed.provider,
77+
userId: parsed.userId,
78+
conversationId: parsed.conversationId,
79+
sessionId: parsed.sessionId,
80+
userMessage: parsed.userMessage,
81+
createdAtMs: parsed.createdAtMs,
82+
updatedAtMs: parsed.updatedAtMs,
83+
...(typeof parsed.channelId === "string"
84+
? { channelId: parsed.channelId }
85+
: {}),
86+
...(typeof parsed.threadTs === "string"
87+
? { threadTs: parsed.threadTs }
88+
: {}),
89+
...(typeof parsed.toolChannelId === "string"
90+
? { toolChannelId: parsed.toolChannelId }
91+
: {}),
92+
...(isRecord(parsed.configuration)
93+
? { configuration: parsed.configuration }
94+
: {}),
95+
...(isRecord(parsed.artifactState)
96+
? { artifactState: parsed.artifactState as ThreadArtifactsState }
97+
: {}),
98+
...(typeof parsed.authorizationUrl === "string"
99+
? { authorizationUrl: parsed.authorizationUrl }
100+
: {}),
101+
...(typeof parsed.codeVerifier === "string"
102+
? { codeVerifier: parsed.codeVerifier }
103+
: {}),
104+
};
105+
} catch {
106+
return undefined;
107+
}
108+
}
109+
110+
function parseStoredCredentials(
111+
value: unknown,
112+
): McpStoredOAuthCredentials | undefined {
113+
if (typeof value !== "string") {
114+
return undefined;
115+
}
116+
117+
try {
118+
const parsed = JSON.parse(value) as Record<string, unknown>;
119+
if (!isRecord(parsed)) {
120+
return undefined;
121+
}
122+
123+
return {
124+
...(isRecord(parsed.clientInformation)
125+
? {
126+
clientInformation:
127+
parsed.clientInformation as OAuthClientInformationMixed,
128+
}
129+
: {}),
130+
...(isRecord(parsed.discoveryState)
131+
? {
132+
discoveryState:
133+
parsed.discoveryState as unknown as OAuthDiscoveryState,
134+
}
135+
: {}),
136+
...(isRecord(parsed.tokens)
137+
? { tokens: parsed.tokens as OAuthTokens }
138+
: {}),
139+
};
140+
} catch {
141+
return undefined;
142+
}
143+
}
144+
145+
export async function getMcpAuthSession(
146+
authSessionId: string,
147+
): Promise<McpAuthSessionState | undefined> {
148+
await getStateAdapter().connect();
149+
return parseMcpAuthSession(
150+
await getStateAdapter().get(sessionKey(authSessionId)),
151+
);
152+
}
153+
154+
export async function putMcpAuthSession(
155+
session: McpAuthSessionState,
156+
ttlMs: number = MCP_AUTH_SESSION_TTL_MS,
157+
): Promise<void> {
158+
await getStateAdapter().connect();
159+
await getStateAdapter().set(
160+
sessionKey(session.authSessionId),
161+
JSON.stringify(session),
162+
ttlMs,
163+
);
164+
}
165+
166+
export async function patchMcpAuthSession(
167+
authSessionId: string,
168+
patch: Partial<McpAuthSessionState>,
169+
): Promise<McpAuthSessionState> {
170+
const current = await getMcpAuthSession(authSessionId);
171+
if (!current) {
172+
throw new Error(`Unknown MCP auth session: ${authSessionId}`);
173+
}
174+
175+
const next: McpAuthSessionState = {
176+
...current,
177+
...patch,
178+
authSessionId: current.authSessionId,
179+
provider: current.provider,
180+
userId: current.userId,
181+
conversationId: current.conversationId,
182+
sessionId: current.sessionId,
183+
userMessage: current.userMessage,
184+
createdAtMs: current.createdAtMs,
185+
updatedAtMs: Date.now(),
186+
};
187+
await putMcpAuthSession(next);
188+
return next;
189+
}
190+
191+
export async function deleteMcpAuthSession(
192+
authSessionId: string,
193+
): Promise<void> {
194+
await getStateAdapter().connect();
195+
await getStateAdapter().delete(sessionKey(authSessionId));
196+
}
197+
198+
export async function getMcpStoredOAuthCredentials(
199+
userId: string,
200+
provider: string,
201+
): Promise<McpStoredOAuthCredentials | undefined> {
202+
await getStateAdapter().connect();
203+
return parseStoredCredentials(
204+
await getStateAdapter().get(credentialsKey(userId, provider)),
205+
);
206+
}
207+
208+
export async function putMcpStoredOAuthCredentials(
209+
userId: string,
210+
provider: string,
211+
value: McpStoredOAuthCredentials,
212+
ttlMs: number = MCP_AUTH_CREDENTIALS_TTL_MS,
213+
): Promise<void> {
214+
await getStateAdapter().connect();
215+
await getStateAdapter().set(
216+
credentialsKey(userId, provider),
217+
JSON.stringify(value),
218+
ttlMs,
219+
);
220+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client";
2+
import {
3+
UnauthorizedError,
4+
type OAuthClientProvider,
5+
} from "@modelcontextprotocol/sdk/client/auth.js";
6+
import {
7+
StreamableHTTPClientTransport,
8+
StreamableHTTPError,
9+
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
10+
import type { PluginDefinition } from "@/chat/plugins/types";
11+
12+
type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
13+
type ToolCallResult = Awaited<ReturnType<Client["callTool"]>>;
14+
15+
const MCP_CLIENT_INFO = {
16+
name: "junior-mcp-client",
17+
version: "1.0.0",
18+
};
19+
20+
export class McpAuthorizationRequiredError extends Error {
21+
readonly provider: string;
22+
23+
constructor(provider: string, message: string) {
24+
super(message);
25+
this.name = "McpAuthorizationRequiredError";
26+
this.provider = provider;
27+
}
28+
}
29+
30+
export interface PluginMcpClientOptions {
31+
authProvider?: OAuthClientProvider;
32+
fetch?: typeof fetch;
33+
sessionId?: string;
34+
}
35+
36+
export class PluginMcpClient {
37+
private client?: Client;
38+
private transport?: StreamableHTTPClientTransport;
39+
private listedTools?: ListedTool[];
40+
41+
constructor(
42+
private readonly plugin: PluginDefinition,
43+
private readonly options: PluginMcpClientOptions = {},
44+
) {}
45+
46+
async listTools(): Promise<ListedTool[]> {
47+
if (this.listedTools) {
48+
return [...this.listedTools];
49+
}
50+
51+
const client = await this.getClient();
52+
const discovered: ListedTool[] = [];
53+
const seen = new Set<string>();
54+
let cursor: string | undefined;
55+
56+
do {
57+
const result = await this.wrapAuth(
58+
client.listTools(cursor ? { cursor } : undefined),
59+
);
60+
for (const tool of result.tools) {
61+
if (seen.has(tool.name)) {
62+
continue;
63+
}
64+
seen.add(tool.name);
65+
discovered.push(tool);
66+
}
67+
cursor = result.nextCursor;
68+
} while (cursor);
69+
70+
this.listedTools = discovered.sort((left, right) =>
71+
left.name.localeCompare(right.name),
72+
);
73+
return [...this.listedTools];
74+
}
75+
76+
async callTool(
77+
name: string,
78+
args: Record<string, unknown> | undefined,
79+
): Promise<ToolCallResult> {
80+
const client = await this.getClient();
81+
return await this.wrapAuth(
82+
client.callTool({
83+
name,
84+
...(args && Object.keys(args).length > 0 ? { arguments: args } : {}),
85+
}),
86+
);
87+
}
88+
89+
async close(): Promise<void> {
90+
this.listedTools = undefined;
91+
92+
const transport = this.transport;
93+
this.transport = undefined;
94+
this.client = undefined;
95+
96+
if (transport) {
97+
await transport.close();
98+
}
99+
}
100+
101+
private async getClient(): Promise<Client> {
102+
if (this.client) {
103+
return this.client;
104+
}
105+
106+
const mcp = this.plugin.manifest.mcp;
107+
if (!mcp) {
108+
throw new Error(
109+
`Plugin "${this.plugin.manifest.name}" does not declare MCP config`,
110+
);
111+
}
112+
113+
const requestInit: RequestInit = {};
114+
if (mcp.headers && Object.keys(mcp.headers).length > 0) {
115+
requestInit.headers = new Headers(mcp.headers);
116+
}
117+
118+
const transport = new StreamableHTTPClientTransport(new URL(mcp.url), {
119+
...(Object.keys(requestInit).length > 0 ? { requestInit } : {}),
120+
...(this.options.fetch ? { fetch: this.options.fetch } : {}),
121+
...(this.options.authProvider
122+
? { authProvider: this.options.authProvider }
123+
: {}),
124+
...(this.options.sessionId ? { sessionId: this.options.sessionId } : {}),
125+
});
126+
const client = new Client(MCP_CLIENT_INFO, {
127+
capabilities: {},
128+
});
129+
130+
await this.wrapAuth(client.connect(transport));
131+
132+
this.transport = transport;
133+
this.client = client;
134+
return client;
135+
}
136+
137+
private async wrapAuth<T>(promise: Promise<T>): Promise<T> {
138+
try {
139+
return await promise;
140+
} catch (error) {
141+
if (error instanceof McpAuthorizationRequiredError) {
142+
throw error;
143+
}
144+
if (error instanceof UnauthorizedError) {
145+
throw new McpAuthorizationRequiredError(
146+
this.plugin.manifest.name,
147+
`MCP authorization required for plugin "${this.plugin.manifest.name}"`,
148+
);
149+
}
150+
if (error instanceof StreamableHTTPError && error.code === 401) {
151+
throw new McpAuthorizationRequiredError(
152+
this.plugin.manifest.name,
153+
`MCP authorization required for plugin "${this.plugin.manifest.name}"`,
154+
);
155+
}
156+
throw error;
157+
}
158+
}
159+
}
160+
161+
export type {
162+
ListedTool as PluginMcpListedTool,
163+
ToolCallResult as PluginMcpToolCallResult,
164+
};

0 commit comments

Comments
 (0)