Skip to content
Draft
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
25 changes: 24 additions & 1 deletion reference-implementation/server/package-rs-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,22 @@ import type { PackageProfile } from "../vendor/mcp-server/src/package-profile.ts
import { RsClient } from "../vendor/mcp-server/src/rs-client.ts";

interface PackageMember {
access_mode: string;
active: boolean;
connection_id: string | null;
grant: {
purpose: string;
streams: readonly { readonly instance_ids: readonly string[]; readonly name: string }[];
};
grant_id: string;
package_id: string;
source: {
connection_id?: string;
connector_display_name?: string;
display_name?: string;
id: string;
kind: "connector" | "provider_native";
} | null;
token: string;
}

Expand Down Expand Up @@ -61,8 +76,16 @@ export function createPackageProfile({
);
}
return {
children: members.map(({ grant_id, token }) => ({
children: members.map(({ access_mode, active, grant, grant_id, source, token }) => ({
access_mode,
active,
grant_id,
purpose: grant.purpose,
source: source ? { ...source } : null,
streams: grant.streams.map(({ name, instance_ids }) => ({
name,
instance_ids: [...instance_ids],
})),
rs: new RsClient({
accessToken: token,
fetch,
Expand Down
119 changes: 113 additions & 6 deletions reference-implementation/server/routes/rs-hosted-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,47 @@ interface AppLike {
}

export interface GrantPackageMember {
readonly accessToken: string;
readonly grantId: string;
readonly access_mode: string;
readonly active: boolean;
readonly connection_id: string | null;
readonly grant: {
readonly purpose: string;
readonly streams: readonly {
readonly instance_ids: readonly string[];
readonly name: string;
}[];
};
readonly grant_id: string;
readonly package_id: string;
readonly source: {
readonly connection_id?: string;
readonly connector_display_name?: string;
readonly display_name?: string;
readonly id: string;
readonly kind: "connector" | "provider_native";
} | null;
readonly token: string;
}

interface GrantPackageMemberInput extends Omit<GrantPackageMember, "access_mode" | "active" | "grant"> {
readonly access_mode?: string;
readonly active?: boolean;
readonly grant: Omit<GrantPackageMember["grant"], "purpose"> & {
readonly access_mode?: string;
readonly purpose?: string | { readonly code?: string; readonly description?: string };
readonly purpose_code?: string;
readonly purpose_description?: string;
};
}

interface GrantPackageAccessInput {
readonly members: readonly GrantPackageMemberInput[];
readonly package: Readonly<Record<string, unknown>>;
}

export interface GrantPackageAccess {
readonly members: readonly GrantPackageMember[];
readonly package: Readonly<Record<string, unknown>>;
}

interface McpServerIcon {
Expand Down Expand Up @@ -125,8 +160,8 @@ export interface MountRsHostedMcpContext {
/** Resolved RS public URL (or null; adapter derives it per-request). */
readonly explicitResource: string | null | undefined;
readonly dispatchApp: DispatchApp;
/** From auth.js: resolve active grant-package members for a package token. */
getGrantPackageAccess: (packageId: string) => Promise<GrantPackageAccess | null>;
/** Resolve every grant-package member, including inactive public members. */
getGrantPackageAccess: (packageId: string) => Promise<GrantPackageAccessInput | null>;
/**
* `handleStreamableHttpRequest` from `@pdpp/mcp-server/server`.
* Injected because the package is JS-only without type declarations.
Expand Down Expand Up @@ -251,6 +286,70 @@ function hostedMcpIconLink(resource: string): string {
return `<${hostedMcpIconUrl(resource)}>; rel="icon"; type="image/svg+xml"`;
}

function normalizeGrantPackageAccess(access: GrantPackageAccessInput): GrantPackageAccess {
const members = access.members;
if (!Array.isArray(members)) {
throw new TypeError("Malformed grant package access");
}
const hasActiveKey = (member: GrantPackageMemberInput) =>
Object.hasOwn(member, "active");
const legacyShape = members.every((member) => !hasActiveKey(member));
const frozenShape = members.every(
(member) => hasActiveKey(member) && typeof member.active === "boolean"
);
if (!legacyShape && !frozenShape) {
throw new TypeError("Malformed grant package access");
}

const grantIds = new Set<string>();
for (const member of members) {
if (!member || typeof member !== "object" || typeof member.grant_id !== "string" || !member.grant_id) {
throw new TypeError("Malformed grant package access");
}
if (grantIds.has(member.grant_id)) {
throw new TypeError("Malformed grant package access");
}
grantIds.add(member.grant_id);
const memberMode = member.access_mode;
const grantMode = member.grant?.access_mode;
if (
(memberMode !== undefined && typeof memberMode !== "string") ||
(grantMode !== undefined && typeof grantMode !== "string") ||
(memberMode !== undefined && grantMode !== undefined && memberMode !== grantMode)
) {
throw new TypeError("Malformed grant package access");
}
}

return {
...access,
members: members.map((member) => {
const accessMode = member.access_mode ?? member.grant.access_mode;
if (typeof accessMode !== "string") {
throw new TypeError("Malformed grant package access");
}
const purpose = typeof member.grant.purpose === "string"
? member.grant.purpose
: member.grant.purpose?.description ?? member.grant.purpose?.code ??
member.grant.purpose_description ?? member.grant.purpose_code;
if (typeof purpose !== "string") {
throw new TypeError("Malformed grant package access");
}
// The current producer returns only active legacy members. Revoked
// children are absent and keep B1's unknown-selector behavior.
return {
...member,
access_mode: accessMode,
active: legacyShape ? true : member.active as boolean,
grant: { ...member.grant, purpose },
source: member.source && member.connection_id
? { ...member.source, connection_id: member.connection_id }
: member.source,
};
}),
};
}

export function mountRsHostedMcp(app: AppLike, ctx: MountRsHostedMcpContext): void {
const {
explicitResource,
Expand Down Expand Up @@ -297,8 +396,16 @@ export function mountRsHostedMcp(app: AppLike, ctx: MountRsHostedMcpContext): vo
resource: string,
internalBase: string
): Promise<McpServerOptions | null> {
const access = await ctx.getGrantPackageAccess(req.tokenInfo?.grant_package_id as string);
if (!access || access.members.length === 0) {
const resolvedAccess = await ctx.getGrantPackageAccess(req.tokenInfo?.grant_package_id as string);
let access: GrantPackageAccess | null;
try {
access = resolvedAccess ? normalizeGrantPackageAccess(resolvedAccess) : null;
} catch {
// Malformed member lists fail closed without logging or returning member data.
ctx.pdppError(res, 500, "api_error", "Unable to resolve package access");
return null;
}
if (!access?.members.some((member) => member.active)) {
const metadataUrl = res.locals[PROTECTED_RESOURCE_METADATA_URL_LOCAL];
if (typeof metadataUrl === "string" && metadataUrl) {
res.setHeader(
Expand Down
119 changes: 102 additions & 17 deletions reference-implementation/test/hosted-mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3081,8 +3081,14 @@ test("multi-source hosted MCP picker requires explicit grant_id selection for pa
params: {},
});
for (const tool of resultOf(packageTools).tools as Array<{
name: string;
inputSchema: { properties: Record<string, unknown>; required: string[] };
}>) {
if (tool.name === "list_package_children") {
assert.deepEqual(tool.inputSchema.required ?? [], []);
assert.deepEqual(tool.inputSchema.properties ?? {}, {});
continue;
}
assert.equal(
(tool.inputSchema.properties.grant_id as { type?: string }).type,
"string",
Expand Down Expand Up @@ -3216,7 +3222,7 @@ test("package refresh replay deactivates every family-linked package bearer", as
}
});

test("revoked package child selectors fail while an explicitly selected sibling remains usable", async () => {
test("revoked legacy package children stay isolated while siblings remain usable", async () => {
const server = await startOpenTestServer();
const asUrl = `http://localhost:${server.asPort}`;
const rsUrl = `http://localhost:${server.rsPort}`;
Expand Down Expand Up @@ -3278,22 +3284,31 @@ test("revoked package child selectors fail while an explicitly selected sibling
request_id: "multi-source-child-revoke-test",
});

const revokedSelection = await selectSchema(spotifyChild.grant_id, 2);
const discovery = await postMcpJson(rsUrl, accessToken, {
id: 2,
jsonrpc: "2.0",
method: "tools/call",
params: { arguments: {}, name: "list_package_children" },
});
assert.equal(discovery.status, 200);
assert.equal(resultOf(discovery).isError, undefined);
const children = (resultOf(discovery).structuredContent as { children: Array<Record<string, unknown>> }).children;
// The current producer omits revoked children from its legacy member
// shape, so discovery cannot list them and B1's selector error applies.
assert.equal(children.some((child) => child.grant_id === spotifyChild.grant_id), false);
const discoveredGithub = mustExist(
children.find((child) => child.grant_id === githubChild.grant_id),
"active github child remains discoverable",
);
assert.equal(discoveredGithub.active, true);

const revokedSelection = await selectSchema(spotifyChild.grant_id, 3);
assert.equal(revokedSelection.status, 200);
assert.equal(resultOf(revokedSelection).isError, true);
assert.equal(
(
(
resultOf(revokedSelection).structuredContent as Record<
string,
unknown
>
).error as Record<string, unknown>
).code,
"unknown_grant_selector",
"the later inactive-child result is not implemented in this change",
);
const siblingSelection = await selectSchema(githubChild.grant_id, 3);
const revokedError = (resultOf(revokedSelection).structuredContent as { error: Record<string, unknown> }).error;
assert.equal(revokedError.code, "unknown_grant_selector");
assert.match(String(revokedError.message), /Unknown grant_id.*list_package_children/);
const siblingSelection = await selectSchema(githubChild.grant_id, 4);
assert.equal(siblingSelection.status, 200);
assert.equal(resultOf(siblingSelection).isError, undefined);

Expand All @@ -3304,7 +3319,7 @@ test("revoked package child selectors fail while an explicitly selected sibling
}
});

test("revoking a package's last child challenges its token as invalid", async () => {
test("revoking the last package child makes its token invalid", async () => {
const server = await startOpenTestServer();
const asUrl = `http://localhost:${server.asPort}`;
const rsUrl = `http://localhost:${server.rsPort}`;
Expand All @@ -3323,12 +3338,21 @@ test("revoking a package's last child challenges its token as invalid", async ()
const packageMember = packageMembers?.members?.[0];
assert.ok(packageMember?.grant_id);
assert.equal(packageMembers?.members?.length, 1);

const before = await postMcpJson(rsUrl, accessToken, {
id: 1,
jsonrpc: "2.0",
method: "tools/list",
params: {},
});
assert.equal(before.status, 200, "the package token serves MCP before its last child is revoked");

await revokeGrant(packageMember.grant_id, {
request_id: "mcp-package-last-child-revoked-test",
});
assert.equal((await introspectAccessToken(asUrl, accessToken)).active, true);
const emptyPackageAccess = await getGrantPackageAccess(packageId);
assert.equal((emptyPackageAccess?.members as unknown[] | undefined)?.length, 0);
assert.equal((emptyPackageAccess?.members as Array<{ active?: boolean }> | undefined)?.length, 0);

const after = await postMcpJson(rsUrl, accessToken, {
id: 1,
Expand Down Expand Up @@ -3487,6 +3511,67 @@ async function registerPackageChild(
return connectionId;
}

test("current package sessions replay discovery and every read tool", async () => {
const server = await startOpenTestServer();
const asUrl = `http://localhost:${server.asPort}`;
const rsUrl = `http://localhost:${server.rsPort}`;

try {
const connectionId = await registerPackageChild(asUrl, "profile-replay-child");
const client = await registerAuthCodeClient(asUrl);
const { accessToken } = await completeMultiSourcePackageFlow({
asUrl,
client,
connectorIds: ["profile-replay-child"],
});
let requestId = 1;
const call = async (name: string, args: Record<string, unknown> = {}) => {
const response = await postMcpJson(rsUrl, accessToken, {
id: requestId++,
jsonrpc: "2.0",
method: "tools/call",
params: { arguments: args, name },
});
assert.equal(response.status, 200, `${name} stays on the MCP tool surface`);
assert.equal(resultOf(response).isError, undefined, `${name} succeeds`);
return resultOf(response);
};

const listed = await postMcpJson(rsUrl, accessToken, {
id: requestId++,
jsonrpc: "2.0",
method: "tools/list",
params: {},
});
assert.equal(listed.status, 200);
const tools = resultOf(listed).tools as Array<{ name: string }>;
assert.ok(tools.some((tool) => tool.name === "list_package_children"));

const discovered = await call("list_package_children");
const children = (discovered.structuredContent as { children: Array<Record<string, unknown>> }).children;
const child = mustExist(
children.find((candidate) => {
const source = candidate.source as Record<string, unknown>;
return source.connection_id === connectionId;
}),
"current producer child is discoverable by its pairwise handle",
);
const grantId = stringField(child, "grant_id");
await call("schema", { grant_id: grantId });
const searched = await call("search", {
grant_id: grantId,
q: packageChildSentinel(connectionId),
streams: [PIN_STREAM],
});
const searchData = searched.structuredContent as { results: Array<{ id: string }> };
const hit = mustExist(searchData.results[0], "search returns the seeded package child record");
await call("fetch", { grant_id: grantId, id: hit.id });
await call("query_records", { grant_id: grantId, stream: PIN_STREAM });
} finally {
await closeServer(server);
}
});

test("package grant_id selection isolates a real sibling record", async () => {
const server = await startOpenTestServer();
const asUrl = `http://localhost:${server.asPort}`;
Expand Down
24 changes: 22 additions & 2 deletions reference-implementation/test/package-rs-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ import { createPackageProfile } from "../server/package-rs-client.ts";

const PROVIDER = "https://rs.test";

function member(grant_id: string, token: string) {
return {
access_mode: "continuous",
active: true,
connection_id: `pairwise-${grant_id}`,
grant: {
purpose: "personal records",
streams: [{ name: "notes", instance_ids: [`pairwise-${grant_id}`] }],
},
grant_id,
package_id: "package-test",
source: {
connection_id: `pairwise-${grant_id}`,
id: `source-${grant_id}`,
kind: "connector" as const,
},
token,
};
}

test("package profile keeps each child bound to its own RS bearer", async () => {
const calls: Array<{ authorization: string | null; url: string }> = [];
const profile = createPackageProfile({
Expand All @@ -18,8 +38,8 @@ test("package profile keeps each child bound to its own RS bearer", async () =>
return Response.json({ object: "schema", streams: [] });
},
members: [
{ grant_id: "child-A", token: "token-A" },
{ grant_id: "child.B", token: "token-B" },
member("child-A", "token-A"),
member("child.B", "token-B"),
],
providerUrl: PROVIDER,
});
Expand Down
Loading