Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/foundry-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Foundry Pi package

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
# Exercise exactly the Git-subdirectory package Foundry installs. A
# broad monorepo build alone can miss a broken Pi extension entrypoint.
- run: npm install --prefix packages/pi --ignore-scripts --no-audit --no-fund
- run: npm run --prefix packages/pi typecheck
- run: npm run --prefix packages/pi test
- name: Inspect packed Pi resources
working-directory: packages/pi
run: |
npm pack --dry-run --json > /tmp/context7-pi-pack.json
node -e 'const p=require("/tmp/context7-pi-pack.json")[0]; for (const file of ["extensions/context7.ts", "lib/api.ts", "LICENSE"]) if (!p.files.some(f=>f.path===file)) process.exitCode=1'
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
# This public fork must never publish to upstream npm/GitHub registries.
if: github.repository == 'upstash/context7'
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ jobs:
run: pnpm typecheck

- name: Configure AWS credentials
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
if: github.repository == 'upstash/context7' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_ROLE_ARN }}
Expand All @@ -79,12 +79,20 @@ jobs:
role-session-name: context7-bedrock-${{ github.run_id }}

- name: Test
run: pnpm test
# This fork has no upstream Bedrock credentials: the tools-ai-sdk
# suite makes live AWS calls. Still lint/build/typecheck the full
# monorepo, and run the complete Pi suite that Foundry consumes.
run: |
if [ "$GITHUB_REPOSITORY" = "upstash/context7" ]; then
pnpm test
else
pnpm --filter @upstash/context7-pi test
fi
env:
CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}

- name: SDK Integration Test
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
if: github.repository == 'upstash/context7' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
run: pnpm --filter @upstash/context7-sdk test:integration
env:
CONTEXT7_API_KEY: ${{ secrets.CONTEXT7_API_KEY }}
98 changes: 98 additions & 0 deletions packages/pi/__tests__/api-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchLibraryContext, searchLibraries } from "../lib/api";

// Observe the actual fetch request, not a model's description of a tool call.
afterEach(() => vi.unstubAllGlobals());

describe("bounded Context7 transport", () => {
it("uses only Context7's fixed endpoint and honors the caller's abort", async () => {
const fetchMock = vi.fn(
async (_url: URL, _options: RequestInit) =>
new Response(JSON.stringify({ results: [{ id: "/facebook/react" }] }), { status: 200 })
);
vi.stubGlobal("fetch", fetchMock);
const abort = new AbortController();
expect((await searchLibraries("React hooks", "React", abort.signal)).results).toHaveLength(1);
const [url, options] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(url.origin).toBe("https://context7.com");
expect(url.pathname).toBe("/api/v2/libs/search");
expect(url.searchParams.get("query")).toBe("React hooks");
expect(options.signal?.aborted).toBe(false);
abort.abort();
expect(options.signal?.aborted).toBe(true);
});

it("rejects long or control-character queries before transmitting any bytes", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchLibraryContext("private\nfile", "/facebook/react")).rejects.toThrow(
/Invalid Context7 query/
);
await expect(searchLibraries("x".repeat(801), "React")).rejects.toThrow(
/Invalid Context7 query/
);
expect(fetchMock).not.toHaveBeenCalled();
});

it("refuses an oversized documentation response", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("x".repeat(128 * 1024 + 1), { status: 200 }))
);
await expect(fetchLibraryContext("What is useEffect?", "/facebook/react")).rejects.toThrow(
/response exceeded/
);
});

it("cancels an in-flight query when the caller aborts", async () => {
const fetchMock = vi.fn(
(_url: URL, options: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
options.signal?.addEventListener(
"abort",
() => reject(new DOMException("cancelled", "AbortError")),
{ once: true }
);
})
);
vi.stubGlobal("fetch", fetchMock);
const abort = new AbortController();
const pending = fetchLibraryContext("What is useEffect?", "/facebook/react", abort.signal);
abort.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("does not mistake cancellation of an HTTP error body for a completed tool result", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (_url: URL, options: RequestInit) => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
options.signal?.addEventListener(
"abort",
() => controller.error(new DOMException("cancelled", "AbortError")),
{ once: true }
);
},
});
return new Response(body, { status: 503 });
})
);
const abort = new AbortController();
const pending = searchLibraries("React hooks", "React", abort.signal);
abort.abort();
await expect(pending).rejects.toMatchObject({ name: "AbortError" });
});

it("returns bounded error text without treating HTTP errors as healthy results", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ message: "not ready" }), { status: 503 }))
);
expect(await searchLibraries("React hooks", "React")).toMatchObject({
results: [],
error: "not ready",
});
});
});
83 changes: 70 additions & 13 deletions packages/pi/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,67 @@
import type { SearchResponse } from "./types";

