Skip to content
Closed
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
8 changes: 6 additions & 2 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,9 +874,9 @@ it.effect("admits provider and OAuth client credentials and points only clients
),
Layer.provide(
Layer.mock(McpSessionRegistry.McpSessionRegistry)({
resolve: (token) =>
resolve: (token, discoveryOnly) =>
Effect.succeed(
token === providerToken
token === providerToken || (token === "pendingProvider" && discoveryOnly === true)
? (providerScope as McpInvocationContext.McpThreadInvocationScope)
: undefined,
),
Expand Down Expand Up @@ -942,6 +942,10 @@ it.effect("admits provider and OAuth client credentials and points only clients
const deadProvider = yield* call("deadProviderToken");
expect(deadProvider.status).toBe(401);
expect(deadProvider.headers["www-authenticate"]).toBe('Bearer error="invalid_token"');
// Discovery can initialize the native MCP client before a T3 thread exists.
// Even a read-only tool stays unavailable until the credential is bound.
expect((yield* call("pendingProvider")).status).toBe(401);
expect(seen).toHaveLength(2);
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,33 @@ const makeMcpAuthMiddleware = Effect.gen(function* () {
: "";
const invocation =
(yield* registry.resolve(token)) ??
(yield* Effect.gen(function* () {
// Pending native spares may discover tools, but cannot invoke a tool,
// read a resource, or authenticate a persistent GET stream.
if (request.method !== "POST" || !looksLikeProviderToken(token)) return undefined;
const body = yield* request.json.pipe(Effect.option);
if (
Option.isNone(body) ||
typeof body.value !== "object" ||
body.value === null ||
!("method" in body.value)
)
return undefined;
const method = body.value.method;
if (
typeof method !== "string" ||
![
"initialize",
"notifications/initialized",
"tools/list",
"resources/list",
"resources/templates/list",
"prompts/list",
].includes(method)
)
return undefined;
return yield* registry.resolve(token, true);
})) ??
(Option.isSome(clients) && token.length > 0
? yield* clients.value.authenticate(request)
: undefined);
Expand Down
27 changes: 27 additions & 0 deletions apps/server/src/mcp/McpSessionRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ const makeRegistry = (now: () => number, httpServer = fakeHttpServer) =>
Effect.provide(NodeServices.layer),
);

it.effect(
"a spare credential only authenticates discovery until bound once to its real thread",
() =>
Effect.gen(function* () {
const registry = yield* makeRegistry(() => 1_000);
const providerInstanceId = ProviderInstanceId.make("codex");
const issued = yield* registry.issue({
threadId: ThreadId.make("unsubmitted"),
providerInstanceId,
pending: true,
});
const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, "");
expect(yield* registry.resolve(token)).toBeUndefined();
expect(yield* registry.resolve(token, true)).toBeDefined();
const threadId = ThreadId.make("submitted");
expect(yield* registry.bind(issued.config.providerSessionId, threadId)).toBe(true);
expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId);
expect(yield* registry.bind(issued.config.providerSessionId, ThreadId.make("another"))).toBe(
false,
);
expect((yield* registry.resolve(token))?.thread.threadId).toBe(threadId);
yield* registry.revokeThread(threadId);
expect(yield* registry.resolve(token, true)).toBeUndefined();
expect(yield* registry.bind(issued.config.providerSessionId, threadId)).toBe(false);
}),
);

it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () =>
Effect.gen(function* () {
let timestamp = 1_000;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/McpSessionRegistry.testkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const layer = Layer.succeed(
}),
// @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type.
resolve: () => Effect.succeed(undefined),
bind: () => Effect.succeed(false),
touch: () => Effect.void,
revokeProviderSession: () => Effect.void,
revokeThread: () => Effect.void,
Expand Down
36 changes: 33 additions & 3 deletions apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import * as McpInvocationContext from "./McpInvocationContext.ts";
import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";

export interface McpCredentialRequest {
/** Discovery is allowed while the native spare has no submitted T3 thread. */
readonly pending?: boolean;
readonly threadId: ThreadId;
readonly providerInstanceId: ProviderInstanceId;
/** False omits the preview capability wherever the token is honored. Defaults to full access. */
Expand All @@ -29,7 +31,9 @@ export interface McpSessionRegistryShape {
readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>;
readonly resolve: (
rawToken: string,
discoveryOnly?: boolean,
) => Effect.Effect<McpInvocationContext.McpThreadInvocationScope | undefined>;
readonly bind: (providerSessionId: string, threadId: ThreadId) => Effect.Effect<boolean>;
readonly touch: (threadId: ThreadId) => Effect.Effect<void>;
readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>;
readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>;
Expand All @@ -42,6 +46,7 @@ export class McpSessionRegistry extends Context.Service<
>()("t3/mcp/McpSessionRegistry") {}

interface CredentialRecord {
readonly pending: boolean;
readonly tokenHash: string;
readonly scope: McpInvocationContext.McpThreadInvocationScope;
readonly lastAliveAt: number;
Expand Down Expand Up @@ -119,7 +124,12 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
};
yield* SynchronizedRef.update(state, ({ records }) => {
const next = new Map(records);
next.set(tokenHash, { tokenHash, scope, lastAliveAt: issuedAt });
next.set(tokenHash, {
tokenHash,
scope,
lastAliveAt: issuedAt,
pending: request.pending === true,
});
return { records: next };
});
return {
Expand All @@ -138,13 +148,17 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
);

const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")(
function* (rawToken) {
function* (rawToken, discoveryOnly = false) {
if (rawToken.length === 0) return undefined;
const tokenHash = yield* hashToken(rawToken);
const timestamp = yield* currentTimeMillis;
return yield* SynchronizedRef.modify(state, ({ records }) => {
const record = records.get(tokenHash);
if (!record || timestamp - record.lastAliveAt > livenessWindowMs) {
if (
!record ||
(record.pending && !discoveryOnly) ||
timestamp - record.lastAliveAt > livenessWindowMs
) {
return [undefined, { records }] as const;
}
const next = new Map(records);
Expand Down Expand Up @@ -177,6 +191,22 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
return McpSessionRegistry.of({
issue,
resolve,
bind: Effect.fn("McpSessionRegistry.bind")(function* (providerSessionId, threadId) {
return yield* SynchronizedRef.modify(state, ({ records }) => {
const entry = [...records].find(
([, record]) =>
record.pending && record.scope.thread.providerSessionId === providerSessionId,
);
if (entry === undefined) return [false, { records }] as const;
const [hash, record] = entry;
const next = new Map(records).set(hash, {
...record,
pending: false,
scope: { ...record.scope, thread: { ...record.scope.thread, threadId } },
});
return [true, { records: next }] as const;
});
}),
touch,
revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")(
function* (providerSessionId) {
Expand Down
Loading
Loading