const BASE_URL = "https://context7.com/api";
// Foundry's trusted Pi process must not wait indefinitely or consume an
// unbounded response from a remote documentation service. Agent-selected query
// text is bounded here; the seeded Foundry health probe uses only fixed public
// documentation strings (normal Pi usage remains the developer's choice).
const REQUEST_TIMEOUT_MS = 12_000;
const MAX_QUERY_CHARS = 800;
const MAX_RESULT_BYTES = 128 * 1024;

function authHeaders(): Record<string, string> {
const apiKey = process.env.CONTEXT7_API_KEY;
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
}

function checkedInput(value: string, name: string): string {
if (!value.trim() || value.length > MAX_QUERY_CHARS || /[\x00-\x1f\x7f]/.test(value)) {
throw new Error(
`Invalid Context7 ${name}: nonempty public-doc text of at most ${MAX_QUERY_CHARS} characters is required`
);
}
return value;
}

function requestSignal(signal?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}

async function boundedText(response: Response): Promise<string> {
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bytes = 0;
let result = "";
let completed = false;
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
completed = true;
break;
}
bytes += value.byteLength;
if (bytes > MAX_RESULT_BYTES) throw new Error("Context7 response exceeded the 128 KiB limit");
result += decoder.decode(value, { stream: true });
}
return result + decoder.decode();
} finally {
if (!completed) await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}

async function parseErrorResponse(response: Response): Promise<string> {
// A failed/aborted body read is not a usable HTTP error message. Only a
// malformed JSON payload may fall back to the status-based diagnostic.
const text = await boundedText(response);
try {
const json = (await response.json()) as { message?: string };
if (json.message) return json.message;
const json = JSON.parse(text) as { message?: unknown };
if (typeof json.message === "string") return json.message.slice(0, 300);
} catch {
// JSON parsing failed, fall through to status-based message
// The status below remains useful if the error body is not JSON.
}

const hasKey = Boolean(process.env.CONTEXT7_API_KEY);
Expand All @@ -35,29 +84,37 @@ async function parseErrorResponse(response: Response): Promise<string> {
return `Request failed with status ${response.status}. Please try again later.`;
}

export async function searchLibraries(query: string, libraryName: string): Promise<SearchResponse> {
export async function searchLibraries(
query: string,
libraryName: string,
signal?: AbortSignal
): Promise<SearchResponse> {
const url = new URL(`${BASE_URL}/v2/libs/search`);
url.searchParams.set("query", query);
url.searchParams.set("libraryName", libraryName);
url.searchParams.set("query", checkedInput(query, "query"));
url.searchParams.set("libraryName", checkedInput(libraryName, "library name"));

const response = await fetch(url, { headers: authHeaders() });
const response = await fetch(url, { headers: authHeaders(), signal: requestSignal(signal) });
if (!response.ok) {
return { results: [], error: await parseErrorResponse(response) };
}
return (await response.json()) as SearchResponse;
return JSON.parse(await boundedText(response)) as SearchResponse;
}

export async function fetchLibraryContext(query: string, libraryId: string): Promise<string> {
export async function fetchLibraryContext(
query: string,
libraryId: string,
signal?: AbortSignal
): Promise<string> {
const url = new URL(`${BASE_URL}/v2/context`);
url.searchParams.set("query", query);
url.searchParams.set("libraryId", libraryId);
url.searchParams.set("query", checkedInput(query, "query"));
url.searchParams.set("libraryId", checkedInput(libraryId, "library ID"));

const response = await fetch(url, { headers: authHeaders() });
const response = await fetch(url, { headers: authHeaders(), signal: requestSignal(signal) });
if (!response.ok) {
return parseErrorResponse(response);
}

const text = await response.text();
const text = await boundedText(response);
if (!text) {
return "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.";
}
Expand Down
4 changes: 2 additions & 2 deletions packages/pi/lib/tools/query-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export const queryDocsTool: ToolDefinition<typeof Params, undefined> = {
label: QUERY_DOCS_TITLE,
description: QUERY_DOCS_DESCRIPTION,
parameters: Params,
async execute(_toolCallId: string, params: Static<typeof Params>) {
const text = await fetchLibraryContext(params.query, params.libraryId);
async execute(_toolCallId: string, params: Static<typeof Params>, signal?: AbortSignal) {
const text = await fetchLibraryContext(params.query, params.libraryId, signal);
return toToolResult(text);
},
};
4 changes: 2 additions & 2 deletions packages/pi/lib/tools/resolve-library-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export const resolveLibraryIdTool: ToolDefinition<typeof Params, undefined> = {
label: RESOLVE_LIBRARY_ID_TITLE,
description: RESOLVE_LIBRARY_ID_DESCRIPTION,
parameters: Params,
async execute(_toolCallId: string, params: Static<typeof Params>) {
const searchResponse = await searchLibraries(params.query, params.libraryName);
async execute(_toolCallId: string, params: Static<typeof Params>, signal?: AbortSignal) {
const searchResponse = await searchLibraries(params.query, params.libraryName, signal);
if (!searchResponse.results || searchResponse.results.length === 0) {
return toToolResult(searchResponse.error ?? "No libraries found matching the provided name.");
}
Expand Down
Loading